diff --git a/spikes/3dpipeline/.gitignore b/spikes/3dpipeline/.gitignore new file mode 100644 index 000000000..1462cc00e --- /dev/null +++ b/spikes/3dpipeline/.gitignore @@ -0,0 +1,7 @@ +# Godot generated +.godot/ +*.import +*.uid + +# Extracted textures (regenerated by Godot import) +*_Image_*.png diff --git a/spikes/3dpipeline/README.md b/spikes/3dpipeline/README.md new file mode 100644 index 000000000..0bd7eb4ec --- /dev/null +++ b/spikes/3dpipeline/README.md @@ -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/` diff --git a/spikes/3dpipeline/models/furniture/baroque_table.glb b/spikes/3dpipeline/models/furniture/baroque_table.glb new file mode 100644 index 000000000..eb93d966e Binary files /dev/null and b/spikes/3dpipeline/models/furniture/baroque_table.glb differ diff --git a/spikes/3dpipeline/models/furniture/baroque_table_mask.png b/spikes/3dpipeline/models/furniture/baroque_table_mask.png new file mode 100644 index 000000000..953a16546 Binary files /dev/null and b/spikes/3dpipeline/models/furniture/baroque_table_mask.png differ diff --git a/spikes/3dpipeline/models/furniture/modernist_chair.glb b/spikes/3dpipeline/models/furniture/modernist_chair.glb new file mode 100644 index 000000000..13a93f5b8 Binary files /dev/null and b/spikes/3dpipeline/models/furniture/modernist_chair.glb differ diff --git a/spikes/3dpipeline/models/furniture/modernist_chair_mask.png b/spikes/3dpipeline/models/furniture/modernist_chair_mask.png new file mode 100644 index 000000000..e83c64d66 Binary files /dev/null and b/spikes/3dpipeline/models/furniture/modernist_chair_mask.png differ diff --git a/spikes/3dpipeline/models/furniture/scifi_desk.glb b/spikes/3dpipeline/models/furniture/scifi_desk.glb new file mode 100644 index 000000000..b43f45724 Binary files /dev/null and b/spikes/3dpipeline/models/furniture/scifi_desk.glb differ diff --git a/spikes/3dpipeline/models/furniture/scifi_desk_mask.png b/spikes/3dpipeline/models/furniture/scifi_desk_mask.png new file mode 100644 index 000000000..02cc956f5 Binary files /dev/null and b/spikes/3dpipeline/models/furniture/scifi_desk_mask.png differ diff --git a/spikes/3dpipeline/models/props/lion_statue.glb b/spikes/3dpipeline/models/props/lion_statue.glb new file mode 100644 index 000000000..ce4530802 Binary files /dev/null and b/spikes/3dpipeline/models/props/lion_statue.glb differ diff --git a/spikes/3dpipeline/models/props/lion_statue_mask.png b/spikes/3dpipeline/models/props/lion_statue_mask.png new file mode 100644 index 000000000..7161d2e78 Binary files /dev/null and b/spikes/3dpipeline/models/props/lion_statue_mask.png differ diff --git a/spikes/3dpipeline/models/props/vw_beetle.glb b/spikes/3dpipeline/models/props/vw_beetle.glb new file mode 100644 index 000000000..018ec1dfd Binary files /dev/null and b/spikes/3dpipeline/models/props/vw_beetle.glb differ diff --git a/spikes/3dpipeline/models/props/vw_beetle_mask.png b/spikes/3dpipeline/models/props/vw_beetle_mask.png new file mode 100644 index 000000000..e9a4ec5eb Binary files /dev/null and b/spikes/3dpipeline/models/props/vw_beetle_mask.png differ diff --git a/spikes/3dpipeline/project.godot b/spikes/3dpipeline/project.godot new file mode 100644 index 000000000..6fc56e28f --- /dev/null +++ b/spikes/3dpipeline/project.godot @@ -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 diff --git a/spikes/3dpipeline/scenes/spike_3d.tscn b/spikes/3dpipeline/scenes/spike_3d.tscn new file mode 100644 index 000000000..be6156dea --- /dev/null +++ b/spikes/3dpipeline/scenes/spike_3d.tscn @@ -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 diff --git a/spikes/3dpipeline/scripts/spike/spike_main.gd b/spikes/3dpipeline/scripts/spike/spike_main.gd new file mode 100644 index 000000000..a304c3bd0 --- /dev/null +++ b/spikes/3dpipeline/scripts/spike/spike_main.gd @@ -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) + ) diff --git a/spikes/3dpipeline/shaders/spike/outline.gdshader b/spikes/3dpipeline/shaders/spike/outline.gdshader new file mode 100644 index 000000000..645b0b7e8 --- /dev/null +++ b/spikes/3dpipeline/shaders/spike/outline.gdshader @@ -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; +} diff --git a/spikes/3dpipeline/shaders/spike/toon.gdshader b/spikes/3dpipeline/shaders/spike/toon.gdshader new file mode 100644 index 000000000..874a0cc0c --- /dev/null +++ b/spikes/3dpipeline/shaders/spike/toon.gdshader @@ -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; +} diff --git a/spikes/3dpipeline/shaders/spike/toon_masked.gdshader b/spikes/3dpipeline/shaders/spike/toon_masked.gdshader new file mode 100644 index 000000000..5dfa82100 --- /dev/null +++ b/spikes/3dpipeline/shaders/spike/toon_masked.gdshader @@ -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; +}