feat(assets): add 3D-to-2D sprite render pipeline and /render-sprite skill
Godot @tool render scene that takes a textured 3D model and produces 2D sprites at 1024/256/64 from 4 cardinal directions with outlines applied at working resolution. Camera at "the angle" (-72.5deg ortho). Includes two POC wall models with Nano Banana textures, CLI wrapper script with headless import step, and .uid files added to gitignore. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <model_name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Models
|
||||||
|
|
||||||
|
Models live at `client/tooling/sprite_renderer/models/<name>.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/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
<model>_north_1024.png <model>_north_256.png <model>_north_64.png
|
||||||
|
<model>_east_1024.png <model>_east_256.png <model>_east_64.png
|
||||||
|
<model>_south_1024.png <model>_south_256.png <model>_south_64.png
|
||||||
|
<model>_west_1024.png <model>_west_256.png <model>_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/<name>.tscn`
|
||||||
|
2. Apply texture from `textures/` via StandardMaterial3D
|
||||||
|
3. Run: `.claude/skills/render-sprite/scripts/render.sh <name>`
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Render a 3D model to 2D sprites at 3 resolutions from 4 cardinal directions.
|
||||||
|
# Usage: render.sh <model_name> [godot_path]
|
||||||
|
# Example: render.sh wall_structural
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MODEL_NAME="${1:?Usage: render.sh <model_name> [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."
|
||||||
@@ -7,6 +7,8 @@ client/.godot/
|
|||||||
client/export/
|
client/export/
|
||||||
client/reports/
|
client/reports/
|
||||||
client/*.import
|
client/*.import
|
||||||
|
client/**/*.uid
|
||||||
|
client/tooling/sprite_renderer/output/*.png
|
||||||
|
|
||||||
# Database (shared across worktrees at ../settledreach.db, not tracked)
|
# Database (shared across worktrees at ../settledreach.db, not tracked)
|
||||||
db/commonwealth.db*
|
db/commonwealth.db*
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 991 KiB |
Reference in New Issue
Block a user