feat(assets): add 3D pipeline spike — Trellis to Godot with recoloring
Proves the full asset pipeline: concept image → Trellis 3D → Blender post-process → Godot render with toon shader and recolor masks. Key findings: - gltf/embedded_image_handling=3 required (extract mode silently fails) - Luminance-preserving recolor shader keeps texture detail - Trellis output quality is sufficient for isometric game assets - Camera uses pivot-based system with screen-aligned WASD pan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Godot generated
|
||||
.godot/
|
||||
*.import
|
||||
*.uid
|
||||
|
||||
# Extracted textures (regenerated by Godot import)
|
||||
*_Image_*.png
|
||||
@@ -0,0 +1,114 @@
|
||||
# 3D Pipeline Spike
|
||||
|
||||
Proof of concept for the Trellis image-to-3D asset pipeline with runtime
|
||||
recoloring in Godot 4.6.
|
||||
|
||||
## What This Proves
|
||||
|
||||
1. **Trellis generates usable 3D assets** from 2D concept art (via /image-gen)
|
||||
2. **Blender post-processing** normalizes scale, centers models, generates
|
||||
recolor masks, and adjusts materials for toon rendering
|
||||
3. **Godot renders textured GLBs** with embedded textures when
|
||||
`gltf/embedded_image_handling=3` (embed uncompressed) is set
|
||||
4. **Luminance-preserving recoloring** via shader + mask sidecar: tint the
|
||||
dominant color region while keeping texture detail (shadows, highlights,
|
||||
edges)
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
concept.png ──→ Trellis ──→ raw.glb ──→ Blender postprocess ──→ model.glb + mask.png
|
||||
│
|
||||
Godot import
|
||||
(embedded textures)
|
||||
│
|
||||
toon_masked.gdshader
|
||||
(texture + mask + tint)
|
||||
```
|
||||
|
||||
## Key Findings
|
||||
|
||||
### GLB Import Settings (Critical)
|
||||
|
||||
Godot's GLTF importer `gltf/embedded_image_handling` values:
|
||||
|
||||
| Value | Mode | Result |
|
||||
|-------|------|--------|
|
||||
| 0 | Discard textures | Models render without any texture |
|
||||
| 1 | Extract textures | **Silently fails** — materials get null texture refs |
|
||||
| 2 | Embed as Basis Universal | Compressed, may lose quality |
|
||||
| 3 | Embed uncompressed | **Works correctly** — textures preserved in .scn |
|
||||
|
||||
**Always use value 3** for Trellis-generated GLBs. Set in project.godot under
|
||||
`[gltf]` so new imports pick it up automatically.
|
||||
|
||||
### Recolor Shader
|
||||
|
||||
The `toon_masked.gdshader` uses luminance-preserving blending:
|
||||
|
||||
```glsl
|
||||
float luma = dot(original.rgb, vec3(0.299, 0.587, 0.114));
|
||||
vec3 tinted = tint_color.rgb * (luma * 1.5 + 0.2);
|
||||
vec3 base = mix(original.rgb, tinted, mask);
|
||||
```
|
||||
|
||||
- Where `mask = 0` (black): original Trellis texture preserved
|
||||
- Where `mask = 1` (white): tint color applied, scaled by original luminance
|
||||
- This keeps shadows, highlights, and detail lines even in recolored regions
|
||||
|
||||
The mask default is `hint_default_black` (replace nothing when no mask loaded).
|
||||
|
||||
### Toon Shadow
|
||||
|
||||
Gentle 15% darkening with `smoothstep` transition — not the harsh binary
|
||||
shadow/lit split. This preserves texture readability at isometric camera angles
|
||||
where many faces would otherwise be fully in shadow.
|
||||
|
||||
### Alpha / See-Through Fix
|
||||
|
||||
Trellis texture atlases sometimes have alpha artifacts at UV seams. The shader
|
||||
does NOT output ALPHA — all models render fully opaque. If transparency is
|
||||
needed for specific assets (glass, holograms), use a separate shader variant.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd spikes/3dpipeline
|
||||
godot --headless --import # first time only — generates .godot/imported/
|
||||
godot --path . # run the spike
|
||||
```
|
||||
|
||||
### Controls
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| WASD | Pan camera (screen-aligned) |
|
||||
| Mouse wheel | Zoom in/out |
|
||||
| T | Cycle camera angle (top-down / 45 iso / 30 dramatic) |
|
||||
| R | Randomize character colors |
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
project.godot # Minimal Godot project config
|
||||
scenes/spike_3d.tscn # Main scene (Camera3D + script)
|
||||
scripts/spike/spike_main.gd # Camera, model loading, shader application
|
||||
shaders/spike/toon_masked.gdshader # Texture + recolor mask + toon shadow
|
||||
shaders/spike/toon.gdshader # Flat color toon (characters)
|
||||
shaders/spike/outline.gdshader # Inverted hull outline (not used yet)
|
||||
models/furniture/*.glb + *_mask.png # Post-processed Trellis models + masks
|
||||
models/props/*.glb + *_mask.png # Post-processed Trellis props + masks
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
- Baroque table mask is too aggressive — tints the whole model bright white
|
||||
- Character scale needs tuning relative to furniture
|
||||
- Trellis mesh quality varies — some models have holes visible at close zoom
|
||||
- No outline shader applied yet (inverted hull ready but not wired up)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Godot 4.6+ (gl_compatibility renderer)
|
||||
- Models generated by: `tooling/db/trellis_connector.py` + `.claude/skills/glb-gen/`
|
||||
- Concept images generated by: `.claude/skills/image-gen/`
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,21 @@
|
||||
; Settled Reach — 3D Pipeline Spike
|
||||
; Trellis GLB models + toon shader + recolor mask pipeline
|
||||
|
||||
[application]
|
||||
|
||||
config/name="SR 3D Pipeline Spike"
|
||||
run/main_scene="res://scenes/spike_3d.tscn"
|
||||
config/features=PackedStringArray("4.6")
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1280
|
||||
window/size/viewport_height=720
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
|
||||
[gltf]
|
||||
|
||||
embedded_image_handling=3
|
||||
@@ -0,0 +1,13 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/spike/spike_main.gd" id="1"]
|
||||
|
||||
[node name="SpikeMain" type="Node3D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="."]
|
||||
projection = 1
|
||||
current = true
|
||||
size = 14.0
|
||||
near = 0.1
|
||||
far = 100.0
|
||||
@@ -0,0 +1,353 @@
|
||||
extends Node3D
|
||||
|
||||
## 3D Toon Shader Prototype — Spike
|
||||
## Controls:
|
||||
## Mouse wheel: zoom in/out
|
||||
## T: toggle camera tilt (top-down ↔ 30° angle)
|
||||
## WASD: pan camera
|
||||
## R: randomize all character colors
|
||||
|
||||
@onready var camera: Camera3D = $Camera3D
|
||||
var characters: Array[Node3D] = []
|
||||
|
||||
## Camera pivot — the point on the ground the camera orbits around
|
||||
var camera_pivot := Vector3(6.5, 0.0, 6.5) # center of 14x14 floor
|
||||
var camera_size_target: float = 14.0
|
||||
var camera_angle: int = 2 # 0=top-down, 1=45° iso, 2=30° default
|
||||
var camera_target_rot := Vector3.ZERO
|
||||
var camera_current_rot := Vector3.ZERO
|
||||
|
||||
const ZOOM_MIN: float = 3.0
|
||||
const ZOOM_MAX: float = 50.0
|
||||
const ZOOM_SPEED: float = 1.5
|
||||
const PAN_SPEED: float = 15.0
|
||||
const TILT_SPEED: float = 3.0
|
||||
const CAM_DIST: float = 30.0 # distance from pivot (ortho, so just needs to clear geometry)
|
||||
|
||||
## Angle presets: [tilt_x, azimuth_y]
|
||||
const ANGLE_PRESETS := [
|
||||
Vector3(-90.0, 0.0, 0.0), # top-down
|
||||
Vector3(-45.0, 45.0, 0.0), # classic isometric
|
||||
Vector3(-30.0, 45.0, 0.0), # 30° dramatic (default)
|
||||
]
|
||||
|
||||
func _ready() -> void:
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
camera.size = camera_size_target
|
||||
camera.near = 0.1
|
||||
camera.far = 100.0
|
||||
|
||||
# Start at the target angle immediately — no lerp on first frame
|
||||
camera_target_rot = ANGLE_PRESETS[camera_angle]
|
||||
camera_current_rot = camera_target_rot
|
||||
_apply_camera()
|
||||
|
||||
_build_floor()
|
||||
_build_characters()
|
||||
_load_furniture()
|
||||
|
||||
print("Spike ready! Controls: scroll=zoom, WASD=pan, T=tilt, R=randomize colors")
|
||||
|
||||
func _apply_camera() -> void:
|
||||
## Position camera at `camera_pivot` + offset derived from rotation angles.
|
||||
camera.rotation_degrees = camera_current_rot
|
||||
# Camera looks along local -Z; offset along local +Z to sit behind the target
|
||||
var basis := Basis.from_euler(camera_current_rot * (PI / 180.0))
|
||||
camera.position = camera_pivot + basis * Vector3(0, 0, CAM_DIST)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Smooth zoom
|
||||
camera.size = lerp(camera.size, camera_size_target, 8.0 * delta)
|
||||
|
||||
# Smooth angle transition
|
||||
camera_current_rot = camera_current_rot.lerp(camera_target_rot, TILT_SPEED * delta)
|
||||
_apply_camera()
|
||||
|
||||
# WASD pan — aligned to screen axes, not world axes
|
||||
var input_dir := Vector2.ZERO
|
||||
if Input.is_key_pressed(KEY_W): input_dir.y += 1.0
|
||||
if Input.is_key_pressed(KEY_S): input_dir.y -= 1.0
|
||||
if Input.is_key_pressed(KEY_A): input_dir.x -= 1.0
|
||||
if Input.is_key_pressed(KEY_D): input_dir.x += 1.0
|
||||
if input_dir.length() > 0:
|
||||
input_dir = input_dir.normalized()
|
||||
# Project camera's right/up axes onto the XZ ground plane
|
||||
var cam_right := camera.global_basis.x
|
||||
var cam_up := camera.global_basis.y
|
||||
var pan_right := Vector3(cam_right.x, 0, cam_right.z).normalized()
|
||||
var pan_up := Vector3(cam_up.x, 0, cam_up.z).normalized()
|
||||
camera_pivot += (pan_right * input_dir.x + pan_up * input_dir.y) * PAN_SPEED * delta
|
||||
_apply_camera()
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
|
||||
camera_size_target = max(ZOOM_MIN, camera_size_target - ZOOM_SPEED)
|
||||
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
|
||||
camera_size_target = min(ZOOM_MAX, camera_size_target + ZOOM_SPEED)
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
if event.keycode == KEY_T:
|
||||
camera_angle = (camera_angle + 1) % ANGLE_PRESETS.size()
|
||||
camera_target_rot = ANGLE_PRESETS[camera_angle]
|
||||
var names := ["top-down (-90°)", "isometric (-45°)", "dramatic (-30°)"]
|
||||
print("Camera: ", names[camera_angle])
|
||||
elif event.keycode == KEY_R:
|
||||
_randomize_colors()
|
||||
print("Colors randomized!")
|
||||
|
||||
func _build_floor() -> void:
|
||||
for x in range(14):
|
||||
for z in range(14):
|
||||
var mi := MeshInstance3D.new()
|
||||
var quad := PlaneMesh.new()
|
||||
quad.size = Vector2(0.95, 0.95)
|
||||
mi.mesh = quad
|
||||
mi.position = Vector3(float(x), 0.0, float(z))
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
if (x + z) % 2 == 0:
|
||||
mat.albedo_color = Color(0.35, 0.38, 0.42)
|
||||
else:
|
||||
mat.albedo_color = Color(0.28, 0.30, 0.34)
|
||||
mi.material_override = mat
|
||||
add_child(mi)
|
||||
|
||||
# Walls for spatial reference
|
||||
for i in range(4):
|
||||
var wall := MeshInstance3D.new()
|
||||
var box := BoxMesh.new()
|
||||
box.size = Vector3(0.9, 1.5, 0.15)
|
||||
wall.mesh = box
|
||||
wall.position = Vector3(float(i) * 3.0 + 1.5, 0.75, 0.0)
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.albedo_color = Color(0.5, 0.45, 0.4)
|
||||
wall.material_override = mat
|
||||
add_child(wall)
|
||||
|
||||
func _build_characters() -> void:
|
||||
var skin_tones := [
|
||||
Color(0.96, 0.80, 0.65), Color(0.82, 0.64, 0.45),
|
||||
Color(0.62, 0.44, 0.30), Color(0.45, 0.30, 0.20),
|
||||
Color(0.92, 0.75, 0.58),
|
||||
]
|
||||
var hair_colors := [
|
||||
Color(0.15, 0.10, 0.05), Color(0.55, 0.35, 0.15),
|
||||
Color(0.85, 0.75, 0.45), Color(0.65, 0.20, 0.15),
|
||||
Color(0.30, 0.30, 0.35), Color(0.90, 0.85, 0.80),
|
||||
]
|
||||
var clothing_colors := [
|
||||
Color(0.25, 0.35, 0.55), Color(0.55, 0.25, 0.25),
|
||||
Color(0.30, 0.50, 0.30), Color(0.50, 0.40, 0.25),
|
||||
Color(0.60, 0.55, 0.50), Color(0.20, 0.20, 0.25),
|
||||
Color(0.45, 0.25, 0.50),
|
||||
]
|
||||
|
||||
# Fixed positions to avoid furniture overlap
|
||||
var char_positions := [
|
||||
Vector3(4, 0, 1),
|
||||
Vector3(8, 0, 3),
|
||||
Vector3(10, 0, 10),
|
||||
Vector3(4, 0, 11),
|
||||
]
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 42
|
||||
|
||||
for i in range(4):
|
||||
var pos: Vector3 = char_positions[i]
|
||||
var skin: Color = skin_tones[rng.randi() % skin_tones.size()]
|
||||
var hair: Color = hair_colors[rng.randi() % hair_colors.size()]
|
||||
var clothing: Color = clothing_colors[rng.randi() % clothing_colors.size()]
|
||||
_create_character(pos, skin, hair, clothing)
|
||||
|
||||
func _create_character(pos: Vector3, skin: Color, hair: Color, clothing: Color) -> void:
|
||||
var root := Node3D.new()
|
||||
root.position = pos
|
||||
|
||||
# Legs / lower body
|
||||
var legs := MeshInstance3D.new()
|
||||
var legs_mesh := CylinderMesh.new()
|
||||
legs_mesh.top_radius = 0.15
|
||||
legs_mesh.bottom_radius = 0.12
|
||||
legs_mesh.height = 0.35
|
||||
legs.mesh = legs_mesh
|
||||
legs.position = Vector3(0.0, 0.175, 0.0)
|
||||
_set_flat_color(legs, clothing.darkened(0.2))
|
||||
root.add_child(legs)
|
||||
|
||||
# Torso / clothing
|
||||
var torso := MeshInstance3D.new()
|
||||
var torso_mesh := CylinderMesh.new()
|
||||
torso_mesh.top_radius = 0.18
|
||||
torso_mesh.bottom_radius = 0.16
|
||||
torso_mesh.height = 0.4
|
||||
torso.mesh = torso_mesh
|
||||
torso.position = Vector3(0.0, 0.55, 0.0)
|
||||
_set_flat_color(torso, clothing)
|
||||
root.add_child(torso)
|
||||
|
||||
# Head / skin
|
||||
var head := MeshInstance3D.new()
|
||||
var head_mesh := SphereMesh.new()
|
||||
head_mesh.radius = 0.16
|
||||
head_mesh.height = 0.32
|
||||
head.mesh = head_mesh
|
||||
head.position = Vector3(0.0, 0.91, 0.0)
|
||||
_set_flat_color(head, skin)
|
||||
root.add_child(head)
|
||||
|
||||
# Hair — slightly larger, offset up
|
||||
var hair_node := MeshInstance3D.new()
|
||||
var hair_mesh := SphereMesh.new()
|
||||
hair_mesh.radius = 0.18
|
||||
hair_mesh.height = 0.28
|
||||
hair_node.mesh = hair_mesh
|
||||
hair_node.position = Vector3(0.0, 1.02, -0.02)
|
||||
_set_flat_color(hair_node, hair)
|
||||
root.add_child(hair_node)
|
||||
|
||||
add_child(root)
|
||||
characters.append(root)
|
||||
|
||||
func _load_furniture() -> void:
|
||||
# Three material modes:
|
||||
# "recolor" = texture + mask, dominant color swappable at runtime
|
||||
# "fixed" = texture as-is from Trellis, no recoloring
|
||||
# "flat" = rare: no texture, just a solid color
|
||||
var items: Array[Dictionary] = [
|
||||
{"path": "res://models/props/lion_statue.glb", "pos": Vector3(2, 0, 3), "color": Color(0.82, 0.78, 0.68), "mode": "fixed", "scale": 1.5},
|
||||
{"path": "res://models/furniture/baroque_table.glb", "pos": Vector3(2, 0, 8), "color": Color(0.88, 0.82, 0.72), "mode": "recolor", "scale": 1.5},
|
||||
{"path": "res://models/furniture/modernist_chair.glb", "pos": Vector3(6, 0, 3), "color": Color(0.85, 0.70, 0.50), "mode": "recolor"},
|
||||
{"path": "res://models/furniture/modernist_chair.glb", "pos": Vector3(6, 0, 6), "color": Color(0.55, 0.60, 0.75), "mode": "recolor"},
|
||||
{"path": "res://models/furniture/scifi_desk.glb", "pos": Vector3(6, 0, 9), "color": Color(0.70, 0.72, 0.78), "mode": "recolor", "scale": 2.0},
|
||||
{"path": "res://models/props/vw_beetle.glb", "pos": Vector3(11, 0, 6), "color": Color(0.85, 0.35, 0.30), "mode": "recolor", "rot_y": 180.0, "scale": 3.0},
|
||||
]
|
||||
for item in items:
|
||||
var scene: PackedScene = load(item["path"] as String)
|
||||
if scene == null:
|
||||
print("WARNING: not found: ", item["path"])
|
||||
continue
|
||||
var inst: Node3D = scene.instantiate()
|
||||
inst.position = item["pos"] as Vector3
|
||||
if item.has("rot_y"):
|
||||
inst.rotation_degrees.y = item["rot_y"] as float
|
||||
if item.has("scale"):
|
||||
var s: float = item["scale"] as float
|
||||
inst.scale = Vector3(s, s, s)
|
||||
var mode: String = item.get("mode", "fixed") as String
|
||||
match mode:
|
||||
"recolor":
|
||||
_apply_masked_shader(inst, item["color"] as Color, item["path"] as String)
|
||||
"fixed":
|
||||
_apply_textured_fixed(inst)
|
||||
"flat":
|
||||
_apply_flat_color(inst, item.get("color", Color(0.7, 0.7, 0.7)) as Color)
|
||||
add_child(inst)
|
||||
print(" Placed [%s]: %s" % [mode, (item["path"] as String).get_file()])
|
||||
|
||||
var _toon_masked_shader: Shader = preload("res://shaders/spike/toon_masked.gdshader")
|
||||
|
||||
func _apply_masked_shader(node: Node, tint: Color, glb_path: String) -> void:
|
||||
## Masked pipeline: keep Trellis texture, apply recolor mask + tint via shader.
|
||||
## The mask marks the dominant color region as replaceable.
|
||||
## Expects a _mask.png sidecar next to the GLB.
|
||||
var mask_path := glb_path.replace(".glb", "_mask.png")
|
||||
var mask_tex: Texture2D = null
|
||||
if ResourceLoader.exists(mask_path):
|
||||
mask_tex = load(mask_path) as Texture2D
|
||||
|
||||
_apply_masked_recursive(node, tint, mask_tex)
|
||||
|
||||
func _apply_masked_recursive(node: Node, tint: Color, mask_tex: Texture2D) -> void:
|
||||
if node is MeshInstance3D:
|
||||
var mi := node as MeshInstance3D
|
||||
var mesh: Mesh = mi.mesh
|
||||
if mesh:
|
||||
for surf_idx in range(mesh.get_surface_count()):
|
||||
var existing: Material = mi.get_active_material(surf_idx)
|
||||
var albedo_tex: Texture2D = _extract_albedo_texture(existing)
|
||||
|
||||
var shader_mat := ShaderMaterial.new()
|
||||
shader_mat.shader = _toon_masked_shader
|
||||
if albedo_tex:
|
||||
shader_mat.set_shader_parameter("albedo_tex", albedo_tex)
|
||||
else:
|
||||
print(" WARNING: no albedo texture on surface %d of %s (material type: %s)" % [surf_idx, mi.name, existing.get_class() if existing else "null"])
|
||||
if mask_tex:
|
||||
shader_mat.set_shader_parameter("recolor_mask", mask_tex)
|
||||
shader_mat.set_shader_parameter("tint_color", tint)
|
||||
mi.set_surface_override_material(surf_idx, shader_mat)
|
||||
|
||||
for child in node.get_children():
|
||||
_apply_masked_recursive(child, tint, mask_tex)
|
||||
|
||||
func _extract_albedo_texture(mat: Material) -> Texture2D:
|
||||
## Extract the albedo/base color texture from an imported material.
|
||||
## Godot's GLTF importer creates StandardMaterial3D — try that first,
|
||||
## then fall back to checking shader parameters for BaseMaterial3D.
|
||||
if mat == null:
|
||||
return null
|
||||
if mat is StandardMaterial3D:
|
||||
var tex := (mat as StandardMaterial3D).albedo_texture
|
||||
if tex:
|
||||
return tex
|
||||
if mat is BaseMaterial3D:
|
||||
var tex := (mat as BaseMaterial3D).albedo_texture
|
||||
if tex:
|
||||
return tex
|
||||
# ShaderMaterial fallback — check common parameter names
|
||||
if mat is ShaderMaterial:
|
||||
var sm := mat as ShaderMaterial
|
||||
for param_name in ["albedo_tex", "albedo_texture", "texture_albedo", "base_color_texture"]:
|
||||
var val = sm.get_shader_parameter(param_name)
|
||||
if val is Texture2D:
|
||||
return val as Texture2D
|
||||
return null
|
||||
|
||||
func _apply_textured_fixed(node: Node) -> void:
|
||||
## Fixed pipeline: keep Trellis texture as-is, just ensure it renders (unshaded).
|
||||
## No recoloring — what Trellis baked is the final look.
|
||||
if node is MeshInstance3D:
|
||||
var mi := node as MeshInstance3D
|
||||
var mesh: Mesh = mi.mesh
|
||||
if mesh:
|
||||
for surf_idx in range(mesh.get_surface_count()):
|
||||
var existing: Material = mi.get_active_material(surf_idx)
|
||||
if existing is StandardMaterial3D:
|
||||
var fixed_mat: StandardMaterial3D = existing.duplicate() as StandardMaterial3D
|
||||
fixed_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mi.set_surface_override_material(surf_idx, fixed_mat)
|
||||
for child in node.get_children():
|
||||
_apply_textured_fixed(child)
|
||||
|
||||
func _apply_flat_color(node: Node, color: Color) -> void:
|
||||
## Flat pipeline: rare cases — no texture, just a solid unshaded color.
|
||||
if node is MeshInstance3D:
|
||||
var mi := node as MeshInstance3D
|
||||
var mesh: Mesh = mi.mesh
|
||||
if mesh:
|
||||
for surf_idx in range(mesh.get_surface_count()):
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.albedo_color = color
|
||||
mi.set_surface_override_material(surf_idx, mat)
|
||||
for child in node.get_children():
|
||||
_apply_flat_color(child, color)
|
||||
|
||||
func _set_flat_color(mi: MeshInstance3D, color: Color) -> void:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.albedo_color = color
|
||||
mi.material_override = mat
|
||||
|
||||
func _randomize_colors() -> void:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.randomize()
|
||||
for character in characters:
|
||||
for part in character.get_children():
|
||||
if part is MeshInstance3D and part.material_override is StandardMaterial3D:
|
||||
part.material_override.albedo_color = Color(
|
||||
rng.randf_range(0.1, 0.9),
|
||||
rng.randf_range(0.1, 0.9),
|
||||
rng.randf_range(0.1, 0.9)
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
shader_type spatial;
|
||||
render_mode unshaded, cull_front;
|
||||
|
||||
uniform vec4 outline_color : source_color = vec4(0.1, 0.1, 0.15, 1.0);
|
||||
uniform float outline_width : hint_range(0.0, 0.1) = 0.03;
|
||||
|
||||
void vertex() {
|
||||
// Inverted hull outline: push vertices along normals, render back faces only
|
||||
VERTEX += NORMAL * outline_width;
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
ALBEDO = outline_color.rgb;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
shader_type spatial;
|
||||
render_mode unshaded;
|
||||
|
||||
uniform vec4 base_color : source_color = vec4(0.8, 0.6, 0.4, 1.0);
|
||||
uniform vec4 shadow_color : source_color = vec4(0.4, 0.3, 0.2, 1.0);
|
||||
uniform float shadow_threshold : hint_range(0.0, 1.0) = 0.5;
|
||||
|
||||
void fragment() {
|
||||
// Simple toon shading: use normal dot light to pick between base and shadow
|
||||
vec3 light_dir = normalize(vec3(0.3, -1.0, 0.5));
|
||||
float ndl = dot(NORMAL, -light_dir);
|
||||
float toon = step(shadow_threshold, ndl);
|
||||
vec3 color = mix(shadow_color.rgb, base_color.rgb, toon);
|
||||
ALBEDO = color;
|
||||
ALPHA = base_color.a;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
shader_type spatial;
|
||||
render_mode unshaded;
|
||||
|
||||
// Original Trellis atlas texture
|
||||
uniform sampler2D albedo_tex : source_color;
|
||||
// Recolor mask: white = replaceable, black = keep original
|
||||
uniform sampler2D recolor_mask : hint_default_black;
|
||||
// Runtime tint color set by game code
|
||||
uniform vec4 tint_color : source_color = vec4(0.8, 0.8, 0.8, 1.0);
|
||||
// Subtle darkening on shadow side (0.0 = no shadow, 1.0 = full black)
|
||||
uniform float shadow_strength : hint_range(0.0, 1.0) = 0.15;
|
||||
uniform float shadow_threshold : hint_range(0.0, 1.0) = 0.3;
|
||||
|
||||
void fragment() {
|
||||
vec4 original = texture(albedo_tex, UV);
|
||||
float mask = texture(recolor_mask, UV).r;
|
||||
|
||||
// Recolor: blend tint with original, preserving luminance in masked regions
|
||||
// This keeps texture detail (shadows, edges, highlights) while changing the hue
|
||||
float luma = dot(original.rgb, vec3(0.299, 0.587, 0.114));
|
||||
vec3 tinted = tint_color.rgb * (luma * 1.5 + 0.2); // scale luma to avoid too-dark
|
||||
vec3 base = mix(original.rgb, tinted, mask);
|
||||
|
||||
// Gentle toon shadow — just a subtle darkening, not a color replacement
|
||||
vec3 light_dir = normalize(vec3(0.3, -1.0, 0.5));
|
||||
float ndl = dot(NORMAL, -light_dir);
|
||||
float toon = smoothstep(shadow_threshold - 0.1, shadow_threshold + 0.1, ndl);
|
||||
vec3 color = base * mix(1.0 - shadow_strength, 1.0, toon);
|
||||
|
||||
ALBEDO = color;
|
||||
}
|
||||
Reference in New Issue
Block a user