Skip to content
Closed
99 changes: 89 additions & 10 deletions docs/PRDs/open-world/PRD-492-colour-grading-and-film-grain.md

Large diffs are not rendered by default.

91 changes: 90 additions & 1 deletion packages/core/__tests__/render-chain.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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);
});
});
41 changes: 39 additions & 2 deletions packages/core/src/render/chain.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`,
);
Expand Down Expand Up @@ -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`);
Expand Down
23 changes: 20 additions & 3 deletions packages/core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -788,14 +800,19 @@ 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);
const nextPipeline = new RenderPipeline(
raw as unknown as ConstructorParameters<typeof RenderPipeline>[0],
node as ConstructorParameters<typeof RenderPipeline>[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;
Expand Down
Loading
Loading