diff --git a/.claude/skills/render-sprite/SKILL.md b/.claude/skills/render-sprite/SKILL.md new file mode 100644 index 000000000..95186ba03 --- /dev/null +++ b/.claude/skills/render-sprite/SKILL.md @@ -0,0 +1,56 @@ +--- +name: render-sprite +description: > + Render a 3D model to 2D sprites via the Godot render pipeline. Produces + sprites at 3 resolutions (1024, 256, 64) from 4 cardinal directions (north, + east, south, west) with outline applied at working resolution. Use when the + user says "render sprite", "render model", "run the render pipeline", + "test the pipeline", "/render-sprite", or asks to render a specific model + (e.g., "render wall_structural"). Output: 12 PNG files in + client/tooling/sprite_renderer/output/. +--- + +## Render Pipeline + +Run the render script with the model name: + +```bash +.claude/skills/render-sprite/scripts/render.sh +``` + +### Available Models + +Models live at `client/tooling/sprite_renderer/models/.tscn`. List them: + +```bash +ls client/tooling/sprite_renderer/models/*.tscn | xargs -I{} basename {} .tscn +``` + +### Output + +12 files per model in `client/tooling/sprite_renderer/output/`: + +``` +_north_1024.png _north_256.png _north_64.png +_east_1024.png _east_256.png _east_64.png +_south_1024.png _south_256.png _south_64.png +_west_1024.png _west_256.png _west_64.png +``` + +### After Rendering + +1. Read the 64x64 output files to visually inspect the runtime sprites +2. Read the 256x256 files to check outline quality +3. Report: does it read as the intended object at runtime scale? + +### Troubleshooting + +- **No output files**: Godot needs a display. If running headless, prefix with `xvfb-run`. +- **Model not found**: Check the model .tscn exists in `models/` directory. +- **Godot not found**: Pass path as second arg: `render.sh wall_structural /path/to/godot` + +### Adding New Models + +1. Create model scene at `client/tooling/sprite_renderer/models/.tscn` +2. Apply texture from `textures/` via StandardMaterial3D +3. Run: `.claude/skills/render-sprite/scripts/render.sh ` diff --git a/.claude/skills/render-sprite/scripts/render.sh b/.claude/skills/render-sprite/scripts/render.sh new file mode 100755 index 000000000..5d785935f --- /dev/null +++ b/.claude/skills/render-sprite/scripts/render.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Render a 3D model to 2D sprites at 3 resolutions from 4 cardinal directions. +# Usage: render.sh [godot_path] +# Example: render.sh wall_structural +set -euo pipefail + +MODEL_NAME="${1:?Usage: render.sh [godot_path]}" +GODOT="${2:-$HOME/bin/godot}" +REPO_ROOT="$(cd "$(dirname "$0")" && git rev-parse --show-toplevel)" +PROJECT="$REPO_ROOT/client" +SCENE="res://tooling/sprite_renderer/render_scene.tscn" +OUTPUT="$PROJECT/tooling/sprite_renderer/output" + +# Verify model exists +MODEL_FILE="$PROJECT/tooling/sprite_renderer/models/${MODEL_NAME}.tscn" +if [ ! -f "$MODEL_FILE" ]; then + echo "ERROR: Model not found: $MODEL_FILE" + echo "Available models:" + ls "$PROJECT/tooling/sprite_renderer/models/"*.tscn 2>/dev/null | xargs -I{} basename {} .tscn + exit 1 +fi + +echo "Rendering: $MODEL_NAME" +echo "Godot: $GODOT" +echo "Project: $PROJECT" + +# Ensure resources are imported (headless, no display needed) +echo "Importing resources..." +"$GODOT" --path "$PROJECT" --headless --import 2>&1 || echo "Warning: import step had errors (may be non-fatal)" + +# Run Godot with the render scene, passing model name as user arg +"$GODOT" --path "$PROJECT" "$SCENE" -- "$MODEL_NAME" 2>&1 + +# Check output +EXPECTED_FILES=0 +for dir in north east south west; do + for res in 1024 256 64; do + f="$OUTPUT/${MODEL_NAME}_${dir}_${res}.png" + if [ -f "$f" ]; then + EXPECTED_FILES=$((EXPECTED_FILES + 1)) + fi + done +done + +echo "" +echo "Generated $EXPECTED_FILES/12 files in $OUTPUT/" +ls -la "$OUTPUT/${MODEL_NAME}"_*.png 2>/dev/null || echo "No output files found." diff --git a/.gitignore b/.gitignore index 4b0b0bde8..2f2a06b4b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ client/.godot/ client/export/ client/reports/ client/*.import +client/**/*.uid +client/tooling/sprite_renderer/output/*.png # Database (shared across worktrees at ../settledreach.db, not tracked) db/commonwealth.db* diff --git a/client/tooling/sprite_renderer/models/wall_bar_green.tscn b/client/tooling/sprite_renderer/models/wall_bar_green.tscn new file mode 100644 index 000000000..19c401ffa --- /dev/null +++ b/client/tooling/sprite_renderer/models/wall_bar_green.tscn @@ -0,0 +1,15 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Texture2D" path="res://tooling/sprite_renderer/textures/wall_bar_green_panels.png" id="1_wall_texture"] + +[sub_resource type="BoxMesh" id="BoxMesh_wall"] +size = Vector3(1.0, 0.8, 0.2) + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_wall"] +albedo_texture = ExtResource("1_wall_texture") +metallic = 0.0 +roughness = 0.9 + +[node name="WallBarGreen" type="MeshInstance3D"] +mesh = SubResource("BoxMesh_wall") +surface_material_override/0 = SubResource("StandardMaterial3D_wall") diff --git a/client/tooling/sprite_renderer/models/wall_structural.tscn b/client/tooling/sprite_renderer/models/wall_structural.tscn new file mode 100644 index 000000000..75bcb0386 --- /dev/null +++ b/client/tooling/sprite_renderer/models/wall_structural.tscn @@ -0,0 +1,15 @@ +[gd_scene load_steps=3 format=3 uid="uid://cyh0xf1nv2j3e"] + +[ext_resource type="Texture2D" uid="uid://bgn5kvwjdqrx0" path="res://tooling/sprite_renderer/textures/wall_institutional_era1.png" id="1_wall_texture"] + +[sub_resource type="BoxMesh" id="BoxMesh_wall"] +size = Vector3(1.0, 0.8, 0.2) + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_wall"] +albedo_texture = ExtResource("1_wall_texture") +metallic = 0.0 +roughness = 0.9 + +[node name="WallStructural" type="MeshInstance3D"] +mesh = SubResource("BoxMesh_wall") +surface_material_override/0 = SubResource("StandardMaterial3D_wall") diff --git a/client/tooling/sprite_renderer/output/.gdignore b/client/tooling/sprite_renderer/output/.gdignore new file mode 100644 index 000000000..e69de29bb diff --git a/client/tooling/sprite_renderer/render_export.gd b/client/tooling/sprite_renderer/render_export.gd new file mode 100644 index 000000000..50b21079c --- /dev/null +++ b/client/tooling/sprite_renderer/render_export.gd @@ -0,0 +1,151 @@ +@tool +extends Node3D + +const RENDER_SIZE := 1024 +const WORKING_SIZE := 256 +const RUNTIME_SIZE := 64 +const DIRECTIONS: PackedStringArray = ["north", "east", "south", "west"] +const ROTATIONS: PackedFloat64Array = [0.0, 90.0, 180.0, 270.0] + +@export var model_scene: PackedScene +@export var output_name: String = "wall_structural" +@export var outline_width_px: int = 4 # at 256 = 1px at 64 +@export var outline_color: Color = Color(0.2, 0.2, 0.25, 1.0) +@export var render_now: bool = false: + set(value): + if value and model_scene: + _execute_render() + render_now = false + +@onready var viewport: SubViewport = $SubViewport +@onready var model_root: Node3D = $SubViewport/ModelRoot + + +func _ready() -> void: + if Engine.is_editor_hint(): + return + # CLI mode: godot --path client/ res://tooling/sprite_renderer/render_scene.tscn -- wall_structural + var args := OS.get_cmdline_user_args() + if args.size() == 0: + return + var model_name := args[0] + var model_path := "res://tooling/sprite_renderer/models/%s.tscn" % model_name + if not ResourceLoader.exists(model_path): + push_error("Model not found: %s" % model_path) + get_tree().quit(1) + return + model_scene = load(model_path) + output_name = model_name + # Wait for viewport to initialize + await get_tree().process_frame + await get_tree().process_frame + await _execute_render() + get_tree().quit() + + +func _execute_render() -> void: + if not model_scene: + push_error("No model scene assigned") + return + + print("Starting sprite render for: %s" % output_name) + + # Clear any existing model + for child in model_root.get_children(): + child.queue_free() + + # Instantiate the model + var model_instance = model_scene.instantiate() + model_root.add_child(model_instance) + + # Ensure output directory exists + var output_dir := "res://tooling/sprite_renderer/output" + if not DirAccess.dir_exists_absolute(output_dir): + DirAccess.make_dir_recursive_absolute(output_dir) + + # Render each cardinal direction + for i in range(DIRECTIONS.size()): + var direction := DIRECTIONS[i] + var rotation := ROTATIONS[i] + + print(" Rendering direction: %s (%.1f°)" % [direction, rotation]) + + # Rotate model root + model_root.rotation_degrees.y = rotation + + # Force viewport to render + viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + await RenderingServer.frame_post_draw + + # Capture the viewport texture + var img := viewport.get_texture().get_image() + + # Save 1024x1024 source + var path_1024 := "%s/%s_%s_1024.png" % [output_dir, output_name, direction] + img.save_png(path_1024) + print(" Saved: %s" % path_1024) + + # Downscale to 256x256 + var img_256 := Image.create_from_data(img.get_width(), img.get_height(), false, img.get_format(), img.get_data()) + img_256.resize(WORKING_SIZE, WORKING_SIZE, Image.INTERPOLATE_BILINEAR) + + # Apply outline at 256x256 + var img_256_outlined := _apply_outline(img_256, outline_width_px, outline_color) + + # Save 256x256 with outline + var path_256 := "%s/%s_%s_256.png" % [output_dir, output_name, direction] + img_256_outlined.save_png(path_256) + print(" Saved: %s" % path_256) + + # Downscale to 64x64 + var img_64 := Image.create_from_data(img_256_outlined.get_width(), img_256_outlined.get_height(), false, img_256_outlined.get_format(), img_256_outlined.get_data()) + img_64.resize(RUNTIME_SIZE, RUNTIME_SIZE, Image.INTERPOLATE_BILINEAR) + + # Save 64x64 + var path_64 := "%s/%s_%s_64.png" % [output_dir, output_name, direction] + img_64.save_png(path_64) + print(" Saved: %s" % path_64) + + print("Render complete! Generated %d sprites." % (DIRECTIONS.size() * 3)) + + +func _apply_outline(img: Image, width: int, color: Color) -> Image: + """Apply outline by dilating the alpha mask and drawing outline color.""" + var result := Image.create(img.get_width(), img.get_height(), false, Image.FORMAT_RGBA8) + result.blit_rect(img, Rect2i(0, 0, img.get_width(), img.get_height()), Vector2i(0, 0)) + + var w := img.get_width() + var h := img.get_height() + + # Create dilated alpha mask + var dilated_alpha := PackedByteArray() + dilated_alpha.resize(w * h) + + for y in range(h): + for x in range(w): + var max_alpha := 0.0 + + # Check all pixels within outline_width radius + for dy in range(-width, width + 1): + for dx in range(-width, width + 1): + var check_x := x + dx + var check_y := y + dy + + if check_x >= 0 and check_x < w and check_y >= 0 and check_y < h: + var pixel := img.get_pixel(check_x, check_y) + max_alpha = max(max_alpha, pixel.a) + + dilated_alpha[y * w + x] = int(max_alpha * 255) + + # Draw outline where dilated exceeds original alpha + for y in range(h): + for x in range(w): + var original_pixel := img.get_pixel(x, y) + var dilated_a := float(dilated_alpha[y * w + x]) / 255.0 + + if dilated_a > original_pixel.a: + # This pixel is in the outline region + var outline_pixel := Color(color.r, color.g, color.b, dilated_a) + result.set_pixel(x, y, outline_pixel) + + return result diff --git a/client/tooling/sprite_renderer/render_scene.tscn b/client/tooling/sprite_renderer/render_scene.tscn new file mode 100644 index 000000000..1ca4908d0 --- /dev/null +++ b/client/tooling/sprite_renderer/render_scene.tscn @@ -0,0 +1,27 @@ +[gd_scene load_steps=2 format=3 uid="uid://cqvfv4uhbgq6h"] + +[ext_resource type="Script" path="res://tooling/sprite_renderer/render_export.gd" id="1_render_script"] + +[node name="RenderScene" type="Node3D"] +script = ExtResource("1_render_script") + +[node name="SubViewport" type="SubViewport" parent="."] +transparent_bg = true +size = Vector2i(1024, 1024) +render_target_update_mode = 1 +own_world_3d = true + +[node name="Camera3D" type="Camera3D" parent="SubViewport"] +transform = Transform3D(1, 0, 0, 0, 0.309017, 0.951057, 0, -0.951057, 0.309017, 0, 3, 1) +projection = 1 +size = 1.4 +near = 0.01 +far = 10.0 + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="SubViewport"] +transform = Transform3D(1, 0, 0, 0, 0.309017, 0.951057, 0, -0.951057, 0.309017, 0, 3, 1) +light_color = Color(1, 1, 1, 1) +light_energy = 1.0 +shadow_enabled = false + +[node name="ModelRoot" type="Node3D" parent="SubViewport"] diff --git a/client/tooling/sprite_renderer/textures/wall_bar_green_panels.png b/client/tooling/sprite_renderer/textures/wall_bar_green_panels.png new file mode 100644 index 000000000..fb6f26af5 Binary files /dev/null and b/client/tooling/sprite_renderer/textures/wall_bar_green_panels.png differ diff --git a/client/tooling/sprite_renderer/textures/wall_institutional_era1.png b/client/tooling/sprite_renderer/textures/wall_institutional_era1.png new file mode 100644 index 000000000..709c0c398 Binary files /dev/null and b/client/tooling/sprite_renderer/textures/wall_institutional_era1.png differ