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) )