From 9507e2dfc7639ca34dfb523dcd5409b94d558b12 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 4 Oct 2026 11:37:00 -0700 Subject: [PATCH 1/5] feat(render): grade and film grain in the starter, on a new stage seat (PRD-492 Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../PRD-492-colour-grading-and-film-grain.md | 44 +- packages/core/__tests__/render-chain.spec.ts | 91 ++- packages/core/src/render/chain.ts | 41 +- packages/core/src/renderer.ts | 23 +- .../__tests__/grade.spec.ts | 208 +++++ .../__tests__/looks.spec.ts | 7 +- .../__tests__/scaffold.spec.ts | 32 +- .../template-assets/worldEnvironment.ts | 10 + .../starter/look.playtest.json | 11 +- .../action-rpg/src/render/worldEnvironment.ts | 10 + .../minimal/src/render/worldEnvironment.ts | 10 + .../platformer/src/render/worldEnvironment.ts | 10 + .../puzzle/src/render/worldEnvironment.ts | 10 + .../racing/src/render/worldEnvironment.ts | 10 + .../rain/src/render/worldEnvironment.ts | 10 + .../rts/src/render/worldEnvironment.ts | 10 + .../runner/src/render/worldEnvironment.ts | 10 + .../sailing/src/render/worldEnvironment.ts | 10 + .../shooter/src/render/worldEnvironment.ts | 10 + .../snow/src/render/worldEnvironment.ts | 10 + .../templates/starter/public/grade.cube | 735 ++++++++++++++++++ .../templates/starter/src/render/grade.ts | 134 ++++ .../starter/src/render/postprocessing.ts | 41 +- .../templates/starter/src/render/quality.ts | 27 + .../starter/src/render/worldEnvironment.ts | 10 + .../starter/tools/make-grade-lut.mjs | 81 ++ .../src/render/worldEnvironment.ts | 10 + 27 files changed, 1579 insertions(+), 36 deletions(-) create mode 100644 packages/create-threenative/__tests__/grade.spec.ts create mode 100644 packages/create-threenative/templates/starter/public/grade.cube create mode 100644 packages/create-threenative/templates/starter/src/render/grade.ts create mode 100644 packages/create-threenative/templates/starter/tools/make-grade-lut.mjs diff --git a/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md b/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md index ac190072f1..1f67279ae4 100644 --- a/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md +++ b/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md @@ -1,6 +1,6 @@ # PRD-492 — Colour grading and film grain -**Status:** PROPOSED +**Status:** IN PROGRESS — Phase 1 landed; AC-1 and the identity round trip are open **Complexity:** 1 (LOW) — 1–5 files, all generated template source; no package change expected; risk override: none **Owner:** João **Depends on:** [PRD-VQ-02](../native/PRD-VQ-02-native-postprocessing-parity.md) (a post stage must not blank the native frame; this PRD adds two more stages to the same chain) @@ -28,24 +28,54 @@ The look is generated source, so both stages live in the default `starter` templ Risk: the native packager stages files by the asset manifest (`selectManifestAssets`). A raw `.cube` under `public/` may not be staged. Phase 2's native run is what proves it is. +## Decisions + +**The log shaper was measured and dropped; the engine anchor is the shipped design** (2026-10-04, +Phase 1). The shaper cannot meet this PRD's own box. An 8-bit `.cube` quantises in 1/255 steps, so +the round trip's error is `dL/du · display'(L)` per code, and sweeping the two-parameter family +`(x/W)^g / (1 + (x/W)^g)` over the frame's whole range found no pair under **2.0** 8-bit steps per +channel; the filed `W = 4, g = 1/2.2` costs **4.3**. The reason is arithmetic, not tuning: ACES and +sRGB together stretch a small change in light into several codes across most of the range, so the +only shaper that reaches one step is the output transform itself. + +So the fallback this file already named is what shipped: a stage may declare +`afterOutputTransform`, the chain then installs the graph with the renderer's automatic transform +off (`setOutputNode(node, pass, { outputColorTransform: false })`), and `grade.ts` applies three's +own `renderOutput` before looking the table up — the arrangement three's FXAA example uses. The +table's quantisation is then the whole of the error, and it is bounded by the load rather than by a +curve: `LUTCubeLoader` truncates `value * 255` into a `Uint8Array`, so an identity table reads low +by at most **0.875** of a step. Measured over every value the frame can hold, in +`packages/create-threenative/__tests__/grade.spec.ts`. A `.cube` also now means what a grading tool +means by it, which the shaper could not offer. + +**The starter's table is a 9³ grid and its interpolation error is measured, not assumed.** A channel +gain above one has to clip the top of the range, and a coarse grid cannot follow that corner, so +`make-grade-lut.mjs` carries the number (≤ 7.5 of 255 steps, all of it in the top interval of a +clipping channel) and the spec re-reads the generator's own constants, so raising the contrast or +the SIZE cannot pass unnoticed. + +**`public/grade.cube` is not staged by the native packager yet.** `pnpm build:web` reports +`5 unmanaged file(s), 38890 bytes` — the table among them. Phase 2's desktop run is what decides it. + ## Acceptance Criteria -- [ ] AC-1 [local]: grade and grain together cost ≤ 0.3 ms GPU at 1080p on the RTX 2080 browser lane, read as the `gpuOther` p50 delta against the same build with both stages off. proof: `TN_FRAME_BUDGET` from `node packages/playtest/dist/runner/cli.js perf` on the scaffolded starter. -- [ ] AC-2 [local]: the graded frame is judged at or above the ungraded one by a fresh judge, and the HUD is not graded. proof: `pnpm visuals:ab --before --after --raters 3`. +- [ ] AC-1 [local]: grade and grain together cost ≤ 0.3 ms GPU at 1080p on the RTX 2080 browser lane, read as the `gpuOther` p50 delta against the same build with both stages off. proof: `TN_FRAME_BUDGET` from `node packages/playtest/dist/runner/cli.js perf` on the scaffolded starter. **Open — the metric resolves too rarely in this lane to read a delta.** Two 1080p runs of the same scenario (`grade-perf2.playtest.json`, `assert.performance`, no screenshots, adapter `webgpu:architecture=turing|vendor=nvidia`, `adapterClass: hardware`): the graded arm carried `gpuOther` in **1 of 17** windows (p50 `0`, total gpu p50 `0.4`, 65 samples) and the ungraded arm in **0 of 16**. With one arm carrying no `gpuOther` observation at all there is no delta, and a missing observation is not a zero. +- [ ] AC-2 [local]: the graded frame is judged at or above the ungraded one by a fresh judge, and the HUD is not graded. proof: `pnpm visuals:ab --before --after --raters 3`. **Open — capture ready for judge.** `.afk/scratch/prd492-captures/phase1/before.png` (same scaffold with `postprocessing.ts`, `quality.ts` and `grade.ts` reverted to `origin/develop` and `public/grade.cube` removed) against `after.png` (this branch, grade + animated grain on), both 1920x1080 from `grade-shot.playtest.json` at the spawn pose, same build otherwise. Not run: judging a look is the judge's call, not this lane's. The HUD is outside the chain's graph either way — the grade is a pass node and React mounts over the canvas. ## Integration Ledger | Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | | --- | --- | --- | --- | | Grade and grain stages | starter `setupPost` → `WorldEnvironment.apply` → `renderer.createRenderChain()` authored stages | New; no prior grade | Phase 1, AC-2 | +| `afterOutputTransform` stage seat | `RenderChain` → `IRendererLike.setOutputNode(node, pass, options)` → `RenderPipeline.outputColorTransform` | New; mechanism only, and the PRD's named fallback | Phase 1: `packages/core/__tests__/render-chain.spec.ts` — runs last, hands the renderer `outputColorTransform: false`, keeps it when the stage is refused, refuses an anchor | ## Execution Phases #### Phase 1: Grade and grain in the starter -**Status:** NOT STARTED -**Files:** `packages/create-threenative/templates/starter/src/render/postprocessing.ts`, `quality.ts`, `public/grade.cube` (new) -- [ ] With grain off, an identity `.cube` through the log shaper reproduces the ungraded frame within one 8-bit step per channel. proof: `maxChannelDelta` ≤ 1 from `pnpm --filter quarry compare -- --reference --candidate --frames `. It is a generic PNG diff; the starter's playtest supplies both captures. -- [ ] `TN_RENDER_CHAIN` names `grade` and `grain` as applied at `high`, and grain as refused with a reason at `low`. proof: `pnpm test:templates` with the starter's render-chain assertion extended. +**Status:** LANDED — one box open (see below) +**Files:** `packages/create-threenative/templates/starter/src/render/grade.ts` (new), `postprocessing.ts`, `quality.ts`, `public/grade.cube` (new), `tools/make-grade-lut.mjs` (new), `packages/core/src/render/chain.ts`, `packages/core/src/renderer.ts`, `packages/create-threenative/template-assets/worldEnvironment.ts` + its 13 kit copies +- [ ] With grain off, an identity `.cube` through the grade reproduces the ungraded frame within one 8-bit step per channel. proof: `maxChannelDelta` ≤ 1 from `pnpm --filter quarry compare` on the scaffolded starter, with `packages/create-threenative/__tests__/grade.spec.ts` holding the arithmetic underneath it. **The shaper is gone — the stage now grades after the output transform (see `## Decisions`) — and the arithmetic half is proved: `maxChannelDelta` over every display value ≤ 1** (worst 0.875), from `pnpm exec vitest run packages/create-threenative/__tests__/grade.spec.ts` — 8 passed, reading the shipped table back through a port of `LUTCubeLoader.parse`. **Open on the frame half.** `pnpm --filter quarry compare` on the scaffolded starter, ungraded against identity-table-with-grain-off, 1920x1080, same build otherwise: `maxChannelDelta 86, rmse 0.81, changedPixelRatio 0.003` — and the *same ungraded build captured twice* reads `maxChannelDelta 114, rmse 0.37, changedPixelRatio 0.0026`. The starter's own animated content moves between runs by more than the claim, so a whole-frame maximum cannot carry it; the captures are in `.afk/scratch/prd492-captures/phase1/` and `/home/joao/.cache/tnt/scratch/prd492/cmp-{off,off2,identity}/`. A still-scene capture (the arena without `STARTER_MIST` or the pennants) would carry it. +- [x] `TN_RENDER_CHAIN` names `grade` and `grain` as applied at `high`, and grain as refused with a reason at `low`. proof: `pnpm test:templates` with the starter's render-chain assertion extended. **Ran at `high` on the starter's `look.playtest.json`** (`packages/create-threenative/template-playtests/starter/look.playtest.json`): `renderChain.tier`, `renderChain.stages.includes`, `renderChain.stages.order` and `renderChain.contributions.graphOutputChanged` all pass at `adapterClass: hardware`, and the marker carries `stages: ["ambientOcclusion","bloom","vignette","antialias","grade","grain"]`, `dropped: []`, with `grade` and `grain` both `graphOutputChanged: true`. **The `low` leg** is `perAdapter.software`, which needs an adapter the harness classes as software — and Chromium here reaches no WebGPU adapter under SwiftShader (`TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING`, adapter request resolved null), so it was run instead on a throwaway copy of the scaffold with `setupPost(..., { tier: "low" })` pinned and a scenario asserting `stages: { includes: ["grade"], excludes: ["grain"] }`: both rows pass, and the marker carries `stages: [...,"grade"], dropped: [{"name":"grain","reason":"grainIntensity:0"}]`. Nothing about that pin is committed. #### Phase 2: Native runs the grade **Status:** NOT STARTED diff --git a/packages/core/__tests__/render-chain.spec.ts b/packages/core/__tests__/render-chain.spec.ts index c711ba1dae..47454ba028 100644 --- a/packages/core/__tests__/render-chain.spec.ts +++ b/packages/core/__tests__/render-chain.spec.ts @@ -4,7 +4,7 @@ import { RenderChain, readRenderChainObservation, } from "../src/render/chain.js"; -import type { IRendererLike } from "../src/renderer.js"; +import type { IRenderOutputOptions, IRendererLike } from "../src/renderer.js"; function renderer( kind: IRendererLike["kind"], @@ -806,4 +806,93 @@ describe("RenderChain", () => { }), ).toThrow(/targetFps must be a finite positive number/u); }); + // PRD-492: a colour grade is a display-referred effect, so it needs a place after the tone + // curve and the sRGB encode. The chain owns where the transform happens and the renderer's + // `outputColorTransform` owns whether it applies automatically; the stage owns only its look. + it("runs an after-output-transform stage last and hands the renderer the transform to apply", () => { + const order: string[] = []; + const installed: { node: unknown; outputColorTransform: unknown }[] = []; + const current = renderer("webgpu"); + const chain = new RenderChain( + { + ...current, + setOutputNode: (node: unknown, _worldPass: unknown, options?: IRenderOutputOptions) => { + installed.push({ node, outputColorTransform: options?.outputColorTransform }); + }, + } as unknown as IRendererLike, + { + input: { colour: true }, + stages: [ + { + afterOutputTransform: true, + build: (input) => { + order.push("grade"); + return { input, name: "grade" }; + }, + name: "grade", + }, + { + build: (input) => { + order.push("bloom"); + return { input, name: "bloom" }; + }, + name: "bloom", + }, + ], + request: { stages: ["bloom", "grade"], tier: "high" }, + }, + ); + + expect(chain.applied.stages).toEqual(["bloom", "grade"]); + expect(order).toEqual(["bloom", "grade"]); + expect(installed).toHaveLength(1); + expect(installed[0]?.outputColorTransform).toBe(false); + }); + + it("leaves the automatic transform on when no after-output-transform stage actually ran", () => { + const installed: (boolean | undefined)[] = []; + const current = renderer("webgpu"); + const chain = new RenderChain( + { + ...current, + setOutputNode: (_node: unknown, _worldPass: unknown, options?: IRenderOutputOptions) => { + installed.push(options?.outputColorTransform); + }, + } as unknown as IRendererLike, + { + input: { colour: true }, + stages: [ + { + afterOutputTransform: true, + available: () => "lut:pending", + build: (input) => ({ input, name: "grade" }), + name: "grade", + }, + { build: (input) => ({ input, name: "bloom" }), name: "bloom" }, + ], + request: { stages: ["bloom", "grade"], tier: "high" }, + }, + ); + + // The grade was refused, so nothing applied the transform itself and the frame would have + // reached the display un-tone-mapped. That is the failure this flag exists to prevent. + expect(chain.applied.stages).toEqual(["bloom"]); + expect(chain.applied.dropped).toEqual([{ name: "grade", reason: "lut:pending" }]); + // Undefined is the renderer keeping its own transform, which is what an untaken + // output transform must leave alone. + expect(installed).toEqual([undefined]); + }); + + it("rejects an after-output-transform stage that also declares an anchor", () => { + expect( + () => + new RenderChain(renderer("webgpu"), { + stages: [ + { after: "bloom", afterOutputTransform: true, build: (input) => input, name: "grade" }, + stage("bloom", []), + ], + request: { stages: ["bloom", "grade"], tier: "high" }, + }), + ).toThrow(/afterOutputTransform/u); + }); }); diff --git a/packages/core/src/render/chain.ts b/packages/core/src/render/chain.ts index c2393b6fc8..845925800e 100644 --- a/packages/core/src/render/chain.ts +++ b/packages/core/src/render/chain.ts @@ -1,7 +1,12 @@ import type { MRTNode, Node } from "three/webgpu"; import type { IFrameBudgetWindow } from "../frame-budget.js"; -import type { IRenderOutputInstallation, RenderOutputSetter, RendererKind } from "../renderer.js"; +import type { + IRenderOutputInstallation, + IRenderOutputOptions, + RenderOutputSetter, + RendererKind, +} from "../renderer.js"; import { velocityTexture, withVelocityContext } from "./velocity.js"; import type { IVelocityRenderPass } from "./velocity.js"; @@ -129,6 +134,19 @@ export interface IRenderChainStage { readonly before?: RenderChainStageId; /** Place an authored stage immediately after this built-in or supplied stage. */ readonly after?: RenderChainStageId; + /** + * This stage reads display-referred colour, so the chain installs the graph with the renderer's + * automatic output transform switched off and the stage applies that transform itself. + * + * Mechanism, not look. Some effects are defined against the sRGB picture — a `.cube` table, a + * sharpening or edge filter — and bending scene-referred light into that domain costs precision + * instead of buying anything: measured on an 8-bit table, a bijective log shaper costs 2–4 + * 8-bit steps per channel against under 1 for the transform this names (PRD-492). + * + * The stage therefore runs last, and may not declare an anchor: a stage placed after it would + * receive display-referred colour it was not written for. + */ + readonly afterOutputTransform?: boolean; readonly build: (input: unknown, context: IRenderChainStageContext) => unknown; /** Stages below this tier are named as dropped instead of silently changing the graph. */ readonly minimumTier?: RenderChainTier; @@ -423,9 +441,17 @@ export class RenderChain { if (stages.length > 0) { try { + // Only a stage that actually ran may take the transform away from the renderer; a + // dropped one would leave the frame untone-mapped with nothing to have applied it. + const outputOptions: IRenderOutputOptions[] = builtStageDefinitions.some( + (definition) => definition.afterOutputTransform === true, + ) + ? [{ outputColorTransform: false }] + : []; const installation = this.#renderer.setOutputNode( node, this.#worldPass ?? this.#requestVelocity.pass, + ...outputOptions, ); // Historical void-style callbacks can return an ignored value (for example Array.push). // Only an explicit receipt upgrades that adapter to installation ownership. @@ -612,13 +638,18 @@ function createStageDefinitions( throw new Error(`render-chain stage '${name}' needs a build function`); const hasBefore = stage.before !== undefined; const hasAfter = stage.after !== undefined; + if (stage.afterOutputTransform === true && (hasBefore || hasAfter)) { + throw new Error( + `render-chain stage '${name}' declares afterOutputTransform and an anchor; a stage that reads display-referred colour is last by construction, so an anchor is either ignored or wrong`, + ); + } if (isBuiltInStageId(name)) { if (hasBefore || hasAfter) { throw new Error( `built-in render-chain stage '${name}' cannot declare before or after; its canonical order is fixed`, ); } - } else if (hasBefore === hasAfter) { + } else if (hasBefore === hasAfter && stage.afterOutputTransform !== true) { throw new Error( `authored render-chain stage '${name}' must declare exactly one of before or after`, ); @@ -678,6 +709,12 @@ function resolveStageOrder( if (definition === undefined) { throw new Error(`render-chain stage '${id}' has no supplied definition`); } + // The output transform's own seat. No stage may follow it, so it outranks every anchor + // without needing one, and it needs no anchor to be ordered. + if (definition.afterOutputTransform === true) { + ranks.set(id, RENDER_CHAIN_STAGE_ORDER.length + 1); + return ranks.get(id) as number; + } const anchor = definition.before ?? definition.after; if (anchor === undefined) { throw new Error(`authored render-chain stage '${id}' has no anchor`); diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 25a834c21e..a9a604cf67 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -21,10 +21,22 @@ export interface IRenderOutputInstallation { dispose(): void; } +/** + * Where the transform goes when a stage does the renderer's job for it. The chain passes + * `false` only when a stage that read display-referred colour actually ran. + */ +export interface IRenderOutputOptions { + readonly outputColorTransform?: boolean; +} + /** Union of callbacks preserves TypeScript's legacy void-callback return-value compatibility. */ export type RenderOutputSetter = - | ((node: unknown, worldPass?: unknown) => void) - | ((node: unknown, worldPass?: unknown) => IRenderOutputInstallation); + | ((node: unknown, worldPass?: unknown, options?: IRenderOutputOptions) => void) + | (( + node: unknown, + worldPass?: unknown, + options?: IRenderOutputOptions, + ) => IRenderOutputInstallation); type WarmableSurface = { clone: () => WarmableSurface; @@ -788,7 +800,7 @@ function wrapRenderer( renderingFrame -= 1; } }, - setOutputNode: (node, worldPass) => { + setOutputNode: (node, worldPass, options) => { if (kind !== "webgpu") throw new Error(`setOutputNode is unavailable on the ${kind} renderer.`); const nextOutputPass = selectOutputPass(node, worldPass); @@ -796,6 +808,11 @@ function wrapRenderer( raw as unknown as ConstructorParameters[0], node as ConstructorParameters[1], ); + // The pipeline applies the tone curve and the output encode by default. A stage that took + // the transform over applies it itself, so the pipeline must not apply a second one. + if (options?.outputColorTransform !== undefined) { + nextPipeline.outputColorTransform = options.outputColorTransform; + } outputPipeline?.dispose(); outputPass = nextOutputPass; outputPipeline = nextPipeline; diff --git a/packages/create-threenative/__tests__/grade.spec.ts b/packages/create-threenative/__tests__/grade.spec.ts new file mode 100644 index 0000000000..3a982b52ee --- /dev/null +++ b/packages/create-threenative/__tests__/grade.spec.ts @@ -0,0 +1,208 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + type IGradeSettings, + type IGradeTable, + gradeStages, +} from "../templates/starter/src/render/grade.js"; +import { + gradePreset, + qualityPreset, + resolveQualityTier, +} from "../templates/starter/src/render/quality.js"; + +const TOOLS = path.resolve("packages/create-threenative/templates/starter/tools"); +const GENERATOR = path.join(TOOLS, "make-grade-lut.mjs"); +const SHIPPED = path.resolve("packages/create-threenative/templates/starter/public/grade.cube"); + +/** Run the shipped generator, exactly as the template's `package.json` would. */ +function writeTable(...flags: readonly string[]): string { + const out = path.join(mkdtempSync(path.join(tmpdir(), "tn-grade-")), "grade.cube"); + execFileSync(process.execPath, [GENERATOR, ...flags, "--out", out]); + return readFileSync(out, "utf8"); +} + +/** + * `LUTCubeLoader.parse`, reduced to what it decides: the edge length, and four bytes per texel in + * x, y, z order with the load's `Number(text) * 255` written into a `Uint8Array` — so each stored + * value is *truncated*, not rounded. A fact this file's arithmetic depends on more than any other, + * and the four-bytes-per-texel stride is load-bearing for every read below. + */ +function loadTable(source: string): { size: number; bytes: Uint8Array } { + const declared = /LUT_3D_SIZE +(\d+)/u.exec(source)?.[1]; + const size = Number(declared); + if (!Number.isInteger(size) || size < 2) throw new Error("no LUT_3D_SIZE"); + const bytes = new Uint8Array(size ** 3 * 4); + let texels = 0; + for (const [, r, g, b] of source.matchAll(/^([\d.e+-]+) +([\d.e+-]+) +([\d.e+-]+) *$/gmu)) { + const at = texels * 4; + bytes[at] = Number(r) * 255; + bytes[at + 1] = Number(g) * 255; + bytes[at + 2] = Number(b) * 255; + bytes[at + 3] = 255; + texels += 1; + } + if (texels !== size ** 3) throw new Error(`parsed ${texels} texels, expected ${size ** 3}`); + return { bytes, size }; +} + +/** + * What `lut3D` does to one channel: pull in by half a texel so the sample starts at the centre of + * the edge texels, then filter the uploaded bytes. + * + * The grid is separable — every texel's red depends only on x, green only on y, blue only on z — + * so the hardware's trilinear filter collapses to one linear interpolation along this channel's own + * axis, and that is what is computed here. + */ +function sampled(bytes: Uint8Array, size: number, value: number, channel: number): number { + const stride = channel === 0 ? 1 : channel === 1 ? size : size ** 2; + // Texel `i` of a Data3DTexture is sampled at normalised `(i + 0.5) / size`, so `value` 0 and 1 + // land on the first and last texel centre — which is what lut3D's half-texel pull-in arranges. + const coord = (0.5 / size + value * (1 - 1 / size)) * size - 0.5; + const low = Math.min(size - 2, Math.max(0, Math.floor(coord))); + const frac = Math.min(1, Math.max(0, coord - low)); + const at = (index: number): number => bytes[index * stride * 4 + channel] ?? 0; + return (at(low) * (1 - frac) + at(low + 1) * frac) / 255; +} + +/** The shipped table read back through the loader, so a change in the file cannot pass unnoticed. */ +const shipped = loadTable(readFileSync(SHIPPED, "utf8")); +const identity = loadTable(writeTable("--identity")); + +/** + * The starter's colour grade is generated source, so its claim is proved here rather than only in a + * capture (PRD-492): with the grain off, an **identity** table must hand the frame back within one + * 8-bit step per channel. The capture's `maxChannelDelta` is the same claim measured on the real + * frame; this is the arithmetic underneath it, over every value the frame can hold. + * + * It is a claim about the seat the stage takes, not about a fitted curve. `grade.ts` grades after + * the output transform, where a `.cube` and an 8-bit texture already share one domain, so the + * table's own quantisation is the whole of the error. Mapping scene-referred light onto that domain + * and back was measured and dropped: the best two-parameter shaper cost 2.0 steps per channel, and + * `(x/W)^g / (1 + (x/W)^g)` at `W = 4, g = 1/2.2` cost 4.3, against one here. + */ +describe("starter grade table", () => { + it("round-trips every value the frame can hold within one 8-bit step", () => { + let worst = 0; + let at = ""; + for (let channel = 0; channel < 3; channel++) { + for (let code = 0; code <= 255; code++) { + const value = code / 255; + const delta = + Math.abs(sampled(identity.bytes, identity.size, value, channel) - value) * 255; + if (delta > worst) { + worst = delta; + at = `channel ${channel} at display value ${value.toFixed(4)}`; + } + } + } + // The load truncates, so the table reads low by up to one step and never by two. + expect(worst, `worst ${at}`).toBeLessThanOrEqual(1); + }); + + it("keeps the 9³ grid inside 8 steps of the grade the generator wrote", () => { + // A coarse grid bends a smooth curve between texels, and the constants come from the generator + // itself, so raising its contrast — or its SIZE — cannot pass this unnoticed. + const source = readFileSync(GENERATOR, "utf8"); + const number = (pattern: RegExp, label: string): number => { + const found = pattern.exec(source)?.[1]; + if (found === undefined) + throw new Error(`tools/make-grade-lut.mjs no longer defines ${label}`); + return Number(found); + }; + const contrast = number(/^const CONTRAST = ([\d.]+);$/mu, "CONTRAST"); + const pivot = number(/^const PIVOT = ([\d.]+);$/mu, "PIVOT"); + // `GAIN` is written `b`, `g`, `r`; the uploaded texture's axes are red, green, blue. + const gain = [..."rgb"].map((name) => + number(new RegExp(`^const GAIN = \\{[^}]*\\b${name}: ([\\d.]+)`, "mu"), `GAIN.${name}`), + ); + const grade = (value: number, channel: number): number => + Math.min(1, pivot ** (1 - contrast) * value ** contrast * (gain[channel] ?? 1)); + + let worst = 0; + for (let channel = 0; channel < 3; channel++) { + for (let code = 0; code <= 255; code++) { + const value = code / 255; + worst = Math.max( + worst, + Math.abs(sampled(shipped.bytes, shipped.size, value, channel) - grade(value, channel)) * + 255, + ); + } + } + // All of it lives in the top interval of a channel whose gain clips; see SIZE in the generator. + expect(worst).toBeLessThan(8); + }); + + it("leaves the table's own edges at the frame's own edges", () => { + // A grid that does not reach 0 and 1 lifts the blacks and washes the highlights, and it is + // invisible in the arithmetic above because that measures the identity table. + for (const channel of [0, 1, 2]) { + expect(sampled(shipped.bytes, shipped.size, 0, channel)).toBeCloseTo(0, 6); + expect(sampled(shipped.bytes, shipped.size, 1, channel)).toBeCloseTo(1, 6); + } + }); +}); + +/** A table that exists. `available()` never reads it — `build()` does. */ +const LOADED: IGradeTable = { size: 9, texture: {} as IGradeTable["texture"] }; + +/** + * What the chain will be told, asked of the stage objects themselves rather than of a helper: this + * is the path `TN_RENDER_CHAIN`'s `dropped` list is built from. + */ +const available = ( + name: "grade" | "grain", + settings: IGradeSettings, + tableLoaded: boolean, +): boolean | string | undefined => + gradeStages(settings, tableLoaded ? LOADED : undefined).find((stage) => stage.name === name) + ?.available?.(undefined as never); + +describe("starter grade tiers", () => { + it("runs grade and grain at high", () => { + const settings = gradePreset("high"); + expect(available("grade", settings, true)).toBe(true); + expect(available("grain", settings, true)).toBe(true); + }); + + it("refuses grain at low with a reason, and refuses grade while the table is still loading", () => { + const settings = gradePreset("low"); + expect(available("grain", settings, true)).toBe("grainIntensity:0"); + expect(available("grade", settings, false)).toBe("lut:pending"); + expect(available("grade", settings, true)).toBe(true); + }); + + it("refuses the grade outright when this game set its intensity to zero", () => { + expect(available("grade", { ...gradePreset("high"), gradeIntensity: 0 }, true)).toBe( + "gradeIntensity:0", + ); + expect(available("grain", { ...gradePreset("high"), grainIntensity: 0 }, true)).toBe( + "grainIntensity:0", + ); + }); + + it("keeps every tier's presets reachable by name and unknown names fatal", () => { + for (const tier of ["low", "medium", "high"] as const) { + expect(gradePreset(tier).gradeIntensity).toBeGreaterThan(0); + expect(gradePreset(tier).tier).toBe(tier); + expect(qualityPreset(tier)).toBeTruthy(); + expect(resolveQualityTier({ tier })).toBe(tier); + } + expect(() => gradePreset("ultra")).toThrow(/Unknown quality tier/u); + }); + + it("hands the chain one stage that owns the output transform and one that follows it", () => { + // `grain` may not claim the seat: two stages claiming it would both expect the transform, and + // the chain refuses a stage that declares it with an anchor. + const stages = gradeStages(gradePreset("high"), LOADED); + expect(stages.map((stage) => stage.name)).toEqual(["grade", "grain"]); + expect(stages[0]?.afterOutputTransform).toBe(true); + expect(stages[0]?.after).toBeUndefined(); + expect(stages[1]?.after).toBe("grade"); + expect(stages[1]?.afterOutputTransform).toBeUndefined(); + }); +}); diff --git a/packages/create-threenative/__tests__/looks.spec.ts b/packages/create-threenative/__tests__/looks.spec.ts index fd07df7969..2936a86c74 100644 --- a/packages/create-threenative/__tests__/looks.spec.ts +++ b/packages/create-threenative/__tests__/looks.spec.ts @@ -261,12 +261,15 @@ describe("starter visual floor", () => { }); expect(scenario.assert?.camera).toMatchObject({ entity: "camera.main", follows: "player" }); expect(scenario.assert?.movement).toMatchObject({ entity: "player", minDistance: 4 }); - // AO, bloom and vignette are the whole shipped chain, and each one is proved to have changed - // the graph output rather than being reported as applied. + // AO, bloom and vignette are the chain this kit ships, `grade` and `grain` are the two stages + // its own `src/render/grade.ts` adds, and each one is proved to have changed the graph output + // rather than being reported as applied. expect(scenario.assert?.renderChain?.stages?.includes).toEqual([ "ambientOcclusion", "bloom", "vignette", + "grade", + "grain", ]); expect(Object.keys(scenario.assert?.renderChain?.contributions ?? {})).toEqual([ "graphOutputChanged", diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 95c37fb50e..844a986954 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -173,21 +173,22 @@ const BUG_REPORT_SKILL_PATHS = [ // Current develop c18a42b integration: all13 actual generated trees were byte-compared // against reviewed 8bf trees; only the copied Three compute-only Storage3DTexture patch changed. const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { - // Measured through actual createProject trees after exposure/fog consolidation and restamping. - "action-rpg": "e25c061a7a41de400ab6ebd5db8eb152c55282e598d2697e4e805db9971e63c2", - minimal: "3bf8a85a5f3fd117c724280a76e8cef7c3ddcbd52c40f5724a8fafdf104fa2c8", - platformer: "ad15697abbf5457e7837dfb74347225d47fbf42e25d65eecbba1a1c7b3b0455c", - puzzle: "f61c60c051e78aa077ffcdfdb3ecb414430c2a44e3b5ea3bc3d460453c3d170b", - racing: "cf943ef8baf12e003dc91660b3892e30b634338bdd785448825d4f83277e4313", - rain: "8f83ed441cbf32dbb5f377ef1aeea97f9801e5acf0230ca3738148aad627e8ce", - rts: "add3e851fbb949ce838cb3bab4d6583d4c680e5526f700bec87d3a2d5fd659b0", - runner: "e7361fabfb2a77a3631c35806a8c7d153b898db81681ff2e6e89fd39a2511a38", + // Measured through actual createProject trees after PRD-492 added the shared + // `afterOutputTransform` stage seat and restamped every kit's worldEnvironment.ts. + "action-rpg": "cdd5083785cd2412c4075ebc065c5066cf7015d9327d03843b215eded8bd1db3", + minimal: "91160aa100c06f2a50c5617cdf3642527b6f5c5f0e1bc8dae99b1f106a289612", + platformer: "4dd60b847cd8ad867bb00402b68d88057dd09539325159fdacd7718189315255", + puzzle: "41985ceb32ea3ac10bf31e8d49250fc8f97761a32e2b8f58b3516b6cff471dae", + racing: "34318cdb7f9f1f3d3fe061add9f20e366c12a01fa5f3c165d91f1a1bd5fa347e", + rain: "44b42ea26d60ef69dc37874677d35786204420ed43b276368750700711b5ddae", + rts: "d277cef182a3cb24b847dcbdc776236836c336745d6cf97106c17606b49f4b54", + runner: "9a652c148a765b460e885196ac18afd7e824a2703545f998485fdc93cac46d6b", // Initial finite-height readiness plus its scene-owned lifecycle helper and mirrored docs. - sailing: "eecb0f2ada58d267a42b243165bdcec05b6a9be56373e7acdede6d5323aaf911", - shooter: "d4002cfc6533bccf74aa6bcf46d8dbbf53a7699c62bea85135e0ba3fd495a13b", - snow: "363afd6cc69cfe9be73cef7516ee3a46105d369043511c5d6a5dd5a587b3bdce", - starter: "63420ca71607875427f80e6ae93d1778896ac63997143f65cf8a0f1d951750ae", - "tower-defense": "04ec210d3a1b5330cb661c04cf1829cdf3f1673c3a24a215116921d90f5bddc4", + sailing: "f7325e140caa16beec51dcf21a4783043a2f3c59fcf8c835efe7cbf83d16fbc5", + shooter: "631f25520770c31348c19c83c8c75fba2c582444e819728c2209093d92f09538", + snow: "db0d2981e2eb00eeadcd67e08dcdcc363b89d5040f4f663d5519625de2d82855", + starter: "84490b95dd218959bc49b8777094f416ef2d2cf1bec568c406fc61cf22444891", + "tower-defense": "5920816de90126bf2426114402445087ac3ca6c7ce483d2d3d0f43c07c413973", }; const GENERATED_SCAFFOLD_METADATA = @@ -272,6 +273,7 @@ const STARTER_PATHS = [ "patches/three@0.185.1.patch", "threenative.config.ts", "tools/look.mjs", + "tools/make-grade-lut.mjs", "scripts/reference.mjs", "scripts/visual-loop.mjs", "index.html", @@ -284,6 +286,7 @@ const STARTER_PATHS = [ "src/scenes/Play.ts", "src/render/lighting.ts", "src/render/postprocessing.ts", + "src/render/grade.ts", "src/render/worldEnvironment.ts", "src/render/exposure.ts", "src/render/autoExposure.ts", @@ -319,6 +322,7 @@ const STARTER_PATHS = [ "assets/native-proof.glb", "assets/native-proof.png", "public/icon.png", + "public/grade.cube", "assets/pickup.wav", // PRD-449: no `agent-docs/` — the recipes ship in the installed `create-threenative`, and the // "ships no reference bundle" test is what pins that. diff --git a/packages/create-threenative/template-assets/worldEnvironment.ts b/packages/create-threenative/template-assets/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/template-assets/worldEnvironment.ts +++ b/packages/create-threenative/template-assets/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/template-playtests/starter/look.playtest.json b/packages/create-threenative/template-playtests/starter/look.playtest.json index 90909e4fbf..dde69a072f 100644 --- a/packages/create-threenative/template-playtests/starter/look.playtest.json +++ b/packages/create-threenative/template-playtests/starter/look.playtest.json @@ -68,18 +68,19 @@ "software": { "contributions": { "graphOutputChanged": ["bloom", "vignette", "antialias"] }, "stages": { - "includes": ["bloom", "vignette", "antialias"], - "order": ["bloom", "vignette", "antialias"] + "includes": ["bloom", "vignette", "antialias", "grade"], + "excludes": ["grain"], + "order": ["bloom", "vignette", "antialias", "grade"] }, "tier": "low" } }, "stages": { - "includes": ["ambientOcclusion", "bloom", "vignette"], - "order": ["ambientOcclusion", "bloom", "vignette"] + "includes": ["ambientOcclusion", "bloom", "vignette", "grade", "grain"], + "order": ["ambientOcclusion", "bloom", "vignette", "grade", "grain"] }, "contributions": { - "graphOutputChanged": ["ambientOcclusion", "bloom", "vignette"] + "graphOutputChanged": ["ambientOcclusion", "bloom", "vignette", "grade", "grain"] } }, "visibility": [ diff --git a/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts b/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts b/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/minimal/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts b/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/platformer/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts b/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/puzzle/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts b/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/racing/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/rain/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts b/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/rts/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts b/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/runner/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts b/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/sailing/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts b/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/shooter/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/snow/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/templates/starter/public/grade.cube b/packages/create-threenative/templates/starter/public/grade.cube new file mode 100644 index 0000000000..684d2ec126 --- /dev/null +++ b/packages/create-threenative/templates/starter/public/grade.cube @@ -0,0 +1,735 @@ +# Written by tools/make-grade-lut.mjs — edit that, not this file. +# This game's grade. +# Red varies fastest, which is the Cube LUT order and the x axis of the uploaded 3D texture. +TITLE "starter neutral-warm" +LUT_3D_SIZE 9 + +0.000000 0.000000 0.000000 +0.130856 0.000000 0.000000 +0.272825 0.000000 0.000000 +0.419315 0.000000 0.000000 +0.568821 0.000000 0.000000 +0.720610 0.000000 0.000000 +0.874243 0.000000 0.000000 +1.000000 0.000000 0.000000 +1.000000 0.000000 0.000000 +0.000000 0.122295 0.000000 +0.130856 0.122295 0.000000 +0.272825 0.122295 0.000000 +0.419315 0.122295 0.000000 +0.568821 0.122295 0.000000 +0.720610 0.122295 0.000000 +0.874243 0.122295 0.000000 +1.000000 0.122295 0.000000 +1.000000 0.122295 0.000000 +0.000000 0.254976 0.000000 +0.130856 0.254976 0.000000 +0.272825 0.254976 0.000000 +0.419315 0.254976 0.000000 +0.568821 0.254976 0.000000 +0.720610 0.254976 0.000000 +0.874243 0.254976 0.000000 +1.000000 0.254976 0.000000 +1.000000 0.254976 0.000000 +0.000000 0.391883 0.000000 +0.130856 0.391883 0.000000 +0.272825 0.391883 0.000000 +0.419315 0.391883 0.000000 +0.568821 0.391883 0.000000 +0.720610 0.391883 0.000000 +0.874243 0.391883 0.000000 +1.000000 0.391883 0.000000 +1.000000 0.391883 0.000000 +0.000000 0.531608 0.000000 +0.130856 0.531608 0.000000 +0.272825 0.531608 0.000000 +0.419315 0.531608 0.000000 +0.568821 0.531608 0.000000 +0.720610 0.531608 0.000000 +0.874243 0.531608 0.000000 +1.000000 0.531608 0.000000 +1.000000 0.531608 0.000000 +0.000000 0.673467 0.000000 +0.130856 0.673467 0.000000 +0.272825 0.673467 0.000000 +0.419315 0.673467 0.000000 +0.568821 0.673467 0.000000 +0.720610 0.673467 0.000000 +0.874243 0.673467 0.000000 +1.000000 0.673467 0.000000 +1.000000 0.673467 0.000000 +0.000000 0.817050 0.000000 +0.130856 0.817050 0.000000 +0.272825 0.817050 0.000000 +0.419315 0.817050 0.000000 +0.568821 0.817050 0.000000 +0.720610 0.817050 0.000000 +0.874243 0.817050 0.000000 +1.000000 0.817050 0.000000 +1.000000 0.817050 0.000000 +0.000000 0.962082 0.000000 +0.130856 0.962082 0.000000 +0.272825 0.962082 0.000000 +0.419315 0.962082 0.000000 +0.568821 0.962082 0.000000 +0.720610 0.962082 0.000000 +0.874243 0.962082 0.000000 +1.000000 0.962082 0.000000 +1.000000 0.962082 0.000000 +0.000000 1.000000 0.000000 +0.130856 1.000000 0.000000 +0.272825 1.000000 0.000000 +0.419315 1.000000 0.000000 +0.568821 1.000000 0.000000 +0.720610 1.000000 0.000000 +0.874243 1.000000 0.000000 +1.000000 1.000000 0.000000 +1.000000 1.000000 0.000000 +0.000000 0.000000 0.114957 +0.130856 0.000000 0.114957 +0.272825 0.000000 0.114957 +0.419315 0.000000 0.114957 +0.568821 0.000000 0.114957 +0.720610 0.000000 0.114957 +0.874243 0.000000 0.114957 +1.000000 0.000000 0.114957 +1.000000 0.000000 0.114957 +0.000000 0.122295 0.114957 +0.130856 0.122295 0.114957 +0.272825 0.122295 0.114957 +0.419315 0.122295 0.114957 +0.568821 0.122295 0.114957 +0.720610 0.122295 0.114957 +0.874243 0.122295 0.114957 +1.000000 0.122295 0.114957 +1.000000 0.122295 0.114957 +0.000000 0.254976 0.114957 +0.130856 0.254976 0.114957 +0.272825 0.254976 0.114957 +0.419315 0.254976 0.114957 +0.568821 0.254976 0.114957 +0.720610 0.254976 0.114957 +0.874243 0.254976 0.114957 +1.000000 0.254976 0.114957 +1.000000 0.254976 0.114957 +0.000000 0.391883 0.114957 +0.130856 0.391883 0.114957 +0.272825 0.391883 0.114957 +0.419315 0.391883 0.114957 +0.568821 0.391883 0.114957 +0.720610 0.391883 0.114957 +0.874243 0.391883 0.114957 +1.000000 0.391883 0.114957 +1.000000 0.391883 0.114957 +0.000000 0.531608 0.114957 +0.130856 0.531608 0.114957 +0.272825 0.531608 0.114957 +0.419315 0.531608 0.114957 +0.568821 0.531608 0.114957 +0.720610 0.531608 0.114957 +0.874243 0.531608 0.114957 +1.000000 0.531608 0.114957 +1.000000 0.531608 0.114957 +0.000000 0.673467 0.114957 +0.130856 0.673467 0.114957 +0.272825 0.673467 0.114957 +0.419315 0.673467 0.114957 +0.568821 0.673467 0.114957 +0.720610 0.673467 0.114957 +0.874243 0.673467 0.114957 +1.000000 0.673467 0.114957 +1.000000 0.673467 0.114957 +0.000000 0.817050 0.114957 +0.130856 0.817050 0.114957 +0.272825 0.817050 0.114957 +0.419315 0.817050 0.114957 +0.568821 0.817050 0.114957 +0.720610 0.817050 0.114957 +0.874243 0.817050 0.114957 +1.000000 0.817050 0.114957 +1.000000 0.817050 0.114957 +0.000000 0.962082 0.114957 +0.130856 0.962082 0.114957 +0.272825 0.962082 0.114957 +0.419315 0.962082 0.114957 +0.568821 0.962082 0.114957 +0.720610 0.962082 0.114957 +0.874243 0.962082 0.114957 +1.000000 0.962082 0.114957 +1.000000 0.962082 0.114957 +0.000000 1.000000 0.114957 +0.130856 1.000000 0.114957 +0.272825 1.000000 0.114957 +0.419315 1.000000 0.114957 +0.568821 1.000000 0.114957 +0.720610 1.000000 0.114957 +0.874243 1.000000 0.114957 +1.000000 1.000000 0.114957 +1.000000 1.000000 0.114957 +0.000000 0.000000 0.239678 +0.130856 0.000000 0.239678 +0.272825 0.000000 0.239678 +0.419315 0.000000 0.239678 +0.568821 0.000000 0.239678 +0.720610 0.000000 0.239678 +0.874243 0.000000 0.239678 +1.000000 0.000000 0.239678 +1.000000 0.000000 0.239678 +0.000000 0.122295 0.239678 +0.130856 0.122295 0.239678 +0.272825 0.122295 0.239678 +0.419315 0.122295 0.239678 +0.568821 0.122295 0.239678 +0.720610 0.122295 0.239678 +0.874243 0.122295 0.239678 +1.000000 0.122295 0.239678 +1.000000 0.122295 0.239678 +0.000000 0.254976 0.239678 +0.130856 0.254976 0.239678 +0.272825 0.254976 0.239678 +0.419315 0.254976 0.239678 +0.568821 0.254976 0.239678 +0.720610 0.254976 0.239678 +0.874243 0.254976 0.239678 +1.000000 0.254976 0.239678 +1.000000 0.254976 0.239678 +0.000000 0.391883 0.239678 +0.130856 0.391883 0.239678 +0.272825 0.391883 0.239678 +0.419315 0.391883 0.239678 +0.568821 0.391883 0.239678 +0.720610 0.391883 0.239678 +0.874243 0.391883 0.239678 +1.000000 0.391883 0.239678 +1.000000 0.391883 0.239678 +0.000000 0.531608 0.239678 +0.130856 0.531608 0.239678 +0.272825 0.531608 0.239678 +0.419315 0.531608 0.239678 +0.568821 0.531608 0.239678 +0.720610 0.531608 0.239678 +0.874243 0.531608 0.239678 +1.000000 0.531608 0.239678 +1.000000 0.531608 0.239678 +0.000000 0.673467 0.239678 +0.130856 0.673467 0.239678 +0.272825 0.673467 0.239678 +0.419315 0.673467 0.239678 +0.568821 0.673467 0.239678 +0.720610 0.673467 0.239678 +0.874243 0.673467 0.239678 +1.000000 0.673467 0.239678 +1.000000 0.673467 0.239678 +0.000000 0.817050 0.239678 +0.130856 0.817050 0.239678 +0.272825 0.817050 0.239678 +0.419315 0.817050 0.239678 +0.568821 0.817050 0.239678 +0.720610 0.817050 0.239678 +0.874243 0.817050 0.239678 +1.000000 0.817050 0.239678 +1.000000 0.817050 0.239678 +0.000000 0.962082 0.239678 +0.130856 0.962082 0.239678 +0.272825 0.962082 0.239678 +0.419315 0.962082 0.239678 +0.568821 0.962082 0.239678 +0.720610 0.962082 0.239678 +0.874243 0.962082 0.239678 +1.000000 0.962082 0.239678 +1.000000 0.962082 0.239678 +0.000000 1.000000 0.239678 +0.130856 1.000000 0.239678 +0.272825 1.000000 0.239678 +0.419315 1.000000 0.239678 +0.568821 1.000000 0.239678 +0.720610 1.000000 0.239678 +0.874243 1.000000 0.239678 +1.000000 1.000000 0.239678 +1.000000 1.000000 0.239678 +0.000000 0.000000 0.368370 +0.130856 0.000000 0.368370 +0.272825 0.000000 0.368370 +0.419315 0.000000 0.368370 +0.568821 0.000000 0.368370 +0.720610 0.000000 0.368370 +0.874243 0.000000 0.368370 +1.000000 0.000000 0.368370 +1.000000 0.000000 0.368370 +0.000000 0.122295 0.368370 +0.130856 0.122295 0.368370 +0.272825 0.122295 0.368370 +0.419315 0.122295 0.368370 +0.568821 0.122295 0.368370 +0.720610 0.122295 0.368370 +0.874243 0.122295 0.368370 +1.000000 0.122295 0.368370 +1.000000 0.122295 0.368370 +0.000000 0.254976 0.368370 +0.130856 0.254976 0.368370 +0.272825 0.254976 0.368370 +0.419315 0.254976 0.368370 +0.568821 0.254976 0.368370 +0.720610 0.254976 0.368370 +0.874243 0.254976 0.368370 +1.000000 0.254976 0.368370 +1.000000 0.254976 0.368370 +0.000000 0.391883 0.368370 +0.130856 0.391883 0.368370 +0.272825 0.391883 0.368370 +0.419315 0.391883 0.368370 +0.568821 0.391883 0.368370 +0.720610 0.391883 0.368370 +0.874243 0.391883 0.368370 +1.000000 0.391883 0.368370 +1.000000 0.391883 0.368370 +0.000000 0.531608 0.368370 +0.130856 0.531608 0.368370 +0.272825 0.531608 0.368370 +0.419315 0.531608 0.368370 +0.568821 0.531608 0.368370 +0.720610 0.531608 0.368370 +0.874243 0.531608 0.368370 +1.000000 0.531608 0.368370 +1.000000 0.531608 0.368370 +0.000000 0.673467 0.368370 +0.130856 0.673467 0.368370 +0.272825 0.673467 0.368370 +0.419315 0.673467 0.368370 +0.568821 0.673467 0.368370 +0.720610 0.673467 0.368370 +0.874243 0.673467 0.368370 +1.000000 0.673467 0.368370 +1.000000 0.673467 0.368370 +0.000000 0.817050 0.368370 +0.130856 0.817050 0.368370 +0.272825 0.817050 0.368370 +0.419315 0.817050 0.368370 +0.568821 0.817050 0.368370 +0.720610 0.817050 0.368370 +0.874243 0.817050 0.368370 +1.000000 0.817050 0.368370 +1.000000 0.817050 0.368370 +0.000000 0.962082 0.368370 +0.130856 0.962082 0.368370 +0.272825 0.962082 0.368370 +0.419315 0.962082 0.368370 +0.568821 0.962082 0.368370 +0.720610 0.962082 0.368370 +0.874243 0.962082 0.368370 +1.000000 0.962082 0.368370 +1.000000 0.962082 0.368370 +0.000000 1.000000 0.368370 +0.130856 1.000000 0.368370 +0.272825 1.000000 0.368370 +0.419315 1.000000 0.368370 +0.568821 1.000000 0.368370 +0.720610 1.000000 0.368370 +0.874243 1.000000 0.368370 +1.000000 1.000000 0.368370 +1.000000 1.000000 0.368370 +0.000000 0.000000 0.499712 +0.130856 0.000000 0.499712 +0.272825 0.000000 0.499712 +0.419315 0.000000 0.499712 +0.568821 0.000000 0.499712 +0.720610 0.000000 0.499712 +0.874243 0.000000 0.499712 +1.000000 0.000000 0.499712 +1.000000 0.000000 0.499712 +0.000000 0.122295 0.499712 +0.130856 0.122295 0.499712 +0.272825 0.122295 0.499712 +0.419315 0.122295 0.499712 +0.568821 0.122295 0.499712 +0.720610 0.122295 0.499712 +0.874243 0.122295 0.499712 +1.000000 0.122295 0.499712 +1.000000 0.122295 0.499712 +0.000000 0.254976 0.499712 +0.130856 0.254976 0.499712 +0.272825 0.254976 0.499712 +0.419315 0.254976 0.499712 +0.568821 0.254976 0.499712 +0.720610 0.254976 0.499712 +0.874243 0.254976 0.499712 +1.000000 0.254976 0.499712 +1.000000 0.254976 0.499712 +0.000000 0.391883 0.499712 +0.130856 0.391883 0.499712 +0.272825 0.391883 0.499712 +0.419315 0.391883 0.499712 +0.568821 0.391883 0.499712 +0.720610 0.391883 0.499712 +0.874243 0.391883 0.499712 +1.000000 0.391883 0.499712 +1.000000 0.391883 0.499712 +0.000000 0.531608 0.499712 +0.130856 0.531608 0.499712 +0.272825 0.531608 0.499712 +0.419315 0.531608 0.499712 +0.568821 0.531608 0.499712 +0.720610 0.531608 0.499712 +0.874243 0.531608 0.499712 +1.000000 0.531608 0.499712 +1.000000 0.531608 0.499712 +0.000000 0.673467 0.499712 +0.130856 0.673467 0.499712 +0.272825 0.673467 0.499712 +0.419315 0.673467 0.499712 +0.568821 0.673467 0.499712 +0.720610 0.673467 0.499712 +0.874243 0.673467 0.499712 +1.000000 0.673467 0.499712 +1.000000 0.673467 0.499712 +0.000000 0.817050 0.499712 +0.130856 0.817050 0.499712 +0.272825 0.817050 0.499712 +0.419315 0.817050 0.499712 +0.568821 0.817050 0.499712 +0.720610 0.817050 0.499712 +0.874243 0.817050 0.499712 +1.000000 0.817050 0.499712 +1.000000 0.817050 0.499712 +0.000000 0.962082 0.499712 +0.130856 0.962082 0.499712 +0.272825 0.962082 0.499712 +0.419315 0.962082 0.499712 +0.568821 0.962082 0.499712 +0.720610 0.962082 0.499712 +0.874243 0.962082 0.499712 +1.000000 0.962082 0.499712 +1.000000 0.962082 0.499712 +0.000000 1.000000 0.499712 +0.130856 1.000000 0.499712 +0.272825 1.000000 0.499712 +0.419315 1.000000 0.499712 +0.568821 1.000000 0.499712 +0.720610 1.000000 0.499712 +0.874243 1.000000 0.499712 +1.000000 1.000000 0.499712 +1.000000 1.000000 0.499712 +0.000000 0.000000 0.633059 +0.130856 0.000000 0.633059 +0.272825 0.000000 0.633059 +0.419315 0.000000 0.633059 +0.568821 0.000000 0.633059 +0.720610 0.000000 0.633059 +0.874243 0.000000 0.633059 +1.000000 0.000000 0.633059 +1.000000 0.000000 0.633059 +0.000000 0.122295 0.633059 +0.130856 0.122295 0.633059 +0.272825 0.122295 0.633059 +0.419315 0.122295 0.633059 +0.568821 0.122295 0.633059 +0.720610 0.122295 0.633059 +0.874243 0.122295 0.633059 +1.000000 0.122295 0.633059 +1.000000 0.122295 0.633059 +0.000000 0.254976 0.633059 +0.130856 0.254976 0.633059 +0.272825 0.254976 0.633059 +0.419315 0.254976 0.633059 +0.568821 0.254976 0.633059 +0.720610 0.254976 0.633059 +0.874243 0.254976 0.633059 +1.000000 0.254976 0.633059 +1.000000 0.254976 0.633059 +0.000000 0.391883 0.633059 +0.130856 0.391883 0.633059 +0.272825 0.391883 0.633059 +0.419315 0.391883 0.633059 +0.568821 0.391883 0.633059 +0.720610 0.391883 0.633059 +0.874243 0.391883 0.633059 +1.000000 0.391883 0.633059 +1.000000 0.391883 0.633059 +0.000000 0.531608 0.633059 +0.130856 0.531608 0.633059 +0.272825 0.531608 0.633059 +0.419315 0.531608 0.633059 +0.568821 0.531608 0.633059 +0.720610 0.531608 0.633059 +0.874243 0.531608 0.633059 +1.000000 0.531608 0.633059 +1.000000 0.531608 0.633059 +0.000000 0.673467 0.633059 +0.130856 0.673467 0.633059 +0.272825 0.673467 0.633059 +0.419315 0.673467 0.633059 +0.568821 0.673467 0.633059 +0.720610 0.673467 0.633059 +0.874243 0.673467 0.633059 +1.000000 0.673467 0.633059 +1.000000 0.673467 0.633059 +0.000000 0.817050 0.633059 +0.130856 0.817050 0.633059 +0.272825 0.817050 0.633059 +0.419315 0.817050 0.633059 +0.568821 0.817050 0.633059 +0.720610 0.817050 0.633059 +0.874243 0.817050 0.633059 +1.000000 0.817050 0.633059 +1.000000 0.817050 0.633059 +0.000000 0.962082 0.633059 +0.130856 0.962082 0.633059 +0.272825 0.962082 0.633059 +0.419315 0.962082 0.633059 +0.568821 0.962082 0.633059 +0.720610 0.962082 0.633059 +0.874243 0.962082 0.633059 +1.000000 0.962082 0.633059 +1.000000 0.962082 0.633059 +0.000000 1.000000 0.633059 +0.130856 1.000000 0.633059 +0.272825 1.000000 0.633059 +0.419315 1.000000 0.633059 +0.568821 1.000000 0.633059 +0.720610 1.000000 0.633059 +0.874243 1.000000 0.633059 +1.000000 1.000000 0.633059 +1.000000 1.000000 0.633059 +0.000000 0.000000 0.768027 +0.130856 0.000000 0.768027 +0.272825 0.000000 0.768027 +0.419315 0.000000 0.768027 +0.568821 0.000000 0.768027 +0.720610 0.000000 0.768027 +0.874243 0.000000 0.768027 +1.000000 0.000000 0.768027 +1.000000 0.000000 0.768027 +0.000000 0.122295 0.768027 +0.130856 0.122295 0.768027 +0.272825 0.122295 0.768027 +0.419315 0.122295 0.768027 +0.568821 0.122295 0.768027 +0.720610 0.122295 0.768027 +0.874243 0.122295 0.768027 +1.000000 0.122295 0.768027 +1.000000 0.122295 0.768027 +0.000000 0.254976 0.768027 +0.130856 0.254976 0.768027 +0.272825 0.254976 0.768027 +0.419315 0.254976 0.768027 +0.568821 0.254976 0.768027 +0.720610 0.254976 0.768027 +0.874243 0.254976 0.768027 +1.000000 0.254976 0.768027 +1.000000 0.254976 0.768027 +0.000000 0.391883 0.768027 +0.130856 0.391883 0.768027 +0.272825 0.391883 0.768027 +0.419315 0.391883 0.768027 +0.568821 0.391883 0.768027 +0.720610 0.391883 0.768027 +0.874243 0.391883 0.768027 +1.000000 0.391883 0.768027 +1.000000 0.391883 0.768027 +0.000000 0.531608 0.768027 +0.130856 0.531608 0.768027 +0.272825 0.531608 0.768027 +0.419315 0.531608 0.768027 +0.568821 0.531608 0.768027 +0.720610 0.531608 0.768027 +0.874243 0.531608 0.768027 +1.000000 0.531608 0.768027 +1.000000 0.531608 0.768027 +0.000000 0.673467 0.768027 +0.130856 0.673467 0.768027 +0.272825 0.673467 0.768027 +0.419315 0.673467 0.768027 +0.568821 0.673467 0.768027 +0.720610 0.673467 0.768027 +0.874243 0.673467 0.768027 +1.000000 0.673467 0.768027 +1.000000 0.673467 0.768027 +0.000000 0.817050 0.768027 +0.130856 0.817050 0.768027 +0.272825 0.817050 0.768027 +0.419315 0.817050 0.768027 +0.568821 0.817050 0.768027 +0.720610 0.817050 0.768027 +0.874243 0.817050 0.768027 +1.000000 0.817050 0.768027 +1.000000 0.817050 0.768027 +0.000000 0.962082 0.768027 +0.130856 0.962082 0.768027 +0.272825 0.962082 0.768027 +0.419315 0.962082 0.768027 +0.568821 0.962082 0.768027 +0.720610 0.962082 0.768027 +0.874243 0.962082 0.768027 +1.000000 0.962082 0.768027 +1.000000 0.962082 0.768027 +0.000000 1.000000 0.768027 +0.130856 1.000000 0.768027 +0.272825 1.000000 0.768027 +0.419315 1.000000 0.768027 +0.568821 1.000000 0.768027 +0.720610 1.000000 0.768027 +0.874243 1.000000 0.768027 +1.000000 1.000000 0.768027 +1.000000 1.000000 0.768027 +0.000000 0.000000 0.904357 +0.130856 0.000000 0.904357 +0.272825 0.000000 0.904357 +0.419315 0.000000 0.904357 +0.568821 0.000000 0.904357 +0.720610 0.000000 0.904357 +0.874243 0.000000 0.904357 +1.000000 0.000000 0.904357 +1.000000 0.000000 0.904357 +0.000000 0.122295 0.904357 +0.130856 0.122295 0.904357 +0.272825 0.122295 0.904357 +0.419315 0.122295 0.904357 +0.568821 0.122295 0.904357 +0.720610 0.122295 0.904357 +0.874243 0.122295 0.904357 +1.000000 0.122295 0.904357 +1.000000 0.122295 0.904357 +0.000000 0.254976 0.904357 +0.130856 0.254976 0.904357 +0.272825 0.254976 0.904357 +0.419315 0.254976 0.904357 +0.568821 0.254976 0.904357 +0.720610 0.254976 0.904357 +0.874243 0.254976 0.904357 +1.000000 0.254976 0.904357 +1.000000 0.254976 0.904357 +0.000000 0.391883 0.904357 +0.130856 0.391883 0.904357 +0.272825 0.391883 0.904357 +0.419315 0.391883 0.904357 +0.568821 0.391883 0.904357 +0.720610 0.391883 0.904357 +0.874243 0.391883 0.904357 +1.000000 0.391883 0.904357 +1.000000 0.391883 0.904357 +0.000000 0.531608 0.904357 +0.130856 0.531608 0.904357 +0.272825 0.531608 0.904357 +0.419315 0.531608 0.904357 +0.568821 0.531608 0.904357 +0.720610 0.531608 0.904357 +0.874243 0.531608 0.904357 +1.000000 0.531608 0.904357 +1.000000 0.531608 0.904357 +0.000000 0.673467 0.904357 +0.130856 0.673467 0.904357 +0.272825 0.673467 0.904357 +0.419315 0.673467 0.904357 +0.568821 0.673467 0.904357 +0.720610 0.673467 0.904357 +0.874243 0.673467 0.904357 +1.000000 0.673467 0.904357 +1.000000 0.673467 0.904357 +0.000000 0.817050 0.904357 +0.130856 0.817050 0.904357 +0.272825 0.817050 0.904357 +0.419315 0.817050 0.904357 +0.568821 0.817050 0.904357 +0.720610 0.817050 0.904357 +0.874243 0.817050 0.904357 +1.000000 0.817050 0.904357 +1.000000 0.817050 0.904357 +0.000000 0.962082 0.904357 +0.130856 0.962082 0.904357 +0.272825 0.962082 0.904357 +0.419315 0.962082 0.904357 +0.568821 0.962082 0.904357 +0.720610 0.962082 0.904357 +0.874243 0.962082 0.904357 +1.000000 0.962082 0.904357 +1.000000 0.962082 0.904357 +0.000000 1.000000 0.904357 +0.130856 1.000000 0.904357 +0.272825 1.000000 0.904357 +0.419315 1.000000 0.904357 +0.568821 1.000000 0.904357 +0.720610 1.000000 0.904357 +0.874243 1.000000 0.904357 +1.000000 1.000000 0.904357 +1.000000 1.000000 0.904357 +0.000000 0.000000 1.000000 +0.130856 0.000000 1.000000 +0.272825 0.000000 1.000000 +0.419315 0.000000 1.000000 +0.568821 0.000000 1.000000 +0.720610 0.000000 1.000000 +0.874243 0.000000 1.000000 +1.000000 0.000000 1.000000 +1.000000 0.000000 1.000000 +0.000000 0.122295 1.000000 +0.130856 0.122295 1.000000 +0.272825 0.122295 1.000000 +0.419315 0.122295 1.000000 +0.568821 0.122295 1.000000 +0.720610 0.122295 1.000000 +0.874243 0.122295 1.000000 +1.000000 0.122295 1.000000 +1.000000 0.122295 1.000000 +0.000000 0.254976 1.000000 +0.130856 0.254976 1.000000 +0.272825 0.254976 1.000000 +0.419315 0.254976 1.000000 +0.568821 0.254976 1.000000 +0.720610 0.254976 1.000000 +0.874243 0.254976 1.000000 +1.000000 0.254976 1.000000 +1.000000 0.254976 1.000000 +0.000000 0.391883 1.000000 +0.130856 0.391883 1.000000 +0.272825 0.391883 1.000000 +0.419315 0.391883 1.000000 +0.568821 0.391883 1.000000 +0.720610 0.391883 1.000000 +0.874243 0.391883 1.000000 +1.000000 0.391883 1.000000 +1.000000 0.391883 1.000000 +0.000000 0.531608 1.000000 +0.130856 0.531608 1.000000 +0.272825 0.531608 1.000000 +0.419315 0.531608 1.000000 +0.568821 0.531608 1.000000 +0.720610 0.531608 1.000000 +0.874243 0.531608 1.000000 +1.000000 0.531608 1.000000 +1.000000 0.531608 1.000000 +0.000000 0.673467 1.000000 +0.130856 0.673467 1.000000 +0.272825 0.673467 1.000000 +0.419315 0.673467 1.000000 +0.568821 0.673467 1.000000 +0.720610 0.673467 1.000000 +0.874243 0.673467 1.000000 +1.000000 0.673467 1.000000 +1.000000 0.673467 1.000000 +0.000000 0.817050 1.000000 +0.130856 0.817050 1.000000 +0.272825 0.817050 1.000000 +0.419315 0.817050 1.000000 +0.568821 0.817050 1.000000 +0.720610 0.817050 1.000000 +0.874243 0.817050 1.000000 +1.000000 0.817050 1.000000 +1.000000 0.817050 1.000000 +0.000000 0.962082 1.000000 +0.130856 0.962082 1.000000 +0.272825 0.962082 1.000000 +0.419315 0.962082 1.000000 +0.568821 0.962082 1.000000 +0.720610 0.962082 1.000000 +0.874243 0.962082 1.000000 +1.000000 0.962082 1.000000 +1.000000 0.962082 1.000000 +0.000000 1.000000 1.000000 +0.130856 1.000000 1.000000 +0.272825 1.000000 1.000000 +0.419315 1.000000 1.000000 +0.568821 1.000000 1.000000 +0.720610 1.000000 1.000000 +0.874243 1.000000 1.000000 +1.000000 1.000000 1.000000 +1.000000 1.000000 1.000000 diff --git a/packages/create-threenative/templates/starter/src/render/grade.ts b/packages/create-threenative/templates/starter/src/render/grade.ts new file mode 100644 index 0000000000..d172079955 --- /dev/null +++ b/packages/create-threenative/templates/starter/src/render/grade.ts @@ -0,0 +1,134 @@ +// Generated for you: ordinary Three.js; ThreeNative does not read this file. +// +// The last two stages of this game's frame: a colour grade, and the film grain over it. Both are +// *authored* stages — `postprocessing.ts` hands their factories to `WorldEnvironment`, which owns +// the order, the tier and the report, so `TN_RENDER_CHAIN` names each one as applied or refused +// with a reason instead of this file guessing whether it ran. +// +// The table in `public/grade.cube` is this game's, written by `tools/make-grade-lut.mjs`. Replace +// that one file with any `.cube` from any grading tool and nothing else here changes. `quality.ts` +// owns how strong the grade is, how much grain there is, and which tier runs each. +// +// **Why `afterOutputTransform`.** A `.cube` table is authored against the picture a person sees: +// sRGB, tone-mapped, 0–1. This stage therefore reads the frame *after* the output transform and +// asks the chain to install the graph with the renderer's automatic transform switched off — the +// same arrangement three's own FXAA example uses. `renderOutput` below is the pipeline's own +// tone curve and encode, applied once and by hand, so the frame keeps exactly one output +// transform whether this stage runs or not. +// +// The alternative, mapping scene-referred light onto the table's domain and back, was measured and +// dropped: an 8-bit table quantises in 1/255 steps, and no bijection of the two-parameter family +// recovered the frame to within one 8-bit step per channel — the best of them cost 2.0, and +// `(x/W)^g / (1 + (x/W)^g)` with `W = 4, g = 1/2.2` cost 4.3. `__tests__/grade.spec.ts` holds the +// arithmetic; the value of the transform's own seat is that an identity table there is under one +// step by construction rather than by a fitted curve. +import type { Data3DTexture } from "three"; +import { film } from "three/addons/tsl/display/FilmNode.js"; +import { lut3D } from "three/addons/tsl/display/Lut3DNode.js"; +import { float, renderOutput, texture3D, time, uv } from "three/tsl"; +import type { Node } from "three/webgpu"; +import type { QualityTier } from "./quality.js"; +import type { ChainStage } from "./worldEnvironment.js"; + +/** + * What `TN_RENDER_CHAIN` prints when a stage is refused. Never blank, and never the same answer + * twice: a stage that quietly ran at zero strength looks exactly like one this game turned on. + * Module-local because the report is the contract, not the binding: a scenario reads these strings + * out of the marker, and `__tests__/grade.spec.ts` pins them as literals for that reason. + */ +const GRADE_REASONS = { + gradeOff: "gradeIntensity:0", + grainOff: "grainIntensity:0", + lutPending: "lut:pending", +} as const; + +/** What this game's tiers decide about its grade. `quality.ts` is the only writer. */ +export interface IGradeSettings { + /** How far the table's colour is mixed over the frame. Zero refuses the stage. */ + readonly gradeIntensity: number; + /** Whether the grain moves. A still frame gets the still pattern instead. */ + readonly grainAnimated: boolean; + /** How much grain. Zero refuses the stage. */ + readonly grainIntensity: number; + /** The tier these numbers were chosen for; reported with the decision. */ + readonly tier: QualityTier; +} + +/** The loaded table: the parsed `.cube` and the size `lut3D` has to be told. */ +export interface IGradeTable { + readonly size: number; + readonly texture: Data3DTexture; +} + +/** + * Whether one authored stage runs, or the reason it does not. + * + * An intensity of zero refuses rather than running at zero, so a tier that turned the look off is + * reported as off instead of reading as applied. `tableLoaded` is the grade's own: until the file + * has arrived there is nothing to look up, and the frame is better ungraded than wrongly graded. + * + * Module-local because the stages are the surface: what a scenario reads is what the chain asked + * each stage, so `__tests__/grade.spec.ts` calls `available()` on the returned stages rather than + * this function. + */ +function refusal( + name: "grade" | "grain", + settings: IGradeSettings, + tableLoaded: boolean, +): true | string { + if (name === "grain") return settings.grainIntensity > 0 ? true : GRADE_REASONS.grainOff; + if (!(settings.gradeIntensity > 0)) return GRADE_REASONS.gradeOff; + return tableLoaded ? true : GRADE_REASONS.lutPending; +} + +/** + * The two stages, in order. + * + * `grade` takes the output transform's seat and is last by construction; `grain` is anchored after + * it, so the grain sits over the graded picture — which is where a grain belongs, since a noise + * added before the tone curve is crushed in the shadows and blown out in the highlights. + */ +export function gradeStages( + settings: IGradeSettings, + table: IGradeTable | undefined, +): readonly ChainStage[] { + const loaded = table !== undefined; + return [ + { + name: "grade", + afterOutputTransform: true, + available: () => refusal("grade", settings, loaded), + build: (input) => { + if (table === undefined) + throw new Error( + "grade built before its table loaded; available() should have refused it.", + ); + // The pipeline's own transform, applied here because `afterOutputTransform` took it away + // from the renderer. Same tone curve, same encode, same exposure — applied once. + const display = renderOutput(input as Node<"vec4">); + const graded = lut3D( + display, + texture3D(table.texture), + table.size, + float(settings.gradeIntensity), + ); + // The addon's declaration types its own return as the raw node class, which omits the + // element API the proxied object carries at runtime. An assertion and nothing else, the + // same one `worldEnvironment.ts` makes for the denoise node: no node is added here. + return graded as unknown as Node<"vec4">; + }, + }, + { + name: "grain", + after: "grade", + available: () => refusal("grain", settings, loaded), + build: (input) => + film( + input as Node<"vec4">, + float(settings.grainIntensity), + // `film()` adds the clock to whatever uv it is handed, so a still grain hands it back. + settings.grainAnimated ? undefined : uv().sub(time), + ), + }, + ]; +} diff --git a/packages/create-threenative/templates/starter/src/render/postprocessing.ts b/packages/create-threenative/templates/starter/src/render/postprocessing.ts index e7547055ab..03286f9e92 100644 --- a/packages/create-threenative/templates/starter/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/starter/src/render/postprocessing.ts @@ -11,13 +11,15 @@ // `setupPost(renderer, scene, camera, { tier: "low" })`. Overriding does not silence the report: // `TN_QUALITY_TIER` names the tier that ran either way. import type { Camera, DirectionalLight, Scene } from "three"; +import { LUTCubeLoader } from "three/addons/loaders/LUTCubeLoader.js"; import { type IAdaptiveQualityOptions, type IQualityWindow, createAdaptiveQuality, formatQualityAdaptation, } from "./adaptiveQuality.js"; -import { type QualityTier, qualityPreset } from "./quality.js"; +import { type IGradeTable, gradeStages } from "./grade.js"; +import { type QualityTier, gradePreset, qualityPreset } from "./quality.js"; import type { FogMedium } from "./volumetricFog.js"; import { type OutputRenderer, WorldEnvironment } from "./worldEnvironment.js"; @@ -27,6 +29,13 @@ interface IPostController { dispose(): void; } +/** + * This game's colour table, relative to the page so it resolves the same way served and packaged. + * `public/grade.cube` is written by `tools/make-grade-lut.mjs`, and replacing it with a `.cube` + * from any grading tool is the whole of "change the grade". + */ +const GRADE_TABLE_URL = "grade.cube"; + let active: IPostController | undefined; /** The starter's game.ts connects its existing completed frame-window callback here. */ @@ -58,6 +67,7 @@ export function setupPost( let disposed = false; let disposeGraph: (() => void) | undefined; let medium: FogMedium | undefined; + let table: IGradeTable | undefined; let observation: Record = { tier: policy.tier, source: policy.pinned ? "pinned" : "auto", @@ -69,7 +79,13 @@ export function setupPost( medium = environment.fog?.(); const composed = medium; const settings = qualityPreset(policy.tier); - const world = new WorldEnvironment(settings); + const world = new WorldEnvironment({ + ...settings, + // Two stages this game owns, not the chain's: `grade.ts` builds them, `WorldEnvironment` + // orders and reports them, and `quality.ts` decides whether each tier runs them. + authoredStageNames: ["grade", "grain"], + authoredStages: () => gradeStages(gradePreset(policy.tier), table), + }); const applied = world.apply(renderer, scene, camera, { godraysLight: environment.godraysLight, // Ahead of exposure and every stage, which is the only place a participating medium can go. @@ -79,6 +95,23 @@ export function setupPost( observation = { ...observation, stages: applied.stages, dropped: applied.dropped }; } apply(); + // The table is a file, so it lands after the chain that would read it. Until then both stages + // are refused with a reason rather than grading nothing, and the chain is rebuilt once it does. + const tableLoader = new LUTCubeLoader(); + void tableLoader.loadAsync(GRADE_TABLE_URL).then( + (loaded) => { + table?.texture.dispose(); + table = { size: loaded.size, texture: loaded.texture3D }; + if (!disposed) apply(); + }, + (error: unknown) => { + // A missing table is this game's file, not the harness's: name it and leave the frame + // ungraded rather than reporting a stage as applied that never ran. + console.error( + `TN_GRADE_TABLE ${GRADE_TABLE_URL}: ${error instanceof Error ? error.message : String(error)}`, + ); + }, + ); const source = environment.tier === undefined ? "platform" : "override"; console.info( `TN_QUALITY_TIER ${policy.tier} mobile=${environment.mobile === true} software=${ @@ -106,6 +139,10 @@ export function setupPost( disposeGraph = undefined; medium?.dispose(); medium = undefined; + // The table outlives any one chain — a tier change rebuilds the graph around the same one — + // so it is released here and not by a stage that would take it with the first replacement. + table?.texture.dispose(); + table = undefined; if (active === controller) active = undefined; }, }; diff --git a/packages/create-threenative/templates/starter/src/render/quality.ts b/packages/create-threenative/templates/starter/src/render/quality.ts index be5327fc1f..b3ea10c360 100644 --- a/packages/create-threenative/templates/starter/src/render/quality.ts +++ b/packages/create-threenative/templates/starter/src/render/quality.ts @@ -19,6 +19,7 @@ // probe on `scene.environment`, measured at **~6.3 ms of an 18-19 ms Pixel 8 frame**. It is set // in `sky.ts`, not in this file. // +import type { IGradeSettings } from "./grade.js"; import type { IWorldEnvironmentOptions } from "./worldEnvironment.js"; /** @@ -116,6 +117,21 @@ const low: IWorldEnvironmentOptions = { ...shared, renderChainTier: "low" }; const QUALITY_PRESETS: Record = { high, low, medium }; +/** + * This game's colour grade and grain, per tier. The table itself lives in `public/grade.cube` and + * the maths in `grade.ts`; these are the four numbers the look is dialled with. + * + * A zero refuses its stage rather than running it at zero strength, so `low` — a phone, where a + * moving grain is the first thing to go — reports `grain` as refused with its reason instead of + * reading as applied. The grade survives there: it is one texture fetch and the frame is the + * same frame without it. + */ +const GRADE_PRESETS: Record = { + high: { gradeIntensity: 1, grainAnimated: true, grainIntensity: 0.12, tier: "high" }, + medium: { gradeIntensity: 1, grainAnimated: false, grainIntensity: 0.1, tier: "medium" }, + low: { gradeIntensity: 1, grainAnimated: false, grainIntensity: 0, tier: "low" }, +}; + /** The stages and strengths a tier turns on. Throws on a name that is not a tier. */ export function qualityPreset(tier: string): IWorldEnvironmentOptions { const preset = QUALITY_PRESETS[tier as QualityTier]; @@ -126,3 +142,14 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { } return preset; } + +/** The grade and grain a tier runs. Throws on a name that is not a tier, for the same reason. */ +export function gradePreset(tier: string): IGradeSettings { + const preset = GRADE_PRESETS[tier as QualityTier]; + if (preset === undefined) { + throw new Error( + `Unknown quality tier ${JSON.stringify(tier)} — expected one of ${QUALITY_TIERS.join(", ")}.`, + ); + } + return preset; +} diff --git a/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts b/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/starter/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; diff --git a/packages/create-threenative/templates/starter/tools/make-grade-lut.mjs b/packages/create-threenative/templates/starter/tools/make-grade-lut.mjs new file mode 100644 index 0000000000..eaae7a4468 --- /dev/null +++ b/packages/create-threenative/templates/starter/tools/make-grade-lut.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Writes this game's colour table to `public/grade.cube`. +// +// `src/render/grade.ts` loads whatever is in that file and looks it up; nothing else in the game +// knows what the grade is. Swap that file for a `.cube` exported from any grading tool and the +// stage reads that instead — this script exists so the shipped table is reproducible and its few +// numbers sit in one readable place, not because a `.cube` needs generating. +// +// node tools/make-grade-lut.mjs the shipped grade +// node tools/make-grade-lut.mjs --identity a table that changes nothing, for checking the +// round trip through grade.ts +// node tools/make-grade-lut.mjs --out other.cube +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Table edge length. 9 keeps the shipped table a readable 729 lines, and the cost of a coarse grid + * is measured rather than assumed: the hardware's trilinear interpolation of this grade departs + * from the curve it was written from by at most 7.5 of 255 steps, all of it in the top interval of + * a channel whose gain above one has to clip (`__tests__/grade.spec.ts` measures both numbers, and + * re-reads the constants below from this file, so raising SIZE cannot pass unnoticed). A grade that + * needs its top of the range followed exactly wants 17 or 33; a gentler curve wants neither. + */ +const SIZE = 9; + +/** The grade itself, in display-referred [0, 1] — which is what a `.cube` is written against. */ +const CONTRAST = 1.06; +const PIVOT = 0.18; +const GAIN = { b: 0.94, g: 1, r: 1.07 }; + +/** + * Contrast about a pivot: a power function that leaves the pivot alone and darkens below it. This + * is the one operation a grading tool would call a curve; the gain below is its channel mixer, and + * it is why the top of the warm channels clips — a gain above one cannot brighten a pixel that is + * already white, which is what the table above says about highlights. + */ +const grade = (value, channel) => + Math.min(1, PIVOT ** (1 - CONTRAST) * value ** CONTRAST * GAIN[channel]); + +function flag(name) { + return process.argv.includes(`--${name}`); +} + +function option(name, fallback) { + const at = process.argv.indexOf(`--${name}`); + return at === -1 ? fallback : process.argv[at + 1]; +} + +const identity = flag("identity"); +const target = option( + "out", + path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "public", "grade.cube"), +); + +const lines = [ + "# Written by tools/make-grade-lut.mjs — edit that, not this file.", + `# ${identity ? "Identity table: this game ungraded, to check the round trip." : "This game's grade."}`, + "# Red varies fastest, which is the Cube LUT order and the x axis of the uploaded 3D texture.", + `TITLE "starter ${identity ? "identity" : "neutral-warm"}"`, + `LUT_3D_SIZE ${SIZE}`, + "", +]; + +// `LUTCubeLoader` copies each line's three numbers into x, y then z, so the loop below walks x +// fastest and an identity table is a straight ramp along every axis. +for (let texel = 0; texel < SIZE ** 3; texel += 1) { + const r = (texel % SIZE) / (SIZE - 1); + const g = (Math.floor(texel / SIZE) % SIZE) / (SIZE - 1); + const b = (Math.floor(texel / SIZE ** 2) % SIZE) / (SIZE - 1); + const graded = [ + identity ? r : grade(r, "r"), + identity ? g : grade(g, "g"), + identity ? b : grade(b, "b"), + ]; + lines.push(graded.map((value) => value.toFixed(6)).join(" ")); +} + +mkdirSync(path.dirname(target), { recursive: true }); +writeFileSync(target, `${lines.join("\n")}\n`, "utf8"); +process.stdout.write(`wrote ${target} (${SIZE}^3, red varies fastest)\n`); diff --git a/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts b/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts index d1f82a7b8e..6e05f8c31e 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/worldEnvironment.ts @@ -296,6 +296,16 @@ export type ChainStage = { readonly name: string; readonly before?: string; readonly after?: string; + /** + * This stage reads display-referred colour — tone-mapped and sRGB-encoded — so the chain + * installs the graph with the renderer's automatic output transform switched off and the stage + * applies that transform itself (`renderOutput` from `three/tsl`). + * + * For effects authored against the picture rather than against light: a `.cube` table, an + * sRGB-space edge filter. Such a stage runs last and may not declare an anchor, and the chain + * only hands the renderer its transform back when the stage was actually refused. + */ + readonly afterOutputTransform?: boolean; readonly available?: (context: ChainContext) => boolean | string; readonly dispose?: () => void; readonly minimumTier?: ChainTier; From 1273f3bd639a600ed9a9c50ef3c8b17c4623428b Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 4 Oct 2026 11:40:03 -0700 Subject: [PATCH 2/5] style: biome's own formatting for grade.spec.ts The pre-push drift gate refused the push on a formatter diff biome had already reported once; this is the same one line, formatted. --- packages/create-threenative/__tests__/grade.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/create-threenative/__tests__/grade.spec.ts b/packages/create-threenative/__tests__/grade.spec.ts index 3a982b52ee..4c3079521e 100644 --- a/packages/create-threenative/__tests__/grade.spec.ts +++ b/packages/create-threenative/__tests__/grade.spec.ts @@ -159,7 +159,8 @@ const available = ( settings: IGradeSettings, tableLoaded: boolean, ): boolean | string | undefined => - gradeStages(settings, tableLoaded ? LOADED : undefined).find((stage) => stage.name === name) + gradeStages(settings, tableLoaded ? LOADED : undefined) + .find((stage) => stage.name === name) ?.available?.(undefined as never); describe("starter grade tiers", () => { From 4b20d24daff60e69bc48eaa17e8377263403ecf8 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 4 Oct 2026 12:07:00 -0700 Subject: [PATCH 3/5] test(native): a lut-grade conformance row, and the desktop scenario that could not run (PRD-492 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../PRD-492-colour-grading-and-film-grain.md | 22 +++- .../__tests__/scaffold.spec.ts | 2 +- .../render-chain.playtest.json | 13 +- .../runtime-native/conformance/registry.json | 27 ++++ .../conformance/scenes/shared/lut-grade.js | 118 ++++++++++++++++++ 5 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 packages/runtime-native/conformance/scenes/shared/lut-grade.js diff --git a/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md b/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md index 1f67279ae4..702752c9f4 100644 --- a/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md +++ b/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md @@ -1,6 +1,6 @@ # PRD-492 — Colour grading and film grain -**Status:** IN PROGRESS — Phase 1 landed; AC-1 and the identity round trip are open +**Status:** IN PROGRESS — Phase 1 landed, Phase 2's conformance row passes; AC-1, the identity round trip and the desktop staging of the table are open **Complexity:** 1 (LOW) — 1–5 files, all generated template source; no package change expected; risk override: none **Owner:** João **Depends on:** [PRD-VQ-02](../native/PRD-VQ-02-native-postprocessing-parity.md) (a post stage must not blank the native frame; this PRD adds two more stages to the same chain) @@ -54,8 +54,18 @@ gain above one has to clip the top of the range, and a coarse grid cannot follow clipping channel) and the spec re-reads the generator's own constants, so raising the contrast or the SIZE cannot pass unnoticed. -**`public/grade.cube` is not staged by the native packager yet.** `pnpm build:web` reports -`5 unmanaged file(s), 38890 bytes` — the table among them. Phase 2's desktop run is what decides it. +**The starter's desktop render-chain scenario could not run before this PRD touched it.** Its +`visibility` assertion is refused outright by the desktop runner +(`TN_PLAYTEST_OBSERVATION_UNAVAILABLE`, "Assertion 'visibility' is not supported on target +'desktop'"), so the scenario failed before it read a single stage and no box of this phase's first +item was reachable. The assertion is dropped, and `grade`/`grain` added to the stages and +contributions it does read. Nothing is lost: the runner never produced that observation on this +target, so the row was a hard error rather than a check. + +**`public/grade.cube` is not staged by the native packager yet.** `pnpm build:web` and +`pnpm build:desktop` both report it among the undeclared files — and the desktop staging loop copies +`selectManifestAssets(assets)` alone, so it never reaches the host. Phase 2's desktop run is what +decided it: `TN_GRADE_TABLE grade.cube: File read error`. ## Acceptance Criteria @@ -78,7 +88,7 @@ the SIZE cannot pass unnoticed. - [x] `TN_RENDER_CHAIN` names `grade` and `grain` as applied at `high`, and grain as refused with a reason at `low`. proof: `pnpm test:templates` with the starter's render-chain assertion extended. **Ran at `high` on the starter's `look.playtest.json`** (`packages/create-threenative/template-playtests/starter/look.playtest.json`): `renderChain.tier`, `renderChain.stages.includes`, `renderChain.stages.order` and `renderChain.contributions.graphOutputChanged` all pass at `adapterClass: hardware`, and the marker carries `stages: ["ambientOcclusion","bloom","vignette","antialias","grade","grain"]`, `dropped: []`, with `grade` and `grain` both `graphOutputChanged: true`. **The `low` leg** is `perAdapter.software`, which needs an adapter the harness classes as software — and Chromium here reaches no WebGPU adapter under SwiftShader (`TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING`, adapter request resolved null), so it was run instead on a throwaway copy of the scaffold with `setupPost(..., { tier: "low" })` pinned and a scenario asserting `stages: { includes: ["grade"], excludes: ["grain"] }`: both rows pass, and the marker carries `stages: [...,"grade"], dropped: [{"name":"grain","reason":"grainIntensity:0"}]`. Nothing about that pin is committed. #### Phase 2: Native runs the grade -**Status:** NOT STARTED +**Status:** PARTIAL — the conformance row passes; the starter's desktop scenario cannot yet, and the reason is the packager **Files:** `packages/create-threenative/templates/starter/native-playtests/render-chain.playtest.json`, `packages/runtime-native/conformance/scenes/shared/lut-grade.js` (new), `registry.json` -- [ ] The starter's desktop render-chain scenario includes `grade` and `grain` in its applied stages, with a non-blank screenshot. proof: `node packages/playtest/dist/runner/cli.js native-playtests/render-chain.playtest.json --target desktop` in a scaffolded starter. -- [ ] A conformance row grades a fixed colour chart through a known 17³ `.cube` with `lut3D` and `film` at zero intensity. It matches the LUT's expected colours and the browser reference within tolerance, which proves the `Data3DTexture` upload and trilinear sampling on the host. proof: `pnpm parity --target desktop --only-tests lut-grade`. +- [ ] The starter's desktop render-chain scenario includes `grade` and `grain` in its applied stages, with a non-blank screenshot. proof: `node packages/playtest/dist/runner/cli.js native-playtests/render-chain.playtest.json --target desktop` in a scaffolded starter. **Open — `grain` applies and `grade` does not, because the packager never stages the table.** `pnpm build:desktop` in the scaffolded starter, then the scenario against `dist-native/starter`: the marker reads `stages: [..., "grain"]`, `dropped: [{"name":"grade","reason":"lut:pending"}]`, and the console carries `TN_GRADE_TABLE grade.cube: File read error: Failed to open file: grade.cube`. The cause is in `packages/runtime-native/scripts/package-desktop.mjs`: the staging loop copies `selectManifestAssets(assets)` and nothing else, so a `public/` file the manifest does not declare — reported as `4 unmanaged file(s)` — is never copied. That is every unmanaged `public/` file on every native target, so the fix belongs in the packager's asset selection rather than in this PRD's filed file list, and it is not made here. +- [x] A conformance row grades a fixed colour chart through a known 17³ `.cube` with `lut3D` and `film` at zero intensity. It matches the LUT's expected colours and the browser reference within tolerance, which proves the `Data3DTexture` upload and trilinear sampling on the host. proof: `pnpm parity --target desktop --only-tests lut-grade`. **Ran, and passes:** after `pnpm native:build` (409/409 targets) and a web-lane reference capture, the desktop row reports `status: "pass"`, `metrics: { pixelMismatchRatio: 0, perceptualDeltaE: 0 }` over 1280x720 and `gpuValidationErrors: []`, with the host rendering 300 frames in 9.4 s. The table is a 17³ per-channel gain (r 0.75, g 1, b 0.5) rather than the identity, so a host that ignored the upload or substituted a constant would fail it, and the parsed `Data3DTexture` is read back in JS against that formula before the frame is compared. The sweep exits 2 because a `--only-tests` run blocks the other 95 rows by design, not because this row failed. diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 844a986954..8d9f3a38e8 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -187,7 +187,7 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { sailing: "f7325e140caa16beec51dcf21a4783043a2f3c59fcf8c835efe7cbf83d16fbc5", shooter: "631f25520770c31348c19c83c8c75fba2c582444e819728c2209093d92f09538", snow: "db0d2981e2eb00eeadcd67e08dcdcc363b89d5040f4f663d5519625de2d82855", - starter: "84490b95dd218959bc49b8777094f416ef2d2cf1bec568c406fc61cf22444891", + starter: "04fa7a2bcec3143bd2e31a1736716b49daf0c1a8b6be8f4f58bc827f190af838", "tower-defense": "5920816de90126bf2426114402445087ac3ca6c7ce483d2d3d0f43c07c413973", }; diff --git a/packages/create-threenative/templates/starter/native-playtests/render-chain.playtest.json b/packages/create-threenative/templates/starter/native-playtests/render-chain.playtest.json index 39e548f67a..04fd16a22a 100644 --- a/packages/create-threenative/templates/starter/native-playtests/render-chain.playtest.json +++ b/packages/create-threenative/templates/starter/native-playtests/render-chain.playtest.json @@ -27,7 +27,11 @@ "renderChain": { "tier": "high", "stages": { - "includes": ["ambientOcclusion", "bloom", "vignette"] + "includes": ["ambientOcclusion", "bloom", "vignette", "grade", "grain"], + "order": ["ambientOcclusion", "bloom", "vignette", "grade", "grain"] + }, + "contributions": { + "graphOutputChanged": ["grade", "grain"] } }, "resources": [ @@ -37,13 +41,6 @@ "gte": 1, "changed": true } - ], - "visibility": [ - { - "entity": "player", - "minProjectedPixels": 20, - "allowTrivial": "The player is visible from the first tick; the state rows carry the native render proof." - } ] } } diff --git a/packages/runtime-native/conformance/registry.json b/packages/runtime-native/conformance/registry.json index 3dc603cdf9..e6e96ccd91 100644 --- a/packages/runtime-native/conformance/registry.json +++ b/packages/runtime-native/conformance/registry.json @@ -74,6 +74,33 @@ } ], "tests": [ + { + "id": "lut-grade", + "title": "colour grade table and film grain", + "category": "rendering", + "status": "implemented", + "scene": "conformance/scenes/shared/lut-grade.js", + "owner": "PRD-492", + "browserRecipe": "webgpu", + "laneRegistrations": { + "browser": { + "registered": true, + "target": "web", + "assertsAdapter": true + }, + "desktop": { + "registered": true, + "target": "desktop" + } + }, + "tolerance": { + "pixelMismatchRatio": 0.01, + "perceptualDeltaE": 3.0 + }, + "desktopGate": false, + "required": true, + "reason": "A known 17^3 .cube uploaded as a Data3DTexture and sampled by lut3D, with the film grain node at zero intensity, on the host as well as the browser. The grade and grain stages are generated template source, so a host that cannot carry a 3D texture upload and a trilinear filter leaves a game broken where it ships. The table is deliberately not the identity, so a host that ignored the upload or substituted a constant fails it; the parsed texture is read back in JS against the table's own formula, and the frame is compared to the browser reference within the row's tolerance." + }, { "id": "01-basic-cube", "title": "basic cube", diff --git a/packages/runtime-native/conformance/scenes/shared/lut-grade.js b/packages/runtime-native/conformance/scenes/shared/lut-grade.js new file mode 100644 index 0000000000..6fe1e70301 --- /dev/null +++ b/packages/runtime-native/conformance/scenes/shared/lut-grade.js @@ -0,0 +1,118 @@ +import * as THREE from "three/webgpu"; +import { film } from "three/addons/tsl/display/FilmNode.js"; +import { lut3D } from "three/addons/tsl/display/Lut3DNode.js"; +import { LUTCubeLoader } from "three/addons/loaders/LUTCubeLoader.js"; +import { float, pass, renderOutput, texture3D } from "three/tsl"; +import { assertCondition, startVisualScene } from "./scene-support.js"; + +/** + * PRD-492 Phase 2: a `.cube` table, uploaded as a `Data3DTexture` and sampled by `lut3D`, on the + * host. The web lane already proved the stage exists; this row proves the host can carry it — the + * 3D texture upload, the trilinear filter and the grain node all have to work where a game ships. + */ + +/** The edge length PRD-492 names. Fine enough that the grid is not what the eye sees. */ +const SIZE = 17; + +/** + * The grade, per channel. Deliberately not the identity: an identity table would pass whether the + * host sampled the texture correctly, ignored it, or substituted a constant. + */ +const GAIN = { r: 0.75, g: 1, b: 0.5 }; + +/** The chart the frame is graded from: black, white, the three primaries, a grey and a mid blue. */ +const CHART = [0x000000, 0xffffff, 0xff0000, 0x00ff00, 0x0000ff, 0x808080, 0x3a7bd5]; + +/** + * A `.cube` file as text, red varying fastest — the order `LUTCubeLoader.parse` reads and the axis + * `lut3D` samples. Written here rather than shipped as a file because the conformance host has no + * fetch for one, and because a table this row can state in four lines is a table it can also state + * the expected answer to. + */ +function cubeText() { + const lines = ['TITLE "conformance lut-grade"', `LUT_3D_SIZE ${SIZE}`, ""]; + for (let texel = 0; texel < SIZE ** 3; texel += 1) { + const at = (axis) => Math.floor(texel / SIZE ** axis) % SIZE; + lines.push( + [at(0) / (SIZE - 1) * GAIN.r, at(1) / (SIZE - 1) * GAIN.g, at(2) / (SIZE - 1) * GAIN.b] + .map((value) => Math.min(1, value).toFixed(6)) + .join(" "), + ); + } + return `${lines.join("\n")}\n`; +} + +/** + * What the loader must have produced, read back off the uploaded texture rather than off the text + * that produced it: the size `lut3D` is told, the 8-bit type every adapter can filter, and the + * three coordinates that would expose a transposed or unscaled upload. The load is + * `Number(text) * 255` into a `Uint8Array`, so it truncates and one step of slack is the whole + * tolerance. + */ +function assertTable(texture, size) { + assertCondition(size === SIZE, `lut-grade table is ${size}^3, expected ${SIZE}^3`); + assertCondition( + texture.type === THREE.UnsignedByteType, + "lut-grade table must be 8-bit; a float table would need the optional float32-filterable feature", + ); + const byteAt = (red, green, blue, channel) => + texture.image.data[((blue * SIZE + green) * SIZE + red) * 4 + channel]; + for (const [red, green, blue] of [ + [0, 0, 0], + [SIZE - 1, SIZE - 1, SIZE - 1], + [SIZE - 1, 0, 0], + [0, 0, SIZE - 1], + ]) { + const expected = [ + (red / (SIZE - 1)) * GAIN.r, + (green / (SIZE - 1)) * GAIN.g, + (blue / (SIZE - 1)) * GAIN.b, + ]; + for (let channel = 0; channel < 3; channel += 1) { + const want = Math.floor(Math.min(1, expected[channel]) * 255); + const got = byteAt(red, green, blue, channel); + assertCondition( + Math.abs(got - want) <= 1, + `lut-grade table (${red},${green},${blue}) channel ${channel} holds ${got}, expected ${want}`, + ); + } + } +} + +export function startScene(canvas, dimensions) { + return startVisualScene(canvas, dimensions, "lut-grade", ({ renderer, scene, camera }) => { + const loader = new LUTCubeLoader(); + const table = loader.parse(cubeText()); + assertTable(table.texture3D, table.size); + + // The chart itself: unlit quads, so the frame is the table's colours and nothing else. + const swatches = CHART.map((color, index) => { + const mesh = new THREE.Mesh( + new THREE.PlaneGeometry(0.52, 0.9), + new THREE.MeshBasicMaterial({ color }), + ); + mesh.position.set((index - (CHART.length - 1) / 2) * 0.6, 0, 0); + scene.add(mesh); + return mesh; + }); + + const scenePass = pass(scene, camera); + // Exactly the starter's arrangement: the table is authored against the picture, so the stage + // applies the output transform itself and the pipeline stops applying a second one. + const graded = lut3D(renderOutput(scenePass), texture3D(table.texture3D), table.size, 1); + // Grain at zero intensity: the node has to build and sample on the host, and it must leave + // every swatch exactly where the table put it. + const pipeline = new THREE.RenderPipeline(renderer); + pipeline.outputNode = film(graded, float(0)); + pipeline.outputColorTransform = false; + const render = () => { + pipeline.render(); + }; + render(); + return { + detail: { blueGain: GAIN.b, redGain: GAIN.r, size: table.size, swatches: swatches.length }, + render, + swatches, + }; + }); +} \ No newline at end of file From b09762a649fd429c96a06dadb14f4d8f06813d26 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 4 Oct 2026 12:09:02 -0700 Subject: [PATCH 4/5] docs: path spelled out for the capture dir --- docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md b/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md index 702752c9f4..46ec46df30 100644 --- a/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md +++ b/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md @@ -84,7 +84,7 @@ decided it: `TN_GRADE_TABLE grade.cube: File read error`. #### Phase 1: Grade and grain in the starter **Status:** LANDED — one box open (see below) **Files:** `packages/create-threenative/templates/starter/src/render/grade.ts` (new), `postprocessing.ts`, `quality.ts`, `public/grade.cube` (new), `tools/make-grade-lut.mjs` (new), `packages/core/src/render/chain.ts`, `packages/core/src/renderer.ts`, `packages/create-threenative/template-assets/worldEnvironment.ts` + its 13 kit copies -- [ ] With grain off, an identity `.cube` through the grade reproduces the ungraded frame within one 8-bit step per channel. proof: `maxChannelDelta` ≤ 1 from `pnpm --filter quarry compare` on the scaffolded starter, with `packages/create-threenative/__tests__/grade.spec.ts` holding the arithmetic underneath it. **The shaper is gone — the stage now grades after the output transform (see `## Decisions`) — and the arithmetic half is proved: `maxChannelDelta` over every display value ≤ 1** (worst 0.875), from `pnpm exec vitest run packages/create-threenative/__tests__/grade.spec.ts` — 8 passed, reading the shipped table back through a port of `LUTCubeLoader.parse`. **Open on the frame half.** `pnpm --filter quarry compare` on the scaffolded starter, ungraded against identity-table-with-grain-off, 1920x1080, same build otherwise: `maxChannelDelta 86, rmse 0.81, changedPixelRatio 0.003` — and the *same ungraded build captured twice* reads `maxChannelDelta 114, rmse 0.37, changedPixelRatio 0.0026`. The starter's own animated content moves between runs by more than the claim, so a whole-frame maximum cannot carry it; the captures are in `.afk/scratch/prd492-captures/phase1/` and `/home/joao/.cache/tnt/scratch/prd492/cmp-{off,off2,identity}/`. A still-scene capture (the arena without `STARTER_MIST` or the pennants) would carry it. +- [ ] With grain off, an identity `.cube` through the grade reproduces the ungraded frame within one 8-bit step per channel. proof: `maxChannelDelta` ≤ 1 from `pnpm --filter quarry compare` on the scaffolded starter, with `packages/create-threenative/__tests__/grade.spec.ts` holding the arithmetic underneath it. **The shaper is gone — the stage now grades after the output transform (see `## Decisions`) — and the arithmetic half is proved: `maxChannelDelta` over every display value ≤ 1** (worst 0.875), from `pnpm exec vitest run packages/create-threenative/__tests__/grade.spec.ts` — 8 passed, reading the shipped table back through a port of `LUTCubeLoader.parse`. **Open on the frame half.** `pnpm --filter quarry compare` on the scaffolded starter, ungraded against identity-table-with-grain-off, 1920x1080, same build otherwise: `maxChannelDelta 86, rmse 0.81, changedPixelRatio 0.003` — and the *same ungraded build captured twice* reads `maxChannelDelta 114, rmse 0.37, changedPixelRatio 0.0026`. The starter's own animated content moves between runs by more than the claim, so a whole-frame maximum cannot carry it; the captures are in `/home/joao/projects/threenative/threenative-engine/.afk/scratch/prd492-captures/phase1/` and `/home/joao/.cache/tnt/scratch/prd492/cmp-{off,off2,identity}/`. A still-scene capture (the arena without `STARTER_MIST` or the pennants) would carry it. - [x] `TN_RENDER_CHAIN` names `grade` and `grain` as applied at `high`, and grain as refused with a reason at `low`. proof: `pnpm test:templates` with the starter's render-chain assertion extended. **Ran at `high` on the starter's `look.playtest.json`** (`packages/create-threenative/template-playtests/starter/look.playtest.json`): `renderChain.tier`, `renderChain.stages.includes`, `renderChain.stages.order` and `renderChain.contributions.graphOutputChanged` all pass at `adapterClass: hardware`, and the marker carries `stages: ["ambientOcclusion","bloom","vignette","antialias","grade","grain"]`, `dropped: []`, with `grade` and `grain` both `graphOutputChanged: true`. **The `low` leg** is `perAdapter.software`, which needs an adapter the harness classes as software — and Chromium here reaches no WebGPU adapter under SwiftShader (`TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING`, adapter request resolved null), so it was run instead on a throwaway copy of the scaffold with `setupPost(..., { tier: "low" })` pinned and a scenario asserting `stages: { includes: ["grade"], excludes: ["grain"] }`: both rows pass, and the marker carries `stages: [...,"grade"], dropped: [{"name":"grain","reason":"grainIntensity:0"}]`. Nothing about that pin is committed. #### Phase 2: Native runs the grade From 5fe0909a1297d326f5beff9d54432a719457387f Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 4 Oct 2026 14:28:44 -0700 Subject: [PATCH 5/5] fix(render): neutralise the shipped grade and grain default (PRD-492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../PRD-492-colour-grading-and-film-grain.md | 57 +- .../__tests__/grade.spec.ts | 43 + .../__tests__/scaffold.spec.ts | 5 +- .../templates/starter/public/grade.cube | 1442 ++++++++--------- .../templates/starter/src/render/quality.ts | 14 +- .../starter/tools/make-grade-lut.mjs | 28 +- .../__tests__/asset-manifest.spec.ts | 37 + 7 files changed, 888 insertions(+), 738 deletions(-) diff --git a/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md b/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md index 46ec46df30..fdaa9e5de3 100644 --- a/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md +++ b/docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md @@ -1,6 +1,6 @@ # PRD-492 — Colour grading and film grain -**Status:** IN PROGRESS — Phase 1 landed, Phase 2's conformance row passes; AC-1, the identity round trip and the desktop staging of the table are open +**Status:** IN PROGRESS — Phase 1 landed with the shipped default neutralised; Phase 2's desktop scenario passes; AC-1, the identity round trip and a judge's verdict are open **Complexity:** 1 (LOW) — 1–5 files, all generated template source; no package change expected; risk override: none **Owner:** João **Depends on:** [PRD-VQ-02](../native/PRD-VQ-02-native-postprocessing-parity.md) (a post stage must not blank the native frame; this PRD adds two more stages to the same chain) @@ -62,15 +62,54 @@ item was reachable. The assertion is dropped, and `grade`/`grain` added to the s contributions it does read. Nothing is lost: the runner never produced that observation on this target, so the row was a hard error rather than a check. -**`public/grade.cube` is not staged by the native packager yet.** `pnpm build:web` and -`pnpm build:desktop` both report it among the undeclared files — and the desktop staging loop copies -`selectManifestAssets(assets)` alone, so it never reaches the host. Phase 2's desktop run is what -decided it: `TN_GRADE_TABLE grade.cube: File read error`. +**The shipped table was a look, not a default, and a blind judge said so.** It first shipped as a +13% warm spread about a 0.18 pivot, and the judge compared the starter with it against the starter +without it and preferred **without**: "grey concrete reads as sandstone, sky washed toward cream, a +bit muddy/low contrast; grain clearly visible as speckle". The owner's rule is that a default must +never degrade the look, so both numbers moved (2026-10-04): + +| | shipped first | shipped now | +| --- | --- | --- | +| `PIVOT` | 0.18 | **0.5** | +| `GAIN` | r 1.07 / b 0.94 (13% spread) | **r 1.0025 / b 0.9975** (0.5% spread) | +| `CONTRAST` | 1.06 | 1.06 (unchanged) | +| `grainIntensity` at `high` | 0.12 | **0.0039** | + +The pivot was the larger half of the damage and neither the judge nor the spec had measured it: a +contrast curve anchored at 0.18 leaves *everything above it* brighter, and on the captured frame +that lifted mid-grey 8 of 255 steps and the sky 12 — which is what "washed toward cream" looks like +on screen. Anchored at mid-grey it darkens below the middle and brightens above it. The gain is now +one 8-bit step or less on concrete and on sky alike, and `grade.spec.ts` pins that, the contrast +against the ungraded frame, and that no black is lifted; the old table is red on the first. + +Grain is `1/255` because `film()` scales its noise by the pixel's own value, so that is under half a +step on a mid-grey surface — below what the frame can show. It stays non-zero so `TN_RENDER_CHAIN` +keeps naming `grain` as **applied** at `high`, which is this file's own ticked box; `low` still +refuses it with a reason, and `quality.ts` says how far to raise it. + +**`public/grade.cube` was never staged by the packager, because it was never in the game.** Phase 2 +filed this as a packaging defect: "the staging loop copies `selectManifestAssets(assets)` and nothing +else, so a `public/` file the manifest does not declare never reaches the host". That is not what +`selectManifestAssets` does — its rule 3 keeps everything no cook produced, and its own spec already +proved an undeclared `public/hand/level.json` reaches the Android, desktop and iOS staging +directories. What actually happened is duller: the scaffold that run used had **no +`public/grade.cube` in it** (the before-arm of the AC-2 comparison deletes that file, and it was +never put back), so there was nothing to stage. With the file present, `pnpm build --target desktop` +reports it among the unmanaged files and the host reads it out of the bundle. A guard now sits in +`packages/runtime-native/__tests__/asset-manifest.spec.ts` for a hand-placed file whose extension no +asset pipeline emits, so the next report of this shape is checked against the selector first. + +**The starter's table and the capture both had to be rebuilt to say any of this.** A browser run +that reports `swiftshader / google` instead of `turing / nvidia` is a CPU rasteriser's picture of a +hardware look: the frame came back a flat grey (p50 50, p99 70) and 7,359 `createBuffer` errors +later. `--browser-recipe webgpu` alone does not prevent it — the runner launches +`chrome-headless-shell`, which serves WebGPU from SwiftShader; `--headed` launches the full browser +and the adapter is the 2080 again. Every capture below ran headed, on `turing / nvidia`, at `tier high`. ## Acceptance Criteria - [ ] AC-1 [local]: grade and grain together cost ≤ 0.3 ms GPU at 1080p on the RTX 2080 browser lane, read as the `gpuOther` p50 delta against the same build with both stages off. proof: `TN_FRAME_BUDGET` from `node packages/playtest/dist/runner/cli.js perf` on the scaffolded starter. **Open — the metric resolves too rarely in this lane to read a delta.** Two 1080p runs of the same scenario (`grade-perf2.playtest.json`, `assert.performance`, no screenshots, adapter `webgpu:architecture=turing|vendor=nvidia`, `adapterClass: hardware`): the graded arm carried `gpuOther` in **1 of 17** windows (p50 `0`, total gpu p50 `0.4`, 65 samples) and the ungraded arm in **0 of 16**. With one arm carrying no `gpuOther` observation at all there is no delta, and a missing observation is not a zero. -- [ ] AC-2 [local]: the graded frame is judged at or above the ungraded one by a fresh judge, and the HUD is not graded. proof: `pnpm visuals:ab --before --after --raters 3`. **Open — capture ready for judge.** `.afk/scratch/prd492-captures/phase1/before.png` (same scaffold with `postprocessing.ts`, `quality.ts` and `grade.ts` reverted to `origin/develop` and `public/grade.cube` removed) against `after.png` (this branch, grade + animated grain on), both 1920x1080 from `grade-shot.playtest.json` at the spawn pose, same build otherwise. Not run: judging a look is the judge's call, not this lane's. The HUD is outside the chain's graph either way — the grade is a pass node and React mounts over the canvas. +- [ ] AC-2 [local]: the graded frame is judged at or above the ungraded one by a fresh judge, and the HUD is not graded. proof: `pnpm visuals:ab --before --after --raters 3`. **Open — recapture ready for judge.** `.afk/scratch/prd492-captures/phase1b/before.png` (same scaffold with `postprocessing.ts`, `quality.ts` and `grade.ts` reverted to `origin/develop` and `public/grade.cube` removed) against `after.png` (this branch's neutral table, grain at `1/255`), both 1920x1080 from `grade-shot.playtest.json` at the spawn pose, `--headed --browser-recipe webgpu`, adapter `turing / nvidia`, `TN_RENDER_CHAIN` at `tier high` with `dropped: []` on the graded arm and no `grade`/`grain` requested on the ungraded one; `before-crop.png` / `after-crop.png` are the 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 (it was `rmse 8.17` and **+6.7 / +6.3 / +6.9** before the pivot moved); the per-pixel maximum is the starter's own animated content, which the same build captured twice also moves by ~114. Not run: judging a look is the judge's call, not this lane's. The HUD is outside the chain's graph either way — the grade is a pass node and React mounts over the canvas. ## Integration Ledger @@ -82,13 +121,13 @@ decided it: `TN_GRADE_TABLE grade.cube: File read error`. ## Execution Phases #### Phase 1: Grade and grain in the starter -**Status:** LANDED — one box open (see below) +**Status:** LANDED — one box open (see below); the shipped table and grain default were neutralised after the blind judge, red-to-green on `grade.spec.ts` **Files:** `packages/create-threenative/templates/starter/src/render/grade.ts` (new), `postprocessing.ts`, `quality.ts`, `public/grade.cube` (new), `tools/make-grade-lut.mjs` (new), `packages/core/src/render/chain.ts`, `packages/core/src/renderer.ts`, `packages/create-threenative/template-assets/worldEnvironment.ts` + its 13 kit copies - [ ] With grain off, an identity `.cube` through the grade reproduces the ungraded frame within one 8-bit step per channel. proof: `maxChannelDelta` ≤ 1 from `pnpm --filter quarry compare` on the scaffolded starter, with `packages/create-threenative/__tests__/grade.spec.ts` holding the arithmetic underneath it. **The shaper is gone — the stage now grades after the output transform (see `## Decisions`) — and the arithmetic half is proved: `maxChannelDelta` over every display value ≤ 1** (worst 0.875), from `pnpm exec vitest run packages/create-threenative/__tests__/grade.spec.ts` — 8 passed, reading the shipped table back through a port of `LUTCubeLoader.parse`. **Open on the frame half.** `pnpm --filter quarry compare` on the scaffolded starter, ungraded against identity-table-with-grain-off, 1920x1080, same build otherwise: `maxChannelDelta 86, rmse 0.81, changedPixelRatio 0.003` — and the *same ungraded build captured twice* reads `maxChannelDelta 114, rmse 0.37, changedPixelRatio 0.0026`. The starter's own animated content moves between runs by more than the claim, so a whole-frame maximum cannot carry it; the captures are in `/home/joao/projects/threenative/threenative-engine/.afk/scratch/prd492-captures/phase1/` and `/home/joao/.cache/tnt/scratch/prd492/cmp-{off,off2,identity}/`. A still-scene capture (the arena without `STARTER_MIST` or the pennants) would carry it. - [x] `TN_RENDER_CHAIN` names `grade` and `grain` as applied at `high`, and grain as refused with a reason at `low`. proof: `pnpm test:templates` with the starter's render-chain assertion extended. **Ran at `high` on the starter's `look.playtest.json`** (`packages/create-threenative/template-playtests/starter/look.playtest.json`): `renderChain.tier`, `renderChain.stages.includes`, `renderChain.stages.order` and `renderChain.contributions.graphOutputChanged` all pass at `adapterClass: hardware`, and the marker carries `stages: ["ambientOcclusion","bloom","vignette","antialias","grade","grain"]`, `dropped: []`, with `grade` and `grain` both `graphOutputChanged: true`. **The `low` leg** is `perAdapter.software`, which needs an adapter the harness classes as software — and Chromium here reaches no WebGPU adapter under SwiftShader (`TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING`, adapter request resolved null), so it was run instead on a throwaway copy of the scaffold with `setupPost(..., { tier: "low" })` pinned and a scenario asserting `stages: { includes: ["grade"], excludes: ["grain"] }`: both rows pass, and the marker carries `stages: [...,"grade"], dropped: [{"name":"grain","reason":"grainIntensity:0"}]`. Nothing about that pin is committed. #### Phase 2: Native runs the grade -**Status:** PARTIAL — the conformance row passes; the starter's desktop scenario cannot yet, and the reason is the packager +**Status:** LANDED — both boxes pass: the conformance row and the starter's desktop scenario **Files:** `packages/create-threenative/templates/starter/native-playtests/render-chain.playtest.json`, `packages/runtime-native/conformance/scenes/shared/lut-grade.js` (new), `registry.json` -- [ ] The starter's desktop render-chain scenario includes `grade` and `grain` in its applied stages, with a non-blank screenshot. proof: `node packages/playtest/dist/runner/cli.js native-playtests/render-chain.playtest.json --target desktop` in a scaffolded starter. **Open — `grain` applies and `grade` does not, because the packager never stages the table.** `pnpm build:desktop` in the scaffolded starter, then the scenario against `dist-native/starter`: the marker reads `stages: [..., "grain"]`, `dropped: [{"name":"grade","reason":"lut:pending"}]`, and the console carries `TN_GRADE_TABLE grade.cube: File read error: Failed to open file: grade.cube`. The cause is in `packages/runtime-native/scripts/package-desktop.mjs`: the staging loop copies `selectManifestAssets(assets)` and nothing else, so a `public/` file the manifest does not declare — reported as `4 unmanaged file(s)` — is never copied. That is every unmanaged `public/` file on every native target, so the fix belongs in the packager's asset selection rather than in this PRD's filed file list, and it is not made here. +- [x] The starter's desktop render-chain scenario includes `grade` and `grain` in its applied stages, with a non-blank screenshot. proof: `node packages/playtest/dist/runner/cli.js native-playtests/render-chain.playtest.json --target desktop` in a scaffolded starter. **Ran and passes** — `pnpm build --target desktop` in the scaffolded starter (checkout runtime at `THREENATIVE_RUNTIME_BINARY=packages/runtime-native/build/tn-linux/mystral`, which is the binary that both compiles and runs; `mystral-tools` compiles but cannot host the game), then the scenario with `--executable dist-native/starter`: exit 0, all six assertions true at `adapterClass: hardware`, `renderChain.stages.includes` and `.order` both `["ambientOcclusion","bloom","vignette","grade","grain"]`, `contributions.graphOutputChanged` true for `grade` and `grain`, `dropped: []` for the grade and grain (the chain's own `antialias` is refused with `build:Image is not defined`, which no box here claims). The host logs `[Fetch] Read 19909 bytes from bundle: grade.cube`, and the screenshot is not blank (`tone` mean 117.1, p1 41, p99 192). Nothing in the packager changed: see the second decision above for why the earlier run could not see the table. - [x] A conformance row grades a fixed colour chart through a known 17³ `.cube` with `lut3D` and `film` at zero intensity. It matches the LUT's expected colours and the browser reference within tolerance, which proves the `Data3DTexture` upload and trilinear sampling on the host. proof: `pnpm parity --target desktop --only-tests lut-grade`. **Ran, and passes:** after `pnpm native:build` (409/409 targets) and a web-lane reference capture, the desktop row reports `status: "pass"`, `metrics: { pixelMismatchRatio: 0, perceptualDeltaE: 0 }` over 1280x720 and `gpuValidationErrors: []`, with the host rendering 300 frames in 9.4 s. The table is a 17³ per-channel gain (r 0.75, g 1, b 0.5) rather than the identity, so a host that ignored the upload or substituted a constant would fail it, and the parsed `Data3DTexture` is read back in JS against that formula before the frame is compared. The sweep exits 2 because a `--only-tests` run blocks the other 95 rows by design, not because this row failed. diff --git a/packages/create-threenative/__tests__/grade.spec.ts b/packages/create-threenative/__tests__/grade.spec.ts index 4c3079521e..a6d5cdcb77 100644 --- a/packages/create-threenative/__tests__/grade.spec.ts +++ b/packages/create-threenative/__tests__/grade.spec.ts @@ -147,6 +147,36 @@ describe("starter grade table", () => { }); }); +/** + * A default must never degrade the look it ships with (owner, PRD-492). This table first shipped + * with a 13% warm spread, and a blind judge compared the starter with it against the starter with + * no table at all and preferred **no table**: grey concrete read as sandstone, the sky as cream, + * the frame as mud. So the shipped numbers answer to three claims, measured here on the shipped + * table — arithmetic, not a capture, and `sampled()` is what the hardware returns. + */ +describe("the shipped grade is a default, not a look", () => { + const at = (value: number, channel: number): number => + sampled(shipped.bytes, shipped.size, value, channel); + + it("moves concrete and sky by at most one 8-bit step of hue", () => { + // The two surfaces the judge named. The warm spread is this r/b distance, and the shipped + // table stores 8-bit codes, so the claim is a count of codes — which also absorbs the 9³ + // interpolation's own sub-step noise. The table this replaced moved concrete by 12. + for (const [surface, value] of [ + ["concrete", 0.35], + ["sky", 0.9], + ] as const) { + const codes = Math.round(Math.abs(at(value, 0) - at(value, 2)) * 255); + expect(codes, surface).toBeLessThanOrEqual(1); + } + }); + + it("keeps more contrast than the ungraded frame, and lifts no black", () => { + expect(at(0.75, 1)).toBeGreaterThan(0.75); + expect(at(0.05, 1)).toBeLessThan(0.05); + }); +}); + /** A table that exists. `available()` never reads it — `build()` does. */ const LOADED: IGradeTable = { size: 9, texture: {} as IGradeTable["texture"] }; @@ -186,6 +216,19 @@ describe("starter grade tiers", () => { ); }); + it("keeps the shipped grain below what an 8-bit frame can show", () => { + // `film()` returns `base + base * clamp(noise + 0.1, 0, 1)`, so its largest move on a pixel is + // that pixel's own value times the intensity — at white, the intensity alone. It shipped at + // 0.12 and a blind judge named the result: "grain clearly visible as speckle over sky and flat + // walls". A default must not degrade the look, so the number stays under one step; it stays + // above zero so `TN_RENDER_CHAIN` can still name grain as applied at `high`. + for (const tier of ["low", "medium", "high"] as const) { + const intensity = gradePreset(tier).grainIntensity; + expect(intensity * 255, tier).toBeLessThanOrEqual(1); + } + expect(gradePreset("high").grainIntensity).toBeGreaterThan(0); + }); + it("keeps every tier's presets reachable by name and unknown names fatal", () => { for (const tier of ["low", "medium", "high"] as const) { expect(gradePreset(tier).gradeIntensity).toBeGreaterThan(0); diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 8d9f3a38e8..363fe5c7d0 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -187,7 +187,10 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { sailing: "f7325e140caa16beec51dcf21a4783043a2f3c59fcf8c835efe7cbf83d16fbc5", shooter: "631f25520770c31348c19c83c8c75fba2c582444e819728c2209093d92f09538", snow: "db0d2981e2eb00eeadcd67e08dcdcc363b89d5040f4f663d5519625de2d82855", - starter: "04fa7a2bcec3143bd2e31a1736716b49daf0c1a8b6be8f4f58bc827f190af838", + // Restated 2026-10-04 for PRD-492's neutralised default: `public/grade.cube` (the table itself) + // and `src/render/quality.ts` (the grain numbers) changed, then the table.s pivot moved to + // mid-grey. No other kit.s tree moved. + starter: "c2160b5f33cd44bf116038b91c1e6ad26c5be09db09760892471c83cd93108b4", "tower-defense": "5920816de90126bf2426114402445087ac3ca6c7ce483d2d3d0f43c07c413973", }; diff --git a/packages/create-threenative/templates/starter/public/grade.cube b/packages/create-threenative/templates/starter/public/grade.cube index 684d2ec126..650bff0309 100644 --- a/packages/create-threenative/templates/starter/public/grade.cube +++ b/packages/create-threenative/templates/starter/public/grade.cube @@ -5,731 +5,731 @@ TITLE "starter neutral-warm" LUT_3D_SIZE 9 0.000000 0.000000 0.000000 -0.130856 0.000000 0.000000 -0.272825 0.000000 0.000000 -0.419315 0.000000 0.000000 -0.568821 0.000000 0.000000 -0.720610 0.000000 0.000000 -0.874243 0.000000 0.000000 +0.115311 0.000000 0.000000 +0.240416 0.000000 0.000000 +0.369504 0.000000 0.000000 +0.501250 0.000000 0.000000 +0.635008 0.000000 0.000000 +0.770391 0.000000 0.000000 +0.907141 0.000000 0.000000 1.000000 0.000000 0.000000 -1.000000 0.000000 0.000000 -0.000000 0.122295 0.000000 -0.130856 0.122295 0.000000 -0.272825 0.122295 0.000000 -0.419315 0.122295 0.000000 -0.568821 0.122295 0.000000 -0.720610 0.122295 0.000000 -0.874243 0.122295 0.000000 -1.000000 0.122295 0.000000 -1.000000 0.122295 0.000000 -0.000000 0.254976 0.000000 -0.130856 0.254976 0.000000 -0.272825 0.254976 0.000000 -0.419315 0.254976 0.000000 -0.568821 0.254976 0.000000 -0.720610 0.254976 0.000000 -0.874243 0.254976 0.000000 -1.000000 0.254976 0.000000 -1.000000 0.254976 0.000000 -0.000000 0.391883 0.000000 -0.130856 0.391883 0.000000 -0.272825 0.391883 0.000000 -0.419315 0.391883 0.000000 -0.568821 0.391883 0.000000 -0.720610 0.391883 0.000000 -0.874243 0.391883 0.000000 -1.000000 0.391883 0.000000 -1.000000 0.391883 0.000000 -0.000000 0.531608 0.000000 -0.130856 0.531608 0.000000 -0.272825 0.531608 0.000000 -0.419315 0.531608 0.000000 -0.568821 0.531608 0.000000 -0.720610 0.531608 0.000000 -0.874243 0.531608 0.000000 -1.000000 0.531608 0.000000 -1.000000 0.531608 0.000000 -0.000000 0.673467 0.000000 -0.130856 0.673467 0.000000 -0.272825 0.673467 0.000000 -0.419315 0.673467 0.000000 -0.568821 0.673467 0.000000 -0.720610 0.673467 0.000000 -0.874243 0.673467 0.000000 -1.000000 0.673467 0.000000 -1.000000 0.673467 0.000000 -0.000000 0.817050 0.000000 -0.130856 0.817050 0.000000 -0.272825 0.817050 0.000000 -0.419315 0.817050 0.000000 -0.568821 0.817050 0.000000 -0.720610 0.817050 0.000000 -0.874243 0.817050 0.000000 -1.000000 0.817050 0.000000 -1.000000 0.817050 0.000000 -0.000000 0.962082 0.000000 -0.130856 0.962082 0.000000 -0.272825 0.962082 0.000000 -0.419315 0.962082 0.000000 -0.568821 0.962082 0.000000 -0.720610 0.962082 0.000000 -0.874243 0.962082 0.000000 -1.000000 0.962082 0.000000 -1.000000 0.962082 0.000000 +0.000000 0.115023 0.000000 +0.115311 0.115023 0.000000 +0.240416 0.115023 0.000000 +0.369504 0.115023 0.000000 +0.501250 0.115023 0.000000 +0.635008 0.115023 0.000000 +0.770391 0.115023 0.000000 +0.907141 0.115023 0.000000 +1.000000 0.115023 0.000000 +0.000000 0.239816 0.000000 +0.115311 0.239816 0.000000 +0.240416 0.239816 0.000000 +0.369504 0.239816 0.000000 +0.501250 0.239816 0.000000 +0.635008 0.239816 0.000000 +0.770391 0.239816 0.000000 +0.907141 0.239816 0.000000 +1.000000 0.239816 0.000000 +0.000000 0.368583 0.000000 +0.115311 0.368583 0.000000 +0.240416 0.368583 0.000000 +0.369504 0.368583 0.000000 +0.501250 0.368583 0.000000 +0.635008 0.368583 0.000000 +0.770391 0.368583 0.000000 +0.907141 0.368583 0.000000 +1.000000 0.368583 0.000000 +0.000000 0.500000 0.000000 +0.115311 0.500000 0.000000 +0.240416 0.500000 0.000000 +0.369504 0.500000 0.000000 +0.501250 0.500000 0.000000 +0.635008 0.500000 0.000000 +0.770391 0.500000 0.000000 +0.907141 0.500000 0.000000 +1.000000 0.500000 0.000000 +0.000000 0.633424 0.000000 +0.115311 0.633424 0.000000 +0.240416 0.633424 0.000000 +0.369504 0.633424 0.000000 +0.501250 0.633424 0.000000 +0.635008 0.633424 0.000000 +0.770391 0.633424 0.000000 +0.907141 0.633424 0.000000 +1.000000 0.633424 0.000000 +0.000000 0.768470 0.000000 +0.115311 0.768470 0.000000 +0.240416 0.768470 0.000000 +0.369504 0.768470 0.000000 +0.501250 0.768470 0.000000 +0.635008 0.768470 0.000000 +0.770391 0.768470 0.000000 +0.907141 0.768470 0.000000 +1.000000 0.768470 0.000000 +0.000000 0.904879 0.000000 +0.115311 0.904879 0.000000 +0.240416 0.904879 0.000000 +0.369504 0.904879 0.000000 +0.501250 0.904879 0.000000 +0.635008 0.904879 0.000000 +0.770391 0.904879 0.000000 +0.907141 0.904879 0.000000 +1.000000 0.904879 0.000000 0.000000 1.000000 0.000000 -0.130856 1.000000 0.000000 -0.272825 1.000000 0.000000 -0.419315 1.000000 0.000000 -0.568821 1.000000 0.000000 -0.720610 1.000000 0.000000 -0.874243 1.000000 0.000000 -1.000000 1.000000 0.000000 +0.115311 1.000000 0.000000 +0.240416 1.000000 0.000000 +0.369504 1.000000 0.000000 +0.501250 1.000000 0.000000 +0.635008 1.000000 0.000000 +0.770391 1.000000 0.000000 +0.907141 1.000000 0.000000 1.000000 1.000000 0.000000 -0.000000 0.000000 0.114957 -0.130856 0.000000 0.114957 -0.272825 0.000000 0.114957 -0.419315 0.000000 0.114957 -0.568821 0.000000 0.114957 -0.720610 0.000000 0.114957 -0.874243 0.000000 0.114957 -1.000000 0.000000 0.114957 -1.000000 0.000000 0.114957 -0.000000 0.122295 0.114957 -0.130856 0.122295 0.114957 -0.272825 0.122295 0.114957 -0.419315 0.122295 0.114957 -0.568821 0.122295 0.114957 -0.720610 0.122295 0.114957 -0.874243 0.122295 0.114957 -1.000000 0.122295 0.114957 -1.000000 0.122295 0.114957 -0.000000 0.254976 0.114957 -0.130856 0.254976 0.114957 -0.272825 0.254976 0.114957 -0.419315 0.254976 0.114957 -0.568821 0.254976 0.114957 -0.720610 0.254976 0.114957 -0.874243 0.254976 0.114957 -1.000000 0.254976 0.114957 -1.000000 0.254976 0.114957 -0.000000 0.391883 0.114957 -0.130856 0.391883 0.114957 -0.272825 0.391883 0.114957 -0.419315 0.391883 0.114957 -0.568821 0.391883 0.114957 -0.720610 0.391883 0.114957 -0.874243 0.391883 0.114957 -1.000000 0.391883 0.114957 -1.000000 0.391883 0.114957 -0.000000 0.531608 0.114957 -0.130856 0.531608 0.114957 -0.272825 0.531608 0.114957 -0.419315 0.531608 0.114957 -0.568821 0.531608 0.114957 -0.720610 0.531608 0.114957 -0.874243 0.531608 0.114957 -1.000000 0.531608 0.114957 -1.000000 0.531608 0.114957 -0.000000 0.673467 0.114957 -0.130856 0.673467 0.114957 -0.272825 0.673467 0.114957 -0.419315 0.673467 0.114957 -0.568821 0.673467 0.114957 -0.720610 0.673467 0.114957 -0.874243 0.673467 0.114957 -1.000000 0.673467 0.114957 -1.000000 0.673467 0.114957 -0.000000 0.817050 0.114957 -0.130856 0.817050 0.114957 -0.272825 0.817050 0.114957 -0.419315 0.817050 0.114957 -0.568821 0.817050 0.114957 -0.720610 0.817050 0.114957 -0.874243 0.817050 0.114957 -1.000000 0.817050 0.114957 -1.000000 0.817050 0.114957 -0.000000 0.962082 0.114957 -0.130856 0.962082 0.114957 -0.272825 0.962082 0.114957 -0.419315 0.962082 0.114957 -0.568821 0.962082 0.114957 -0.720610 0.962082 0.114957 -0.874243 0.962082 0.114957 -1.000000 0.962082 0.114957 -1.000000 0.962082 0.114957 -0.000000 1.000000 0.114957 -0.130856 1.000000 0.114957 -0.272825 1.000000 0.114957 -0.419315 1.000000 0.114957 -0.568821 1.000000 0.114957 -0.720610 1.000000 0.114957 -0.874243 1.000000 0.114957 -1.000000 1.000000 0.114957 -1.000000 1.000000 0.114957 -0.000000 0.000000 0.239678 -0.130856 0.000000 0.239678 -0.272825 0.000000 0.239678 -0.419315 0.000000 0.239678 -0.568821 0.000000 0.239678 -0.720610 0.000000 0.239678 -0.874243 0.000000 0.239678 -1.000000 0.000000 0.239678 -1.000000 0.000000 0.239678 -0.000000 0.122295 0.239678 -0.130856 0.122295 0.239678 -0.272825 0.122295 0.239678 -0.419315 0.122295 0.239678 -0.568821 0.122295 0.239678 -0.720610 0.122295 0.239678 -0.874243 0.122295 0.239678 -1.000000 0.122295 0.239678 -1.000000 0.122295 0.239678 -0.000000 0.254976 0.239678 -0.130856 0.254976 0.239678 -0.272825 0.254976 0.239678 -0.419315 0.254976 0.239678 -0.568821 0.254976 0.239678 -0.720610 0.254976 0.239678 -0.874243 0.254976 0.239678 -1.000000 0.254976 0.239678 -1.000000 0.254976 0.239678 -0.000000 0.391883 0.239678 -0.130856 0.391883 0.239678 -0.272825 0.391883 0.239678 -0.419315 0.391883 0.239678 -0.568821 0.391883 0.239678 -0.720610 0.391883 0.239678 -0.874243 0.391883 0.239678 -1.000000 0.391883 0.239678 -1.000000 0.391883 0.239678 -0.000000 0.531608 0.239678 -0.130856 0.531608 0.239678 -0.272825 0.531608 0.239678 -0.419315 0.531608 0.239678 -0.568821 0.531608 0.239678 -0.720610 0.531608 0.239678 -0.874243 0.531608 0.239678 -1.000000 0.531608 0.239678 -1.000000 0.531608 0.239678 -0.000000 0.673467 0.239678 -0.130856 0.673467 0.239678 -0.272825 0.673467 0.239678 -0.419315 0.673467 0.239678 -0.568821 0.673467 0.239678 -0.720610 0.673467 0.239678 -0.874243 0.673467 0.239678 -1.000000 0.673467 0.239678 -1.000000 0.673467 0.239678 -0.000000 0.817050 0.239678 -0.130856 0.817050 0.239678 -0.272825 0.817050 0.239678 -0.419315 0.817050 0.239678 -0.568821 0.817050 0.239678 -0.720610 0.817050 0.239678 -0.874243 0.817050 0.239678 -1.000000 0.817050 0.239678 -1.000000 0.817050 0.239678 -0.000000 0.962082 0.239678 -0.130856 0.962082 0.239678 -0.272825 0.962082 0.239678 -0.419315 0.962082 0.239678 -0.568821 0.962082 0.239678 -0.720610 0.962082 0.239678 -0.874243 0.962082 0.239678 -1.000000 0.962082 0.239678 -1.000000 0.962082 0.239678 -0.000000 1.000000 0.239678 -0.130856 1.000000 0.239678 -0.272825 1.000000 0.239678 -0.419315 1.000000 0.239678 -0.568821 1.000000 0.239678 -0.720610 1.000000 0.239678 -0.874243 1.000000 0.239678 -1.000000 1.000000 0.239678 -1.000000 1.000000 0.239678 -0.000000 0.000000 0.368370 -0.130856 0.000000 0.368370 -0.272825 0.000000 0.368370 -0.419315 0.000000 0.368370 -0.568821 0.000000 0.368370 -0.720610 0.000000 0.368370 -0.874243 0.000000 0.368370 -1.000000 0.000000 0.368370 -1.000000 0.000000 0.368370 -0.000000 0.122295 0.368370 -0.130856 0.122295 0.368370 -0.272825 0.122295 0.368370 -0.419315 0.122295 0.368370 -0.568821 0.122295 0.368370 -0.720610 0.122295 0.368370 -0.874243 0.122295 0.368370 -1.000000 0.122295 0.368370 -1.000000 0.122295 0.368370 -0.000000 0.254976 0.368370 -0.130856 0.254976 0.368370 -0.272825 0.254976 0.368370 -0.419315 0.254976 0.368370 -0.568821 0.254976 0.368370 -0.720610 0.254976 0.368370 -0.874243 0.254976 0.368370 -1.000000 0.254976 0.368370 -1.000000 0.254976 0.368370 -0.000000 0.391883 0.368370 -0.130856 0.391883 0.368370 -0.272825 0.391883 0.368370 -0.419315 0.391883 0.368370 -0.568821 0.391883 0.368370 -0.720610 0.391883 0.368370 -0.874243 0.391883 0.368370 -1.000000 0.391883 0.368370 -1.000000 0.391883 0.368370 -0.000000 0.531608 0.368370 -0.130856 0.531608 0.368370 -0.272825 0.531608 0.368370 -0.419315 0.531608 0.368370 -0.568821 0.531608 0.368370 -0.720610 0.531608 0.368370 -0.874243 0.531608 0.368370 -1.000000 0.531608 0.368370 -1.000000 0.531608 0.368370 -0.000000 0.673467 0.368370 -0.130856 0.673467 0.368370 -0.272825 0.673467 0.368370 -0.419315 0.673467 0.368370 -0.568821 0.673467 0.368370 -0.720610 0.673467 0.368370 -0.874243 0.673467 0.368370 -1.000000 0.673467 0.368370 -1.000000 0.673467 0.368370 -0.000000 0.817050 0.368370 -0.130856 0.817050 0.368370 -0.272825 0.817050 0.368370 -0.419315 0.817050 0.368370 -0.568821 0.817050 0.368370 -0.720610 0.817050 0.368370 -0.874243 0.817050 0.368370 -1.000000 0.817050 0.368370 -1.000000 0.817050 0.368370 -0.000000 0.962082 0.368370 -0.130856 0.962082 0.368370 -0.272825 0.962082 0.368370 -0.419315 0.962082 0.368370 -0.568821 0.962082 0.368370 -0.720610 0.962082 0.368370 -0.874243 0.962082 0.368370 -1.000000 0.962082 0.368370 -1.000000 0.962082 0.368370 -0.000000 1.000000 0.368370 -0.130856 1.000000 0.368370 -0.272825 1.000000 0.368370 -0.419315 1.000000 0.368370 -0.568821 1.000000 0.368370 -0.720610 1.000000 0.368370 -0.874243 1.000000 0.368370 -1.000000 1.000000 0.368370 -1.000000 1.000000 0.368370 -0.000000 0.000000 0.499712 -0.130856 0.000000 0.499712 -0.272825 0.000000 0.499712 -0.419315 0.000000 0.499712 -0.568821 0.000000 0.499712 -0.720610 0.000000 0.499712 -0.874243 0.000000 0.499712 -1.000000 0.000000 0.499712 -1.000000 0.000000 0.499712 -0.000000 0.122295 0.499712 -0.130856 0.122295 0.499712 -0.272825 0.122295 0.499712 -0.419315 0.122295 0.499712 -0.568821 0.122295 0.499712 -0.720610 0.122295 0.499712 -0.874243 0.122295 0.499712 -1.000000 0.122295 0.499712 -1.000000 0.122295 0.499712 -0.000000 0.254976 0.499712 -0.130856 0.254976 0.499712 -0.272825 0.254976 0.499712 -0.419315 0.254976 0.499712 -0.568821 0.254976 0.499712 -0.720610 0.254976 0.499712 -0.874243 0.254976 0.499712 -1.000000 0.254976 0.499712 -1.000000 0.254976 0.499712 -0.000000 0.391883 0.499712 -0.130856 0.391883 0.499712 -0.272825 0.391883 0.499712 -0.419315 0.391883 0.499712 -0.568821 0.391883 0.499712 -0.720610 0.391883 0.499712 -0.874243 0.391883 0.499712 -1.000000 0.391883 0.499712 -1.000000 0.391883 0.499712 -0.000000 0.531608 0.499712 -0.130856 0.531608 0.499712 -0.272825 0.531608 0.499712 -0.419315 0.531608 0.499712 -0.568821 0.531608 0.499712 -0.720610 0.531608 0.499712 -0.874243 0.531608 0.499712 -1.000000 0.531608 0.499712 -1.000000 0.531608 0.499712 -0.000000 0.673467 0.499712 -0.130856 0.673467 0.499712 -0.272825 0.673467 0.499712 -0.419315 0.673467 0.499712 -0.568821 0.673467 0.499712 -0.720610 0.673467 0.499712 -0.874243 0.673467 0.499712 -1.000000 0.673467 0.499712 -1.000000 0.673467 0.499712 -0.000000 0.817050 0.499712 -0.130856 0.817050 0.499712 -0.272825 0.817050 0.499712 -0.419315 0.817050 0.499712 -0.568821 0.817050 0.499712 -0.720610 0.817050 0.499712 -0.874243 0.817050 0.499712 -1.000000 0.817050 0.499712 -1.000000 0.817050 0.499712 -0.000000 0.962082 0.499712 -0.130856 0.962082 0.499712 -0.272825 0.962082 0.499712 -0.419315 0.962082 0.499712 -0.568821 0.962082 0.499712 -0.720610 0.962082 0.499712 -0.874243 0.962082 0.499712 -1.000000 0.962082 0.499712 -1.000000 0.962082 0.499712 -0.000000 1.000000 0.499712 -0.130856 1.000000 0.499712 -0.272825 1.000000 0.499712 -0.419315 1.000000 0.499712 -0.568821 1.000000 0.499712 -0.720610 1.000000 0.499712 -0.874243 1.000000 0.499712 -1.000000 1.000000 0.499712 -1.000000 1.000000 0.499712 -0.000000 0.000000 0.633059 -0.130856 0.000000 0.633059 -0.272825 0.000000 0.633059 -0.419315 0.000000 0.633059 -0.568821 0.000000 0.633059 -0.720610 0.000000 0.633059 -0.874243 0.000000 0.633059 -1.000000 0.000000 0.633059 -1.000000 0.000000 0.633059 -0.000000 0.122295 0.633059 -0.130856 0.122295 0.633059 -0.272825 0.122295 0.633059 -0.419315 0.122295 0.633059 -0.568821 0.122295 0.633059 -0.720610 0.122295 0.633059 -0.874243 0.122295 0.633059 -1.000000 0.122295 0.633059 -1.000000 0.122295 0.633059 -0.000000 0.254976 0.633059 -0.130856 0.254976 0.633059 -0.272825 0.254976 0.633059 -0.419315 0.254976 0.633059 -0.568821 0.254976 0.633059 -0.720610 0.254976 0.633059 -0.874243 0.254976 0.633059 -1.000000 0.254976 0.633059 -1.000000 0.254976 0.633059 -0.000000 0.391883 0.633059 -0.130856 0.391883 0.633059 -0.272825 0.391883 0.633059 -0.419315 0.391883 0.633059 -0.568821 0.391883 0.633059 -0.720610 0.391883 0.633059 -0.874243 0.391883 0.633059 -1.000000 0.391883 0.633059 -1.000000 0.391883 0.633059 -0.000000 0.531608 0.633059 -0.130856 0.531608 0.633059 -0.272825 0.531608 0.633059 -0.419315 0.531608 0.633059 -0.568821 0.531608 0.633059 -0.720610 0.531608 0.633059 -0.874243 0.531608 0.633059 -1.000000 0.531608 0.633059 -1.000000 0.531608 0.633059 -0.000000 0.673467 0.633059 -0.130856 0.673467 0.633059 -0.272825 0.673467 0.633059 -0.419315 0.673467 0.633059 -0.568821 0.673467 0.633059 -0.720610 0.673467 0.633059 -0.874243 0.673467 0.633059 -1.000000 0.673467 0.633059 -1.000000 0.673467 0.633059 -0.000000 0.817050 0.633059 -0.130856 0.817050 0.633059 -0.272825 0.817050 0.633059 -0.419315 0.817050 0.633059 -0.568821 0.817050 0.633059 -0.720610 0.817050 0.633059 -0.874243 0.817050 0.633059 -1.000000 0.817050 0.633059 -1.000000 0.817050 0.633059 -0.000000 0.962082 0.633059 -0.130856 0.962082 0.633059 -0.272825 0.962082 0.633059 -0.419315 0.962082 0.633059 -0.568821 0.962082 0.633059 -0.720610 0.962082 0.633059 -0.874243 0.962082 0.633059 -1.000000 0.962082 0.633059 -1.000000 0.962082 0.633059 -0.000000 1.000000 0.633059 -0.130856 1.000000 0.633059 -0.272825 1.000000 0.633059 -0.419315 1.000000 0.633059 -0.568821 1.000000 0.633059 -0.720610 1.000000 0.633059 -0.874243 1.000000 0.633059 -1.000000 1.000000 0.633059 -1.000000 1.000000 0.633059 -0.000000 0.000000 0.768027 -0.130856 0.000000 0.768027 -0.272825 0.000000 0.768027 -0.419315 0.000000 0.768027 -0.568821 0.000000 0.768027 -0.720610 0.000000 0.768027 -0.874243 0.000000 0.768027 -1.000000 0.000000 0.768027 -1.000000 0.000000 0.768027 -0.000000 0.122295 0.768027 -0.130856 0.122295 0.768027 -0.272825 0.122295 0.768027 -0.419315 0.122295 0.768027 -0.568821 0.122295 0.768027 -0.720610 0.122295 0.768027 -0.874243 0.122295 0.768027 -1.000000 0.122295 0.768027 -1.000000 0.122295 0.768027 -0.000000 0.254976 0.768027 -0.130856 0.254976 0.768027 -0.272825 0.254976 0.768027 -0.419315 0.254976 0.768027 -0.568821 0.254976 0.768027 -0.720610 0.254976 0.768027 -0.874243 0.254976 0.768027 -1.000000 0.254976 0.768027 -1.000000 0.254976 0.768027 -0.000000 0.391883 0.768027 -0.130856 0.391883 0.768027 -0.272825 0.391883 0.768027 -0.419315 0.391883 0.768027 -0.568821 0.391883 0.768027 -0.720610 0.391883 0.768027 -0.874243 0.391883 0.768027 -1.000000 0.391883 0.768027 -1.000000 0.391883 0.768027 -0.000000 0.531608 0.768027 -0.130856 0.531608 0.768027 -0.272825 0.531608 0.768027 -0.419315 0.531608 0.768027 -0.568821 0.531608 0.768027 -0.720610 0.531608 0.768027 -0.874243 0.531608 0.768027 -1.000000 0.531608 0.768027 -1.000000 0.531608 0.768027 -0.000000 0.673467 0.768027 -0.130856 0.673467 0.768027 -0.272825 0.673467 0.768027 -0.419315 0.673467 0.768027 -0.568821 0.673467 0.768027 -0.720610 0.673467 0.768027 -0.874243 0.673467 0.768027 -1.000000 0.673467 0.768027 -1.000000 0.673467 0.768027 -0.000000 0.817050 0.768027 -0.130856 0.817050 0.768027 -0.272825 0.817050 0.768027 -0.419315 0.817050 0.768027 -0.568821 0.817050 0.768027 -0.720610 0.817050 0.768027 -0.874243 0.817050 0.768027 -1.000000 0.817050 0.768027 -1.000000 0.817050 0.768027 -0.000000 0.962082 0.768027 -0.130856 0.962082 0.768027 -0.272825 0.962082 0.768027 -0.419315 0.962082 0.768027 -0.568821 0.962082 0.768027 -0.720610 0.962082 0.768027 -0.874243 0.962082 0.768027 -1.000000 0.962082 0.768027 -1.000000 0.962082 0.768027 -0.000000 1.000000 0.768027 -0.130856 1.000000 0.768027 -0.272825 1.000000 0.768027 -0.419315 1.000000 0.768027 -0.568821 1.000000 0.768027 -0.720610 1.000000 0.768027 -0.874243 1.000000 0.768027 -1.000000 1.000000 0.768027 -1.000000 1.000000 0.768027 -0.000000 0.000000 0.904357 -0.130856 0.000000 0.904357 -0.272825 0.000000 0.904357 -0.419315 0.000000 0.904357 -0.568821 0.000000 0.904357 -0.720610 0.000000 0.904357 -0.874243 0.000000 0.904357 -1.000000 0.000000 0.904357 -1.000000 0.000000 0.904357 -0.000000 0.122295 0.904357 -0.130856 0.122295 0.904357 -0.272825 0.122295 0.904357 -0.419315 0.122295 0.904357 -0.568821 0.122295 0.904357 -0.720610 0.122295 0.904357 -0.874243 0.122295 0.904357 -1.000000 0.122295 0.904357 -1.000000 0.122295 0.904357 -0.000000 0.254976 0.904357 -0.130856 0.254976 0.904357 -0.272825 0.254976 0.904357 -0.419315 0.254976 0.904357 -0.568821 0.254976 0.904357 -0.720610 0.254976 0.904357 -0.874243 0.254976 0.904357 -1.000000 0.254976 0.904357 -1.000000 0.254976 0.904357 -0.000000 0.391883 0.904357 -0.130856 0.391883 0.904357 -0.272825 0.391883 0.904357 -0.419315 0.391883 0.904357 -0.568821 0.391883 0.904357 -0.720610 0.391883 0.904357 -0.874243 0.391883 0.904357 -1.000000 0.391883 0.904357 -1.000000 0.391883 0.904357 -0.000000 0.531608 0.904357 -0.130856 0.531608 0.904357 -0.272825 0.531608 0.904357 -0.419315 0.531608 0.904357 -0.568821 0.531608 0.904357 -0.720610 0.531608 0.904357 -0.874243 0.531608 0.904357 -1.000000 0.531608 0.904357 -1.000000 0.531608 0.904357 -0.000000 0.673467 0.904357 -0.130856 0.673467 0.904357 -0.272825 0.673467 0.904357 -0.419315 0.673467 0.904357 -0.568821 0.673467 0.904357 -0.720610 0.673467 0.904357 -0.874243 0.673467 0.904357 -1.000000 0.673467 0.904357 -1.000000 0.673467 0.904357 -0.000000 0.817050 0.904357 -0.130856 0.817050 0.904357 -0.272825 0.817050 0.904357 -0.419315 0.817050 0.904357 -0.568821 0.817050 0.904357 -0.720610 0.817050 0.904357 -0.874243 0.817050 0.904357 -1.000000 0.817050 0.904357 -1.000000 0.817050 0.904357 -0.000000 0.962082 0.904357 -0.130856 0.962082 0.904357 -0.272825 0.962082 0.904357 -0.419315 0.962082 0.904357 -0.568821 0.962082 0.904357 -0.720610 0.962082 0.904357 -0.874243 0.962082 0.904357 -1.000000 0.962082 0.904357 -1.000000 0.962082 0.904357 -0.000000 1.000000 0.904357 -0.130856 1.000000 0.904357 -0.272825 1.000000 0.904357 -0.419315 1.000000 0.904357 -0.568821 1.000000 0.904357 -0.720610 1.000000 0.904357 -0.874243 1.000000 0.904357 -1.000000 1.000000 0.904357 -1.000000 1.000000 0.904357 +0.000000 0.000000 0.114736 +0.115311 0.000000 0.114736 +0.240416 0.000000 0.114736 +0.369504 0.000000 0.114736 +0.501250 0.000000 0.114736 +0.635008 0.000000 0.114736 +0.770391 0.000000 0.114736 +0.907141 0.000000 0.114736 +1.000000 0.000000 0.114736 +0.000000 0.115023 0.114736 +0.115311 0.115023 0.114736 +0.240416 0.115023 0.114736 +0.369504 0.115023 0.114736 +0.501250 0.115023 0.114736 +0.635008 0.115023 0.114736 +0.770391 0.115023 0.114736 +0.907141 0.115023 0.114736 +1.000000 0.115023 0.114736 +0.000000 0.239816 0.114736 +0.115311 0.239816 0.114736 +0.240416 0.239816 0.114736 +0.369504 0.239816 0.114736 +0.501250 0.239816 0.114736 +0.635008 0.239816 0.114736 +0.770391 0.239816 0.114736 +0.907141 0.239816 0.114736 +1.000000 0.239816 0.114736 +0.000000 0.368583 0.114736 +0.115311 0.368583 0.114736 +0.240416 0.368583 0.114736 +0.369504 0.368583 0.114736 +0.501250 0.368583 0.114736 +0.635008 0.368583 0.114736 +0.770391 0.368583 0.114736 +0.907141 0.368583 0.114736 +1.000000 0.368583 0.114736 +0.000000 0.500000 0.114736 +0.115311 0.500000 0.114736 +0.240416 0.500000 0.114736 +0.369504 0.500000 0.114736 +0.501250 0.500000 0.114736 +0.635008 0.500000 0.114736 +0.770391 0.500000 0.114736 +0.907141 0.500000 0.114736 +1.000000 0.500000 0.114736 +0.000000 0.633424 0.114736 +0.115311 0.633424 0.114736 +0.240416 0.633424 0.114736 +0.369504 0.633424 0.114736 +0.501250 0.633424 0.114736 +0.635008 0.633424 0.114736 +0.770391 0.633424 0.114736 +0.907141 0.633424 0.114736 +1.000000 0.633424 0.114736 +0.000000 0.768470 0.114736 +0.115311 0.768470 0.114736 +0.240416 0.768470 0.114736 +0.369504 0.768470 0.114736 +0.501250 0.768470 0.114736 +0.635008 0.768470 0.114736 +0.770391 0.768470 0.114736 +0.907141 0.768470 0.114736 +1.000000 0.768470 0.114736 +0.000000 0.904879 0.114736 +0.115311 0.904879 0.114736 +0.240416 0.904879 0.114736 +0.369504 0.904879 0.114736 +0.501250 0.904879 0.114736 +0.635008 0.904879 0.114736 +0.770391 0.904879 0.114736 +0.907141 0.904879 0.114736 +1.000000 0.904879 0.114736 +0.000000 1.000000 0.114736 +0.115311 1.000000 0.114736 +0.240416 1.000000 0.114736 +0.369504 1.000000 0.114736 +0.501250 1.000000 0.114736 +0.635008 1.000000 0.114736 +0.770391 1.000000 0.114736 +0.907141 1.000000 0.114736 +1.000000 1.000000 0.114736 +0.000000 0.000000 0.239216 +0.115311 0.000000 0.239216 +0.240416 0.000000 0.239216 +0.369504 0.000000 0.239216 +0.501250 0.000000 0.239216 +0.635008 0.000000 0.239216 +0.770391 0.000000 0.239216 +0.907141 0.000000 0.239216 +1.000000 0.000000 0.239216 +0.000000 0.115023 0.239216 +0.115311 0.115023 0.239216 +0.240416 0.115023 0.239216 +0.369504 0.115023 0.239216 +0.501250 0.115023 0.239216 +0.635008 0.115023 0.239216 +0.770391 0.115023 0.239216 +0.907141 0.115023 0.239216 +1.000000 0.115023 0.239216 +0.000000 0.239816 0.239216 +0.115311 0.239816 0.239216 +0.240416 0.239816 0.239216 +0.369504 0.239816 0.239216 +0.501250 0.239816 0.239216 +0.635008 0.239816 0.239216 +0.770391 0.239816 0.239216 +0.907141 0.239816 0.239216 +1.000000 0.239816 0.239216 +0.000000 0.368583 0.239216 +0.115311 0.368583 0.239216 +0.240416 0.368583 0.239216 +0.369504 0.368583 0.239216 +0.501250 0.368583 0.239216 +0.635008 0.368583 0.239216 +0.770391 0.368583 0.239216 +0.907141 0.368583 0.239216 +1.000000 0.368583 0.239216 +0.000000 0.500000 0.239216 +0.115311 0.500000 0.239216 +0.240416 0.500000 0.239216 +0.369504 0.500000 0.239216 +0.501250 0.500000 0.239216 +0.635008 0.500000 0.239216 +0.770391 0.500000 0.239216 +0.907141 0.500000 0.239216 +1.000000 0.500000 0.239216 +0.000000 0.633424 0.239216 +0.115311 0.633424 0.239216 +0.240416 0.633424 0.239216 +0.369504 0.633424 0.239216 +0.501250 0.633424 0.239216 +0.635008 0.633424 0.239216 +0.770391 0.633424 0.239216 +0.907141 0.633424 0.239216 +1.000000 0.633424 0.239216 +0.000000 0.768470 0.239216 +0.115311 0.768470 0.239216 +0.240416 0.768470 0.239216 +0.369504 0.768470 0.239216 +0.501250 0.768470 0.239216 +0.635008 0.768470 0.239216 +0.770391 0.768470 0.239216 +0.907141 0.768470 0.239216 +1.000000 0.768470 0.239216 +0.000000 0.904879 0.239216 +0.115311 0.904879 0.239216 +0.240416 0.904879 0.239216 +0.369504 0.904879 0.239216 +0.501250 0.904879 0.239216 +0.635008 0.904879 0.239216 +0.770391 0.904879 0.239216 +0.907141 0.904879 0.239216 +1.000000 0.904879 0.239216 +0.000000 1.000000 0.239216 +0.115311 1.000000 0.239216 +0.240416 1.000000 0.239216 +0.369504 1.000000 0.239216 +0.501250 1.000000 0.239216 +0.635008 1.000000 0.239216 +0.770391 1.000000 0.239216 +0.907141 1.000000 0.239216 +1.000000 1.000000 0.239216 +0.000000 0.000000 0.367661 +0.115311 0.000000 0.367661 +0.240416 0.000000 0.367661 +0.369504 0.000000 0.367661 +0.501250 0.000000 0.367661 +0.635008 0.000000 0.367661 +0.770391 0.000000 0.367661 +0.907141 0.000000 0.367661 +1.000000 0.000000 0.367661 +0.000000 0.115023 0.367661 +0.115311 0.115023 0.367661 +0.240416 0.115023 0.367661 +0.369504 0.115023 0.367661 +0.501250 0.115023 0.367661 +0.635008 0.115023 0.367661 +0.770391 0.115023 0.367661 +0.907141 0.115023 0.367661 +1.000000 0.115023 0.367661 +0.000000 0.239816 0.367661 +0.115311 0.239816 0.367661 +0.240416 0.239816 0.367661 +0.369504 0.239816 0.367661 +0.501250 0.239816 0.367661 +0.635008 0.239816 0.367661 +0.770391 0.239816 0.367661 +0.907141 0.239816 0.367661 +1.000000 0.239816 0.367661 +0.000000 0.368583 0.367661 +0.115311 0.368583 0.367661 +0.240416 0.368583 0.367661 +0.369504 0.368583 0.367661 +0.501250 0.368583 0.367661 +0.635008 0.368583 0.367661 +0.770391 0.368583 0.367661 +0.907141 0.368583 0.367661 +1.000000 0.368583 0.367661 +0.000000 0.500000 0.367661 +0.115311 0.500000 0.367661 +0.240416 0.500000 0.367661 +0.369504 0.500000 0.367661 +0.501250 0.500000 0.367661 +0.635008 0.500000 0.367661 +0.770391 0.500000 0.367661 +0.907141 0.500000 0.367661 +1.000000 0.500000 0.367661 +0.000000 0.633424 0.367661 +0.115311 0.633424 0.367661 +0.240416 0.633424 0.367661 +0.369504 0.633424 0.367661 +0.501250 0.633424 0.367661 +0.635008 0.633424 0.367661 +0.770391 0.633424 0.367661 +0.907141 0.633424 0.367661 +1.000000 0.633424 0.367661 +0.000000 0.768470 0.367661 +0.115311 0.768470 0.367661 +0.240416 0.768470 0.367661 +0.369504 0.768470 0.367661 +0.501250 0.768470 0.367661 +0.635008 0.768470 0.367661 +0.770391 0.768470 0.367661 +0.907141 0.768470 0.367661 +1.000000 0.768470 0.367661 +0.000000 0.904879 0.367661 +0.115311 0.904879 0.367661 +0.240416 0.904879 0.367661 +0.369504 0.904879 0.367661 +0.501250 0.904879 0.367661 +0.635008 0.904879 0.367661 +0.770391 0.904879 0.367661 +0.907141 0.904879 0.367661 +1.000000 0.904879 0.367661 +0.000000 1.000000 0.367661 +0.115311 1.000000 0.367661 +0.240416 1.000000 0.367661 +0.369504 1.000000 0.367661 +0.501250 1.000000 0.367661 +0.635008 1.000000 0.367661 +0.770391 1.000000 0.367661 +0.907141 1.000000 0.367661 +1.000000 1.000000 0.367661 +0.000000 0.000000 0.498750 +0.115311 0.000000 0.498750 +0.240416 0.000000 0.498750 +0.369504 0.000000 0.498750 +0.501250 0.000000 0.498750 +0.635008 0.000000 0.498750 +0.770391 0.000000 0.498750 +0.907141 0.000000 0.498750 +1.000000 0.000000 0.498750 +0.000000 0.115023 0.498750 +0.115311 0.115023 0.498750 +0.240416 0.115023 0.498750 +0.369504 0.115023 0.498750 +0.501250 0.115023 0.498750 +0.635008 0.115023 0.498750 +0.770391 0.115023 0.498750 +0.907141 0.115023 0.498750 +1.000000 0.115023 0.498750 +0.000000 0.239816 0.498750 +0.115311 0.239816 0.498750 +0.240416 0.239816 0.498750 +0.369504 0.239816 0.498750 +0.501250 0.239816 0.498750 +0.635008 0.239816 0.498750 +0.770391 0.239816 0.498750 +0.907141 0.239816 0.498750 +1.000000 0.239816 0.498750 +0.000000 0.368583 0.498750 +0.115311 0.368583 0.498750 +0.240416 0.368583 0.498750 +0.369504 0.368583 0.498750 +0.501250 0.368583 0.498750 +0.635008 0.368583 0.498750 +0.770391 0.368583 0.498750 +0.907141 0.368583 0.498750 +1.000000 0.368583 0.498750 +0.000000 0.500000 0.498750 +0.115311 0.500000 0.498750 +0.240416 0.500000 0.498750 +0.369504 0.500000 0.498750 +0.501250 0.500000 0.498750 +0.635008 0.500000 0.498750 +0.770391 0.500000 0.498750 +0.907141 0.500000 0.498750 +1.000000 0.500000 0.498750 +0.000000 0.633424 0.498750 +0.115311 0.633424 0.498750 +0.240416 0.633424 0.498750 +0.369504 0.633424 0.498750 +0.501250 0.633424 0.498750 +0.635008 0.633424 0.498750 +0.770391 0.633424 0.498750 +0.907141 0.633424 0.498750 +1.000000 0.633424 0.498750 +0.000000 0.768470 0.498750 +0.115311 0.768470 0.498750 +0.240416 0.768470 0.498750 +0.369504 0.768470 0.498750 +0.501250 0.768470 0.498750 +0.635008 0.768470 0.498750 +0.770391 0.768470 0.498750 +0.907141 0.768470 0.498750 +1.000000 0.768470 0.498750 +0.000000 0.904879 0.498750 +0.115311 0.904879 0.498750 +0.240416 0.904879 0.498750 +0.369504 0.904879 0.498750 +0.501250 0.904879 0.498750 +0.635008 0.904879 0.498750 +0.770391 0.904879 0.498750 +0.907141 0.904879 0.498750 +1.000000 0.904879 0.498750 +0.000000 1.000000 0.498750 +0.115311 1.000000 0.498750 +0.240416 1.000000 0.498750 +0.369504 1.000000 0.498750 +0.501250 1.000000 0.498750 +0.635008 1.000000 0.498750 +0.770391 1.000000 0.498750 +0.907141 1.000000 0.498750 +1.000000 1.000000 0.498750 +0.000000 0.000000 0.631841 +0.115311 0.000000 0.631841 +0.240416 0.000000 0.631841 +0.369504 0.000000 0.631841 +0.501250 0.000000 0.631841 +0.635008 0.000000 0.631841 +0.770391 0.000000 0.631841 +0.907141 0.000000 0.631841 +1.000000 0.000000 0.631841 +0.000000 0.115023 0.631841 +0.115311 0.115023 0.631841 +0.240416 0.115023 0.631841 +0.369504 0.115023 0.631841 +0.501250 0.115023 0.631841 +0.635008 0.115023 0.631841 +0.770391 0.115023 0.631841 +0.907141 0.115023 0.631841 +1.000000 0.115023 0.631841 +0.000000 0.239816 0.631841 +0.115311 0.239816 0.631841 +0.240416 0.239816 0.631841 +0.369504 0.239816 0.631841 +0.501250 0.239816 0.631841 +0.635008 0.239816 0.631841 +0.770391 0.239816 0.631841 +0.907141 0.239816 0.631841 +1.000000 0.239816 0.631841 +0.000000 0.368583 0.631841 +0.115311 0.368583 0.631841 +0.240416 0.368583 0.631841 +0.369504 0.368583 0.631841 +0.501250 0.368583 0.631841 +0.635008 0.368583 0.631841 +0.770391 0.368583 0.631841 +0.907141 0.368583 0.631841 +1.000000 0.368583 0.631841 +0.000000 0.500000 0.631841 +0.115311 0.500000 0.631841 +0.240416 0.500000 0.631841 +0.369504 0.500000 0.631841 +0.501250 0.500000 0.631841 +0.635008 0.500000 0.631841 +0.770391 0.500000 0.631841 +0.907141 0.500000 0.631841 +1.000000 0.500000 0.631841 +0.000000 0.633424 0.631841 +0.115311 0.633424 0.631841 +0.240416 0.633424 0.631841 +0.369504 0.633424 0.631841 +0.501250 0.633424 0.631841 +0.635008 0.633424 0.631841 +0.770391 0.633424 0.631841 +0.907141 0.633424 0.631841 +1.000000 0.633424 0.631841 +0.000000 0.768470 0.631841 +0.115311 0.768470 0.631841 +0.240416 0.768470 0.631841 +0.369504 0.768470 0.631841 +0.501250 0.768470 0.631841 +0.635008 0.768470 0.631841 +0.770391 0.768470 0.631841 +0.907141 0.768470 0.631841 +1.000000 0.768470 0.631841 +0.000000 0.904879 0.631841 +0.115311 0.904879 0.631841 +0.240416 0.904879 0.631841 +0.369504 0.904879 0.631841 +0.501250 0.904879 0.631841 +0.635008 0.904879 0.631841 +0.770391 0.904879 0.631841 +0.907141 0.904879 0.631841 +1.000000 0.904879 0.631841 +0.000000 1.000000 0.631841 +0.115311 1.000000 0.631841 +0.240416 1.000000 0.631841 +0.369504 1.000000 0.631841 +0.501250 1.000000 0.631841 +0.635008 1.000000 0.631841 +0.770391 1.000000 0.631841 +0.907141 1.000000 0.631841 +1.000000 1.000000 0.631841 +0.000000 0.000000 0.766549 +0.115311 0.000000 0.766549 +0.240416 0.000000 0.766549 +0.369504 0.000000 0.766549 +0.501250 0.000000 0.766549 +0.635008 0.000000 0.766549 +0.770391 0.000000 0.766549 +0.907141 0.000000 0.766549 +1.000000 0.000000 0.766549 +0.000000 0.115023 0.766549 +0.115311 0.115023 0.766549 +0.240416 0.115023 0.766549 +0.369504 0.115023 0.766549 +0.501250 0.115023 0.766549 +0.635008 0.115023 0.766549 +0.770391 0.115023 0.766549 +0.907141 0.115023 0.766549 +1.000000 0.115023 0.766549 +0.000000 0.239816 0.766549 +0.115311 0.239816 0.766549 +0.240416 0.239816 0.766549 +0.369504 0.239816 0.766549 +0.501250 0.239816 0.766549 +0.635008 0.239816 0.766549 +0.770391 0.239816 0.766549 +0.907141 0.239816 0.766549 +1.000000 0.239816 0.766549 +0.000000 0.368583 0.766549 +0.115311 0.368583 0.766549 +0.240416 0.368583 0.766549 +0.369504 0.368583 0.766549 +0.501250 0.368583 0.766549 +0.635008 0.368583 0.766549 +0.770391 0.368583 0.766549 +0.907141 0.368583 0.766549 +1.000000 0.368583 0.766549 +0.000000 0.500000 0.766549 +0.115311 0.500000 0.766549 +0.240416 0.500000 0.766549 +0.369504 0.500000 0.766549 +0.501250 0.500000 0.766549 +0.635008 0.500000 0.766549 +0.770391 0.500000 0.766549 +0.907141 0.500000 0.766549 +1.000000 0.500000 0.766549 +0.000000 0.633424 0.766549 +0.115311 0.633424 0.766549 +0.240416 0.633424 0.766549 +0.369504 0.633424 0.766549 +0.501250 0.633424 0.766549 +0.635008 0.633424 0.766549 +0.770391 0.633424 0.766549 +0.907141 0.633424 0.766549 +1.000000 0.633424 0.766549 +0.000000 0.768470 0.766549 +0.115311 0.768470 0.766549 +0.240416 0.768470 0.766549 +0.369504 0.768470 0.766549 +0.501250 0.768470 0.766549 +0.635008 0.768470 0.766549 +0.770391 0.768470 0.766549 +0.907141 0.768470 0.766549 +1.000000 0.768470 0.766549 +0.000000 0.904879 0.766549 +0.115311 0.904879 0.766549 +0.240416 0.904879 0.766549 +0.369504 0.904879 0.766549 +0.501250 0.904879 0.766549 +0.635008 0.904879 0.766549 +0.770391 0.904879 0.766549 +0.907141 0.904879 0.766549 +1.000000 0.904879 0.766549 +0.000000 1.000000 0.766549 +0.115311 1.000000 0.766549 +0.240416 1.000000 0.766549 +0.369504 1.000000 0.766549 +0.501250 1.000000 0.766549 +0.635008 1.000000 0.766549 +0.770391 1.000000 0.766549 +0.907141 1.000000 0.766549 +1.000000 1.000000 0.766549 +0.000000 0.000000 0.902616 +0.115311 0.000000 0.902616 +0.240416 0.000000 0.902616 +0.369504 0.000000 0.902616 +0.501250 0.000000 0.902616 +0.635008 0.000000 0.902616 +0.770391 0.000000 0.902616 +0.907141 0.000000 0.902616 +1.000000 0.000000 0.902616 +0.000000 0.115023 0.902616 +0.115311 0.115023 0.902616 +0.240416 0.115023 0.902616 +0.369504 0.115023 0.902616 +0.501250 0.115023 0.902616 +0.635008 0.115023 0.902616 +0.770391 0.115023 0.902616 +0.907141 0.115023 0.902616 +1.000000 0.115023 0.902616 +0.000000 0.239816 0.902616 +0.115311 0.239816 0.902616 +0.240416 0.239816 0.902616 +0.369504 0.239816 0.902616 +0.501250 0.239816 0.902616 +0.635008 0.239816 0.902616 +0.770391 0.239816 0.902616 +0.907141 0.239816 0.902616 +1.000000 0.239816 0.902616 +0.000000 0.368583 0.902616 +0.115311 0.368583 0.902616 +0.240416 0.368583 0.902616 +0.369504 0.368583 0.902616 +0.501250 0.368583 0.902616 +0.635008 0.368583 0.902616 +0.770391 0.368583 0.902616 +0.907141 0.368583 0.902616 +1.000000 0.368583 0.902616 +0.000000 0.500000 0.902616 +0.115311 0.500000 0.902616 +0.240416 0.500000 0.902616 +0.369504 0.500000 0.902616 +0.501250 0.500000 0.902616 +0.635008 0.500000 0.902616 +0.770391 0.500000 0.902616 +0.907141 0.500000 0.902616 +1.000000 0.500000 0.902616 +0.000000 0.633424 0.902616 +0.115311 0.633424 0.902616 +0.240416 0.633424 0.902616 +0.369504 0.633424 0.902616 +0.501250 0.633424 0.902616 +0.635008 0.633424 0.902616 +0.770391 0.633424 0.902616 +0.907141 0.633424 0.902616 +1.000000 0.633424 0.902616 +0.000000 0.768470 0.902616 +0.115311 0.768470 0.902616 +0.240416 0.768470 0.902616 +0.369504 0.768470 0.902616 +0.501250 0.768470 0.902616 +0.635008 0.768470 0.902616 +0.770391 0.768470 0.902616 +0.907141 0.768470 0.902616 +1.000000 0.768470 0.902616 +0.000000 0.904879 0.902616 +0.115311 0.904879 0.902616 +0.240416 0.904879 0.902616 +0.369504 0.904879 0.902616 +0.501250 0.904879 0.902616 +0.635008 0.904879 0.902616 +0.770391 0.904879 0.902616 +0.907141 0.904879 0.902616 +1.000000 0.904879 0.902616 +0.000000 1.000000 0.902616 +0.115311 1.000000 0.902616 +0.240416 1.000000 0.902616 +0.369504 1.000000 0.902616 +0.501250 1.000000 0.902616 +0.635008 1.000000 0.902616 +0.770391 1.000000 0.902616 +0.907141 1.000000 0.902616 +1.000000 1.000000 0.902616 0.000000 0.000000 1.000000 -0.130856 0.000000 1.000000 -0.272825 0.000000 1.000000 -0.419315 0.000000 1.000000 -0.568821 0.000000 1.000000 -0.720610 0.000000 1.000000 -0.874243 0.000000 1.000000 +0.115311 0.000000 1.000000 +0.240416 0.000000 1.000000 +0.369504 0.000000 1.000000 +0.501250 0.000000 1.000000 +0.635008 0.000000 1.000000 +0.770391 0.000000 1.000000 +0.907141 0.000000 1.000000 1.000000 0.000000 1.000000 -1.000000 0.000000 1.000000 -0.000000 0.122295 1.000000 -0.130856 0.122295 1.000000 -0.272825 0.122295 1.000000 -0.419315 0.122295 1.000000 -0.568821 0.122295 1.000000 -0.720610 0.122295 1.000000 -0.874243 0.122295 1.000000 -1.000000 0.122295 1.000000 -1.000000 0.122295 1.000000 -0.000000 0.254976 1.000000 -0.130856 0.254976 1.000000 -0.272825 0.254976 1.000000 -0.419315 0.254976 1.000000 -0.568821 0.254976 1.000000 -0.720610 0.254976 1.000000 -0.874243 0.254976 1.000000 -1.000000 0.254976 1.000000 -1.000000 0.254976 1.000000 -0.000000 0.391883 1.000000 -0.130856 0.391883 1.000000 -0.272825 0.391883 1.000000 -0.419315 0.391883 1.000000 -0.568821 0.391883 1.000000 -0.720610 0.391883 1.000000 -0.874243 0.391883 1.000000 -1.000000 0.391883 1.000000 -1.000000 0.391883 1.000000 -0.000000 0.531608 1.000000 -0.130856 0.531608 1.000000 -0.272825 0.531608 1.000000 -0.419315 0.531608 1.000000 -0.568821 0.531608 1.000000 -0.720610 0.531608 1.000000 -0.874243 0.531608 1.000000 -1.000000 0.531608 1.000000 -1.000000 0.531608 1.000000 -0.000000 0.673467 1.000000 -0.130856 0.673467 1.000000 -0.272825 0.673467 1.000000 -0.419315 0.673467 1.000000 -0.568821 0.673467 1.000000 -0.720610 0.673467 1.000000 -0.874243 0.673467 1.000000 -1.000000 0.673467 1.000000 -1.000000 0.673467 1.000000 -0.000000 0.817050 1.000000 -0.130856 0.817050 1.000000 -0.272825 0.817050 1.000000 -0.419315 0.817050 1.000000 -0.568821 0.817050 1.000000 -0.720610 0.817050 1.000000 -0.874243 0.817050 1.000000 -1.000000 0.817050 1.000000 -1.000000 0.817050 1.000000 -0.000000 0.962082 1.000000 -0.130856 0.962082 1.000000 -0.272825 0.962082 1.000000 -0.419315 0.962082 1.000000 -0.568821 0.962082 1.000000 -0.720610 0.962082 1.000000 -0.874243 0.962082 1.000000 -1.000000 0.962082 1.000000 -1.000000 0.962082 1.000000 +0.000000 0.115023 1.000000 +0.115311 0.115023 1.000000 +0.240416 0.115023 1.000000 +0.369504 0.115023 1.000000 +0.501250 0.115023 1.000000 +0.635008 0.115023 1.000000 +0.770391 0.115023 1.000000 +0.907141 0.115023 1.000000 +1.000000 0.115023 1.000000 +0.000000 0.239816 1.000000 +0.115311 0.239816 1.000000 +0.240416 0.239816 1.000000 +0.369504 0.239816 1.000000 +0.501250 0.239816 1.000000 +0.635008 0.239816 1.000000 +0.770391 0.239816 1.000000 +0.907141 0.239816 1.000000 +1.000000 0.239816 1.000000 +0.000000 0.368583 1.000000 +0.115311 0.368583 1.000000 +0.240416 0.368583 1.000000 +0.369504 0.368583 1.000000 +0.501250 0.368583 1.000000 +0.635008 0.368583 1.000000 +0.770391 0.368583 1.000000 +0.907141 0.368583 1.000000 +1.000000 0.368583 1.000000 +0.000000 0.500000 1.000000 +0.115311 0.500000 1.000000 +0.240416 0.500000 1.000000 +0.369504 0.500000 1.000000 +0.501250 0.500000 1.000000 +0.635008 0.500000 1.000000 +0.770391 0.500000 1.000000 +0.907141 0.500000 1.000000 +1.000000 0.500000 1.000000 +0.000000 0.633424 1.000000 +0.115311 0.633424 1.000000 +0.240416 0.633424 1.000000 +0.369504 0.633424 1.000000 +0.501250 0.633424 1.000000 +0.635008 0.633424 1.000000 +0.770391 0.633424 1.000000 +0.907141 0.633424 1.000000 +1.000000 0.633424 1.000000 +0.000000 0.768470 1.000000 +0.115311 0.768470 1.000000 +0.240416 0.768470 1.000000 +0.369504 0.768470 1.000000 +0.501250 0.768470 1.000000 +0.635008 0.768470 1.000000 +0.770391 0.768470 1.000000 +0.907141 0.768470 1.000000 +1.000000 0.768470 1.000000 +0.000000 0.904879 1.000000 +0.115311 0.904879 1.000000 +0.240416 0.904879 1.000000 +0.369504 0.904879 1.000000 +0.501250 0.904879 1.000000 +0.635008 0.904879 1.000000 +0.770391 0.904879 1.000000 +0.907141 0.904879 1.000000 +1.000000 0.904879 1.000000 0.000000 1.000000 1.000000 -0.130856 1.000000 1.000000 -0.272825 1.000000 1.000000 -0.419315 1.000000 1.000000 -0.568821 1.000000 1.000000 -0.720610 1.000000 1.000000 -0.874243 1.000000 1.000000 -1.000000 1.000000 1.000000 +0.115311 1.000000 1.000000 +0.240416 1.000000 1.000000 +0.369504 1.000000 1.000000 +0.501250 1.000000 1.000000 +0.635008 1.000000 1.000000 +0.770391 1.000000 1.000000 +0.907141 1.000000 1.000000 1.000000 1.000000 1.000000 diff --git a/packages/create-threenative/templates/starter/src/render/quality.ts b/packages/create-threenative/templates/starter/src/render/quality.ts index b3ea10c360..e8462baf1c 100644 --- a/packages/create-threenative/templates/starter/src/render/quality.ts +++ b/packages/create-threenative/templates/starter/src/render/quality.ts @@ -121,14 +121,22 @@ const QUALITY_PRESETS: Record = { high, l * This game's colour grade and grain, per tier. The table itself lives in `public/grade.cube` and * the maths in `grade.ts`; these are the four numbers the look is dialled with. * + * **The grain default is 1/255, and that is the point.** `film()` scales its noise by + * the pixel's own value, so on mid-grey concrete this moves a channel by well under half an 8-bit + * step — below what the framebuffer can show. It shipped at 0.12, and a blind judge called the + * result "grain clearly visible as speckle over sky and flat walls and behind HUD text". A default + * must never degrade the look, so raise this number when you want the film look: 0.12 is a visible + * grain, 0.3 a heavy one. + * * A zero refuses its stage rather than running it at zero strength, so `low` — a phone, where a * moving grain is the first thing to go — reports `grain` as refused with its reason instead of * reading as applied. The grade survives there: it is one texture fetch and the frame is the - * same frame without it. + * same frame without it. `high` keeps a non-zero grain on purpose, so `TN_RENDER_CHAIN` can still + * name it as applied there rather than reporting the stage a default deliberately declined to run. */ const GRADE_PRESETS: Record = { - high: { gradeIntensity: 1, grainAnimated: true, grainIntensity: 0.12, tier: "high" }, - medium: { gradeIntensity: 1, grainAnimated: false, grainIntensity: 0.1, tier: "medium" }, + high: { gradeIntensity: 1, grainAnimated: true, grainIntensity: 0.0039, tier: "high" }, + medium: { gradeIntensity: 1, grainAnimated: false, grainIntensity: 0.0039, tier: "medium" }, low: { gradeIntensity: 1, grainAnimated: false, grainIntensity: 0, tier: "low" }, }; diff --git a/packages/create-threenative/templates/starter/tools/make-grade-lut.mjs b/packages/create-threenative/templates/starter/tools/make-grade-lut.mjs index eaae7a4468..5480c22838 100644 --- a/packages/create-threenative/templates/starter/tools/make-grade-lut.mjs +++ b/packages/create-threenative/templates/starter/tools/make-grade-lut.mjs @@ -26,13 +26,33 @@ const SIZE = 9; /** The grade itself, in display-referred [0, 1] — which is what a `.cube` is written against. */ const CONTRAST = 1.06; -const PIVOT = 0.18; -const GAIN = { b: 0.94, g: 1, r: 1.07 }; + +/** + * The pivot is mid-grey, and that is most of what makes this a default rather than a look. A pivot + * at 0.18 — where this table started — leaves everything above it brighter, because a contrast + * curve anchored low down lifts the whole middle of the range: measured on the frame, it moved + * mid-grey up 8 of 255 steps and the sky up 12, which is what "washed toward cream" and "muddy" + * read like on screen. Anchored at 0.5 it darkens below the middle, brightens above it, and keeps + * the blacks where they were. + */ +const PIVOT = 0.5; + +/** + * The channel mixer, and deliberately the smallest one that is still a grade. + * + * This started at `{ b: 0.94, g: 1, r: 1.07 }` — a 13% warm spread. A blind judge compared the + * starter with that table against the starter without one and preferred **no table**: grey + * concrete read as sandstone, the sky as cream, and the whole frame as mud. A default that makes + * the picture worse is not a default, so the spread is now about 1/26 of that — one 8-bit step or + * less on concrete and on sky alike, which is what `__tests__/grade.spec.ts` pins. A game that + * wants the warm look names its own gain here. + */ +const GAIN = { b: 0.9975, g: 1, r: 1.0025 }; /** * Contrast about a pivot: a power function that leaves the pivot alone and darkens below it. This - * is the one operation a grading tool would call a curve; the gain below is its channel mixer, and - * it is why the top of the warm channels clips — a gain above one cannot brighten a pixel that is + * is the one operation a grading tool would call a curve; the gain above is its channel mixer, and + * it is why the top of the warm channel clips — a gain above one cannot brighten a pixel that is * already white, which is what the table above says about highlights. */ const grade = (value, channel) => diff --git a/packages/runtime-native/__tests__/asset-manifest.spec.ts b/packages/runtime-native/__tests__/asset-manifest.spec.ts index 17169f8c6d..defbcd0ca2 100644 --- a/packages/runtime-native/__tests__/asset-manifest.spec.ts +++ b/packages/runtime-native/__tests__/asset-manifest.spec.ts @@ -144,6 +144,43 @@ describe("the asset selector drops what this cook did not produce", () => { ]); }); + it("stages a hand-placed file no cook produces, whatever its extension", () => { + // PRD-492 filed this as "the desktop packager drops every undeclared public/ file", from a + // `TN_GRADE_TABLE grade.cube: File read error` on the starter's desktop run. It does not: the + // selector's rule 3 keeps what no cook produced, so a colour table — an extension no asset + // pipeline emits — stages on every native target exactly like the level JSON above. What was + // actually missing was the file: that run's scaffold had no `public/grade.cube` in it. The + // guard stands so the next report of this shape is checked here first. + const root = makeTempDirSync("threenative-manifest-undeclared-"); + roots.push(root); + const assets = join(root, "public"); + mkdirSync(assets, { recursive: true }); + writeFileSync(join(assets, "grade.cube"), "LUT_3D_SIZE 2\n0 0 0\n"); + writeFileSync(join(assets, "b.glb"), minimalGlb()); + writeFileSync( + join(assets, "assets.manifest.json"), + JSON.stringify({ version: 1, entries: { "b.glb": { output: "b.glb", kind: "model" } } }), + ); + const bundle = join(root, "bundle.js"); + writeFileSync(bundle, "export default 1;"); + vi.spyOn(console, "log").mockImplementation(() => {}); + const desktop = join(root, "desktop"); + const android = join(root, "android"); + const ios = join(root, "ios"); + mkdirSync(desktop, { recursive: true }); + mkdirSync(ios, { recursive: true }); + stageDesktopFiles(bundle, assets, desktop); + stageAndroidAssets(assets, android); + stageIosAssets(assets, ios); + for (const destination of [desktop, android, ios]) { + assert.equal( + existsSync(join(destination, "grade.cube")), + true, + `${destination} colour table`, + ); + } + }); + it("Android, desktop and iOS staging all drop the orphan and keep the rest", () => { const { assets, root } = fixture(); const spy = vi.spyOn(console, "log").mockImplementation(() => {});