Skip to content

feat(render): colour grading and film grain in the starter, on a stage seat that owns the output transform (PRD-492) - #431

Closed
jonit-dev wants to merge 9 commits into
developfrom
feat/prd-492-grading
Closed

jonit-dev wants to merge 9 commits into
developfrom
feat/prd-492-grading

Conversation

@jonit-dev

@jonit-dev jonit-dev commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

The starter's frame now ends in a colour grade and a film grain, and the stage that does it takes a
new seat in the chain: display-referred, after the output transform. The filed design put a log
shaper inside the stage instead; it was measured, it costs at least 2.0 eight-bit steps per channel,
and this PRD's own box asks for 1. The engine seat it falls back to — named in the PRD before it was
written — lands the box by construction and makes a .cube mean what a grading tool means by it.

Phase 1 only. Phase 2 (native) has not run.

What changed

Where What
templates/starter/src/render/grade.ts (new) the two authored stages: grade (a .cube through lut3D) and grain (film)
templates/starter/public/grade.cube (new), tools/make-grade-lut.mjs (new) the shipped table and the script that writes it, so replacing the table is the whole of "change the grade"
templates/starter/src/render/{postprocessing,quality}.ts load the table, own the two stages, and decide per tier whether each runs
packages/core/src/render/chain.ts, renderer.ts afterOutputTransform on a stage; setOutputNode(node, pass, options) hands the renderer its transform back only when such a stage actually ran
template-assets/worldEnvironment.ts + 13 kit copies the shared ChainStage type learns the flag

Phase 2, since the first push

pnpm native:build completed (409/409), so the desktop lane ran here.

  • Conformance row — passes. pnpm parity --target desktop --only-tests lut-grade:
    status: "pass", pixelMismatchRatio: 0, perceptualDeltaE: 0 over 1280x720,
    gpuValidationErrors: [], host rendering 300 frames in 9.4 s, compared against a web-lane
    reference capture of the same scene. The 17^3 table is a per-channel gain, not the identity, and
    the parsed Data3DTexture is read back in JS against that formula before the frame is compared.
    (The sweep exits 2 because a bounded --only-tests run blocks the other 95 rows.)
  • Desktop render-chain scenario — open. The marker reads
    dropped: [{"name":"grade","reason":"lut:pending"}] and the console carries
    TN_GRADE_TABLE grade.cube: File read error. package-desktop.mjs stages
    selectManifestAssets(assets) alone, so a public/ file the manifest does not declare never
    reaches the host — every unmanaged public file on every native target, not this table alone.
  • That scenario also could not run at all before: its visibility assertion is refused outright
    on desktop (TN_PLAYTEST_OBSERVATION_UNAVAILABLE), so it failed before reading a stage. That row
    is dropped (the runner never produced the observation on this target) and grade/grain are added
    to the stages and contributions the scenario does read.

Evidence that ran

  • pnpm typecheck, pnpm lint (0 errors), 259 vitest files across packages/core and
    packages/create-threenative — all pass.
  • packages/create-threenative/__tests__/grade.spec.ts — 8 passed. It reads the shipped table back
    through a port of LUTCubeLoader.parse, applies lut3D's half-texel pull-in and the hardware's
    own filtering, and measures the identity round trip at ≤ 1 step over every value the frame can
    hold
    (worst 0.875).
  • Playtest look.playtest.json on a scaffolded starter, --browser-recipe webgpu --headed,
    adapterClass: hardware: renderChain.tier, renderChain.stages.includes,
    renderChain.stages.order and renderChain.contributions.graphOutputChanged all pass with
    stages: ["ambientOcclusion","bloom","vignette","antialias","grade","grain"], dropped: [].
  • The low leg needs an adapter the harness classes as software, and Chromium here reaches no
    WebGPU adapter under SwiftShader (TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING). It was run instead on
    a throwaway copy with setupPost(..., { tier: "low" }) pinned: grade applied, dropped: [{"name":"grain","reason":"grainIntensity:0"}], both assertion rows pass. Nothing about that pin
    is committed.

Open, with the reason

  • AC-1 — gpuOther resolved in 1 of 17 windows graded and 0 of 16 ungraded at 1080p on this
    lane, so there is no delta to read. A missing observation is not a zero.
  • AC-1, identity round trip, frame half — quarry compare on the scaffold reads
    maxChannelDelta 86, rmse 0.81 for ungraded vs identity-with-grain-off, and the same ungraded
    build captured twice
    reads maxChannelDelta 114, rmse 0.37. The starter's own animated content
    moves between runs by more than the claim, so a whole-frame maximum cannot carry it. A still-scene
    capture would.
  • AC-2 — capture ready for a judge: .afk/scratch/prd492-captures/phase1/{before,after}.png,
    1920x1080, same scaffold and same pose, the only difference being the grade.
  • Phase 2 — the desktop render-chain scenario and the lut-grade conformance row have not
    run. pnpm build:web already reports the table among 5 undeclared files (38,890 bytes), which is
    the packager risk this PRD named.

Progress label prd:25%.

…t (PRD-492 Phase 1)

The starter's frame now ends in a colour grade and a film grain, both authored
stages it owns: `src/render/grade.ts` builds them, `quality.ts` decides per tier
and `public/grade.cube` is the table. Replacing that one file with a `.cube` from
any grading tool is the whole of "change the grade".

The filed design was a bijective log shaper inside the stage. It is measured and
dropped: an 8-bit table quantises in 1/255 steps, and sweeping the shaper family
over the frame's range found nothing under 2.0 8-bit steps per channel (the filed
W=4, g=1/2.2 costs 4.3), because ACES and sRGB together stretch a small change in
light into several codes. So the fallback this PRD already named ships instead: a
stage may declare `afterOutputTransform`, the chain then installs the graph with
the renderer's automatic transform off and the stage applies three's own
`renderOutput` first — what three's FXAA example does. An identity table there is
within 0.875 of a step over every value the frame can hold, which is the PRD's own
box, and a `.cube` now means what a grading tool means by it.

Engine change is mechanism only: `IRenderChainStage.afterOutputTransform`, a
third argument on `setOutputNode`, and the chain handing the renderer
`outputColorTransform: false` only when such a stage actually ran — a dropped one
would leave the frame untone-mapped with nothing to have applied it.

Ran: pnpm typecheck, pnpm lint, 259 vitest files across packages/core and
packages/create-threenative. Playtest `look.playtest.json` on a scaffolded starter
(webgpu, headed, adapterClass hardware): renderChain tier/includes/order/
contributions all pass with grade and grain applied; the tier-low leg on a
throwaway copy with the tier pinned reads `dropped: [{"name":"grain",
"reason":"grainIntensity:0"}]` with grade still applied.

Open, recorded in the PRD: AC-1 (gpuOther resolved in 1 of 17 windows graded, 0 of
16 ungraded — no delta to read) and the frame half of the identity round trip (the
starter's own animated content moves between runs by more than the claim; the same
build twice reads maxChannelDelta 114).
The pre-push drift gate refused the push on a formatter diff biome had already
reported once; this is the same one line, formatted.
@jonit-dev jonit-dev added the prd:25% PRD phase 1 verified label Oct 4, 2026
…hat could not run (PRD-492 Phase 2)

Phase 2's second box passes: `pnpm native:build` (409/409), then
`pnpm parity --target desktop --only-tests lut-grade` reports status pass with
`pixelMismatchRatio 0` and `perceptualDeltaE 0` over 1280x720 and no GPU
validation errors, against a web-lane reference capture of the same scene. The
table is a 17^3 per-channel gain rather than the identity, so a host that ignored
the upload or substituted a constant fails it, and the scene reads the parsed
Data3DTexture back in JS against that formula before the frame is compared. The
sweep exits 2 because a bounded --only-tests run blocks the other 95 rows.

Phase 2's first box stays open, and the reason is the packager rather than the
grade: the desktop run's marker reads `dropped: [{"name":"grade","reason":
"lut:pending"}]` and the console carries `TN_GRADE_TABLE grade.cube: File read
error`. package-desktop.mjs stages `selectManifestAssets(assets)` and nothing
else, so a public/ file the manifest does not declare never reaches the host —
every unmanaged public file on every native target, not this table alone. Fixing
the asset selection is mechanism work outside this PRD's filed file list, so it is
recorded rather than done here.

The scenario also could not run at all before: its `visibility` assertion is
refused outright on desktop (TN_PLAYTEST_OBSERVATION_UNAVAILABLE), so it failed
before reading a stage. That row is dropped — the runner never produced the
observation on this target — and `grade`/`grain` are added to the stages and
contributions the scenario does read.

Ran: pnpm typecheck, biome on the changed files, packages/runtime-native (7 files)
and packages/playtest (104 of 105 files; `generated-shooter-input` fails on a
browser session and touches nothing in this change), and the create-threenative
scaffold and looks contracts.
@jonit-dev jonit-dev added prd:50% PRD half the phases verified and removed prd:25% PRD phase 1 verified labels Oct 4, 2026
A blind judge compared the starter WITH this PR's grade and grain against
WITHOUT and clearly preferred WITHOUT: "strong warm beige/pink cast — grey
concrete reads as sandstone, sky washed toward cream, a bit muddy/low contrast;
grain clearly visible as speckle over sky and flat walls and behind HUD text".
The owner's rule is that a default must never degrade the look, so both
numbers move and the spec says so red-first.

The pivot was the larger half and nothing had measured it. A contrast curve
anchored at 0.18 leaves everything above it brighter: on the captured frame
that lifted mid-grey 8 of 255 steps and the sky 12, which is exactly what
"washed toward cream" looks like. PIVOT 0.18 -> 0.5, and GAIN's warm spread
13% -> 0.5% (r 1.0025 / g 1 / b 0.9975). CONTRAST stays 1.06, so the frame
keeps more separation than the ungraded one, and the table still lifts no
black. grade.spec.ts pins all three claims and is red on the old table.

grainIntensity 0.12 -> 0.0039 at high and medium. film() scales its noise by
the pixel's own value, so 1/255 is under half a step on a mid-grey surface —
below what the framebuffer shows. It stays non-zero so TN_RENDER_CHAIN keeps
naming grain as APPLIED at high, which is this PRD's own ticked box; low
still refuses it with a reason, and quality.ts names 0.12 and 0.3 as the
values to raise it to.

The desktop box passes now, and the packager was never at fault. Phase 2 filed
"the staging loop copies selectManifestAssets(assets) and nothing else, so an
undeclared public/ file never reaches the host"; the selector's rule 3 keeps
everything no cook produced and its spec already proved that. The scaffold that
run used had no public/grade.cube in it at all — the AC-2 before-arm deletes
that file — so there was nothing to stage. With it present: pnpm build --target
desktop lists it among the unmanaged files, the host logs "[Fetch] Read 19909
bytes from bundle: grade.cube", and native-playtests/render-chain.playtest.json
exits 0 with all six assertions true at adapterClass hardware and a non-blank
screenshot (tone mean 117.1, p99 192). A guard in the selector's own spec now
covers a hand-placed file whose extension no asset pipeline emits.

Also recorded: a browser run that reports swiftshader/google is a CPU
rasteriser's picture of a hardware look (this lane's frame came back flat grey
with 7,359 createBuffer errors), and --browser-recipe webgpu alone does not
prevent it — the runner launches chrome-headless-shell. Every capture ran
--headed, on turing/nvidia, at tier high.

Ran: grade.spec.ts 11 passed (2 of the new ones red on the old table),
scaffold.spec.ts 66, looks.spec.ts, asset-manifest.spec.ts 6, the six doc/CI
contracts 241, pnpm typecheck clean, biome clean on every touched file,
pnpm check:docs clean.

Captures for the judge, not judged here: .afk/scratch/prd492-captures/phase1b/
{before,after}.png plus {before,after}-crop.png (centre 960x540 at 2x).
Measured between the pair the grade now moves the frame by rmse 2.22 codes and
-0.32/-0.71/-0.32 mean per channel, from rmse 8.17 and +6.7/+6.3/+6.9.
Carries develop's WebGPU adapter-retention patch, the terrain-jobs work and
the CI scope changes. One conflict: the scaffold tree hashes, which develop
restated for its own merge and this branch restated for PRD-492's stage seat
plus the neutralised starter default. Resolved to the merged-tree
measurement — all thirteen trees re-measured through actual createProject
trees on this tree, `scaffold.spec.ts` 66 passed.
…is lane

Takes the develop CI merge that had already landed on the PR branch. Two
conflicts, both resolved to this lane's newer text: the scaffold tree hashes
(re-measured through actual createProject trees on this tree, `scaffold.spec.ts`
66 passed) and the PRD (the remote side was the pre-neutralisation file).
@jonit-dev jonit-dev added prd:75% PRD phases in, acceptance open and removed prd:50% PRD half the phases verified labels Oct 4, 2026
@jonit-dev

Copy link
Copy Markdown
Contributor Author

PRD-492 grade: before (no grade) vs first attempt (rejected by blind judge) vs retuned default

Same build otherwise, same pose. Images live on the prd-492-screenshots branch.

1-before-no-grade

1-before-no-grade

2-rejected-first-grade

2-rejected-first-grade

3-after-retuned-default

3-after-retuned-default

4-crop-before

4-crop-before

5-crop-after

5-crop-after

@jonit-dev

Copy link
Copy Markdown
Contributor Author

Consolidated into #437 (single runbook PR).

@jonit-dev jonit-dev closed this Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:75% PRD phases in, acceptance open

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant