forge-render — Headless Render + QA Eyes
The render-in-the-loop layer. Every Forge build cycle ends here: emit a render, read the
PNG, critique vs brief, fix or advance. This skill drives that loop from code with no GUI.
Project memory: if FORGE.md exists at the project root, read it first — it
carries the render engine, sample counts, output paths, and coordinate system for this
project. Honor those settings; do not override them without a reason.
Suite map
forge (router) → dispatches here via forge-lookdev agent. This skill is called by:
forge-material — after shader authoring, for material QA preview
forge-texture — baked maps verified by rendering with the texture applied
forge-light — lighting rig validation (beauty render comparison)
forge-uv — UV checker pass is the primary unwrap QA output
forge-validate — invokes this skill's contact-sheet mode as the visual gate
When the model hands off to another skill, Run = call the Skill tool with its exact
name. Writing "now run forge-validate" in prose runs nothing.
Atelier connection: rendered hero stills feed atelier-webgl as poster fallbacks
(public/forge/<slug>-hero-poster.webp). The web-runtime/a11y gate is
atelier-perf-a11y's job — invoked downstream via Skill("atelier-perf-a11y") after
forge-optimize packages the GLB + poster (forge-render does not own that call). For
art-direction moodboards from rendered output, see atelier-direction.
Decide first: engine + availability
Before any render attempt:
- Check FORGE.md for
## Render section (engine, samples, output path).
- Verify Blender exists — run
scripts/preflight.py --json to confirm blender is found
and capture blender_path. (forge-render ships its own scripts/preflight.py — a focused
Blender + Pillow check that returns blender_path; the router's forge/scripts/probe.py is
the broader project-state probe. This skill only needs preflight.)
- Pick the render mode:
| Goal |
Engine |
Rationale |
| Iterative QA (geometry, normals, wireframe) |
BLENDER_WORKBENCH |
Headless-safe on Windows, ~1 s/frame, no GPU needed |
| UV checker pass |
CYCLES CPU 16 samples |
Workbench ignores shader nodes; Cycles respects UV mapping |
| Normal pass (world-space) |
CYCLES CPU 1 sample + compositor |
Accurate per-pixel world normals |
| Beauty / material hero render |
CYCLES GPU (OptiX) or CPU fallback |
Physically correct; AgX color management |
| Full contact-sheet QA |
Workbench + selective Cycles |
Combine both above |
EEVEE Next is UNSUPPORTED headless on Windows. Never use BLENDER_EEVEE_NEXT or
BLENDER_EEVEE as the engine for any headless render. Use Cycles (CPU or GPU) for all
passes requiring node-material evaluation, and Workbench for fast geometry diagnostics.
GPU activation (Cycles): GPU devices are NOT auto-activated in headless mode. Always
call prefs.refresh_devices() (Blender 4.x) before setting cycles.device = 'GPU'.
See references/cycles-gpu-passes.md §GPU for the exact pattern.
The flow
Read FORGE.md (if present) — extract engine, samples, output path, slug.
Preflight — run scripts/preflight.py --json. Confirm Blender is found; determine
the Blender path for all subsequent subprocess calls. If missing, surface the install
URL and stop.
Write the render script — compose render.py or a targeted diagnostic script from
the patterns in references/cycles-gpu-passes.md and references/workbench-diagnostic.md.
Key non-negotiables every script must follow:
- UTF-8 stdout wrapper at the top
--factory-startup for determinism
--python-exit-code 1 so Python errors fail the Blender process
- Absolute forward-slash paths in
scene.render.filepath (never // relative)
-f 1 (or -a) as the last Blender argument
pathlib.Path(out_dir).mkdir(parents=True, exist_ok=True) before render
bpy.ops.render.render(write_still=True) — without write_still, no file is saved
Invoke Blender (PowerShell):
$BLENDER = $env:BLENDER_EXE # set by preflight or FORGE.md
& $BLENDER -b --factory-startup --python-exit-code 1 -P "scripts/render.py" -- <args>
Full invocation reference: references/cli-invocations.md.
Verify output — check the PNG exists and is >1 KB. A <1 KB PNG is almost certainly
an error image (Blender writes a minimal stub when it fails silently).
Read the PNG — call Read on the output path. Visually inspect:
- Turntable: silhouette consistency, no floating geometry, scale vs brief
- Wireframe: edge density, no poles >6, no N-gons on SDS surfaces
- Normals (RGB): smooth color gradients; abrupt reversals = flipped normals
- UV checker: uniform checker scale = even texel density; distorted squares = stretching
- Matcap: smooth shading flow; black patches = inverted normals
- Beauty: no fireflies, correct color transform (AgX), transparent background if RGBA
Assemble contact sheet (multi-pass QA) — if running a full QA suite, after all
individual renders complete, run scripts/contact_sheet.py to tile them (PowerShell
backtick continuation):
python "skills/forge-render/scripts/contact_sheet.py" `
--input-dir C:/forge/out/qa --output C:/forge/out/qa_sheet.png --cols 4
The cap is enforced: >36 tiles auto-splits into numbered sheets
(qa_sheet_01.png, qa_sheet_02.png, …) — Read each one. Override with
--max-per-sheet N. Then Read the contact sheet PNG(s).
Decide: iterate or advance — if defects found, amend the geometry/material script
(via the responsible skill) and loop back to step 3. If clean, advance to the next
pipeline stage. For hard validation, call Skill("forge-validate").
Render modes in detail
Quick geometry QA (Workbench, <5 s total)
Turntable + wireframe + matcap in one Blender invocation. The fastest read of asset health.
Full pattern: references/workbench-diagnostic.md §turntable.
Passes + AOVs (Cycles compositor)
Enable per-pass toggles on bpy.context.view_layer before the render call. Passes not
enabled at render time cannot be recovered. Route each pass to a separate File Output node.
Critical: keep scene.render.filepath on a different stem from compositor fo.base_path
or they overwrite each other.
Full pattern + gotcha table: references/cycles-gpu-passes.md §passes.
Normal pass (world-space → PNG)
Use Cycles CPU, 1 sample, compositor: Normal pass → multiply 0.5 → add 0.5 → File Output.
Do not use CompositorNodeNormalize — it acts on pixel min/max, not on the [-1,1] theoretical
range. Full remap pattern: references/cycles-gpu-passes.md §normals.
Contact-sheet assembly
scripts/contact_sheet.py tiles all PNGs in a directory into a single labelled sheet.
Degrades gracefully when Pillow is absent (emits a plain text manifest instead). Two
enforced ceilings keep the result Read-friendly: cell cap 512×512 px, and 36 tiles per
sheet (--max-per-sheet, default 36) — beyond that it auto-splits into numbered sheets.
Full usage + layout spec: references/contact-sheet.md.
Operating principles
- Verify, don't assume. Every render attempt must be followed by a file-existence check
and a
Read of the output PNG before reporting success. A zero-KB file is a silent failure.
- Workbench first, Cycles only when needed. Workbench is headless-safe on Windows and
~60× faster than Cycles for geometry QA. Use Cycles only for node-material passes (UV
checker, normals) and final beauty renders.
- Absolute forward-slash paths everywhere.
scene.render.filepath and fo.base_path
must use forward slashes (e.g. C:/forge/out/frame_####). Backslashes in Blender Python
strings are escape sequences; they silently produce wrong paths.
- Scripts, not inline code. All render logic lives in
scripts/; SKILL.md code blocks
are illustration only — the model pre-evaluates $() expressions it sees in Markdown,
so never rely on them for real execution.
- One earned read per loop. Each iteration renders, reads the PNG, critiques against
the brief, and makes a single clear fix decision. Do not accumulate multiple unread renders
before inspecting.
- Deterministic rebuilds. The Cycles seed is pinned to 0 by default (
--seed N to change)
and --clean (on by default) clears this skill's prior output stems first, so two builds of
the same scene produce byte-comparable renders and stale frames never leak into a contact
sheet. render.py --json carries a health field (per-file exists / size / blank-frame
check) — let the loop iterate-or-advance on that signal, not on prose.
1---2name: forge-render3description: Forge suite — headless rendering engine and QA eyes. Drives Blender (Cycles/Workbench) to produce render outputs without a GUI: beauty stills, render passes/AOVs (normals, depth, AO, wireframe, UV checker), turntable sequences, 6-view orthographic sheets, and assembled contact-sheet PNGs that the model reads back to verify geometry, shading, and UV quality. Use whenever rendering a 3D asset to PNG, generating a QA contact sheet, setting up Cycles passes, enabling AOVs, doing a turntable render, checking normals or wireframe headlessly, verifying materials or UV unwraps visually, or running any render → Read → critique → fix loop. Triggers: "render the model", "turntable", "contact sheet", "wireframe render", "normal pass", "AO pass", "render passes", "headless render", "Cycles render", "QA render", "render and check", "AOV", "render to PNG". HEADLESS-ONLY: driven from code, output verified by reading a PNG. Part of the Forge suite.4---56# forge-render — Headless Render + QA Eyes78The render-in-the-loop layer. Every Forge build cycle ends here: emit a render, read the9PNG, critique vs brief, fix or advance. This skill drives that loop from code with no GUI.1011> **Project memory:** if **`FORGE.md`** exists at the project root, read it first — it12> carries the render engine, sample counts, output paths, and coordinate system for this13> project. Honor those settings; do not override them without a reason.1415---1617> **Suite map**18>19> **forge** (router) → dispatches here via forge-lookdev agent. This skill is called by:20> - **`forge-material`** — after shader authoring, for material QA preview21> - **`forge-texture`** — baked maps verified by rendering with the texture applied22> - **`forge-light`** — lighting rig validation (beauty render comparison)23> - **`forge-uv`** — UV checker pass is the primary unwrap QA output24> - **`forge-validate`** — invokes this skill's contact-sheet mode as the visual gate25>26> When the model hands off to another skill, **Run = call the Skill tool with its exact27> name. Writing "now run forge-validate" in prose runs nothing.**28>29> Atelier connection: rendered hero stills feed **`atelier-webgl`** as poster fallbacks30> (`public/forge/<slug>-hero-poster.webp`). The web-runtime/a11y gate is31> **`atelier-perf-a11y`**'s job — invoked downstream via `Skill("atelier-perf-a11y")` after32> **`forge-optimize`** packages the GLB + poster (forge-render does not own that call). For33> art-direction moodboards from rendered output, see **`atelier-direction`**.3435---3637## Decide first: engine + availability3839Before any render attempt:40411. **Check FORGE.md** for `## Render` section (engine, samples, output path).422. **Verify Blender exists** — run `scripts/preflight.py --json` to confirm `blender` is found43 and capture `blender_path`. (forge-render ships its own `scripts/preflight.py` — a focused44 Blender + Pillow check that returns `blender_path`; the router's `forge/scripts/probe.py` is45 the broader project-state probe. This skill only needs preflight.)463. **Pick the render mode:**4748| Goal | Engine | Rationale |49|---|---|---|50| Iterative QA (geometry, normals, wireframe) | `BLENDER_WORKBENCH` | Headless-safe on Windows, ~1 s/frame, no GPU needed |51| UV checker pass | `CYCLES` CPU 16 samples | Workbench ignores shader nodes; Cycles respects UV mapping |52| Normal pass (world-space) | `CYCLES` CPU 1 sample + compositor | Accurate per-pixel world normals |53| Beauty / material hero render | `CYCLES` GPU (OptiX) or CPU fallback | Physically correct; AgX color management |54| Full contact-sheet QA | Workbench + selective Cycles | Combine both above |5556**EEVEE Next is UNSUPPORTED headless on Windows.** Never use `BLENDER_EEVEE_NEXT` or57`BLENDER_EEVEE` as the engine for any headless render. Use Cycles (CPU or GPU) for all58passes requiring node-material evaluation, and Workbench for fast geometry diagnostics.5960**GPU activation (Cycles):** GPU devices are NOT auto-activated in headless mode. Always61call `prefs.refresh_devices()` (Blender 4.x) before setting `cycles.device = 'GPU'`.62See `references/cycles-gpu-passes.md §GPU` for the exact pattern.6364---6566## The flow67681. **Read FORGE.md** (if present) — extract engine, samples, output path, slug.69702. **Preflight** — run `scripts/preflight.py --json`. Confirm Blender is found; determine71 the Blender path for all subsequent subprocess calls. If missing, surface the install72 URL and stop.73743. **Write the render script** — compose `render.py` or a targeted diagnostic script from75 the patterns in `references/cycles-gpu-passes.md` and `references/workbench-diagnostic.md`.76 Key non-negotiables every script must follow:77 - UTF-8 stdout wrapper at the top78 - `--factory-startup` for determinism79 - `--python-exit-code 1` so Python errors fail the Blender process80 - Absolute forward-slash paths in `scene.render.filepath` (never `//` relative)81 - `-f 1` (or `-a`) as the **last** Blender argument82 - `pathlib.Path(out_dir).mkdir(parents=True, exist_ok=True)` before render83 - `bpy.ops.render.render(write_still=True)` — without `write_still`, no file is saved84854. **Invoke Blender** (PowerShell):86 ```87 $BLENDER = $env:BLENDER_EXE # set by preflight or FORGE.md88 & $BLENDER -b --factory-startup --python-exit-code 1 -P "scripts/render.py" -- <args>89 ```90 Full invocation reference: `references/cli-invocations.md`.91925. **Verify output** — check the PNG exists and is >1 KB. A <1 KB PNG is almost certainly93 an error image (Blender writes a minimal stub when it fails silently).94956. **Read the PNG** — call `Read` on the output path. Visually inspect:96 - Turntable: silhouette consistency, no floating geometry, scale vs brief97 - Wireframe: edge density, no poles >6, no N-gons on SDS surfaces98 - Normals (RGB): smooth color gradients; abrupt reversals = flipped normals99 - UV checker: uniform checker scale = even texel density; distorted squares = stretching100 - Matcap: smooth shading flow; black patches = inverted normals101 - Beauty: no fireflies, correct color transform (AgX), transparent background if RGBA1021037. **Assemble contact sheet** (multi-pass QA) — if running a full QA suite, after all104 individual renders complete, run `scripts/contact_sheet.py` to tile them (PowerShell105 backtick continuation):106 ```powershell107 python "skills/forge-render/scripts/contact_sheet.py" `108 --input-dir C:/forge/out/qa --output C:/forge/out/qa_sheet.png --cols 4109 ```110 The cap is **enforced**: >36 tiles auto-splits into numbered sheets111 (`qa_sheet_01.png`, `qa_sheet_02.png`, …) — `Read` each one. Override with112 `--max-per-sheet N`. Then `Read` the contact sheet PNG(s).1131148. **Decide: iterate or advance** — if defects found, amend the geometry/material script115 (via the responsible skill) and loop back to step 3. If clean, advance to the next116 pipeline stage. For hard validation, call `Skill("forge-validate")`.117118---119120## Render modes in detail121122### Quick geometry QA (Workbench, <5 s total)123124Turntable + wireframe + matcap in one Blender invocation. The fastest read of asset health.125Full pattern: `references/workbench-diagnostic.md §turntable`.126127### Passes + AOVs (Cycles compositor)128129Enable per-pass toggles on `bpy.context.view_layer` **before** the render call. Passes not130enabled at render time cannot be recovered. Route each pass to a separate File Output node.131Critical: keep `scene.render.filepath` on a **different stem** from compositor `fo.base_path`132or they overwrite each other.133Full pattern + gotcha table: `references/cycles-gpu-passes.md §passes`.134135### Normal pass (world-space → PNG)136137Use Cycles CPU, 1 sample, compositor: Normal pass → multiply 0.5 → add 0.5 → File Output.138Do not use CompositorNodeNormalize — it acts on pixel min/max, not on the [-1,1] theoretical139range. Full remap pattern: `references/cycles-gpu-passes.md §normals`.140141### Contact-sheet assembly142143`scripts/contact_sheet.py` tiles all PNGs in a directory into a single labelled sheet.144Degrades gracefully when Pillow is absent (emits a plain text manifest instead). Two145enforced ceilings keep the result `Read`-friendly: cell cap 512×512 px, and **36 tiles per146sheet** (`--max-per-sheet`, default 36) — beyond that it auto-splits into numbered sheets.147Full usage + layout spec: `references/contact-sheet.md`.148149---150151## Operating principles152153- **Verify, don't assume.** Every render attempt must be followed by a file-existence check154 and a `Read` of the output PNG before reporting success. A zero-KB file is a silent failure.155- **Workbench first, Cycles only when needed.** Workbench is headless-safe on Windows and156 ~60× faster than Cycles for geometry QA. Use Cycles only for node-material passes (UV157 checker, normals) and final beauty renders.158- **Absolute forward-slash paths everywhere.** `scene.render.filepath` and `fo.base_path`159 must use forward slashes (e.g. `C:/forge/out/frame_####`). Backslashes in Blender Python160 strings are escape sequences; they silently produce wrong paths.161- **Scripts, not inline code.** All render logic lives in `scripts/`; SKILL.md code blocks162 are illustration only — the model pre-evaluates `$()` expressions it sees in Markdown,163 so never rely on them for real execution.164- **One earned read per loop.** Each iteration renders, reads the PNG, critiques against165 the brief, and makes a single clear fix decision. Do not accumulate multiple unread renders166 before inspecting.167- **Deterministic rebuilds.** The Cycles seed is pinned to 0 by default (`--seed N` to change)168 and `--clean` (on by default) clears this skill's prior output stems first, so two builds of169 the same scene produce byte-comparable renders and stale frames never leak into a contact170 sheet. `render.py --json` carries a `health` field (per-file exists / size / blank-frame171 check) — let the loop iterate-or-advance on that signal, not on prose.