diff --git a/docs/PRDs/done/PRD-561-contact-shadows-from-screen-depth.md b/docs/PRDs/done/PRD-561-contact-shadows-from-screen-depth.md new file mode 100644 index 0000000000..25504020d0 --- /dev/null +++ b/docs/PRDs/done/PRD-561-contact-shadows-from-screen-depth.md @@ -0,0 +1,217 @@ +--- +prd_contract: v1 +--- + +# PRD-561 — Small things touch the ground: contact shadows from screen depth + +**Closed:** 2026-10-10 at `791500b1e`, PR #480, declined and archived to `done/`. + +**Status:** DECLINED 2026-10-10 after three judged poses (4096 and 1024 key-light maps, two rounds on the first). The stage never grounded the feet and added speckle on coarse maps. The mechanism is deleted; see `## Decisions` for the commits that hold it. +**Priority:** P2 — declined; the platformer keeps its blob mesh. +**Complexity:** 5 (MEDIUM) — 1–5 implementation files (1), new mechanism module (+2), a compute pass with workgroup memory and a CPU-built dispatch list (+2); risk override: none +**Owner:** João +**Depends on:** None + +## Context + +A shadow map cannot resolve the contact between a small object and the ground. Its texels are too +large, and its bias detaches the shadow from the caster. The templates show both symptoms: + +- `templates/platformer/src/render/fox.ts:147-155` draws a 0.42 m `CircleGeometry` blob under the fox, + because "the shadow map's own shadow is easy to lose" on a bright grass cap. +- `templates/puzzle/src/render/lighting.ts:48-50` tunes `normalBias` so that bias does not "detach the + contact shadow under every crate". The bias trade-off still exists. + +The repository has no screen-space contact shadow (`rg -i "contact.?shadow|screen.?space.?shadow"` in +`packages/core/src` and every template's `src/render/`, 2026-10-09). This also serves the convention +"feet meet the floor": the feet touch the floor, but no shadow shows the contact. +[PRD-344](../rendering/PRD-344-contact-occlusion-baked-from-the-geometry-it-ships-with.md) bakes occlusion into +procedural geometry creases. That is static self-occlusion, not a dynamic shadow from a light, so +this PRD does not overlap it. + +### What Unreal does (UE 5.8.3, read 2026-10-09) + +- Unreal ships two contact-shadow methods: a stochastic jittered screen trace (method 0, the default) + and **Bend Studio's Screen Space Shadows** (method 1). + `UE 5.8.3: Engine/Source/Runtime/Renderer/Private/Shadows/ScreenSpaceShadows.cpp:25-33`. Contact + shadows are off for each light by default (`ContactShadowLength = 0`). + `UE 5.8.3: Engine/Source/Runtime/Engine/Private/Components/LightComponent.cpp:470-473`. +- Bend SSS is third-party code under **Apache-2.0** (`Engine/Shaders/Private/bend_sss_gpu.tps`, + `Engine/Source/Runtime/Renderer/Private/Shadows/bend_sss_cpu.h:3-15`). Its upstream is Bend Studio's + own release, linked from https://www.bendstudio.com/blog/inside-bend-screen-space-shadows/. + **This PRD ports the upstream release, never the copy in the Unreal tree.** +- The algorithm, in our words: + 1. The CPU projects the light into screen space. It splits the screen into at most 8 dispatches, + wedges that radiate from the light's screen point. When the light is behind the camera + (projected `w` sign), the trace direction reverses. + `UE 5.8.3: Engine/Source/Runtime/Renderer/Private/Shadows/bend_sss_cpu.h:38-48, 73-170`. + 2. Each 64-thread group handles one 64-pixel line toward the light. It loads a strip of depth + samples into workgroup memory once. Then each pixel walks the shared strip toward the light, and + counts a hit where a sample is in front of it within a fixed surface thickness. The defaults are + 60 samples, the first 4 hard (not averaged), the last 8 faded, and a thickness of 0.005 in + Unreal's override. `UE 5.8.3: Engine/Shaders/Private/bend_sss_gpu.ush:36-54, 242-249`. + 3. A group whose pixels all fall outside the light's depth bounds exits early. The early exit uses + wave operations when the hardware wave size matches, and falls back to workgroup memory and + barriers when it does not. `UE 5.8.3: Engine/Shaders/Private/bend_sss_gpu.ush:370-391`. + 4. Unreal writes the result into the light's screen shadow mask, so it darkens only that light's + direct term. `UE 5.8.3: Engine/Source/Runtime/Renderer/Private/Shadows/ScreenSpaceShadows.cpp:226-234, 467-521`. +- WebGPU differences that this PRD must handle: + - WebGPU core has no wave operations. The workgroup-memory fallback in step 3 is therefore the + only path. + - WebGPU has no clamp-to-border sampler. Bend expects one (`bend_sss_gpu.ush:160`), so the kernel + bounds-checks its depth reads. + - three renders forward, so no shadow-mask texture exists before shading. See the first design + decision below. + +## Solution + +**The split, by the repository's two questions:** + +- *(a) Could a game write this portably?* Yes. It needs only a depth texture, a compute pass and + TSL. But the dispatch-list builder and the workgroup-memory kernel are plumbing that every game + would repeat, and they decide nothing about the look. That is the mechanism row of "Where a change + goes". +- *(b) Does it decide how anything looks?* The strength, length, thickness, fade, which light, and + where the mask is applied all do. +- So the split follows the shape of `GPUParticles3D`: + - `packages/core/src/render/contact-shadow.ts` (new) owns the dispatch list and the kernel. It + takes depth, a camera and a light direction, and returns a mask texture node. Every appearance + parameter comes from the caller, and the module sets no default look. + - Each template's `src/render/` decides whether to use the mask, how to apply it, and every + strength value. + - The port keeps its Apache-2.0 header in the file. `packages/core/THIRD_PARTY_NOTICES.md` (new) + carries the licence text and ships in the published package. + +**First design decision (Phase 1 records it under `## Decisions`):** where the mask applies. + +- **Arm A, post stage.** A `contactShadows` render-chain stage multiplies the lit colour by the mask + after the scene pass, using current-frame depth. This is the cheapest arm. It also darkens ambient + light, which reads like ambient occlusion over the short default length. +- **Arm B, light term.** The sun's shadow node samples the mask, so only direct light darkens. This + matches Unreal. three renders forward, so this arm needs a depth prepass or reprojected + previous-frame depth. +- Arm A ships unless the judge rejects it on the PRD-345 dark-environment fixture. The stage name + enters `RENDER_CHAIN_STAGE_ORDER` in `packages/core/src/render/chain.ts`, before `ambientOcclusion`. + That is an ordering mechanism only. + +**Consumers:** the platformer replaces its blob with the stage, and the starter enables the stage at +the `high` and `medium` tiers. A tier whose cost gate fails refuses the stage by name in +`TN_RENDER_CHAIN`, like the existing refusals in `worldEnvironment.ts`. + +```mermaid +flowchart LR + D[scene pass depth] --> K[core contact-shadow kernel
dispatch list + workgroup strip] + L[template: light dir, length, thickness] --> K + K --> M[mask texture node] + M --> S[template stage: strength, apply] + S --> O[RenderChain output] +``` + +## Acceptance Criteria + +- [ ] AC-1 [local]: In a platformer scenario, with the blob mesh removed and the stage on, the region under the standing fox's feet has a dark-pixel ratio at least 0.3. The same scenario fails with the stage off. proof: `node packages/playtest/dist/runner/cli.js playtests/contact-shadow.playtest.json --url --browser-recipe webgpu` with a `region` `minDarkPixelRatio` assertion — Evidence: open. Arm A at this camera scale did not produce it; two judge rounds REGRESSION (see `## Decisions`). +- [ ] AC-2 [local]: The stage costs at most 0.3 ms GPU at 1080p on the RTX 2080 browser WebGPU lane. proof: `TN_FRAME_BUDGET` p50 delta, stage on against stage off, from `node packages/playtest/dist/runner/cli.js perf` — Evidence: open. Informal gpuCompute 0.1 to 0.2 ms; the `perf` p50 delta was not run on a shipped stage. +- [ ] AC-3 [local]: On the Pixel 8, the stage costs at most 0.5 ms GPU at the starter's mobile resolution, or the `low` tier refuses it by name. proof: `node packages/playtest/dist/runner/cli.js perf --logcat ` stage on against stage off — Evidence: open. Physical device, no shipped stage to measure (see `## Blocked on`). + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +|---|---|---|---| +| Contact-shadow mask | template `postprocessing.ts`/`worldEnvironment.ts` stage → `contactShadow()` from `@threenative/core` → compute dispatches → mask node | New mechanism | Phase 1, AC-2 | +| Contact-shadow stage | `renderer.createRenderChain()` with `contactShadows` in the canonical order | Replaces the platformer blob (`fox.ts:147-155`) | AC-1 | + +## Execution Phases + +#### Phase 1: Mechanism and the apply decision +**Status:** PARKED — box 1 done; the kernel runs on a GPU; Arm A judged REGRESSION twice (see `## Decisions`) +**Files:** `packages/core/src/render/contact-shadow.ts` (new), `packages/core/src/render/chain.ts`, `packages/core/src/index.ts`, `packages/core/THIRD_PARTY_NOTICES.md` (new), `packages/core/src/render/contact-shadow-dispatch.ts` (new, the CPU dispatch builder), `packages/core/__tests__/contact-shadow-dispatch.spec.ts` (new), `docs/architecture/CHARTER.md` +- [x] The dispatch-list builder writes every on-screen pixel at least once, except the pixel under the light, for a light in front of, behind, beside and far off screen, and for viewports that are not multiples of 64. Overlap stays bounded (see `## Decisions`). proof: `pnpm exec vitest run packages/core/__tests__/contact-shadow-dispatch.spec.ts` — Evidence: 7 passed (1920x1080, 1001x577, 63x65, 1x1, 130x70, 1024x512, six light placements each; bounds case 301,203 to 700,510). +- [ ] Arm A or Arm B is chosen with the judge's verdict, and the choice is written under `## Decisions`. proof: `pnpm visuals:ab --before --after --raters 3` on the starter and the dark-environment fixture. + +#### Phase 2: Templates use it +**Status:** BLOCKED — the stage was withdrawn after two REGRESSION verdicts (see `## Blocked on`) +**Files:** `packages/create-threenative/templates/platformer/src/render/fox.ts`, `packages/create-threenative/templates/platformer/src/render/postprocessing.ts`, `packages/create-threenative/templates/starter/src/render/quality.ts`, `packages/create-threenative/templates/starter/src/render/worldEnvironment.ts` (stage only if the shared-source spec allows it, else the starter's own `postprocessing.ts`), `packages/create-threenative/templates/platformer/playtests/contact-shadow.playtest.json` (new) +- [ ] The platformer ships without the blob, and the template gate passes for the platformer and the starter. proof: `pnpm test:templates`. + +#### Phase 3: Native +**Status:** BLOCKED — waits on a stage that ships (see `## Blocked on`) +**Files:** `packages/runtime-native/conformance/scenes/shared/contact-shadow.js` (new), `packages/runtime-native/conformance/registry.json` +- [ ] The desktop native host produces the same mask as the browser within tolerance for a fixed depth fixture and light. proof: `pnpm parity --target desktop --only-tests contact-shadow`. + +## Decisions + +- 2026-10-09 — **Coverage is "at least once", not "exactly once".** Emulating every (dispatch, group, + thread) of the ported builder shows the 64-line fans overlap where they converge on the light and at + tile seams: about 8% of pixels at 1080p are written twice, and the pixels next to the light up to 127 + times. Beyond one wave of the light a pixel is written at most twice, and every write carries the same + value because each thread marches its own pixel. The pixel under the light is never written; it is the + sun disc, not a surface. The PRD text said "exactly once", which the upstream algorithm does not give. +- 2026-10-09 — **One deliberate change from the upstream CPU builder.** Upstream makes the bounds + relative to `round(light)` while the kernel measures from the centre of `floor(light)`. They differ by a + pixel whenever the fraction is 0.5 or more, and the upstream sample hides it by passing `max = viewport + size`. The emulation found it: a light at x = 38400 on a 1920-wide target left column 1919 unwritten. + The port uses `floor(light)` for both and an inclusive `max = size - 1`, and rounds the light to float32 + so the CPU and the GPU agree on `floor`. The spec case "far off screen" at 1920x1080 is the red-green. +- 2026-10-10 — **Arm A is built, and it is withdrawn from the templates.** The platformer and starter + stages (commit `4afd70ca2`, PR #480) ran the Bend march, then multiplied the mask into the frame. Two + gates were needed before the result stopped speckling: an N·L gate (back-facing and grazing surfaces + self-shadow in a screen-space march) and a silhouette gate (a depth jump between neighbours reads as a + shadow edge). Both gates read a view-space normal rebuilt from depth, so the stage needs no normal + target. Asking the scene pass for `normal()` is not an option: it creates an MRT target that nothing + writes unless SSGI, SSR or GTAO is on, and the frame goes black. +- 2026-10-10 — **Finding: a post stage must use its pass camera's projection inverse.** In a stage the + global `cameraProjectionMatrixInverse` belongs to the post quad's orthographic camera, so every + rebuilt normal was degenerate and every gate returned zero. `uniform(context.camera.projectionMatrixInverse)` + fixes it. This holds for any future depth-reading stage. +- 2026-10-10 — **Two fresh judges returned REGRESSION; Phase 2 is not shipped.** Same pose, headed + `--browser-recipe webgpu`, two runs per arm, BEFORE (painted blob), CONTROL (blob removed, stage off) + and AFTER (blob removed, stage on). Round 1 judged `final3`, round 2 judged `final8` (`silhouetteJump` + 4, `strength` 1). In both rounds AFTER did not beat CONTROL, and both lost to BEFORE under the feet. + The cause is measured, not guessed. The key-light shadow map is 4096 over a 44 m window that follows + the player, about 1 cm per texel. It already resolves the foot-to-ground gap, so the premise in the + Motivation ("shadow-map texels wider than the gap") does not hold at the platformer camera. What the + blob gives is an omnidirectional darkening under the sole that a sun-direction march cannot produce. + The stage was removed from the default wiring instead of being explained away. +- 2026-10-10 — **AC-1 is unreachable with this arm at this camera scale.** The 0.3 dark-pixel ratio + needs under-foot darkening that the directional march does not make with the blob removed. The + scenario file was dropped with the stage. A follow-up needs a different term (ambient-occlusion style, + not sun direction) or a scene with no shadow map in range (far field, or a tier that drops the key + light shadow), and it needs its own judge. +- 2026-10-10 — **Side finding, deferred: the fox's soles float 0.215 m above y = 0.** The blob hid it. + A `BODY_REST = 0.377` correction exists in `4afd70ca2` (`Fox.ts`, `fox.ts`). It changes what reaches + the screen, so it needs its own visual judge and its own change, not a ride in this PR. +- 2026-10-10 — **Cost, informal.** The arms that logged `gpuCompute` read 0.1 to 0.2 ms at 1080p on the + desktop RTX 2080 lane with the stage on, and CPU render time rose about 1.7 ms. This is not the + `perf` p50 delta AC-2 asks for, so AC-2 stays open. The scenario p95 of 32 to 70 ms in those runs was + load-driven (other lanes shared the GPU) and says nothing about the stage. +- 2026-10-10 — **What stays.** The kernel, the CPU dispatch builder and its spec (Phase 1 box 1), the + `contactShadow()` export, and one WGSL fix found on the GPU: the `flag` buffer element needs an `int` + index with a `uint` value (`flag.element(int(0)).assign(uint(1))`). With the mechanism shipped and no + template using it, the Integration Ledger rows stay open. + +- 2026-10-10 — **DECLINED after one falsifier round on a coarse shadow map.** The platformer round + said the premise was false because the key-light map resolves the foot gap, so one more pose was + judged where it should hold: same platformer, key-light map cut to 1024 over 44 m (about 4.3 cm a + texel, coarser than the 2048 mobile tier), headed `--browser-recipe webgpu` on the RTX 2080 (adapter + `nvidia` / `turing`, not SwiftShader), two runs per arm, BEFORE / CONTROL / AFTER burned in, a fresh + read-only judge. Verdict: **REGRESSION** (AFTER against CONTROL and against BEFORE). The fox's feet were + pixel-identical between CONTROL and AFTER, so the stage did not touch the contact it was built for. + It did add dotted dark dashes under tower rims, a halo on the windmill silhouette, a stair-stepped + dash pattern on the bridge edge and a darker brow on the fox. Frames: + `https://raw.githubusercontent.com/ThreeNativeHQ/threenative-engine/2378b8be0a57d50968f18cc2aae009111e799d53/prd-561/round3-coarse-1024/`. + Three judged poses, three failures, and the failure moved from "no gain" to "speckle" as the map + got coarser, so the gain is not hiding in the map size. +- 2026-10-10 — **Threshold below which it could pay: not found.** At 4.3 cm a texel the stage still + gave no gain at the platformer camera. The only untested region is a far field with texels near or + above 10 cm (a distant cascade, or a 1024 map over 100 m), where a contact gap of a few centimetres is + below one texel and no key-light PCF can hold it. No template renders that, and nothing the engine + measures says a game will. A new PRD needs that scene and its own judge before any code. +- 2026-10-10 — **The mechanism is deleted, not parked.** Nothing consumes `contactShadow()` and the + kill switch says a dead mechanism goes. Phase 1's kernel and dispatch builder are recoverable from + commit `9bd6b3c25` (with the WGSL flag-index fix in `c9773d985`) and the stage wiring from `4afd70ca2`, + all on branch `feat/prd-561-contact-shadows` and in PR #480. The CHARTER paragraph, the capability + entries, the API-surface lines and `THIRD_PARTY_NOTICES.md` went back to their pre-PRD text. +- 2026-10-10 — **Separate note, not part of this PRD: the fox rig's soles float 0.215 m above y = 0.** + The painted blob hides it and the platformer playtest cannot see it. `BODY_REST = 0.377` in + `4afd70ca2` (`Fox.ts`, `fox.ts`) corrects it. It changes what reaches the screen, so it needs its own + small PRD, its own change and its own visual judge. diff --git a/docs/PRDs/unreal-source-borrowing/PRD-561-contact-shadows-from-screen-depth.md b/docs/PRDs/unreal-source-borrowing/PRD-561-contact-shadows-from-screen-depth.md deleted file mode 100644 index a71e31c0de..0000000000 --- a/docs/PRDs/unreal-source-borrowing/PRD-561-contact-shadows-from-screen-depth.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -prd_contract: v1 ---- - -# PRD-561 — Small things touch the ground: contact shadows from screen depth - -**Status:** NOT STARTED -**Priority:** P2 — AC-1 to AC-3 are open: no template has a screen-space contact shadow, and the platformer fakes one with a blob mesh. -**Complexity:** 5 (MEDIUM) — 1–5 implementation files (1), new mechanism module (+2), a compute pass with workgroup memory and a CPU-built dispatch list (+2); risk override: none -**Owner:** João -**Depends on:** None - -## Context - -A shadow map cannot resolve the contact between a small object and the ground. Its texels are too -large, and its bias detaches the shadow from the caster. The templates show both symptoms: - -- `templates/platformer/src/render/fox.ts:147-155` draws a 0.42 m `CircleGeometry` blob under the fox, - because "the shadow map's own shadow is easy to lose" on a bright grass cap. -- `templates/puzzle/src/render/lighting.ts:48-50` tunes `normalBias` so that bias does not "detach the - contact shadow under every crate". The bias trade-off still exists. - -The repository has no screen-space contact shadow (`rg -i "contact.?shadow|screen.?space.?shadow"` in -`packages/core/src` and every template's `src/render/`, 2026-10-09). This also serves the convention -"feet meet the floor": the feet touch the floor, but no shadow shows the contact. -[PRD-344](../rendering/PRD-344-contact-occlusion-baked-from-the-geometry-it-ships-with.md) bakes occlusion into -procedural geometry creases. That is static self-occlusion, not a dynamic shadow from a light, so -this PRD does not overlap it. - -### What Unreal does (UE 5.8.3, read 2026-10-09) - -- Unreal ships two contact-shadow methods: a stochastic jittered screen trace (method 0, the default) - and **Bend Studio's Screen Space Shadows** (method 1). - `UE 5.8.3: Engine/Source/Runtime/Renderer/Private/Shadows/ScreenSpaceShadows.cpp:25-33`. Contact - shadows are off for each light by default (`ContactShadowLength = 0`). - `UE 5.8.3: Engine/Source/Runtime/Engine/Private/Components/LightComponent.cpp:470-473`. -- Bend SSS is third-party code under **Apache-2.0** (`Engine/Shaders/Private/bend_sss_gpu.tps`, - `Engine/Source/Runtime/Renderer/Private/Shadows/bend_sss_cpu.h:3-15`). Its upstream is Bend Studio's - own release, linked from https://www.bendstudio.com/blog/inside-bend-screen-space-shadows/. - **This PRD ports the upstream release, never the copy in the Unreal tree.** -- The algorithm, in our words: - 1. The CPU projects the light into screen space. It splits the screen into at most 8 dispatches, - wedges that radiate from the light's screen point. When the light is behind the camera - (projected `w` sign), the trace direction reverses. - `UE 5.8.3: Engine/Source/Runtime/Renderer/Private/Shadows/bend_sss_cpu.h:38-48, 73-170`. - 2. Each 64-thread group handles one 64-pixel line toward the light. It loads a strip of depth - samples into workgroup memory once. Then each pixel walks the shared strip toward the light, and - counts a hit where a sample is in front of it within a fixed surface thickness. The defaults are - 60 samples, the first 4 hard (not averaged), the last 8 faded, and a thickness of 0.005 in - Unreal's override. `UE 5.8.3: Engine/Shaders/Private/bend_sss_gpu.ush:36-54, 242-249`. - 3. A group whose pixels all fall outside the light's depth bounds exits early. The early exit uses - wave operations when the hardware wave size matches, and falls back to workgroup memory and - barriers when it does not. `UE 5.8.3: Engine/Shaders/Private/bend_sss_gpu.ush:370-391`. - 4. Unreal writes the result into the light's screen shadow mask, so it darkens only that light's - direct term. `UE 5.8.3: Engine/Source/Runtime/Renderer/Private/Shadows/ScreenSpaceShadows.cpp:226-234, 467-521`. -- WebGPU differences that this PRD must handle: - - WebGPU core has no wave operations. The workgroup-memory fallback in step 3 is therefore the - only path. - - WebGPU has no clamp-to-border sampler. Bend expects one (`bend_sss_gpu.ush:160`), so the kernel - bounds-checks its depth reads. - - three renders forward, so no shadow-mask texture exists before shading. See the first design - decision below. - -## Solution - -**The split, by the repository's two questions:** - -- *(a) Could a game write this portably?* Yes. It needs only a depth texture, a compute pass and - TSL. But the dispatch-list builder and the workgroup-memory kernel are plumbing that every game - would repeat, and they decide nothing about the look. That is the mechanism row of "Where a change - goes". -- *(b) Does it decide how anything looks?* The strength, length, thickness, fade, which light, and - where the mask is applied all do. -- So the split follows the shape of `GPUParticles3D`: - - `packages/core/src/render/contact-shadow.ts` (new) owns the dispatch list and the kernel. It - takes depth, a camera and a light direction, and returns a mask texture node. Every appearance - parameter comes from the caller, and the module sets no default look. - - Each template's `src/render/` decides whether to use the mask, how to apply it, and every - strength value. - - The port keeps its Apache-2.0 header in the file. `packages/core/THIRD_PARTY_NOTICES.md` (new) - carries the licence text and ships in the published package. - -**First design decision (Phase 1 records it under `## Decisions`):** where the mask applies. - -- **Arm A, post stage.** A `contactShadows` render-chain stage multiplies the lit colour by the mask - after the scene pass, using current-frame depth. This is the cheapest arm. It also darkens ambient - light, which reads like ambient occlusion over the short default length. -- **Arm B, light term.** The sun's shadow node samples the mask, so only direct light darkens. This - matches Unreal. three renders forward, so this arm needs a depth prepass or reprojected - previous-frame depth. -- Arm A ships unless the judge rejects it on the PRD-345 dark-environment fixture. The stage name - enters `RENDER_CHAIN_STAGE_ORDER` in `packages/core/src/render/chain.ts`, before `ambientOcclusion`. - That is an ordering mechanism only. - -**Consumers:** the platformer replaces its blob with the stage, and the starter enables the stage at -the `high` and `medium` tiers. A tier whose cost gate fails refuses the stage by name in -`TN_RENDER_CHAIN`, like the existing refusals in `worldEnvironment.ts`. - -```mermaid -flowchart LR - D[scene pass depth] --> K[core contact-shadow kernel
dispatch list + workgroup strip] - L[template: light dir, length, thickness] --> K - K --> M[mask texture node] - M --> S[template stage: strength, apply] - S --> O[RenderChain output] -``` - -## Acceptance Criteria - -- [ ] AC-1 [local]: In a platformer scenario, with the blob mesh removed and the stage on, the region under the standing fox's feet has a dark-pixel ratio at least 0.3. The same scenario fails with the stage off. proof: `node packages/playtest/dist/runner/cli.js playtests/contact-shadow.playtest.json --url --browser-recipe webgpu` with a `region` `minDarkPixelRatio` assertion — Evidence: pending. -- [ ] AC-2 [local]: The stage costs at most 0.3 ms GPU at 1080p on the RTX 2080 browser WebGPU lane. proof: `TN_FRAME_BUDGET` p50 delta, stage on against stage off, from `node packages/playtest/dist/runner/cli.js perf` — Evidence: pending. -- [ ] AC-3 [local]: On the Pixel 8, the stage costs at most 0.5 ms GPU at the starter's mobile resolution, or the `low` tier refuses it by name. proof: `node packages/playtest/dist/runner/cli.js perf --logcat ` stage on against stage off — Evidence: pending. - -## Integration Ledger - -| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | -|---|---|---|---| -| Contact-shadow mask | template `postprocessing.ts`/`worldEnvironment.ts` stage → `contactShadow()` from `@threenative/core` → compute dispatches → mask node | New mechanism | Phase 1, AC-2 | -| Contact-shadow stage | `renderer.createRenderChain()` with `contactShadows` in the canonical order | Replaces the platformer blob (`fox.ts:147-155`) | AC-1 | - -## Execution Phases - -#### Phase 1: Mechanism and the apply decision -**Status:** NOT STARTED -**Files:** `packages/core/src/render/contact-shadow.ts` (new), `packages/core/src/render/chain.ts`, `packages/core/src/index.ts`, `packages/core/THIRD_PARTY_NOTICES.md` (new), `packages/core/__tests__/contact-shadow.spec.ts` (new) -- [ ] The dispatch-list builder covers every on-screen pixel exactly once, for a light in front of, behind and beside the camera, and for viewports that are not multiples of 64. proof: `pnpm exec vitest run packages/core/__tests__/contact-shadow.spec.ts`. -- [ ] Arm A or Arm B is chosen with the judge's verdict, and the choice is written under `## Decisions`. proof: `pnpm visuals:ab --before --after --raters 3` on the starter and the dark-environment fixture. - -#### Phase 2: Templates use it -**Status:** NOT STARTED -**Files:** `templates/platformer/src/render/fox.ts`, `templates/platformer/src/render/postprocessing.ts`, `templates/starter/src/render/quality.ts`, `templates/starter/src/render/worldEnvironment.ts` (stage only if the shared-source spec allows it, else the starter's own `postprocessing.ts`), `templates/platformer/playtests/contact-shadow.playtest.json` (new) -- [ ] The platformer ships without the blob, and the template gate passes for the platformer and the starter. proof: `pnpm test:templates`. - -#### Phase 3: Native -**Status:** NOT STARTED -**Files:** `packages/runtime-native/conformance/scenes/shared/contact-shadow.js` (new), `packages/runtime-native/conformance/registry.json` -- [ ] The desktop native host produces the same mask as the browser within tolerance for a fixed depth fixture and light. proof: `pnpm parity --target desktop --only-tests contact-shadow`. diff --git a/docs/PRDs/unreal-source-borrowing/README.md b/docs/PRDs/unreal-source-borrowing/README.md index 4cd33fc309..5efe18acd3 100644 --- a/docs/PRDs/unreal-source-borrowing/README.md +++ b/docs/PRDs/unreal-source-borrowing/README.md @@ -75,7 +75,7 @@ earlier wave unless the "Waits on" column says so. | 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 | — | +| 2 | [PRD-561](../done/PRD-561-contact-shadows-from-screen-depth.md) | Small objects and feet sit on the ground (screen-space contact shadows) | 5 MEDIUM | — | | 2 | [PRD-563](../done/PRD-563-the-first-frame-starts-at-the-tier-the-gpu-family-holds.md) | The first frame starts at the tier the GPU family can hold | 5 MEDIUM | — | | 2 | [PRD-569](./PRD-569-gpu-particle-emitters-cull-and-yield-to-the-frame-budget.md) | Off-view GPU emitters stop costing; effects yield to the frame budget | 5 MEDIUM | — | | 2 | [PRD-566](./PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md) | Terrain LOD morphs on the GPU and picks its level by screen error | 3 LOW | — |