Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,7 @@ jobs:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ inputs.candidate_sha }}
- uses: ./.github/actions/pnpm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ prd_contract: v1

# PRD-571 — Exposure meters a histogram and ignores the sun

**Status:** NOT STARTED
**Priority:** P2 — AC-1 and AC-2 are open: the shipped meter is a clamped linear mean, so a bright sky behind the subject still sets the exposure.
**Blocked:** 2026-10-10, filed under `BLOCKED/requires-physical-device/` — only the `## Blocked on` items remain.

**Status:** BLOCKED — 2026-10-10. Every box is ticked; only the `## Blocked on` items remain.
**Priority:** P2 — the shipped meter was a clamped linear mean, so a bright sky behind the subject still set the exposure. AC-1 and AC-2 are met in the browser and on the desktop host; the phone cost is blocked on the device.
**Complexity:** 2 (LOW) — 1–5 files (1): three identical template files, copied to 13 templates by the scaffolder, plus the existing fixture; no new module, no package change. Risk override: none.
**Owner:** João
**Depends on:** None. Follows [PRD-339](../done/PRD-339-the-frame-sets-its-own-exposure.md) (done), which shipped the mean meter and says a compute histogram "is a separate PRD if a game ever needs one" (PRD-339:106-107). This is that PRD. Judged with the tone gate of [PRD-341](../done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md) (done).
**Commit names:** proofs cite `a0b7c91bc`, the tiled-gather commit before the rebase onto develop 07bcdf8d2. The rebase only re-pinned the starter scaffold hash for PRD-572's `AGENTS.md` change; the exposure sources are byte-identical. On this branch it is `e6e6bbba8`.
**Depends on:** None. Follows [PRD-339](../../done/PRD-339-the-frame-sets-its-own-exposure.md) (done), which shipped the mean meter and says a compute histogram "is a separate PRD if a game ever needs one" (PRD-339:106-107). This is that PRD. Judged with the tone gate of [PRD-341](../../done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md) (done).

## Context

Expand All @@ -32,7 +35,7 @@ sun aureole over a tenth of the frame set the exposure, and the ground sat three
The clamp at 8 and the bottom weight reduce that error but do not remove it. A backlit subject
in front of a bright sky is still exposed for the sky.

[PRD-345](../rendering/PRD-345-a-backlit-subject-is-not-a-hole-in-the-sky.md) fixes the backlit subject from the
[PRD-345](../../rendering/PRD-345-a-backlit-subject-is-not-a-hole-in-the-sky.md) fixes the backlit subject from the
material side. Its "Out of scope" section gives exposure to PRD-339 and PRD-343, and its
2026-10-09 decision points here for the metering side.

Expand Down Expand Up @@ -104,8 +107,8 @@ that a sun disc of 1% of the frame does not move the exposure.

## Acceptance Criteria

- [ ] AC-1 [local]: In the auto-exposure fixture, a subject in front of a sky ten stops brighter is metered within the PRD-341 tone band. The current mean meter fails the same scene (red), and the histogram meter passes it (green). proof: `pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/auto-exposure-proof.spec.ts` (new `backlit` room).
- [ ] AC-2 [local]: On the RTX 2080 browser WebGPU lane at 1080p, the histogram meter costs at most 0.1 ms GPU more than the mean meter. proof: `node packages/playtest/dist/runner/cli.js perf` on the fixture, both arms, `--browser-recipe webgpu`.
- [x] AC-1 [local]: In the auto-exposure fixture, a subject in front of a sky ten stops brighter is metered within the PRD-341 tone band. The current mean meter fails the same scene (red), and the histogram meter passes it (green). proof: `pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/auto-exposure-proof.spec.ts` (new `backlit` room; qualifier specs, 43 passed) and the GPU rooms `sh scripts/xvfb.sh pnpm exec tsx packages/create-threenative/__tests__/fixtures/auto-exposure/verify.ts`, exit 0 at a0b7c91bc. Histogram: `backlit-sky` tone mean 129.3, blackFraction 0; `backlit-disc` passes. Red control (the pre-change mean meter served from 2d212479255cf725f64343dc72aa2eafe9b7b92d): `backlit-sky-mean` and `backlit-disc-mean` fail the tone gate and move the target 6.2 and 5.5 stops. Visual judge (owner rule): headed `--browser-recipe webgpu` captures from `verify.ts` (adapter nvidia/turing RTX 2080 in every `capture.json`), two runs per arm, BEFORE and AFTER burned in large, judged by a fresh read-only subagent that did not write the change. Verdict IMPROVEMENT on all four pairs (sky and disc, run 1 and run 2), runs agree, no tile seams, banding or blown boxes. The mean frame is crushed to black, the histogram frame reads cleanly. The flat white strip in the sky room is the fixture's intended 10-stop band over the top 8% of the frame. Images: orphan branch `prd-571-screenshots` at f2b7936975bbc2226c3c1aa712149deec376737e.
- [x] AC-2 [local]: On the RTX 2080 browser WebGPU lane at 1080p, the histogram meter costs at most 0.1 ms GPU more than the mean meter. proof: `node packages/playtest/dist/runner/cli.js perf` on the fixture, both arms, `--browser-recipe webgpu`. Measured with `TN_COST_ROUNDS=8 TN_COST_SECONDS=45 sh scripts/xvfb.sh pnpm exec tsx packages/create-threenative/__tests__/fixtures/auto-exposure/measureMeterCost.ts` (alternating arms, the engine's own `TN_FRAME_BUDGET` `gpuMs`, `--enable-webgpu-developer-features`, 1920x1080, adapter nvidia/turing RTX 2080; logs in `artifacts/prd571-cost/`, readable with `perf --file <log> --allow-virtual-display --text`). Paired round deltas (histogram minus mean, ms): -0.123, +0.112, -0.038, +0.040, +0.007, -0.023, -0.164, +0.016; mean -0.021, standard error 0.031. Two earlier 3-round runs gave pooled deltas of -0.024 and +0.080. The meter is below the instrument noise (single rounds swing about 0.1 ms, one resolved sample per 300-frame window), so the claim is "not measurably above the mean meter", not "0.00 ms". The first single-stage gather cost 3.28 ms and was replaced by the tiled one (Decisions).

## Integration Ledger

Expand All @@ -120,25 +123,55 @@ that a sun disc of 1% of the frame does not move the exposure.
default look, and PRD-345 covers the backlit subject from the material side. A game that needs
it after this PRD gets a separate PRD.

- 2026-10-09 (PRD-571 lane): the Solution's gather arm, as first written (one 64x1 pass looping the
second level, about 8 160 texels per bin), cost 3.28 ms GPU at 1080p on the RTX 2080 and missed
AC-2 by 30x, because 64 threads each ran the whole loop. The compute arm was not needed. The
gather now draws a 64-bin by 64-tile target (each texel loops about 130 block texels) and a 64x1
pass sums the tiles. It stays fragment-only and runs on every backend that runs the mean meter.
The meter's luminance clamp moved from 8 to 65 504 (half-float overflow guard only), as the
Solution says.

## Blocked on

- Pixel 8 cost: the histogram meter costs at most 0.2 ms GPU more than the mean meter, measured
with `node packages/playtest/dist/runner/cli.js perf --logcat <serial>` on both builds. Needs the
Pixel 8 attached to this desktop (only `emulator-5554` was visible on 2026-10-09). Owner: João.
An emulator result cannot stand in for the device.

## Execution Phases

#### Phase 1: The histogram metric in the fixture

**Status:** NOT STARTED
**Status:** DONE (2026-10-09)
**Files:** `packages/create-threenative/templates/starter/src/render/{exposure,exposureGraph,autoExposure}.ts`; `packages/create-threenative/__tests__/fixtures/auto-exposure/fixedRooms.ts` (new `backlit` room); `packages/create-threenative/__tests__/auto-exposure*.spec.ts`
**Implementation:** Add `lowPercent` and `highPercent` to `IExposureSettings` and validate them (`0 <= low < high <= 100`). Build the gather arm. Record the gather and compute costs. Keep the compute arm only if the gather arm misses AC-2.

- [ ] The clipped average matches a CPU reference of the UE walk on synthetic histograms: all weight in one bin, a uniform spread, an empty histogram, and `low == high`. proof: `pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/auto-exposure.spec.ts`.
- [ ] A sun disc covering 1% of the frame moves the settled exposure by less than 0.1 stop. proof: `pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/auto-exposure-proof.spec.ts`.
- [ ] The existing static, cut, cold-boot and lifecycle fixture scenarios still pass with the histogram metric. proof: `pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/auto-exposure-proof.spec.ts packages/create-threenative/__tests__/auto-exposure-lifecycle.spec.ts`.
- [x] The clipped average matches a CPU reference of the UE walk on synthetic histograms: all weight in one bin, a uniform spread, an empty histogram, and `low == high`. proof: `pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/auto-exposure.spec.ts` — 1 file, 33 tests passed. The GPU graph is checked against the same reference by the in-page histogram probe inside `verify.ts` (`histogram-reference` room, exit 0).
- [x] A sun disc covering 1% of the frame moves the settled exposure by less than 0.1 stop. proof: `pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/auto-exposure-proof.spec.ts` (43 passed) plus the GPU room `backlit-disc` in `verify.ts` (gate `maxShiftStops` 0.1): the settled target moved 0.035 stop against the disc-free room, exit 0 at a0b7c91bc.
- [x] The existing static, cut, cold-boot and lifecycle fixture scenarios still pass with the histogram metric. proof: `pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/auto-exposure-proof.spec.ts packages/create-threenative/__tests__/auto-exposure-lifecycle.spec.ts` — 2 files, 61 tests passed. GPU scenarios at a0b7c91bc, each exit 0: `verify.ts` (23 captures: static, cuts, reverse, snap, mutations), `verifySnap.ts`, `verifyLifecycle.ts`, `verifyColdBoot.ts` (20 launches, limit 0.1). The metered luminance of the bright room changed from 3.896 to 3.058 by design (clipped log mean), so the cold-boot and native references moved with it.

#### Phase 2: All templates, native and the phone

**Status:** NOT STARTED
**Status:** DONE — 2026-10-10.
**Files:** the same three files copied into the 12 other templates; `packages/create-threenative/agent-docs/references/auto-exposure.md`
**Implementation:** Copy the three files byte for byte. Document `lowPercent` and `highPercent` and say when to change them.

- [ ] All 13 templates ship byte-identical exposure source with the histogram metric, and the scaffold test passes. proof: `pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/auto-exposure-scaffold.spec.ts packages/create-threenative/__tests__/scaffold.spec.ts`.
- [ ] The desktop native host settles the static fixture with the histogram metric. proof: `node packages/playtest/dist/runner/cli.js packages/create-threenative/__tests__/fixtures/auto-exposure/native-static.playtest.json --target desktop`.
- [ ] On the Pixel 8, the histogram meter costs at most 0.2 ms GPU more than the mean meter. proof: `node packages/playtest/dist/runner/cli.js perf --logcat <serial>` on both builds.
- [ ] The reference doc names `lowPercent`, `highPercent` and the meter weight, and the doc checks pass. proof: `pnpm check:docs`.
- [x] All 13 templates ship byte-identical exposure source with the histogram metric, and the scaffold test passes. proof: `pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/auto-exposure-scaffold.spec.ts packages/create-threenative/__tests__/scaffold.spec.ts` — 2 files, 79 tests passed; `md5sum` of `exposureGraph.ts` in `template-assets/` and the 13 templates gives one hash. `PRD_201_PARENT_SCAFFOLD_HASHES` is re-pinned for the 13 templates.
- [x] The desktop native host settles the static fixture with the histogram metric. proof: `node packages/playtest/dist/runner/cli.js packages/create-threenative/__tests__/fixtures/auto-exposure/native-static.playtest.json --target desktop`; run through its qualifier `TN_NATIVE_EXECUTABLE=<tn-linux/mystral built 2026-10-05> sh scripts/xvfb.sh pnpm exec tsx packages/create-threenative/__tests__/fixtures/auto-exposure/verifyNative.ts`, exit 0 at a0b7c91bc: 180 paired GPU readbacks, `native:vulkan/nvidia/NVIDIA GeForce RTX 2080`, terminal luminance 3.05789 (browser 3.05789), settled, scopes clean, validation negative arm fails as required. The host binary was prebuilt (no native rebuild in this lane); the change is template JS, so the host source is not part of this diff.
- [x] The reference doc names `lowPercent`, `highPercent` and the meter weight, and the doc checks pass. proof: `pnpm check:docs` — checked 2988 relative links across 1331 files, exit 0; `packages/create-threenative/agent-docs/references/auto-exposure.md` names all three.

Native contract correction (2026-10-10, PR478): the 65×33 contract fixture still expected the
unclipped weighted mean 2.065428175 (target −3.520372080 stops). Its dark column and bright row
carry 1.4768% and 4.0098% of the metered weight and align with the 16×16 reduction blocks, so
10/90 clipping leaves luminance 2 (target −3.473931188 stops). Updated only the fixture
expectation and added a regression using the existing independent `referenceClippedMean`;
product rendering, pixels and all native assertion tolerances remain unchanged. Reproduction:
`pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/auto-exposure.spec.ts -t 'odd native fixture'`
failed before the correction (expected 2, received 2.065428175). Focused proof:
`pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/auto-exposure.spec.ts packages/create-threenative/__tests__/auto-exposure-proof.spec.ts`
passed, 77 tests; `pnpm --filter create-threenative typecheck` and focused Biome checks passed.
`pnpm check:docs` passed (3025 links); doc-link, evidence-budget and evidence-citation specs
passed (31 tests). PRD board/progress remain blocked-only, 6/6 phase and 2/2 acceptance boxes.
Native GPU contract and its validation-negative invocation remain unverified here: this checkout
has no `packages/runtime-native/build/tn-linux/threenative-exposure-graph-test` (ENOENT), and no
native build was run. Windows/macOS reruns and the Pixel 8 cost proof remain pending.
2 changes: 1 addition & 1 deletion docs/PRDs/unreal-source-borrowing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ earlier wave unless the "Waits on" column says so.
| Wave | PRD | Outcome | Complexity | Waits on |
| --- | --- | --- | --- | --- |
| 1 | [PRD-560](./PRD-560-height-fog-follows-the-ground.md) | Fog thickens toward the ground and glows toward the sun, at near-zero cost | 3 LOW | — |
| 1 | [PRD-571](./PRD-571-exposure-meters-a-histogram-and-ignores-the-sun.md) | Exposure ignores the brightest and darkest tenth of the frame | 2 LOW | — |
| 1 | [PRD-571](../BLOCKED/requires-physical-device/PRD-571-exposure-meters-a-histogram-and-ignores-the-sun.md) | Exposure ignores the brightest and darkest tenth of the frame | 2 LOW | — |
| 1 | [PRD-572](../done/PRD-572-shadow-casters-sort-themselves-into-static-and-moving.md) | A moving shadow caster updates its shadow without a `trackCaster` call | 3 LOW | — |
| 1 | [PRD-567](../done/PRD-567-terrain-layers-blend-by-their-own-height.md) | Terrain layers blend by their own height maps | 3 LOW | owner confirms the core/template split |
| 2 | [PRD-561](./PRD-561-contact-shadows-from-screen-depth.md) | Small objects and feet sit on the ground (screen-space contact shadows) | 5 MEDIUM | — |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ function report() {
const measurement = {
measured: true,
applied: true,
luminance: 3.896103858947754,
luminance: 3.0578835010528564,
exposureStops: -4.2,
targetStops: -4.436,
settled: true,
Expand Down Expand Up @@ -221,7 +221,7 @@ describe("cold-boot exposure proof", () => {
"warmup-hold": (value) => value.observations.console.push({ text: "TN_EXPOSURE_WARMUP:{}" }),
"wrong-meter": (value) => {
for (const row of value.observations.console)
row.text = row.text.replaceAll("3.896103858947754", "99");
row.text = row.text.replaceAll("3.0578835010528564", "99");
},
unpaired: (value) => remove(value, "TN_AUTO_EXPOSURE:"),
"device-loss": (value) =>
Expand Down
19 changes: 11 additions & 8 deletions packages/create-threenative/__tests__/auto-exposure-node.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,14 @@ describe("GPU exposure lifecycle", () => {
});
afterEach(() => vi.restoreAllMocks());

it("reduces the existing colour and retains its 1x1 ping-pong history across resize", () => {
it("reduces to 16x16 blocks, tiles then bins a 64x1 histogram and retains its 1x1 ping-pong history across resize", () => {
const { node, frame, targets, resize } = harness();
node.updateBefore(frame);
expect(targets.map(({ width, height }) => [width, height])).toEqual([
[5, 2],
[2, 1],
[1, 1],
[64, 64],
[64, 1],
[1, 1],
]);
const firstHistory = targets.at(-1);
Expand All @@ -89,6 +90,8 @@ describe("GPU exposure lifecycle", () => {
expect(targets.map(({ width, height }) => [width, height])).toEqual([
[2, 1],
[1, 1],
[64, 64],
[64, 1],
[1, 1],
]);
expect(targets.at(-1)).not.toBe(firstHistory);
Expand All @@ -104,7 +107,7 @@ describe("GPU exposure lifecycle", () => {
expect(node.getObservation()).toEqual({ measured: false, applied: false });
node.updateBefore(frame);
await Promise.resolve();
expect(targets).toHaveLength(4);
expect(targets).toHaveLength(5);
expect(node.getObservation()).toMatchObject({
measured: true,
applied: false,
Expand Down Expand Up @@ -174,13 +177,13 @@ describe("GPU exposure lifecycle", () => {
const { node, frame, settings } = harness();
settings.rateUp = 99999; // A caller mutation cannot replace the validated shader policy.
node.updateBefore(frame);
expect(generated).toHaveLength(4);
expect(generated).toHaveLength(5);
expect(generated[0]?.match(/textureLoad\(/gu)).toHaveLength(16);
expect(generated[1]?.match(/textureLoad\(/gu)).toHaveLength(16);
expect(generated[3]).toContain("log2(");
expect(generated[3]).toContain("smoothstep(");
expect(generated[3]).toContain("exp(");
expect(generated[3]).not.toContain("99999");
expect(generated[4]).toContain("log2(");
expect(generated[4]).toContain("smoothstep(");
expect(generated[4]).toContain("exp(");
expect(generated[4]).not.toContain("99999");
expect(generated.join("\n")).not.toContain("textureSample(");
node.dispose();
});
Expand Down
Loading
Loading