diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 11fad3144..1ed89c138 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -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 diff --git a/docs/PRDs/unreal-source-borrowing/PRD-571-exposure-meters-a-histogram-and-ignores-the-sun.md b/docs/PRDs/BLOCKED/requires-physical-device/PRD-571-exposure-meters-a-histogram-and-ignores-the-sun.md similarity index 54% rename from docs/PRDs/unreal-source-borrowing/PRD-571-exposure-meters-a-histogram-and-ignores-the-sun.md rename to docs/PRDs/BLOCKED/requires-physical-device/PRD-571-exposure-meters-a-histogram-and-ignores-the-sun.md index c108a4e41..9f5c03f0c 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-571-exposure-meters-a-histogram-and-ignores-the-sun.md +++ b/docs/PRDs/BLOCKED/requires-physical-device/PRD-571-exposure-meters-a-histogram-and-ignores-the-sun.md @@ -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 @@ -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. @@ -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 --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 @@ -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 ` 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 ` 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= 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. diff --git a/docs/PRDs/unreal-source-borrowing/README.md b/docs/PRDs/unreal-source-borrowing/README.md index a7f329489..cd901b07c 100644 --- a/docs/PRDs/unreal-source-borrowing/README.md +++ b/docs/PRDs/unreal-source-borrowing/README.md @@ -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 | — | diff --git a/packages/create-threenative/__tests__/auto-exposure-cold-boot.spec.ts b/packages/create-threenative/__tests__/auto-exposure-cold-boot.spec.ts index 128ed72de..0fc42a28c 100644 --- a/packages/create-threenative/__tests__/auto-exposure-cold-boot.spec.ts +++ b/packages/create-threenative/__tests__/auto-exposure-cold-boot.spec.ts @@ -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, @@ -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) => diff --git a/packages/create-threenative/__tests__/auto-exposure-node.spec.ts b/packages/create-threenative/__tests__/auto-exposure-node.spec.ts index 6719b5fc3..ab7dc97ab 100644 --- a/packages/create-threenative/__tests__/auto-exposure-node.spec.ts +++ b/packages/create-threenative/__tests__/auto-exposure-node.spec.ts @@ -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); @@ -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); @@ -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, @@ -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(); }); diff --git a/packages/create-threenative/__tests__/auto-exposure-proof.spec.ts b/packages/create-threenative/__tests__/auto-exposure-proof.spec.ts index d61c18651..da1199461 100644 --- a/packages/create-threenative/__tests__/auto-exposure-proof.spec.ts +++ b/packages/create-threenative/__tests__/auto-exposure-proof.spec.ts @@ -1,7 +1,13 @@ import { readFileSync } from "node:fs"; import { PerspectiveCamera, Scene } from "three"; import { describe, expect, it } from "vitest"; +import { exposureSettings } from "../template-assets/exposure.js"; import { createFixedExposureRooms } from "./fixtures/auto-exposure/fixedRooms.js"; +import { assertHistogramProbe } from "./fixtures/auto-exposure/histogramProof.js"; +import { + histogramCases, + referenceClippedMean, +} from "./fixtures/auto-exposure/histogramReference.js"; import { assertDeterministicExposureBudget, assertExposureClock, @@ -510,3 +516,32 @@ describe("approved first-update snap acceptance", () => { }, ); }); + +describe("histogram probe qualification", () => { + const reference = () => + histogramCases.map(({ name, bins, lowPercent, highPercent }) => { + const { logMean, kept } = referenceClippedMean(bins, lowPercent, highPercent); + const goal = Math.min( + exposureSettings.maxStops, + Math.max(exposureSettings.minStops, Math.log2(exposureSettings.key) - logMean), + ); + return kept > 0 ? { name, luminance: 2 ** logMean, goal } : { name, luminance: -1, goal: 0 }; + }); + const lines = (values: unknown) => [{ text: `TN_EXPOSURE_HISTOGRAM:${JSON.stringify(values)}` }]; + it("accepts values that match the CPU reference", () => { + expect(assertHistogramProbe(lines(reference()))).toBe(histogramCases.length); + }); + it("rejects a bright tail that moved the mean", () => { + const wrong = reference().map((item) => + item.name === "bright-tail-clipped" ? { ...item, luminance: item.luminance * 1.5 } : item, + ); + expect(() => assertHistogramProbe(lines(wrong))).toThrow(/bright-tail-clipped/); + }); + it("rejects a metered luminance for an empty clip, and a missing marker", () => { + const wrong = reference().map((item) => + item.name === "empty" ? { ...item, luminance: 0.5 } : item, + ); + expect(() => assertHistogramProbe(lines(wrong))).toThrow(/kept no weight/); + expect(() => assertHistogramProbe([])).toThrow(/TN_HISTOGRAM_MISSING/); + }); +}); diff --git a/packages/create-threenative/__tests__/auto-exposure.spec.ts b/packages/create-threenative/__tests__/auto-exposure.spec.ts index 5a14fbe01..cb00ac49b 100644 --- a/packages/create-threenative/__tests__/auto-exposure.spec.ts +++ b/packages/create-threenative/__tests__/auto-exposure.spec.ts @@ -6,6 +6,11 @@ import { exposureSettings, validateExposureSettings, } from "../template-assets/exposure.js"; +import { + histogramCases, + referenceClippedMean, +} from "./fixtures/auto-exposure/histogramReference.js"; +import { exposurePixels } from "./fixtures/auto-exposure/native-contract.js"; describe("authored exposure controls", () => { it("is opt-in and supplies validated asymmetric game-authored rates", () => { @@ -29,6 +34,11 @@ describe("authored exposure controls", () => { { maxDelta: 0 }, { initialExposure: 0 }, { enabled: "yes" }, + { lowPercent: -1 }, + { highPercent: 101 }, + { lowPercent: 60, highPercent: 60 }, + { lowPercent: 90, highPercent: 10 }, + { lowPercent: undefined }, ])("rejects malformed policy %j", (bad) => { expect(() => validateExposureSettings({ ...exposureSettings, ...bad } as never)).toThrow( /exposure/i, @@ -36,6 +46,60 @@ describe("authored exposure controls", () => { }); }); +describe("clipped histogram reference", () => { + const byName = (name: string) => { + const found = histogramCases.find((item) => item.name === name); + if (found === undefined) throw new Error(`missing case ${name}`); + return referenceClippedMean(found.bins, found.lowPercent, found.highPercent); + }; + it("keeps one bin's mean and ignores both tails", () => { + expect(byName("one-bin")).toEqual({ logMean: -3, kept: 4 }); + expect(byName("bright-tail-clipped").logMean).toBeCloseTo(-4, 12); + expect(byName("dark-tail-clipped").logMean).toBeCloseTo(-1, 12); + }); + it("averages a uniform spread symmetrically and keeps everything with no clip", () => { + expect(byName("uniform-spread").logMean).toBeCloseTo(-0.125, 12); + expect(byName("no-clip").kept).toBe(8); + }); + it("keeps no weight for an empty histogram or low == high", () => { + expect(byName("empty").kept).toBe(0); + expect(byName("low-equals-high").kept).toBe(0); + }); + it("splits a bin at the percentile mark", () => { + // 34 weight: drop 6.8 from the first bin (7), keep 0.2 of it, all of 11 and 13, then 3 of 3 up to 27.2. + const { kept } = byName("clip-splits-bins"); + expect(kept).toBeCloseTo(27.2 - 6.8, 12); + }); + it("stays inside the authored default clip", () => { + expect(exposureSettings.lowPercent).toBe(10); + expect(exposureSettings.highPercent).toBe(90); + }); + it("meters the odd native fixture against the clipped reference", () => { + const { pixels, expectedLuminance } = exposurePixels(65, 33); + const bins = Array.from({ length: 3 }, () => ({ weight: 0, logSum: 0 })); + for (let y = 0; y < 33; y++) { + for (let x = 0; x < 65; x++) { + const log = Math.log2(pixels[(y * 65 + x) * 4] ?? Number.NaN); + const bin = bins[log]; + if (bin === undefined) throw new Error("Unexpected native fixture luminance"); + const weight = 1 + (y + 0.5) / 33; + bin.weight += weight; + bin.logSum += weight * log; + } + } + // The tails begin at x=64/y=32, on 16x16 reduction boundaries; no block mixes values. + const { logMean, kept } = referenceClippedMean( + bins, + exposureSettings.lowPercent, + exposureSettings.highPercent, + ); + expect(kept).toBeCloseTo(65 * 33 * 1.5 * 0.8, 10); + expect(logMean).toBeCloseTo(1, 12); + expect(expectedLuminance).toBeCloseTo(2 ** logMean, 12); + expect(Math.log2(exposureSettings.key / expectedLuminance)).toBeCloseTo(-3.473931188, 8); + }); +}); + describe("meter reduction topology", () => { it("reduces odd and skinny buffers without discarding partial edge blocks", () => { expect(exposureReductionSizes(17, 5)).toEqual([ diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/backlit.playtest.json b/packages/create-threenative/__tests__/fixtures/auto-exposure/backlit.playtest.json new file mode 100644 index 000000000..1c63a892e --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/backlit.playtest.json @@ -0,0 +1,50 @@ +{ + "name": "prd571-exposure-backlit", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 640, + "height": 360 + }, + "warmupFrames": 20, + "artifacts": { + "screenshots": true, + "console": true + }, + "steps": [ + { + "kind": "wait", + "label": "settled", + "waitFrames": 180 + } + ], + "assert": { + "diagnostics": { + "runtimeReady": true, + "noConsoleErrors": true + }, + "visual": [ + { + "region": { + "x": 0, + "y": 0, + "width": 640, + "height": 360, + "minNonblankPixelRatio": 0.15 + } + } + ], + "tone": [ + { + "atStep": "settled", + "mean": { + "min": 80, + "max": 180 + }, + "blackFraction": { + "max": 0.05 + } + } + ] + } +} diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/coldBootProof.ts b/packages/create-threenative/__tests__/fixtures/auto-exposure/coldBootProof.ts index 54bfe23ae..0affedc6f 100644 --- a/packages/create-threenative/__tests__/fixtures/auto-exposure/coldBootProof.ts +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/coldBootProof.ts @@ -121,7 +121,7 @@ function validateColdBootSamples( }, }, true, - 3.896103858947754, + 3.0578835010528564, false, ); previous = sample; diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/game.ts b/packages/create-threenative/__tests__/fixtures/auto-exposure/game.ts index 5bdd22fce..340d917e8 100644 --- a/packages/create-threenative/__tests__/fixtures/auto-exposure/game.ts +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/game.ts @@ -2,12 +2,16 @@ import { ACESFilmicToneMapping, AmbientLight, BoxGeometry, + CircleGeometry, Color, DirectionalLight, Mesh, + MeshBasicMaterial, MeshStandardMaterial, type PerspectiveCamera, + PlaneGeometry, Vector2, + Vector3, } from "three"; import { pass } from "three/tsl"; import type { WebGPURenderer } from "three/webgpu"; @@ -28,10 +32,51 @@ export interface IExposureFixtureOptions { deterministic?: boolean; coldBoot?: boolean; cameraCut?: boolean; + /** "sky": a band over the top 8% of the frame, 10 stops over the room. "disc": a 1% disc, 14 stops. */ + backlit?: "sky" | "disc"; nativeValidation?: boolean; nativeValidationInject?: boolean; } +/** A flat emissive patch fixed in front of the camera; it lights nothing, so only the meter sees it. */ +function createBacklit( + ctx: ICtx, + camera: PerspectiveCamera, + kind: IExposureFixtureOptions["backlit"], + roomLuminance: number, +) { + if (kind === undefined) return undefined; + camera.updateMatrixWorld(true); + const distance = 20; + const height = 2 * distance * Math.tan((camera.fov * Math.PI) / 360); + const aspect = 16 / 9; + const stops = kind === "sky" ? 10 : 14; + const material = new MeshBasicMaterial({ + color: new Color(1, 1, 1).multiplyScalar(roomLuminance * 2 ** stops), + toneMapped: false, + }); + const geometry = + kind === "sky" + ? new PlaneGeometry(height * aspect * 2, height * 0.08) + : new CircleGeometry(Math.sqrt((0.01 * aspect) / Math.PI) * height, 48); + const patch = new Mesh(geometry, material); + const up = kind === "sky" ? height * (0.5 - 0.04) : height * 0.3; + const right = kind === "sky" ? 0 : height * aspect * 0.25; + patch.quaternion.copy(camera.quaternion); + patch.position + .copy(camera.position) + .addScaledVector(camera.getWorldDirection(new Vector3()), distance) + .addScaledVector(new Vector3(0, 1, 0).applyQuaternion(camera.quaternion), up) + .addScaledVector(new Vector3(1, 0, 0).applyQuaternion(camera.quaternion), right); + ctx.add(patch); + return { + dispose() { + geometry.dispose(); + material.dispose(); + }, + }; +} + /** Portable scene and engine loop. The browser entry only supplies controls and mounts the canvas. */ export function createExposureFixture(options: IExposureFixtureOptions) { class ExposureRoom extends Scene { @@ -79,7 +124,14 @@ export function createExposureFixture(options: IExposureFixtureOptions) { ctx.scene.background = new Color(0x445565).multiplyScalar(intensity); }; applyLight(); + const backlit = createBacklit( + ctx, + camera, + options.backlit, + options.bright ? 3.896 : 0.0019189, + ); disposeRoom = () => { + backlit?.dispose(); floor.dispose(); block.dispose(); for (const material of materials) material.dispose(); diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/histogramProbe.ts b/packages/create-threenative/__tests__/fixtures/auto-exposure/histogramProbe.ts new file mode 100644 index 000000000..80afa1f0c --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/histogramProbe.ts @@ -0,0 +1,54 @@ +import { DataTexture, FloatType, NearestFilter, RGBAFormat } from "three"; +import { texture, uniform } from "three/tsl"; +import { NodeMaterial, QuadMesh, WebGPURenderer } from "three/webgpu"; +import { exposureSettings } from "../../../template-assets/exposure.js"; +import { + adaptExposure, + exposureBins, + exposureTarget, +} from "../../../template-assets/exposureGraph.js"; +import { histogramCases } from "./histogramReference.js"; + +/** + * Runs the shipped adaptation shader on each synthetic histogram and reports what it metered. + * With the seed reset mode the goal is reached at once, so the luminance output is the walk. + */ +export async function runHistogramProbe(): Promise { + const renderer = new WebGPURenderer({ antialias: false }); + await renderer.init(); + const target = exposureTarget(); + const quad = new QuadMesh(); + const previous = new DataTexture(new Float32Array(4), 1, 1, RGBAFormat, FloatType); + previous.needsUpdate = true; + const results: { name: string; luminance: number; goal: number }[] = []; + for (const { name, bins, lowPercent, highPercent } of histogramCases) { + const data = new Float32Array(exposureBins * 4); + for (const [i, bin] of bins.entries()) { + data[i * 4] = bin.weight; + data[i * 4 + 1] = bin.logSum; + } + const histogram = new DataTexture(data, exposureBins, 1, RGBAFormat, FloatType); + histogram.minFilter = NearestFilter; + histogram.magFilter = NearestFilter; + histogram.needsUpdate = true; + const material = new NodeMaterial(); + material.fragmentNode = adaptExposure( + texture(histogram), + texture(previous), + uniform(2), + uniform(0), + uniform(0.016), + { ...exposureSettings, lowPercent, highPercent }, + (mean) => mean, + ); + quad.material = material; + renderer.setRenderTarget(target); + quad.render(renderer); + const pixels = await renderer.readRenderTargetPixelsAsync(target, 0, 0, 1, 1); + if (!(pixels instanceof Float32Array)) throw new Error("Histogram probe readback failed."); + results.push({ name, luminance: pixels[1] ?? Number.NaN, goal: pixels[2] ?? Number.NaN }); + material.dispose(); + histogram.dispose(); + } + console.info(`TN_EXPOSURE_HISTOGRAM:${JSON.stringify(results)}`); +} diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/histogramProof.ts b/packages/create-threenative/__tests__/fixtures/auto-exposure/histogramProof.ts new file mode 100644 index 000000000..c323f07c3 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/histogramProof.ts @@ -0,0 +1,46 @@ +import { exposureSettings } from "../../../template-assets/exposure.js"; +import { histogramCases, referenceClippedMean } from "./histogramReference.js"; + +/** GPU luminance is -1 when no weight survives the clip, which keeps the previous exposure. */ +export function assertHistogramProbe(consoleLines: readonly { text: string }[]): number { + const marker = consoleLines + .filter(({ text }) => text.startsWith("TN_EXPOSURE_HISTOGRAM:")) + .at(-1); + if (marker === undefined) throw new Error("TN_HISTOGRAM_MISSING: probe marker not observed."); + const results = JSON.parse(marker.text.slice("TN_EXPOSURE_HISTOGRAM:".length)) as { + name: string; + luminance: number; + goal: number; + }[]; + if (results.length !== histogramCases.length) + throw new Error( + `TN_HISTOGRAM_MISMATCH: expected ${histogramCases.length} cases, got ${results.length}.`, + ); + for (const [i, expected] of histogramCases.entries()) { + const got = results[i]; + const { logMean, kept } = referenceClippedMean( + expected.bins, + expected.lowPercent, + expected.highPercent, + ); + if (got?.name !== expected.name) + throw new Error(`TN_HISTOGRAM_MISMATCH: case ${i} is ${got?.name}.`); + if (kept <= 0) { + if (got.luminance !== -1) + throw new Error( + `TN_HISTOGRAM_MISMATCH: ${expected.name} kept no weight; GPU reported ${got.luminance}.`, + ); + continue; + } + const luminance = 2 ** logMean; + const goal = Math.min( + exposureSettings.maxStops, + Math.max(exposureSettings.minStops, Math.log2(exposureSettings.key) - logMean), + ); + if (!(Math.abs(got.luminance / luminance - 1) < 1e-3) || !(Math.abs(got.goal - goal) < 1e-3)) + throw new Error( + `TN_HISTOGRAM_MISMATCH: ${expected.name} luminance ${got.luminance} goal ${got.goal}; reference ${luminance} goal ${goal}.`, + ); + } + return results.length; +} diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/histogramReference.ts b/packages/create-threenative/__tests__/fixtures/auto-exposure/histogramReference.ts new file mode 100644 index 000000000..2b3a02d2b --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/histogramReference.ts @@ -0,0 +1,93 @@ +import { exposureBins } from "../../../template-assets/exposureGraph.js"; + +/** One histogram bin as the GPU stores it: summed weight and summed weight * log2 luminance. */ +export interface IHistogramBin { + weight: number; + logSum: number; +} + +export interface IHistogramCase { + name: string; + bins: IHistogramBin[]; + lowPercent: number; + highPercent: number; +} + +const empty: IHistogramBin = { weight: 0, logSum: 0 }; + +function sparse(entries: Record): IHistogramBin[] { + return Array.from({ length: exposureBins }, (_, i) => entries[i] ?? empty); +} + +/** A bin of `weight` samples that all sit at log2 luminance `log`. */ +function at(weight: number, log: number): IHistogramBin { + return { weight, logSum: weight * log }; +} + +/** Synthetic histograms: one bin, a uniform spread, an empty histogram, low == high, and both tails. */ +export const histogramCases: IHistogramCase[] = [ + { name: "one-bin", bins: sparse({ 20: at(5, -3) }), lowPercent: 10, highPercent: 90 }, + { + name: "uniform-spread", + bins: Array.from({ length: exposureBins }, (_, i) => at(1, (i - 32) / 4)), + lowPercent: 10, + highPercent: 90, + }, + { name: "empty", bins: sparse({}), lowPercent: 10, highPercent: 90 }, + { + name: "low-equals-high", + bins: sparse({ 10: at(3, -5), 40: at(3, 2) }), + lowPercent: 50, + highPercent: 50, + }, + { + name: "bright-tail-clipped", + bins: sparse({ 15: at(90, -4), 60: at(10, 6) }), + lowPercent: 10, + highPercent: 90, + }, + { + name: "dark-tail-clipped", + bins: sparse({ 2: at(10, -8), 30: at(90, -1) }), + lowPercent: 10, + highPercent: 90, + }, + { + name: "clip-splits-bins", + bins: sparse({ 5: at(7, -6), 25: at(11, -2), 45: at(13, 1), 62: at(3, 5) }), + lowPercent: 20, + highPercent: 80, + }, + { + name: "no-clip", + bins: sparse({ 8: at(2, -5), 50: at(6, 3) }), + lowPercent: 0, + highPercent: 100, + }, +]; + +/** + * Overlap form of the clipped average, independent of the shader's running subtraction: each + * bin contributes the part of its weight that lies between the low and high percentile marks. + */ +export function referenceClippedMean( + bins: readonly IHistogramBin[], + lowPercent: number, + highPercent: number, +): { logMean: number; kept: number } { + const total = bins.reduce((sum, bin) => sum + bin.weight, 0); + const from = (total * lowPercent) / 100; + const to = (total * highPercent) / 100; + let seen = 0; + let kept = 0; + let logSum = 0; + for (const { weight, logSum: binLog } of bins) { + const take = Math.max(0, Math.min(seen + weight, to) - Math.max(seen, from)); + if (weight > 0) { + kept += take; + logSum += (binLog * take) / weight; + } + seen += weight; + } + return { logMean: kept > 0 ? logSum / kept : Number.NaN, kept }; +} diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/main.ts b/packages/create-threenative/__tests__/fixtures/auto-exposure/main.ts index e03524116..ff56f9708 100644 --- a/packages/create-threenative/__tests__/fixtures/auto-exposure/main.ts +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/main.ts @@ -20,7 +20,12 @@ const game = createExposureFixture({ deterministic: query.get("deterministic") === "1", coldBoot: query.get("coldBoot") === "1", cameraCut: query.get("cameraCut") === "1", + backlit: (query.get("backlit") as "sky" | "disc" | null) ?? undefined, }); +if (query.get("histogramProbe") === "1") + void import("./histogramProbe.js") + .then(({ runHistogramProbe }) => runHistogramProbe()) + .catch((error: unknown) => console.error(error)); void game .start() .then(async () => { diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/measureMeterCost.ts b/packages/create-threenative/__tests__/fixtures/auto-exposure/measureMeterCost.ts new file mode 100644 index 000000000..da8255175 --- /dev/null +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/measureMeterCost.ts @@ -0,0 +1,98 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build, preview } from "vite"; +import { + PERFORMANCE_BROWSER_ARGS, + WEBGPU_BROWSER_ARGS, +} from "../../../../playtest/dist/runner/index.js"; +import { meanMeterBaselinePlugin } from "./mutations.js"; + +/** + * PRD-571 AC-2. Builds the shipped histogram meter and the pre-change mean meter into two copies + * of the exposure fixture, runs each at 1080p on the real adapter, and keeps the engine's own + * `TN_FRAME_BUDGET` windows (GPU ms per resolved frame). Feed each log to + * `playtest perf --file --allow-virtual-display --text`; the arms alternate so a drifting + * clock shows as a spread between rounds, not as a bias. `--enable-webgpu-developer-features` + * turns off Dawn's timestamp quantization so a 0.1 ms difference is readable. + */ +const fixture = dirname(fileURLToPath(import.meta.url)); +const root = resolve(fixture, "../../../../.."); +// Playwright is a playtest dependency, not this package's. +interface IPage { + evaluate(run: () => Promise): Promise; + goto(url: string): Promise; + on(event: "console", listener: (message: { text(): string }) => void): void; + waitForTimeout(ms: number): Promise; +} +interface IBrowser { + close(): Promise; + newPage(options: { viewport: { height: number; width: number } }): Promise; +} +const { chromium } = createRequire(join(root, "packages/playtest/package.json"))("playwright") as { + chromium: { launch(options: { args: string[]; headless: boolean }): Promise }; +}; +const out = join(root, "artifacts/prd571-cost"); +await mkdir(out, { recursive: true }); +const arms = [ + { name: "histogram", plugins: [] }, + { name: "mean", plugins: [meanMeterBaselinePlugin()] }, +] as const; +const sites = new Map(); +for (const arm of arms) { + const site = join(out, "sites", arm.name); + await build({ + configFile: false, + root: fixture, + plugins: [...arm.plugins], + build: { outDir: site, emptyOutDir: true }, + }); + sites.set(arm.name, site); +} +const rounds = Number(process.env.TN_COST_ROUNDS ?? 3); +const seconds = Number(process.env.TN_COST_SECONDS ?? 75); +for (let round = 0; round < rounds; round++) { + for (const arm of arms) { + const server = await preview({ + configFile: false, + root: fixture, + build: { outDir: sites.get(arm.name) as string }, + preview: { host: "127.0.0.1", port: 0, strictPort: false }, + }); + const url = server.resolvedUrls?.local[0]; + if (url === undefined) throw new Error("Preview server has no URL."); + const browser = await chromium.launch({ + headless: false, + args: [ + ...WEBGPU_BROWSER_ARGS, + ...PERFORMANCE_BROWSER_ARGS, + "--enable-webgpu-developer-features", + ], + }); + try { + const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); + const lines: string[] = []; + page.on("console", (message) => { + const text = message.text(); + if (text.startsWith("TN_FRAME_BUDGET")) lines.push(text); + }); + await page.goto(`${url}?bright=1`); + await page.waitForTimeout(seconds * 1000); + const adapter = await page.evaluate(async () => { + const info = (await navigator.gpu.requestAdapter())?.info; + return info === undefined + ? null + : `${info.vendor}/${info.architecture}/${info.device}/${info.description}`; + }); + const file = join(out, `${arm.name}-${round}.log`); + await writeFile(file, `${lines.join("\n")}\n`); + console.info( + `${arm.name} round ${round}: ${lines.length} windows, adapter ${adapter}, ${file}`, + ); + } finally { + await browser.close(); + await server.close(); + } + } +} diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/mutations.ts b/packages/create-threenative/__tests__/fixtures/auto-exposure/mutations.ts index 80a37ff80..cf92afe21 100644 --- a/packages/create-threenative/__tests__/fixtures/auto-exposure/mutations.ts +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/mutations.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import type { Plugin } from "vite"; @@ -12,8 +13,8 @@ const replacements: Record = { " if (!this.settings.enabled) return;\n const renderer = frame.renderer;", ], meter: [ - "const luminance = decode(measure.r.div(measure.g.max(1e-20)));", - "const luminance = decode(measure.r.div(measure.g.max(1e-20))).mul(2);", + "const luminance = decode(measure.x.exp2());", + "const luminance = decode(measure.x.exp2()).mul(2);", ], consumer: ["const exposure = options.autoExposureEnabled", "const exposure = false"], clock: [ @@ -67,3 +68,37 @@ export function exposureMutationPlugin( }, }; } + +/** Pre-PRD-571 template sources: the clamped 1x1 mean meter, the red control for the histogram. */ +export const meanMeterBaselineRef = "2d212479255cf725f64343dc72aa2eafe9b7b92d"; +const baselineFiles = ["autoExposure", "exposure", "exposureGraph"]; + +/** Serves the baseline meter from git for one disposable fixture build; shipped source is untouched. */ +export function meanMeterBaselinePlugin(ref: string = meanMeterBaselineRef): Plugin { + const served = new Set(); + return { + name: "prd571-mean-meter-baseline", + enforce: "pre", + load(id) { + // The baseline has no histogram to probe; keep the page's dynamic import resolvable. + if (id.endsWith("/fixtures/auto-exposure/histogramProbe.ts")) + return "export const runHistogramProbe = async () => {};"; + const file = baselineFiles.find((name) => id.endsWith(`/template-assets/${name}.ts`)); + if (file === undefined) return; + served.add(file); + return execFileSync( + "git", + ["show", `${ref}:packages/create-threenative/template-assets/${file}.ts`], + { + encoding: "utf8", + }, + ); + }, + buildEnd(error) { + if (error === undefined && served.size !== baselineFiles.length) + throw new Error( + `Mean-meter baseline served ${served.size} of ${baselineFiles.length} modules.`, + ); + }, + }; +} diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/native-contract.ts b/packages/create-threenative/__tests__/fixtures/auto-exposure/native-contract.ts index 1f8183006..a69e89b47 100644 --- a/packages/create-threenative/__tests__/fixtures/auto-exposure/native-contract.ts +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/native-contract.ts @@ -10,20 +10,16 @@ interface IExposureDevice { createBuffer(descriptor: { size: number; usage: number }): unknown; } -function exposurePixels(width: number, height: number) { +export function exposurePixels(width: number, height: number) { const pixels = new Float32Array(width * height * 4); - let weighted = 0; - let weightSum = 0; for (let y = 0; y < height; y++) { - const weight = 1 + (y + 0.5) / height; for (let x = 0; x < width; x++) { const value = y === height - 1 ? 4 : x === width - 1 ? 1 : 2; pixels.set([value, value, value, 1], (y * width + x) * 4); - weighted += value * weight; - weightSum += weight; } } - return { pixels, expectedLuminance: weighted / weightSum }; + // At 65x33, the 10/90 clip removes both aligned edge tails; only luminance 2 remains. + return { pixels, expectedLuminance: 2 }; } async function run() { diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/verify.ts b/packages/create-threenative/__tests__/fixtures/auto-exposure/verify.ts index 2705c22ad..86871da8f 100644 --- a/packages/create-threenative/__tests__/fixtures/auto-exposure/verify.ts +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/verify.ts @@ -7,8 +7,14 @@ import { WEBGPU_BROWSER_ARGS, runStandalonePlaytest, } from "../../../../playtest/dist/runner/index.js"; +import { exposureSettings } from "../../../template-assets/exposure.js"; import { qualifyExposureCapture } from "./captureProof.js"; -import { type ExposureMutation, exposureMutationPlugin } from "./mutations.js"; +import { assertHistogramProbe } from "./histogramProof.js"; +import { + type ExposureMutation, + exposureMutationPlugin, + meanMeterBaselinePlugin, +} from "./mutations.js"; import type { IExposureCaseProof } from "./proof.js"; const fixture = dirname(fileURLToPath(import.meta.url)); @@ -17,13 +23,21 @@ const artifacts = join(root, "artifacts/prd339-exposure"); interface IFixtureCase extends IExposureCaseProof { name: string; query: string; - scenario: "static" | "cut" | "cut-frames"; - mutation?: ExposureMutation; + scenario: "static" | "cut" | "cut-frames" | "backlit"; + mutation?: ExposureMutation | "mean"; + /** Largest allowed move of the settled target from the dark-adapted room (backlit rooms only). */ + maxShiftStops?: number; + /** Also run the synthetic-histogram probe page and compare it with the CPU reference. */ + histogram?: boolean; } -// Scene-linear reference readings from the inspected 5c3b176 fixture, independently of adaptation. -// This pins the meter against a constant/doubled-meter bug; display-tone metrics remain PRD341-owned. -const darkLuminance = 0.0019189472077414393; -const sunlightLuminance = 3.896103858947754; +// Scene-linear readings of the clipped-histogram meter (PRD-571), independently of adaptation. +// The 5c3b176 mean meter read 0.0019189 / 3.8961 on the same frames; the 10/90 log mean sits 0.37 +// stop under that arithmetic mean at both lighting levels. This pins the meter against a +// constant/doubled-meter bug; display-tone metrics remain PRD341-owned. +const darkLuminance = 0.0014869463630020618; +const sunlightLuminance = 3.0578835010528564; +// The dark room's settled target; a backlit patch must leave it where it was. +const darkTargetStops = Math.log2(exposureSettings.key / darkLuminance); const cases: IFixtureCase[] = [ { name: "camera-eleven-reverse", @@ -110,6 +124,46 @@ const cases: IFixtureCase[] = [ applied: true, expectedLuminance: darkLuminance, }, + // PRD-571: a sky 10 stops over the room (8% of the frame) and a 1% sun disc 14 stops over it + // must both leave the metered subject inside backlit.playtest.json's tone band. + { + name: "backlit-sky", + query: "bright=0&backlit=sky", + scenario: "backlit", + applied: true, + maxShiftStops: 0.25, + }, + { + name: "backlit-disc", + query: "bright=0&backlit=disc", + scenario: "backlit", + applied: true, + maxShiftStops: 0.1, + }, + // Red control: the pre-PRD-571 clamped mean meter, served from git, fails the same two rooms. + { + name: "backlit-sky-mean", + query: "bright=0&backlit=sky", + scenario: "backlit", + applied: true, + mutation: "mean", + }, + { + name: "backlit-disc-mean", + query: "bright=0&backlit=disc", + scenario: "backlit", + applied: true, + mutation: "mean", + }, + // PRD-571: the shipped adaptation shader walks synthetic histograms like the CPU reference. + { + name: "histogram-reference", + query: "bright=0&histogramProbe=1", + scenario: "static", + applied: true, + expectedLuminance: darkLuminance, + histogram: true, + }, { name: "fixed-exposure", query: "enabled=0&bright=1", @@ -184,10 +238,15 @@ const cases: IFixtureCase[] = [ reject: "TN_EXPOSURE_WRONG_CLOCK", }, ]; +// TN_EXPOSURE_CASES= runs a subset of the cases while iterating; CI runs them all. +const only = process.env.TN_EXPOSURE_CASES; +const selected = + only === undefined ? cases : cases.filter(({ name }) => new RegExp(only).test(name)); await mkdir(artifacts, { recursive: true }); const sites = new Map(); -for (const mutation of [undefined, "linear", "disabled", "meter", "clock"] as const) { +for (const mutation of [undefined, "linear", "disabled", "meter", "clock", "mean"] as const) { const name = mutation ?? "normal"; + if (!selected.some((item) => item.mutation === mutation)) continue; const site = join(artifacts, "sites", name); let mutationReceipt: unknown; await build({ @@ -196,14 +255,16 @@ for (const mutation of [undefined, "linear", "disabled", "meter", "clock"] as co plugins: mutation === undefined ? [] - : [ - exposureMutationPlugin(mutation, (receipt) => { - mutationReceipt = receipt; - }), - ], + : mutation === "mean" + ? [meanMeterBaselinePlugin()] + : [ + exposureMutationPlugin(mutation, (receipt) => { + mutationReceipt = receipt; + }), + ], build: { outDir: site, emptyOutDir: true }, }); - if (mutation !== undefined && mutationReceipt === undefined) + if (mutation !== undefined && mutation !== "mean" && mutationReceipt === undefined) throw new Error(`${mutation}: mutation receipt missing.`); if (mutationReceipt !== undefined) await writeFile(join(site, "mutation.json"), `${JSON.stringify(mutationReceipt, null, 2)}\n`); @@ -219,7 +280,7 @@ if (!process.argv.includes("--build-only")) { "bin/vite.js", ); const results = []; - for (const item of cases) { + for (const item of selected) { const site = sites.get(item.mutation ?? "normal"); if (site === undefined) throw new Error(`${item.name}: fixture build missing.`); const directory = join(artifacts, item.name); @@ -247,12 +308,49 @@ if (!process.argv.includes("--build-only")) { `${JSON.stringify({ sourceSha, mutation: item.mutation ?? null, ...report }, null, 2)}\n`, ); const screenshot = join(directory, "after.png"); - const result = await qualifyExposureCapture(report, item, screenshot, item.name); + const marker = report.observations?.console + .filter(({ text }) => text.startsWith("TN_AUTO_EXPOSURE:")) + .at(-1); + // A rejected mutation arm legitimately has no measurement; every other arm must. + if (marker === undefined && item.reject === undefined) + throw new Error(`${item.name}: GPU exposure observation missing.`); + const shift = + marker === undefined + ? Number.NaN + : Math.abs( + JSON.parse(marker.text.slice("TN_AUTO_EXPOSURE:".length)).targetStops - darkTargetStops, + ); + // Red control: the pre-PRD-571 clamped mean must fail the scenario and move the exposure + // more than a stop. Its frame may be too dark to read tone at all, so any failure counts. + if (item.mutation === "mean" && (report.pass || !(shift > 1))) + throw new Error( + `${item.name}: the mean meter must fail the backlit scene; pass=${report.pass}, shift=${shift} stops.`, + ); + if ( + item.mutation !== "mean" && + item.maxShiftStops !== undefined && + !(shift <= item.maxShiftStops) + ) + throw new Error( + `${item.name}: backlit patch moved the settled exposure ${shift} stops, over ${item.maxShiftStops}.`, + ); + const histogramCases = + item.histogram === true + ? assertHistogramProbe(report.observations?.console ?? []) + : undefined; + const result = + item.mutation === "mean" + ? { expectedFailure: report.diagnostics.map(({ code }) => code), shiftStops: shift } + : { + ...(await qualifyExposureCapture(report, item, screenshot, item.name)), + shiftStops: shift, + }; results.push({ name: item.name, sourceSha, mutation: item.mutation ?? null, ...result, + ...(histogramCases === undefined ? {} : { histogramCases }), capture: report.capture, screenshot, }); diff --git a/packages/create-threenative/__tests__/fixtures/auto-exposure/verifyNative.ts b/packages/create-threenative/__tests__/fixtures/auto-exposure/verifyNative.ts index 86a7a4296..4d4fb5ec3 100644 --- a/packages/create-threenative/__tests__/fixtures/auto-exposure/verifyNative.ts +++ b/packages/create-threenative/__tests__/fixtures/auto-exposure/verifyNative.ts @@ -26,7 +26,7 @@ const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], { const runtime = resolve( process.env.TN_NATIVE_EXECUTABLE ?? join(root, "packages/runtime-native/build/tn-linux/mystral"), ); -const expectedLuminance = 3.896103858947754; // Same bright-room browser fixture reference and 2% tolerance. +const expectedLuminance = 3.0578835010528564; // Same bright-room browser fixture reference and 2% tolerance. type ConsoleEntry = { text: string; type: string }; function marker(entries: readonly ConsoleEntry[], prefix: string) { diff --git a/packages/create-threenative/__tests__/looks.spec.ts b/packages/create-threenative/__tests__/looks.spec.ts index 2936a86c7..bf9d1b1e0 100644 --- a/packages/create-threenative/__tests__/looks.spec.ts +++ b/packages/create-threenative/__tests__/looks.spec.ts @@ -168,7 +168,14 @@ describe("starter visual floor", () => { // WorldEnvironment is the starter's complete, Godot-named visual recipe. It intentionally // carries the stage contracts and their reasons in one editable file; the ownership check // above still prevents it from becoming a hidden framework import. - if (!name.endsWith("loading.ts") && !name.endsWith("worldEnvironment.ts")) + // AutoExposureNode sequences five GPU passes (meter, reduce, histogram tiles, histogram + // bins, adapt) with their disposal. It stays plain Three.js the game can rewrite, so the + // ownership check above is the rule that matters for it. + if ( + !name.endsWith("loading.ts") && + !name.endsWith("worldEnvironment.ts") && + !name.endsWith("autoExposure.ts") + ) expect(source.trimEnd().split("\n").length, name).toBeLessThan(200); } } diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index e1ef4417c..ea3c848a1 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -231,20 +231,22 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // strata branch: measured through createProject on the merged tree. // Recomputed 2026-10-10 after integrating develop 1a5314a1: measured through createProject on // the merged tree, including its latest template changes. - "action-rpg": "29a2e2f8a8e1dbe84bed6b9752f9b50bc359c0526649b74236cab66e3ce5beec", - minimal: "29de540167ba97c5fd3883c0cf8c3a5435686312a97826426871b21a76f18607", - platformer: "ca93bd64f1efedd927b9cc428e1a20742a89d82930f8296fe0054ddb1d934ab8", - puzzle: "6b6896141c66be49c8a1a519b6c53039cfacb337fa816ad212b80c65daa864d7", - racing: "fd9affe9069f72f1c3a93eb1b126caeb156cb56e342c0768c646cbf1a831af34", - rain: "38302a90e87c5c1e4f864a75a6d4e8bdf108059df470798741a74cc6d3d990e0", - rts: "d7f3f1e9c3664386d32f99a8bdb8b5267632843f5dd2ed1804128fe3ddd0752d", - runner: "11a74a807e5e2f80c703098f3059bef04d3b8b42170b9ba68e37f71c247f5b27", + // Recomputed through all thirteen actual createProject trees after merging develop 2d2124792 + // into PRD-571; these fingerprints cover both the exposure and landed base changes. + "action-rpg": "257fa6d8b3e95c642f829600a74eee7d203deacf356f31dd047181df2da2c033", + minimal: "095a48b7498f6728b60daf13deceff0d93c22045cb903d0319ad80816c6f2365", + platformer: "f0ae170f7b2e9bb52e6a0887d36d7385ef5709e6538cb84595a702ad7e367b46", + puzzle: "a6fb07854dafd8c319dd9aa0dd4fe1cc78b2ae07c555ea3fa250ac2b2a1453a6", + racing: "7dd7aa66804a454f2c3ae6b16e32517f7cdcd92c0f5b38ff971c7ebf66fc834e", + rain: "93f8227373687566e46cc8d7f03586cb346b05bb5a5589c97a3e0ee83be76d91", + rts: "67f59aeac3cf3f8af6a3df412824293b7b1ac1f8f1e6a96ee6565bb29b7624b1", + runner: "82a16382e3d09128324d03468ef9e69fa2ba5c85c8441be3e2d1342e57d4b47d", // Initial finite-height readiness plus its scene-owned lifecycle helper and mirrored docs. - sailing: "3303a84c672a02dbbb5100cc4d647940c83c8a119e55c8cebf498b710cdd3e43", - shooter: "b856407fa828dc9ac76991cbffc4da8d327179a7f901229ccb2670cd4a486bd8", - snow: "0d3c4003a00e9b0772a877359ee204cffde78d2be21449e97ccc6e93ef941248", - starter: "27e4be7581d9ba96af39e2263c7645678aa30de6cb7b8fda4e0abb8195d6ee0b", - "tower-defense": "ff1a494154b4d2db224143046d4b2ffb02dd9cf74ad602b38af7eb72071042b3", + sailing: "349e4e8d9b6e0bf2b7508828fb2422b4007b5724cc76e48785e87c9ab25a0d7b", + shooter: "772513e8e41b1e241863fe9c74bf207085968102e45337b4868d2451609aad52", + snow: "53a8e6e9e8a01f1699ba063368e994d6e1f1431ea580d4fa7270a2c11c661489", + starter: "9d49e7de6d900b217cb6b7ac382309930764e29bda18297659bca4d7137a831b", + "tower-defense": "e409e2de944c81ecd03d31b5ee404ff763263f8acb414e274e37546bc14da01a", }; const GENERATED_SCAFFOLD_METADATA = diff --git a/packages/create-threenative/agent-docs/references/auto-exposure.md b/packages/create-threenative/agent-docs/references/auto-exposure.md index 832036cfe..17a4c7e27 100644 --- a/packages/create-threenative/agent-docs/references/auto-exposure.md +++ b/packages/create-threenative/agent-docs/references/auto-exposure.md @@ -6,8 +6,18 @@ not engine configuration. `enabled: false` is the qualification default; no ship merely because these files exist. The generated quality presets additionally keep `autoExposureEnabled: false`, avoiding GPU metering cost until the game explicitly opts in. -`autoExposure.ts` reduces the existing unexposed colour texture in 4×4 blocks and adapts in log2 -space on separate 1×1 ping-pong targets. Odd-sized edge blocks are masked rather than repeated. +`autoExposure.ts` reduces the existing unexposed colour texture in 4×4 blocks, collects the blocks +into a 64-bin histogram of log2 luminance, and adapts in log2 space on separate 1×1 ping-pong +targets. The bins span `log2(key) - maxStops` to `log2(key) - minStops`. Odd-sized edge blocks are +masked rather than repeated. + +The meter reads the histogram, not a mean. `lowPercent` and `highPercent` (default 10 and 90) set +how much weight each end of the histogram loses before the average: a small bright sun disc or a +dark corner stays out of the exposure. Lower `highPercent` when a large bright backdrop still sets +the exposure. Raise it toward 100 for a night scene whose only light sources are the subject. Keep +`0 <= lowPercent < highPercent <= 100`. The spatial weight in `exposureMeter` (`1 + uv.y`, so the +bottom of the frame counts more than the top) is the weight each sample adds to its bin. When the +clip leaves no weight, the previous exposure stays. There is no extra scene draw, tonemapper, or render loop. Wire it ahead of bloom and the output transform; never multiply both its absolute exposure and the old constant. `applyExposure` scales RGB and preserves alpha: multiplying a whole vec4 darkens low-exposure output again when the diff --git a/packages/create-threenative/template-assets/autoExposure.ts b/packages/create-threenative/template-assets/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/template-assets/autoExposure.ts +++ b/packages/create-threenative/template-assets/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/template-assets/exposure.ts b/packages/create-threenative/template-assets/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/template-assets/exposure.ts +++ b/packages/create-threenative/template-assets/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/template-assets/exposureGraph.ts b/packages/create-threenative/template-assets/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/template-assets/exposureGraph.ts +++ b/packages/create-threenative/template-assets/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/action-rpg/src/render/autoExposure.ts b/packages/create-threenative/templates/action-rpg/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/action-rpg/src/render/exposure.ts b/packages/create-threenative/templates/action-rpg/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/exposure.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/action-rpg/src/render/exposureGraph.ts b/packages/create-threenative/templates/action-rpg/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/minimal/src/render/autoExposure.ts b/packages/create-threenative/templates/minimal/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/minimal/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/minimal/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/minimal/src/render/exposure.ts b/packages/create-threenative/templates/minimal/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/minimal/src/render/exposure.ts +++ b/packages/create-threenative/templates/minimal/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/minimal/src/render/exposureGraph.ts b/packages/create-threenative/templates/minimal/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/minimal/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/minimal/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/platformer/src/render/autoExposure.ts b/packages/create-threenative/templates/platformer/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/platformer/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/platformer/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/platformer/src/render/exposure.ts b/packages/create-threenative/templates/platformer/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/platformer/src/render/exposure.ts +++ b/packages/create-threenative/templates/platformer/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/platformer/src/render/exposureGraph.ts b/packages/create-threenative/templates/platformer/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/platformer/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/platformer/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/puzzle/src/render/autoExposure.ts b/packages/create-threenative/templates/puzzle/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/puzzle/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/puzzle/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/puzzle/src/render/exposure.ts b/packages/create-threenative/templates/puzzle/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/puzzle/src/render/exposure.ts +++ b/packages/create-threenative/templates/puzzle/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/puzzle/src/render/exposureGraph.ts b/packages/create-threenative/templates/puzzle/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/puzzle/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/puzzle/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/racing/src/render/autoExposure.ts b/packages/create-threenative/templates/racing/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/racing/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/racing/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/racing/src/render/exposure.ts b/packages/create-threenative/templates/racing/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/racing/src/render/exposure.ts +++ b/packages/create-threenative/templates/racing/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/racing/src/render/exposureGraph.ts b/packages/create-threenative/templates/racing/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/racing/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/racing/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/rain/src/render/autoExposure.ts b/packages/create-threenative/templates/rain/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/rain/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/rain/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/rain/src/render/exposure.ts b/packages/create-threenative/templates/rain/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/rain/src/render/exposure.ts +++ b/packages/create-threenative/templates/rain/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/rain/src/render/exposureGraph.ts b/packages/create-threenative/templates/rain/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/rain/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/rain/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/rts/src/render/autoExposure.ts b/packages/create-threenative/templates/rts/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/rts/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/rts/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/rts/src/render/exposure.ts b/packages/create-threenative/templates/rts/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/rts/src/render/exposure.ts +++ b/packages/create-threenative/templates/rts/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/rts/src/render/exposureGraph.ts b/packages/create-threenative/templates/rts/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/rts/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/rts/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/runner/src/render/autoExposure.ts b/packages/create-threenative/templates/runner/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/runner/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/runner/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/runner/src/render/exposure.ts b/packages/create-threenative/templates/runner/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/runner/src/render/exposure.ts +++ b/packages/create-threenative/templates/runner/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/runner/src/render/exposureGraph.ts b/packages/create-threenative/templates/runner/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/runner/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/runner/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/sailing/src/render/autoExposure.ts b/packages/create-threenative/templates/sailing/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/sailing/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/sailing/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/sailing/src/render/exposure.ts b/packages/create-threenative/templates/sailing/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/sailing/src/render/exposure.ts +++ b/packages/create-threenative/templates/sailing/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/sailing/src/render/exposureGraph.ts b/packages/create-threenative/templates/sailing/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/sailing/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/sailing/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/shooter/src/render/autoExposure.ts b/packages/create-threenative/templates/shooter/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/shooter/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/shooter/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/shooter/src/render/exposure.ts b/packages/create-threenative/templates/shooter/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/shooter/src/render/exposure.ts +++ b/packages/create-threenative/templates/shooter/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/shooter/src/render/exposureGraph.ts b/packages/create-threenative/templates/shooter/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/shooter/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/shooter/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/snow/src/render/autoExposure.ts b/packages/create-threenative/templates/snow/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/snow/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/snow/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/snow/src/render/exposure.ts b/packages/create-threenative/templates/snow/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/snow/src/render/exposure.ts +++ b/packages/create-threenative/templates/snow/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/snow/src/render/exposureGraph.ts b/packages/create-threenative/templates/snow/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/snow/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/snow/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/starter/src/render/autoExposure.ts b/packages/create-threenative/templates/starter/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/starter/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/starter/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/starter/src/render/exposure.ts b/packages/create-threenative/templates/starter/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/starter/src/render/exposure.ts +++ b/packages/create-threenative/templates/starter/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/starter/src/render/exposureGraph.ts b/packages/create-threenative/templates/starter/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/starter/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/starter/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38)); diff --git a/packages/create-threenative/templates/tower-defense/src/render/autoExposure.ts b/packages/create-threenative/templates/tower-defense/src/render/autoExposure.ts index f4eda0d4a..ce11a507e 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/autoExposure.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/autoExposure.ts @@ -25,7 +25,11 @@ import { type Decode, type Meter, adaptExposure, + exposureBins, exposureTarget, + exposureTiles, + histogramExposure, + histogramTiles, reduceExposure, } from "./exposureGraph.js"; import { ExposureReadback } from "./exposureReadback.js"; @@ -46,6 +50,7 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #size = new Vector2(); readonly #sourceSize = uniform(new Vector2(1, 1)); readonly #reduceSize = uniform(new Vector2(1, 1)); + readonly #blockSize = uniform(new Vector2(1, 1)); readonly #delta = uniform(0); readonly #resetMode = uniform(1); // 0 = history, 1 = seed, 2 = next measured target readonly #seed = uniform(0); @@ -53,11 +58,16 @@ export class AutoExposureNode extends TempNode<"float"> { readonly #quad = new QuadMesh(); readonly #meterMaterial = new NodeMaterial(); readonly #reduceMaterial = new NodeMaterial(); + readonly #tilesMaterial = new NodeMaterial(); + readonly #histogramMaterial = new NodeMaterial(); readonly #adaptMaterial = new NodeMaterial(); + readonly #tilesTarget = exposureTarget(); + readonly #histogramTarget = exposureTarget(); #readTarget = exposureTarget(); #writeTarget = exposureTarget(); #levels: RenderTarget[] = []; readonly #reduced = texture(new Texture()); + readonly #blocks = texture(new Texture()); readonly #previous = texture(this.#readTarget.texture); readonly #result = texture(this.#writeTarget.texture); #disposed = false; @@ -86,8 +96,12 @@ export class AutoExposureNode extends TempNode<"float"> { this.updateBeforeType = NodeUpdateType.FRAME; this.#meterMaterial.fragmentNode = reduceExposure(input, this.#sourceSize, meter); this.#reduceMaterial.fragmentNode = reduceExposure(this.#reduced, this.#reduceSize); + this.#tilesTarget.setSize(exposureBins, exposureTiles); + this.#histogramTarget.setSize(exposureBins, 1); + this.#tilesMaterial.fragmentNode = histogramTiles(this.#blocks, this.#blockSize, policy); + this.#histogramMaterial.fragmentNode = histogramExposure(texture(this.#tilesTarget.texture)); this.#adaptMaterial.fragmentNode = adaptExposure( - this.#reduced, + texture(this.#histogramTarget.texture), this.#previous, this.#resetMode, this.#seed, @@ -140,7 +154,8 @@ export class AutoExposureNode extends TempNode<"float"> { if (!Number.isFinite(frame.deltaTime) || frame.deltaTime < 0 || !Number.isFinite(frame.time)) throw new Error("Exposure frame time must be finite and nonnegative."); renderer.getDrawingBufferSize(this.#size); - const sizes = exposureReductionSizes(this.#size.x, this.#size.y); + // Level 0 meters 4x4 pixels, level 1 averages 16x16. The histogram gathers level 1. + const sizes = exposureReductionSizes(this.#size.x, this.#size.y).slice(0, 2); this.#sourceSize.value.copy(this.#size); this.#delta.value = Math.min(frame.deltaTime, this.settings.maxDelta); while (this.#levels.length > sizes.length) this.#levels.pop()?.dispose(); @@ -164,7 +179,14 @@ export class AutoExposureNode extends TempNode<"float"> { } const final = this.#levels.at(-1); if (final === undefined) throw new Error("Exposure reduction is empty."); - this.#reduced.value = final.texture; + this.#blocks.value = final.texture; + this.#blockSize.value.set(final.width, final.height); + this.#quad.material = this.#tilesMaterial; + renderer.setRenderTarget(this.#tilesTarget); + this.#quad.render(renderer); + this.#quad.material = this.#histogramMaterial; + renderer.setRenderTarget(this.#histogramTarget); + this.#quad.render(renderer); const write = this.#writeTarget; this.#previous.value = this.#readTarget.texture; this.#quad.material = this.#adaptMaterial; @@ -184,10 +206,19 @@ export class AutoExposureNode extends TempNode<"float"> { if (this.#disposed) return; this.#disposed = true; this.#observation.dispose(); - for (const level of [...this.#levels, this.#readTarget, this.#writeTarget]) level.dispose(); + for (const level of [ + ...this.#levels, + this.#tilesTarget, + this.#histogramTarget, + this.#readTarget, + this.#writeTarget, + ]) + level.dispose(); this.#levels = []; this.#meterMaterial.dispose(); this.#reduceMaterial.dispose(); + this.#tilesMaterial.dispose(); + this.#histogramMaterial.dispose(); this.#adaptMaterial.dispose(); super.dispose(); } diff --git a/packages/create-threenative/templates/tower-defense/src/render/exposure.ts b/packages/create-threenative/templates/tower-defense/src/render/exposure.ts index c0d52b29c..c448bbf9e 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/exposure.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/exposure.ts @@ -9,6 +9,12 @@ export interface IExposureSettings { /** Exposure limits in stops. Expand only if the scene needs more range. */ minStops: number; maxStops: number; + /** + * Percent of metered weight, dark to bright, ignored at each end of the histogram. + * Raise highPercent's gap (lower it) when a large bright backdrop still sets the exposure. + */ + lowPercent: number; + highPercent: number; /** Exponential response per second in log2 space. Up opens the eye; down squints. */ rateUp: number; rateDown: number; @@ -30,6 +36,8 @@ export const exposureSettings: Readonly = { key: 0.18, minStops: -12, maxStops: 12, + lowPercent: 10, + highPercent: 90, rateUp: 2, rateDown: 4, snapLo: 3, @@ -41,15 +49,18 @@ export const exposureSettings: Readonly = { reportInterval: 0.5, }; -/** Return weighted luminance and weight. Lower the clamp to reject small bright sources. */ +/** + * Return weighted luminance and weight. The histogram's percent clip, not a clamp here, rejects + * small bright sources. The upper bound only keeps a half-float overflow from poisoning a block. + */ export function exposureMeter(colour: Node<"vec4">, uv: Node<"vec2">): Node<"vec2"> { - const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 8); + const luminance = dot(colour.rgb, vec3(0.2126, 0.7152, 0.0722)).clamp(0.0001, 65504); // Larger bottom-of-frame preference favours ground over bright sky (WebGPU UV y is down). const weight = float(1).add(uv.y); return vec2(luminance.mul(weight), weight); } -/** Change this with the metric if the game encodes RMS or log-mean values. */ +/** Receives the clipped histogram's linear mean luminance. Change it if the game encodes RMS. */ export function exposureLuminance(mean: Node<"float">): Node<"float"> { return mean.max(0.0001); } @@ -71,6 +82,9 @@ export function validateExposureSettings(value: IExposureSettings): void { value.snapHi <= value.snapLo || value.snapGain < 0 || value.snapGain > 1 || + value.lowPercent < 0 || + value.highPercent > 100 || + value.lowPercent >= value.highPercent || value.minStops >= value.maxStops || value.minStops < -126 || value.maxStops > 126 || diff --git a/packages/create-threenative/templates/tower-defense/src/render/exposureGraph.ts b/packages/create-threenative/templates/tower-defense/src/render/exposureGraph.ts index a5b3ba4d9..69eef18ea 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/exposureGraph.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/exposureGraph.ts @@ -3,7 +3,9 @@ import { FloatType, NearestFilter, RenderTarget } from "three"; import { Fn, If, + Loop, float, + int, ivec2, mix, screenCoordinate, @@ -50,8 +52,100 @@ export function reduceExposure( })(); } +/** The histogram has 64 bins. Each bin spans (maxStops - minStops) / 64 stops of scene luminance. */ +export const exposureBins = 64; +/** The gather splits the blocks over 64 tiles, so a bin is summed by 64 short loops, not one long. */ +export const exposureTiles = 64; + +/** + * Stage one of the histogram, drawn as a 64 (bins) x 64 (tiles) target. Texel (bin, tile) gathers + * its tile of the block means of `blocks` (the second reduction level, about 8 000 texels at + * 1080p, so about 130 per tile) that fall in its bin. + * r = summed weight, g = summed weight * log2 luminance, so a bin keeps its exact mean. + */ +export function histogramTiles( + blocks: ColourTexture, + size: Node<"vec2">, + policy: Readonly, +): Node<"vec4"> { + // Range follows the authored stops: goal = log2(key) - log2(L), clamped to [min, max]. + const low = Math.log2(policy.key) - policy.maxStops; + const span = policy.maxStops - policy.minStops; + return Fn(() => { + const bin = screenCoordinate.x.floor(); + const weight = float(0).toVar(); + const logSum = float(0).toVar(); + const width = int(size.x); + const total = int(size.x.mul(size.y)); + const perTile = total.add(exposureTiles - 1).div(exposureTiles); + const first = int(screenCoordinate.y.floor()).mul(perTile); + Loop({ start: 0, end: perTile }, ({ i: offset }) => { + const i = first.add(offset); + If(i.lessThan(total), () => { + const block = textureLoad(blocks, ivec2(i.mod(width), i.div(width))); + const log = block.r + .div(block.g.max(1e-20)) + .max(1e-20) + .log2() + .clamp(low, low + span); + const index = log + .sub(low) + .mul(exposureBins / span) + .floor() + .min(exposureBins - 1); + If(block.g.greaterThan(0).and(index.equal(bin)), () => { + weight.addAssign(block.g); + logSum.addAssign(block.g.mul(log)); + }); + }); + }); + return vec4(weight, logSum, 0, 1); + })(); +} + +/** Stage two, drawn as a 64x1 target: one texel per bin, summing that bin over every tile. */ +export function histogramExposure(tiles: ColourTexture): Node<"vec4"> { + return Fn(() => { + const bin = int(screenCoordinate.x.floor()); + const sum = vec2(0).toVar(); + Loop(exposureTiles, ({ i }) => { + sum.addAssign(textureLoad(tiles, ivec2(bin, i)).rg); + }); + return vec4(sum, 0, 1); + })(); +} + +/** + * UE's average without outliers: drop the darkest lowPercent of the weight, stop at + * highPercent, take the weighted log2 mean of what is left. Returns (log2 mean, kept weight). + */ +function clippedLog2Mean( + histogram: ColourTexture, + policy: Readonly, +): Node<"vec2"> { + const total = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + total.addAssign(textureLoad(histogram, ivec2(i, 0)).r); + }); + const dropLow = total.mul(policy.lowPercent / 100).toVar(); + const keepUpTo = total.mul(policy.highPercent / 100).toVar(); + const kept = float(0).toVar(); + const logSum = float(0).toVar(); + Loop(exposureBins, ({ i }) => { + const bin = textureLoad(histogram, ivec2(i, 0)); + const removed = bin.r.min(dropLow); + dropLow.subAssign(removed); + keepUpTo.subAssign(removed); + const keep = bin.r.sub(removed).min(keepUpTo); + keepUpTo.subAssign(keep); + kept.addAssign(keep); + logSum.addAssign(bin.g.mul(keep.div(bin.r.max(1e-20)))); + }); + return vec2(logSum.div(kept.max(1e-20)), kept); +} + export function adaptExposure( - reduced: ColourTexture, + histogram: ColourTexture, previous: ColourTexture, resetMode: Node<"float">, seed: Node<"float">, @@ -60,9 +154,9 @@ export function adaptExposure( decode: Decode, ): Node<"vec4"> { return Fn(() => { - const measure = textureLoad(reduced, ivec2(0)); - const luminance = decode(measure.r.div(measure.g.max(1e-20))); - const valid = measure.g + const measure = clippedLog2Mean(histogram, policy); + const luminance = decode(measure.x.exp2()); + const valid = measure.y .greaterThan(0) .and(luminance.greaterThan(0)) .and(luminance.lessThan(3.4e38));