Godot Visual Validation
Validate lesson shaders by applying them to PBR-textured meshes in test-scene/ and capturing A/B screenshot proof.
Test Scene
- Project:
D:\code\teach-me\test-scene - PBR assets:
test-scene/assets/polyhaven/(Barrel_01, Camera_01, Lantern_01 — 1K textures) - Shaders:
test-scene/shaders/(copied fromlibrary/godot-gamedev/reference/code/) - Screenshot output:
test-scene/.scratch/screenshots/
glTF Import Fix
MCP reimport/scan do NOT trigger Godot's scene importer for glTF files. If .gltf.import shows valid=false:
- Delete the
.gltf.importfiles - Run
godot --headless --editor --import --quit --path test-scene - Restart the editor (quit + relaunch)
- Then
node_createwithscene_pathwill work
Capturing Screenshots to Disk
editor_screenshot returns inline base64 only — cannot save to files. To get files:
- Run the project (
project_run) - Use
game_evalto position camera, toggle shader, and save:
var img = get_viewport().get_texture().get_image()
img.save_png('user://screenshots/filename.png')
Files land at C:/Users/uosmi/AppData/Roaming/Godot/app_userdata/teach-me-shader-test/screenshots/. Copy to test-scene/.scratch/screenshots/ after capture.
Framing Objects (Fill the Frame)
Calculate camera distance from object AABB so it occupies ~80% of viewport:
var half_fov_tan = tan(cam.fov * 0.5 * PI / 180.0)
var dist = largest_aabb_dimension / half_fov_tan
Position camera at target + offset where offset uses 30° elevation/azimuth:
var elev = 30.0 * PI / 180.0
var azim = 30.0 * PI / 180.0
var offset = Vector3(dist * cos(elev) * sin(azim), dist * sin(elev), dist * cos(elev) * cos(azim))
cam.global_position = target + offset
cam.look_at(target)
MCP Reliability (hard rules — validated 2026-08-26)
The godot-ai MCP has three failure modes that cause silent wrong results:
game_evalmutations are for READING, not persisting. Settingrotation_degreesorset_shader_parameteron a running instance is unreliable — changes may not affect the render or reset on the nextproject_run. Symptom: two different "edits" produce pixel-identical captures. Fix: edit the.tscnon disk (persistent, git-tracked), thenproject_runpicks up saved state. Usegame_evalonly to capture the viewport and sample pixels.NEVER call MCP
save_sceneon a hand-authored.tscn. It strips inline SubResources (ShaderMaterial), ext_resources, andmaterial_override— reverting meshes to their default glTF material. Recover withgit restore. Edit scene params via diskstrReplaceinstead.The agent's visual self-report is UNRELIABLE. It has claimed "crisp cel bands clearly visible" on an image that was actually flat uniform color. NEVER trust the capturing agent's description. Validate every capture with an independent read (fresh
kiro-cli chat --no-interactiveimage analysis, or read the image yourself) AND sample pixels viagame_evalfor objective confirmation.
Reliable capture loop: edit .tscn on disk → project_run → game_eval (read-only viewport capture + pixel sample) → independent image validation → never save_scene.
Diagnose the ASSET before re-capturing when the output looks wrong. After ONE capture whose render looks wrong (dark, muddy, washed out, flat), do NOT re-run the bake/capture with tweaked params hoping for different pixels — that's the failure loop. First MEASURE THE INPUT: the source/baked texture's mean RGB, dominant hue, and luminance histogram (e.g. Pillow, or
tools/albedo-sanity-oracle.py). A wrong-looking render is usually an upstream asset defect, not a capture flake. (Validated #222: two wasted re-bake iterations before measuring showed the "toon-prepped" albedo had drifted red→violet and crushed 94% of pixels dark — a palette-hue defect the eye caught late and the oracle now catches automatically.)Verify file existence with a direct filesystem check, not
res://resolution. A capture agent (#220, 2026-08-28) reported source PNGs "missing on disk" because ares://path failed to resolve — the files were actually present (Godot was loading them fine from the.ctexcache). Ares://lookup failure ≠ file absent. Before reporting a file missing, stat the real OS path. The parent must not act on a subagent's "missing file" claim without a direct check.--headlessCANNOT render 3D to PNG. Under--headlessthe DisplayServer is a dummy driver with no framebuffer —get_viewport().get_texture().get_image()returns blank/black (Blender/Godot parallel; validated #221, 2026-08-28). Headless is only for import/compile validation. A visual A/B capture needs a REAL windowedproject_run(GPU). Reserve--headless --editor --import --quitfor "does it load without errors" (Tier-3a); use the windowedgodot_editorMCP path for pixels (Tier-3b).Opening a project in a newer Godot rewrites
project.godot. Agodot_editor/GUI session (or the specialist) openingtest-sceneon a different Godot build silently downgradesconfig/features(observed 4.7→4.5 twice, 2026-09-07) and drops the[rendering]forward_plusline. After ANY editor session,git diff test-scene/project.godotandgit checkoutit if only the version/rendering lines changed — don't commit that drift. (Editor-open is not a clean read.)
Headless GDScript validation (hard rules — validated 2026-08-28, #249/#236)
For running/validating GDScript headlessly (e.g. mise run ink:validate-gd):
godot --headless --editor --import --quitreturns exit 0 even on GDScript parse errors. Do NOT trust its exit code. A broken script that's onlyload()ed by a scene (not an autoload/class_name/@tool) doesn't even error at import — it errors at scene-instantiation during the run. Validate by running the scene and matchingline.startswith(("SCRIPT ERROR", "ERROR: Failed to load script"))on stderr → treat as a setup failure. Anchor on the line prefix, not a free"Parse Error"substring — interpolated content (story text, labels) can contain those words and false-trip a substring match.Cold
.godot/cache emits benign parse-error noise on first import (SCRIPT ERROR: Parse Error: Could not preload ... icon.svgfrom icon-bearing plugins like inkgd). Double-import (run--importtwice) to warm the cache so the guarded run is clean.When a harness runs COPIES of shipped files, edit the SHIPPED reference, not the copy.
tools/ink-gd-sync.pyregeneratesink-test-project/scenes/lesson0*_player.gdfromlibrary/ink-godot/reference/code/*/story_player.gdon each run — hand-edits to the copies are overwritten.
Shader Toggle for A/B
For a full before/after capture via subagent, copy references/ab-capture-template.md into
.scratch/subagent-input/and dispatchgodot_editor(validated first-try on #220 + #221).
For post-process shaders, toggle PostProcessRect.visible in game_eval (this DOES work — it's a node visibility flag, not a persisted param):
var pr = get_tree().root.get_node('ColorTestScene/PostProcess/PostProcessRect')
pr.visible = false # shader OFF
await get_tree().process_frame
await get_tree().process_frame
# capture here
pr.visible = true # shader ON
For material_override shader PARAMETERS (per-object toon settings), edit the .tscn on disk between captures — runtime set_shader_parameter is unreliable (see MCP Reliability #1).
GDScript Gotchas in game_eval
deg_to_radians()does NOT exist. Useangle * PI / 180.0.- Always
await get_tree().process_frame(×2) after visual changes before capturing. - Return values must be simple types (String, int, float) — not objects.
Asset Rules
- Never use pixel-art (Kenney, KayKit) for color simplification testing — already flat-color, effect is invisible.
- Always use 1K+ PBR textures (Poly Haven CC0) for Kuwahara, posterize, palette snap validation.
mk_toon_lite: gooch IS the band-color ramp (validated 2026-08-27)
In mk_toon_lite.gdshader, discrete light bands only render as distinct colors when gooch_ramp_intensity > 0. The band math computes a lit_factor, but the final color is mix(shadow_color, lit_color, lit_factor) — and with gooch_ramp_intensity = 0, shadow_color == lit_color == ALBEDO, so every band collapses to identical albedo (flat, no visible banding). Turning gooch OFF to "isolate pure banding" produces a flat single tone — the opposite of intent. To show crisp bands: keep gooch_ramp_intensity ≈ 0.5, set light_bands = 3, light_bands_scale ≈ 0.9, and a moderate wrapped_lighting ≈ 0.3.
Crisp-band capture recipe (barrel/cylinder, confirmed via pixel-row + image read):
- ¾ side-raking directional light so the terminator crosses the visible front face (
N·Lsweeps 1→0 left-to-right). Front lighting → one flat band. Compute the light basis from a target travel direction via look_at math; a directional light emits along-basis.z. - Keep the cylinder — a sphere's omnidirectional normals hide front-face washout (the real failure mode). The cylinder is the honest test mesh.
- Result: 3 clean vertical band stripes (e.g.
(255,181,71)→(235,141,56)→(116,76,36)).