VM3 and VM6 user-accepted live at 2560x1440 (High); #424 and #425 fixed during the gate. Roadmap entry flipped to shipped; plan ledger VM7 row SHIPPED; #422 recorded as carried by owner decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
791 lines
48 KiB
Markdown
791 lines
48 KiB
Markdown
# Campaign VM — VisualMaster
|
||
|
||
**Date:** 2026-08-22 · **Status:** PLANNED — awaiting the owner's goal
|
||
**Phase id:** **Campaign VM** — named by the project owner on 2026-08-22
|
||
**Branch:** `claude/git-sync-status-5fb1d2` (= main + Campaign AR, `c51b07ef`)
|
||
**Predecessor:** [Campaign AR](2026-08-21-atmospheric-rendering.md) and its
|
||
[independent review](../research/2026-08-22-campaign-ar-review.md)
|
||
**Scheduling:** owner-directed, like Campaign AR. Does not displace M4.
|
||
|
||
## Goal
|
||
|
||
Make Campaign AR **provably safe to merge** and then **finish the look**:
|
||
close every review finding with evidence rather than assertion, put the
|
||
Tier-1 post stack in linear light so its controls mean what they say, and add
|
||
the one cheap, high-impact enhancement still missing from the discussed
|
||
scope — **gentle, believable wind in Dereth's foliage**, driven by AC's own
|
||
authored weather, opt-in through the same render-pack contract, with the
|
||
default retail-faithful path untouched.
|
||
|
||
Campaign VM is shipped when both sides of the final matrix pass:
|
||
|
||
- **pack off:** pixel-identical to the pre-campaign build outside the two
|
||
intended parity changes (terrain normals, building detail), with production
|
||
CPU/GPU/allocation within the pre-campaign noise envelope; and
|
||
- **pack on:** linear-light post stack, registered-or-removed detail fade,
|
||
moving foliage that the owner judges "real, not a screensaver", shadows
|
||
that move with the leaves, and all of it inside Campaign AR's declared
|
||
budgets.
|
||
|
||
## Operating model
|
||
|
||
The launcher/AR cadence, unchanged:
|
||
|
||
- **Fable** plans, verifies claims against source/decomp, writes the
|
||
closeouts, and drives the work order. Nothing in a closeout may claim more
|
||
than its artifact shows — every "passes" names the command and the lane.
|
||
- **Sonnet** implements each slice against the pinned contract below.
|
||
- **Opus** reviews every slice with two lenses (architectural / retail-
|
||
faithful) before its closeout; findings go through a fix round and a narrow
|
||
re-review.
|
||
- **The owner** is stopped for exactly three things: the cdb read on the
|
||
retail client (VM2), the visual gates (VM3, VM6, VM7), and the merge.
|
||
|
||
Rules binding on every slice: no workarounds (CLAUDE.md), no guessed
|
||
AC-specific constants — a number without a decomp/DAT anchor gets a register
|
||
row in the same commit — and the pack-off path is the oracle: any slice that
|
||
moves a pack-off pixel outside its declared mask is a bug, not a tuning.
|
||
|
||
## Lesson carried into every slice — a branch that exists is not a branch that runs
|
||
|
||
VM2 found that Campaign AR's #226 port, the 2026-08-21 findings doc it was
|
||
briefed from, and the AR review's "probably" were all describing the
|
||
**fallback** branch of retail's detail pass — the `stage == 0` framebuffer
|
||
blend that retail only takes when the adapter cannot advertise
|
||
`D3DTEXOPCAPS_PREMODULATE`. Real hardware (the owner's AMD, and anything
|
||
modern) takes the single-pass texture-stage branch, which computes a
|
||
different formula with the opposite sign of effect (darkens, not brightens).
|
||
The decomp was read correctly three times; what nobody did until VM2 was
|
||
spend two minutes reading the capability on a live client.
|
||
|
||
Binding rule for this campaign and after: **when retail gates a mechanism
|
||
behind a capability, preference, or `trysinglepass`-style switch, the port
|
||
is not done until the gate's live value is recorded** — a cdb read on the
|
||
PDB-paired client, cited in the port's note with the binary GUID. "Grep
|
||
named → decompile → port" answers what the code *says*; only the live read
|
||
answers what it *does* on the hardware the game actually ran on. A guess
|
||
about driver caps ("rarely advertised") is not evidence and must not appear
|
||
in a review verdict.
|
||
|
||
Corollary for brightness questions: the owner's live "too bright" (exposure
|
||
1.0 → 0.80) was the pack-on gamma-space tonemap (F4, VM3), not this. The
|
||
detail overlay's fallback brightening is +3 % on building shells only; the
|
||
single-pass path darkens by ~10 %. Neither explains a scene-wide level.
|
||
|
||
## Slice ledger
|
||
|
||
| Slice | Closes | Gate |
|
||
|---|---|---|
|
||
| VM0 | F1 — default-path invariance | **CLOSED 2026-08-22 — PASS**; [report](../research/2026-08-22-vm0-default-path-invariance.md) |
|
||
| VM1 | F2/F3 — #226 single-pass re-port + fade removal | **CLOSED 2026-08-22** — `05970306`, `388457a7`, `ae651312`, closeout; Opus APPROVE |
|
||
| VM2 | F3 — which retail detail path ran | **CLOSED 2026-08-22** — single-pass; see [cdb note](../research/2026-08-22-vm2-retail-detail-path-cdb.md) |
|
||
| VM3 | F4 — linear-light post stack | **CLOSED — USER-ACCEPTED 2026-08-23 ("Good gate pass")** — `87677f9c`, `51178f7c`, closeout; Opus APPROVE; live Holtburg, High pack, 2560×1440 fullscreen, day group 9 "Rainy", launched from the cloned config under `artifacts/owner-gate` on `132395e6` (after #424/#425 fixed during the gate) |
|
||
| VM4 | F5 — overclaiming docs, incl. the reviewer's own | **CLOSED 2026-08-22** |
|
||
| VM5 | F8 — volumetric banding; F7 filed | **CLOSED 2026-08-22** (#421) |
|
||
| VM6 | Foliage wind | **CODE-COMPLETE 2026-08-23** — `0930c35d`, `39e8408c`, `43e3abed`, `a82959f1`, `fccba839`, `eec95535`, `754d59d9`, `1f151242`; five Opus review rounds (round 3 APPROVE; round 4 REJECT caught the overhead-sun regression, fixed in round 5; round 5 APPROVE, its four nits closed in `1f151242`); pixel proof [note](../research/2026-08-23-vm6-foliage-wind-pixel-proof.md) (14,993 robust wind px vs 65 floor, scenery only; wind-off frames match the plain pipeline at mean |Δ| 0.007); **CLOSED — owner visual gate USER-ACCEPTED 2026-08-23 ("Looks good")** — live Holtburg, High pack, day group 9 "Rainy" (Overcast), 1920×1080, launched from the cloned config under `artifacts/owner-gate`; the remaining §VM6 Acceptance steps (30 s single tree, shadow follows, indoors still, pack-off still, Clear vs Storm) are the owner's to tick or waive |
|
||
| VM7 | Closeout: full gates, register, roadmap, merge | **SHIPPED 2026-08-23 — owner: "accept 422, commit and merge with main"; #422 carried as an open watch item; merged to main as a fast-forward.** AUTOMATED ROWS PASSED 2026-08-23 — release gate 15,283/0/0, connected route PASS pack-off + High, VM0 invariance on the final binary (robust 5–10 px), performance matrix PASS, #422 re-characterised (pack-independent, ~2 %, no stack yet); [closeout note](../research/2026-08-23-vm7-automated-closeout.md). **Owner visual gates (VM3, VM6, VM7 matrix) and the merge OWED** |
|
||
|
||
Order is VM2 (done) → VM0 (done) → VM1 (done) → VM4 (done) → VM5 (done) → VM3 (closed, user-accepted) → VM6 (closed, user-accepted) → VM7 (shipped; #422 carried). VM0 goes first because everything after it is measured
|
||
against the baseline it establishes.
|
||
|
||
---
|
||
|
||
## VM0 — Default-path invariance (F1)
|
||
|
||
**Why:** the only "pre-campaign oracle" is a 2×2 synthetic, one-draw
|
||
recording-device fixture. `WbDrawDispatcher.Rhi.cs` was refactored underneath
|
||
the default path. Nobody has compared real pack-off pixels or production
|
||
performance against `6c79d35c`.
|
||
|
||
**Implementation**
|
||
|
||
1. Build `6c79d35c` in a throwaway worktree (Release). Capture with
|
||
`tools/run-offline-pixel-gate.ps1 -Out artifacts/vm0/base` — the tool's
|
||
own documented baseline mode.
|
||
2. Capture HEAD pack-off twice with the same tool and camera set:
|
||
`BuildingDetailTextures=false` (isolates the A2 normal change) and
|
||
`=true` (the shipped default).
|
||
3. Extend the tool's compare step with a **terrain mask** (same mechanism as
|
||
the existing `sky-mask.png`): pixels whose depth/material belong to
|
||
`terrain_modern` are excluded from the identity assertion and reported
|
||
separately.
|
||
4. Assert: HEAD(detail=false) vs base — **zero** differing pixels outside the
|
||
terrain mask; inside the mask, differences are reported as a histogram
|
||
(expected: low-amplitude shading deltas, no structural change).
|
||
HEAD(detail=true) vs HEAD(detail=false) — differences confined to
|
||
building/EnvCell surfaces (second mask from the detail replay's own
|
||
command set).
|
||
5. Production performance A/B, **no automation observer, no validation
|
||
layers, uncapped Release**, at the CLAUDE.md production-profile camera and
|
||
at pinned dense Arwic: base vs HEAD pack-off. Record CPU/GPU p50/p95/p99,
|
||
FPS, `alloc_kb p50`, GC counts. The pre-campaign references are
|
||
519.7 FPS / 1.869 ms CPU p50 (profile) and ~3.0/4.9 ms CPU p50/p95 (dense
|
||
Caul, digest §render). Deltas must sit inside run-to-run noise; any
|
||
allocation growth on the pack-off path is a regression to fix in this
|
||
slice, not to file.
|
||
6. Add the masked comparison as a repeatable tool mode
|
||
(`-Baseline … -TerrainMask`) so VM3/VM6 can re-run it.
|
||
|
||
**OUTCOME (2026-08-22): PASS.** Connected, visible-window, isolated-config captures: base+normals vs HEAD-off has zero strong static differences outside the animated lifestone at Holtburg and the open field; production CPU is 13% cheaper and allocation 25x lower on HEAD's pack-off path (no observer, uncapped Release). Three config traps (isolated FOV, the real Roaming settings still selecting the pack, minimized-window throttling) are recorded in the report. Replaced the planned terrain mask with a base+normals baseline (no mask needed).
|
||
|
||
**Acceptance:** the two pixel assertions in step 4 hold; the perf A/B shows
|
||
no default-path regression; the Fable report records the exact commands,
|
||
commits and artifact paths. If a regression is found, it is fixed here and
|
||
the report names the cause — no "accepted difference" without a diff that
|
||
explains it.
|
||
|
||
## VM1 — Remove the invented detail fade (F2)
|
||
|
||
**Why:** `RetailDetailTextureContract.FullDetailDistanceMetres = 10` /
|
||
`ZeroDetailDistanceMetres = 50` have no retail anchor. Retail's
|
||
`DrawBuilding`/`DrawEnvCell`/`RenderMeshSubset`/`SetDetailSurfaceInternal`
|
||
carry no distance term; attenuation is the LINEAR mip chain.
|
||
|
||
**Decision (Fable, retail-faithful rule):** remove the ramp. **Amended by VM2:** VM1 also re-ports the blend to the single-pass math (see the VM2 outcome) — the fade removal and the blend correction land together, with the `RetailDetailTextureContract` helper and tests rewritten around `Expected(base, detail, diffuseAlpha)`. Mip averaging
|
||
already converges the live category texture to its 1.033 mean factor.
|
||
|
||
**Implementation**
|
||
|
||
- Delete the two constants and `vDetailFade` from `mesh_detail.vert/.frag`
|
||
(`mesh_detail.vert:83`); the fragment outputs `applyFog(detail.rgb)` with alpha `detail.a * instanceOpacity` — fogged so the two-draw result collapses to retail's fog-after-combine pixel (VM1 review fix).
|
||
- Confirm the detail texture is uploaded with a full mip chain and sampled
|
||
LINEAR/LINEAR/LINEAR, WRAP — that *is* retail's attenuation; test it.
|
||
- Rename `RetailDetailTextureContract` members so nothing un-anchored is
|
||
labelled retail; keep the blend-factor helper and its tests.
|
||
- Update the #226 pseudocode note: strike the "10 m / 50 m" lines, add the
|
||
decomp citations above, and state that attenuation is mip-driven.
|
||
- If the owner later wants a ramp back, it returns as a **pack setting**
|
||
(enhancement), never on the default path.
|
||
|
||
**OUTCOME (2026-08-22): CLOSED.** Four commits: the single-pass re-port + fade removal (`05970306`); the interior shell/detail passes binding their own instance opacity — a pre-campaign stale-slot read (`388457a7`); the Opus fix round — the detail contribution is fogged so the two-draw result collapses to retail's fog-after-combine pixel, mip/sampler test, doc caveats (`ae651312`); and the closeout (AP-232 for the translucent-subset blend weight, the sampler test pinned to the production constant). Measured on Holtburg buildings: predicted +2.2/+0.66/+0.16 levels vs measured +2.17/+0.57/+0.16 (`artifacts/vm1`, grass/sky controls 0.00). Detail-on costs +0.3-0.5 ms CPU / +0.1 ms GPU at dense Arwic, still under the pre-campaign baseline. Opus dual-lens review: APPROVE WITH FIXES -> fix round -> narrow re-review APPROVE.
|
||
|
||
**Acceptance:** no reference to a distance fade remains in src or the note;
|
||
VM0's masked comparison re-run shows the only change is on detail surfaces;
|
||
at >50 m a building reads the same as at 60 m (mips), not a hard step.
|
||
|
||
## VM2 — Which detail path did retail hardware run? (F3, owner-gated)
|
||
|
||
**Why:** the port is of the two-pass framebuffer fallback. With
|
||
`m_caps.bCanDoSinglePassDetailing`, retail used stage-1 texture ops
|
||
(`PREMODULATE` + `BLENDCURRENTALPHA` — a lerp). The cap needs
|
||
`D3DTEXOPCAPS_PREMODULATE` (0x0059f6c6), which consumer drivers rarely
|
||
exposed — probable, not proven.
|
||
|
||
**Implementation:** one cdb script (`tools/cdb/vm2-detail-caps.cdb`): attach
|
||
to the PDB-paired `acclient.exe`, `dt acclient!RenderDevice::render_device`
|
||
→ `m_caps.bCanDoSinglePassDetailing`, `m_caps.bTexOpDotProduct3`, and the raw
|
||
`m_D3DCaps.TextureOpCaps`; `qd`. Record the answer in the #226 note and the
|
||
review doc.
|
||
|
||
- `bCanDoSinglePassDetailing == 0` → the port is the path players saw; close.
|
||
- `== 1` → file the single-pass stage math as the correct target, register
|
||
the current blend as a bounded divergence, and schedule the re-port as its
|
||
own slice (it is a fragment-shader change only).
|
||
|
||
**Acceptance:** the value is recorded with the binary GUID and the date.
|
||
|
||
**OUTCOME (2026-08-22): `bCanDoSinglePassDetailing = 1`, `trysinglepass = 1`.** Retail on the owner's GPU runs the single-pass stage path: `lerp(base·diffuse, detail.rgb, detail.a·diffuse.a)` — a mild darkening, not the fallback's brightening. The #226 port targets the wrong path and is re-ported inside VM1 (blend `SRCALPHA+INVSRCALPHA`, output `detail.rgb, detail.a·diffuseAlpha`, neutral at `detail.a == 0`). Also read: `LandscapeDetailTextures = 0` is a real separate preference. Evidence and the exact stage math: [2026-08-22-vm2-retail-detail-path-cdb.md](../research/2026-08-22-vm2-retail-detail-path-cdb.md).
|
||
|
||
## VM3 — Linear-light post stack (F4)
|
||
|
||
**Why:** bloom threshold, ACES, Rec.709 luma, saturation and the 0.5
|
||
contrast pivot all assume linear light; they receive gamma-encoded retail
|
||
colours and the output is never re-encoded. `exposure = 0.80` is the
|
||
compensation.
|
||
|
||
**Design**
|
||
|
||
- The main-world intermediate stays gamma-encoded `Rgba16Float` — the world
|
||
pass and its alpha blending are retail's and must not change.
|
||
- Every pack read of world colour decodes once: `acdreamDecode(c) = pow(c, 2.2)`
|
||
(a named function in `atmospheric_common.glsl`; 2.2 is the retail-era
|
||
display assumption, documented as such — not sRGB piecewise, which would
|
||
imply a precision the source never had).
|
||
- Bloom chain, sun-ray composite and volumetric composite operate in linear.
|
||
- `atmospheric_filmic.frag`: exposure → ACES → grade → vignette in linear,
|
||
then `acdreamEncode(c) = pow(c, 1/2.2)` to the UNORM swapchain.
|
||
- Defaults re-tuned so the **neutral** preset reproduces the pack-off image
|
||
within the VM0 tolerance when every effect is at its neutral value
|
||
(exposure 1.0, bloom 0, tonemap mix 0, saturation 1, contrast 1,
|
||
vignette 0) — the existing "every effect can be set to neutral" acceptance
|
||
now actually holds numerically, and is asserted by a test.
|
||
- Opinionated defaults (what the owner accepted at 0.80 exposure) are
|
||
re-derived in linear and presented for the visual gate. **Shipped truth
|
||
(not the pre-implementation guess above):** exposure stays at **0.80** —
|
||
at exposure 1.0 the linear pipeline maps gamma-0.5 to 0.6017, essentially
|
||
the same 0.6163 the owner called too bright under the old gamma-space
|
||
pipeline, so 1.0 reproduces the rejected look, not a corrected one. The
|
||
bloom **threshold stays 1.0** (a fixed point of both `pow(x,2.2)` and
|
||
`pow(x,1/2.2)` — 1.0 decodes and encodes to 1.0), while the **knee moves
|
||
0.45 → 0.73** (`AtmosphericPostProcessGraph.BloomKneeLinear`) and
|
||
**vignette-strength moves 0.12 → 0.245** (re-derived so the same accepted
|
||
12% corner darkening survives the encode step). See
|
||
`AtmosphericColorPipelineTests` for the pinned numbers behind each of
|
||
these.
|
||
|
||
Old-vs-new curve at the shipped exposure 0.80 (gamma input →
|
||
old-pipeline display value / new-pipeline display value; filmic
|
||
strength 1, saturation/contrast 1, no vignette — i.e. `acesFitted`
|
||
applied directly to the gamma value versus `encode(acesFitted(0.80 *
|
||
decode(g)))`):
|
||
|
||
| gamma in | old display | new display |
|
||
|---:|---:|---:|
|
||
| 0.05 | 0.031 | 0.023 |
|
||
| 0.10 | 0.091 | 0.052 |
|
||
| 0.15 | 0.162 | 0.091 |
|
||
| 0.20 | 0.233 | 0.140 |
|
||
| 0.30 | 0.360 | 0.265 |
|
||
| 0.46 | 0.511 | 0.488 |
|
||
| 0.50 | 0.541 | 0.539 |
|
||
| 0.70 | 0.652 | 0.735 |
|
||
| 0.90 | 0.725 | 0.845 |
|
||
| 1.00 | 0.752 | 0.879 |
|
||
|
||
Shadows deepen slightly (0.05–0.30 gamma read darker), the crossover sits
|
||
near gamma 0.5 (old and new agree almost exactly there — expected, since
|
||
that is close to the 0.46 midtone the exposure was tuned against), and
|
||
highlights above roughly gamma 0.5 now read brighter instead of
|
||
compressing toward ACES's ~0.75 ceiling.
|
||
|
||
**Acceptance:** neutral preset ≡ pack-off within tolerance — proved by the
|
||
CPU mirror `AtmosphericColorPipeline` (`AtmosphericColorPipelineTests`,
|
||
half-an-8-bit-step identity over a 0..255 grey sweep) plus shader-source
|
||
pins (`AtmosphericPostProcessGraphTests`) guarding the decode/encode call
|
||
sites against silent removal. The real-frame masked capture **was run**
|
||
(retail vs High-with-every-effect-neutral, `artifacts/vm3`): 110,561 px at
|
||
`|Δ|=1` (float/pow round-trip noise, sub-visible) and 95 foliage-silhouette
|
||
pixels at `|Δ|≥5` (58 isolated, max 73 — cutout-edge rasterization between two
|
||
separate client runs; ambient motion is the alternative explanation), nothing
|
||
on any ground/building/water surface. Stage-1's luminance table re-capture is still **owed** at the
|
||
owner gate; owner visual gate: "same look as accepted, no clipping,
|
||
highlights roll off". Budget unchanged (two `pow` per pixel).
|
||
|
||
### VM3 owner visual gate — brief (read this, then play)
|
||
|
||
Launch normally (launcher or `ACDREAM_RETAIL_UI=1` env launch), Options →
|
||
Display → render pack **Atmospheric / High**. Every number below is measured,
|
||
not predicted; the sliders are live so you can tune while looking.
|
||
|
||
**What changed on screen, at the shipped defaults (exposure 0.80):**
|
||
|
||
| input tone (gamma) | before VM3 | after VM3 | Δ |
|
||
|---|---|---|---|
|
||
| deep shadow 0.10 | 0.091 | 0.052 | −43 % |
|
||
| shadow 0.20 | 0.233 | 0.140 | −40 % |
|
||
| mid-grey 0.46 | 0.511 | 0.488 | −5 % |
|
||
| 0.50 | 0.541 | 0.539 | 0 (crossover) |
|
||
| bright 0.70 | 0.652 | 0.735 | +13 % |
|
||
| highlight 0.90 | 0.725 | 0.845 | +16 % |
|
||
|
||
Midtones are where you left them; **shadows are deeper and highlights no
|
||
longer crushed** — that is the linear ACES toe/shoulder, not a bug. Offline
|
||
Holtburg hillside captures (`docs/research/evidence/vm3/`): mean luminance vs
|
||
pack-off **−17 % at noon, −44 % at dusk** (codex's accepted look measured
|
||
−8 % outdoors on a different scene). Vignette was re-derived (0.12 → 0.245) so
|
||
the corner darkening on screen is the same 12 % you accepted.
|
||
|
||
**Checklist (≈10 min):**
|
||
1. Noon outdoors, walk a shaded street: do shadows read as "deep" or as
|
||
"crushed"? If crushed → raise **Exposure** to 0.9–1.0 (mid-greys will
|
||
brighten ~10 %) or lower **Filmic strength** to 0.7–0.8 (lifts the toe,
|
||
keeps the shoulder).
|
||
2. Look at the sky near the sun and a bright roof: highlights should roll off
|
||
smoothly with no clipping (clip % measured 0.06, same as pack off).
|
||
3. Dusk (`/time` or wait): the −44 % is the effect of long shadows plus the
|
||
toe. Judge whether it is atmospheric or merely dark; the same two sliders
|
||
apply.
|
||
4. Corners: vignette should match the previous feel. If you had a saved
|
||
override of 0.12 it is now weaker (the default moved, overrides did not).
|
||
5. Switch the pack to **retail/off** and back: with every effect at its
|
||
neutral value the image is the pack-off image (measured: ≤1 LSB on 99.99 %
|
||
of pixels).
|
||
|
||
Say which slider values you settle on; they become the shipped defaults in
|
||
VM7. Known: #422 (1-in-8 heap-corruption exit after a pack-on run) is open
|
||
and is a VM7 gate item, not a VM3 one.
|
||
|
||
## VM4 — Truthful documents (F5)
|
||
|
||
**OUTCOME (2026-08-22): CLOSED.** Corrected in place, each with a dated
|
||
"VM4 correction" note: the AR plan's 2x2-oracle sentence and both
|
||
"zero skips" totals (now "under the hermetic lane filter"); the Stage-2
|
||
report's turning-hitch paragraph (observer tax, not product); the Track A
|
||
report's and the findings doc's `TerrainUtils.GetNormal` premise; the findings
|
||
doc's "retail brightens" section (fallback only; single-pass lerp is what runs),
|
||
its landscape-detail open question (`LandscapeDetailTextures = 0`, answered by
|
||
VM2), and its open question 5; the completion audit's retail-path row; and the
|
||
review's own F1/F2/F3/F5 headers. The VM0 production table is now the baseline
|
||
in the findings doc §5.
|
||
|
||
Correct, in the AR plan and its reports, the four sentences the review
|
||
named: the 2×2 oracle described as a production pin; the turning-hitch
|
||
conclusion drawn under the automation observer; the "zero skips" headline
|
||
(say "hermetic lanes"); and the Track A premise that `TerrainUtils.GetNormal`
|
||
produced a faceted look. Also correct the reviewer's own findings doc §4
|
||
(`2026-08-21-terrain-and-atmospheric-rendering-findings.md`): that function
|
||
orients scenery; the render normals were already smooth; A2 replaced
|
||
central-difference with retail's split-aware incident-face average.
|
||
Add the VM0 numbers as the new baseline table. Docs only; no code.
|
||
|
||
## VM5 — Volumetric jitter, and file the shared transform buffer (F8, F7)
|
||
|
||
- `atmospheric_volumetric.frag`: interleaved-gradient-noise offset per pixel
|
||
on the march start (`fract(52.9829189 * fract(dot(gl_FragCoord.xy,
|
||
vec2(0.06711056, 0.00583715))))`), plus the existing quarter/half-res
|
||
upsample. Automated: a fixed-camera capture's step-banding metric (row
|
||
autocorrelation at the step period) drops below the current value;
|
||
GPU delta < 0.02 ms.
|
||
- File issue: the shadow pass uploads a second transform buffer; a future
|
||
GPU-culling step should bind the main pass's instance SSBO. Not built here.
|
||
|
||
**OUTCOME (2026-08-22):** IGN jitter landed in atmospheric_volumetric.frag
|
||
(pack-on only); F7 filed as #421.
|
||
|
||
## VM6 — Foliage wind (the feature)
|
||
|
||
### What "real movement" means here
|
||
|
||
Trees do not wave like flags. Real foliage has **three motions at three
|
||
speeds**: the whole tree leans slowly with the mean wind (seconds), branches
|
||
swing at their own natural frequency (about one second), and leaves flutter
|
||
fast and independently (fractions of a second). Gusts come and go over tens
|
||
of seconds. Neighbouring trees are out of step. Trunk bases do not move.
|
||
Anything that ignores one of these reads as "screensaver" — which is what
|
||
the owner does not want.
|
||
|
||
### The rule for what sways (no guessing, data-driven)
|
||
|
||
- **Candidate set:** procedural scenery only — entity ids in the
|
||
`0x8XXYYIII` namespace (`ProceduralSceneryIdAllocator`, top nibble 0x8).
|
||
Landblock statics (fences, signposts, buildings), weenies, creatures and
|
||
players never sway.
|
||
- **Foliage subsets:** within a candidate, the **alpha-cutout material
|
||
subsets** (leaves, fronds, bushes, grass tufts). Rocks have none and stay
|
||
still; a tree's trunk is opaque and gets only the slow lean (below).
|
||
- **Opaque subsets of a candidate that also owns cutout subsets** (trunks,
|
||
branches) receive the slow lean only, scaled by height, never the flutter.
|
||
- A pack may **exclude** object ids (`FoliageExclusions`, a list in the
|
||
descriptor) for the rare scenery object that is cutout but not foliage.
|
||
There is no include list: the rule is the rule.
|
||
|
||
This is a render-only classification flag per batch — bits 1 and 2 of
|
||
`BatchData.flags` (`mesh_atmospheric.vert` already reads `flags`; bit 0 is
|
||
the #226 built-mesh marker). The dispatcher sets it once at classification
|
||
time from the entity id namespace and the subset's blend class; the pack-off
|
||
`mesh_modern` pipelines never read it.
|
||
|
||
### The motion (vertex shader, pack variant only)
|
||
|
||
Applied in `mesh_atmospheric.vert` and in all four
|
||
`directional_shadow_world_*` vertex shaders (constraint: **the shadow must
|
||
move with the leaf** — the caster and receiver displacement are one shared
|
||
include, `foliage_wind.glsl`, so they cannot drift apart).
|
||
|
||
Inputs: instance world origin `o` (translation column of
|
||
`Instances[i].transform`), vertex world position `p`, `time` (from
|
||
`uCameraAndTime.w`, already in the vertex stage), and the pack's wind block
|
||
(new `uFoliageWind` in `atmospheric_common.glsl`):
|
||
|
||
```
|
||
vec4 uFoliageWindDirection; // xy unit direction, z = mean strength [0..1], w = gust strength
|
||
vec4 uFoliageWindParams; // x = lean amplitude m, y = branch amplitude m, z = flutter amplitude m, w = max height m
|
||
```
|
||
|
||
All amplitudes are **metres at the canopy top**; they are pack settings with
|
||
bounded ranges, not shader constants.
|
||
|
||
```
|
||
h = clamp((p.z - o.z) / maxHeight, 0, 1) // 0 at the base, 1 at the top
|
||
k = h * h // bend grows with height²: bases stay put
|
||
ph = dot(o.xy, vec2(0.137, 0.291)) // per-tree phase from world position
|
||
g = 0.5 + 0.5 * sin(0.05 * t + ph) // gust envelope, ~20 s
|
||
+ 0.25 * sin(0.13 * t + 1.7 * ph)
|
||
s = mean + gust * g // instantaneous strength
|
||
|
||
lean = k * leanAmp * s * (0.8 + 0.2 * sin(0.35 * t + ph)) // slow lean
|
||
branch = k * branchAmp * s * sin(1.1 * t + ph + 2.0 * h) // ~1 Hz, phase runs up the tree
|
||
flutter = h * flutterAmp * s * sin(6.0 * t + 7.0 * fract(sin(dot(p.xy, vec2(12.9898, 78.233))) * 43758.5453))
|
||
|
||
d = dir * (lean + branch) + perp(dir) * 0.35 * branch + vec2(flutter) * normalize(vec2(cos, sin) of vertex hash)
|
||
p.xy += d
|
||
p.z -= 0.5 * dot(d, d) / max(h * maxHeight, 0.5) // bend shortens, it does not stretch
|
||
```
|
||
|
||
Opaque (trunk) subsets use `lean` only. Cutout subsets use all three. The
|
||
vertex hash decorrelates leaves on the same tree; the instance phase
|
||
decorrelates trees. The `p.z` term is the cheap length-preserving correction
|
||
so a bent canopy sinks slightly instead of growing.
|
||
|
||
**Deliberate divergence from the pseudocode above (review fix round A7):**
|
||
both `h`'s divisor and the `p.z` correction's divisor guard `maxHeight`
|
||
(`amp.w`/`uAtmosphereWindAmplitude.w`) with `max(maxHeight, 0.5)`, i.e.
|
||
`h = clamp((p.z - o.z) / max(maxHeight, 0.5), 0, 1)` and
|
||
`p.z -= 0.5 * dot(d, d) / max(h * maxHeight, 0.5)`. `wind-canopy-height-metres`
|
||
is an author-facing pack setting with no enforced floor; without the guard a
|
||
misconfigured near-zero canopy height would divide by a near-zero value and
|
||
either blow `h` up to a huge (then clamped) number with a discontinuous
|
||
derivative right at the base, or make the bend-shortening term explode.
|
||
Flooring at 0.5 m (half the shortest plausible sapling) keeps both terms
|
||
well-behaved for any authored value, including 0, and is invisible for every
|
||
realistic canopy height (the built-in default is 8 m). Both the shader
|
||
(`foliage_wind.glsl`) and the CPU mirror (`FoliageWindModel.Displace`) apply
|
||
the guard identically.
|
||
|
||
Normals are **not** rotated (Gouraud on a cutout leaf with flipped lighting
|
||
would flicker); AC's flat-lit foliage does not need it.
|
||
|
||
### Weather drives it (AC owns the weather)
|
||
|
||
The pack's `AtmospherePolicyDeclaration` gains a `FoliageWindByWeather` table
|
||
keyed by the DAT-classified `AcDream.Core.World.WeatherKind` — not the raw
|
||
`activeDayGroup` index, which carries no weather meaning by itself.
|
||
`WeatherState.cs` already classifies each day group's authored DAT name into
|
||
one of these five real kinds, and `AtmosphericFrameInputs.Weather` /
|
||
`uAtmosphereWeather.x` already threads that classification through the
|
||
frame — this table reuses it instead of re-guessing from the index:
|
||
|
||
| WeatherKind | mean | gust |
|
||
|---|---|---|
|
||
| Clear | 0.25 | 0.15 |
|
||
| Overcast | 0.60 | 0.35 |
|
||
| Rain | 0.85 | 0.60 |
|
||
| Snow | 0.35 | 0.20 |
|
||
| Storm | 1.00 | 0.75 |
|
||
|
||
plus a global **Wind** slider (0–2×) and an **Off** setting. Transitions
|
||
between day groups interpolate over the existing weather delta seconds
|
||
(`uAtmosphereWeather.z`), so a weather change never snaps. Wind direction is
|
||
a pack setting (default NE→SW, 225°); there is no authored retail wind
|
||
direction to read, and the register row says so.
|
||
|
||
### Indoor, shadows, physics, picking
|
||
|
||
- Indoors (`!IsOutdoor`) wind is zero — EnvCells have no scenery anyway.
|
||
- Shadow casters apply the identical displacement (shared include).
|
||
- Physics is untouched by construction: the collision BSP is the trunk and
|
||
nothing here touches Runtime or Core physics. The acceptance test asserts
|
||
the Runtime/physics diff is empty.
|
||
- World picking (`WorldPicker`) picks against the undisplaced mesh; a leaf
|
||
may be up to `leanAmp + branchAmp` metres from its pick volume. That is
|
||
accepted and registered (one row, "render-only foliage displacement");
|
||
nobody picks leaves.
|
||
|
||
### Budget and quality scaling
|
||
|
||
- Cost: ~25 ALU per foliage vertex, zero CPU, zero submissions. Target:
|
||
< 0.05 ms GPU at 1080p dense foliage route; measured with the AR matrix
|
||
tooling (`tools/run-atmospheric-performance-matrix.ps1`).
|
||
- Low preset: lean + branch only (no flutter). Medium/High: all three.
|
||
|
||
### Acceptance
|
||
|
||
- Automated: the classification flag is set only for top-nibble-0x8 entities'
|
||
cutout subsets (and lean-only for their opaque subsets); pack-off pipelines never
|
||
read the flag and VM0's masked comparison is unchanged; the shared include
|
||
is byte-identical between receiver and caster variants (test reads both
|
||
SPIR-V inputs' source); weather table interpolates without discontinuity;
|
||
budget row passes.
|
||
- **Owner visual gate**, in this order: (1) Holtburg outskirts, Clear, noon —
|
||
"barely moving, alive"; (2) same place, Rain or Storm (the WeatherKind
|
||
comes from WeatherState's classification of the active day group's DAT
|
||
name — `/time`-cycle day groups or wait for weather) — "clearly windy,
|
||
still not a flag"; (3) watch one tree for 30 s — gusts arrive and leave, neighbours
|
||
out of step; (4) the tree's shadow on the ground moves with it; (5) walk
|
||
indoors — nothing moves; (6) pack off — nothing moves. The owner's words
|
||
decide; the numbers above are starting points to tune live.
|
||
|
||
### VM6 outcome (implementation landed 2026-08-22; code-complete 2026-08-23 at `1f151242`; owner visual gate outstanding)
|
||
|
||
Two commits (VM6a shader ABI v2 plumbing, VM6b the feature) shipped the
|
||
design above; a same-day fix-round commit corrected the weather-table key.
|
||
Settings (`BuiltInAtmosphericRenderPack.Settings()`): `wind-enabled` (bool,
|
||
default on), `wind-strength` (0–2×, default 1.0), `wind-direction-degrees`
|
||
(0–360°, default 225 — no authored retail wind direction exists to read),
|
||
`wind-lean-metres` (default 0.25), `wind-branch-metres` (default 0.15),
|
||
`wind-flutter-metres` (default 0.05, forced to 0 on the Low preset),
|
||
`wind-canopy-height-metres` (default 8). The mean/gust rows live in
|
||
`AtmospherePolicyDeclaration.FoliageWindByWeather`, keyed by NAME
|
||
(`FoliageWindWeatherPoint.WeatherKind`, an exact ordinal match against
|
||
`AcDream.Core.World.WeatherKind`'s member names — `Clear` 0.25/0.15,
|
||
`Overcast` 0.60/0.35, `Rain` 0.85/0.60, `Snow` 0.35/0.20, `Storm` 1.00/0.75),
|
||
not the raw `activeDayGroup` index the original design used: the index
|
||
carries no weather meaning by itself, and `WeatherState.cs` already
|
||
classifies each day group's authored DAT name into one of these five real
|
||
kinds — the same fact `AtmosphericFrameInputs.Weather` /
|
||
`uAtmosphereWeather.x` already threads through the frame, reused here
|
||
instead of re-guessed. `RenderPackAtmospherePolicyEvaluation.FoliageWind`
|
||
matches by `weather.ToString()` and falls back to the declared Clear row for
|
||
an unlisted kind. Classification bits live in
|
||
`FoliageWindClassification` (`AcDream.App.Rendering.Wb`): bit 1 (`0x2`)
|
||
cutout foliage, bit 2 (`0x4`) trunk, computed once per (entity, subset) in
|
||
`WbDrawDispatcher.ClassifyBatches` (world receiver) and
|
||
`AddDirectionalShadowBatches` (caster) from the identical four inputs
|
||
(entity id, `AtmospherePolicyDeclaration.FoliageExclusions` membership,
|
||
subset `TranslucencyKind`, and `ObjectRenderData.HasCutoutSubset` — computed
|
||
once per mesh, not per frame or instance). The shared displacement lives in
|
||
`foliage_wind.glsl`, called once each from `mesh_atmospheric.vert` and all
|
||
four `directional_shadow_world_*` (opaque/cutout, base/multiview) caster
|
||
vertex shaders, reading the ABI v2 `uAtmosphereClockWind`/
|
||
`uAtmosphereWindAmplitude` members `AtmosphericPostProcessGraph.ResolveFoliageWind`
|
||
resolves once per `frame.Serial` (so the caster, which runs first each
|
||
frame, and the receiver read byte-identical values). `FoliageWindModel` is
|
||
the CPU mirror pinned by hermetic tests. Register row IA-25. Owed: the
|
||
six-step owner visual gate above (implementation is otherwise code-complete
|
||
and the automated acceptance criteria pass).
|
||
|
||
**Review fix round (2026-08-22/23):** an Opus dual-lens review of the three
|
||
implementation commits found two blockers, both fixed. (A1) The procedural-
|
||
scenery classifier tested bit 31 alone instead of the full top nibble
|
||
`0xF000_0000 == 0x8000_0000`, which incorrectly matched `LandblockStatic-
|
||
EntityIdAllocator`'s `0xC...` namespace (fences/gates/building shells with a
|
||
cutout subset), the `0xDA11_D0xx` paperdoll id, and the `0xFFFF_FF01` portal-
|
||
tunnel id as procedural scenery — all three would have swayed.
|
||
`ProceduralSceneryIdAllocator.IsInNamespace` now does the exact top-nibble
|
||
test and `FoliageWindClassification.IsProceduralScenery` delegates to it.
|
||
(A2) `GroupKey` (the world-receiver instance-batching key) did not include
|
||
`FoliageFlags` while the caster's dedup key already did, so a scenery
|
||
instance and a non-scenery instance sharing the same mesh subset coalesced
|
||
into one receiver `InstanceGroup` whose flags were whichever entity
|
||
classified it last — the "known limitation" paragraph this outcome section
|
||
used to carry. `GroupKey` now carries `FoliageFlags`, set exactly once at
|
||
group creation from the key, never re-stamped; the two placements now land
|
||
in two distinct groups and the receiver agrees with the caster by
|
||
construction. Two should-fix items also landed: (A3) the world receiver
|
||
pass now explicitly binds `UniformAtmosphericFrame` from the caster's own
|
||
per-frame binding (carried on `DirectionalShadowFrameBinding`) instead of
|
||
relying on Vulkan not resetting the caster pass's leftover binding; (A4) a
|
||
Setup-composed tree's opaque trunk part now gets the trunk flag by OR-ing
|
||
`HasCutoutSubset` across all of the entity's currently-resolved sibling
|
||
parts (`FoliageWindClassification.ComputeEntityHasCutoutSubset`) instead of
|
||
consulting only the trunk part's own (cutout-free) mesh data. Nits A5
|
||
(flutter hash relative to instance origin, not absolute world XY — fp32
|
||
precision at far landblock corners) and A8 (`FoliageWindExclusions` as a
|
||
`FrozenSet`) also landed; A7 is the divergence note on the `max(maxHeight,
|
||
0.5)` guard above.
|
||
|
||
**Review fix round 2 (2026-08-23):** a narrow re-review found one more
|
||
blocker. (F1) The PRODUCTION packed classifier (`WbDrawDispatcher
|
||
.PackedOracle.cs`'s `ClassifyPackedBatches`/`GetOrCreatePackedGroup` — the
|
||
route `RetailPViewPassExecutor.DrawPackedProductionRoute` actually draws
|
||
from) never computed `FoliageFlags` at all: it built its `GroupKey` with the
|
||
field defaulting to `0u` and never copied it onto the created
|
||
`InstanceGroup`, so production `BatchData.flags` bits 1/2 were always zero
|
||
for every scenery entity — the world geometry never swayed even though the
|
||
independently-classified shadow caster did, so shadows visibly swayed under
|
||
rigid trees. Both classifier call sites now compute `FoliageFlags` via the
|
||
identical `FoliageWindClassification.Classify` call and entity-scoped
|
||
`HasCutoutSubset` OR the classic path uses, and `GetOrCreatePackedGroup`
|
||
copies it exactly like `GetOrCreateInstanceGroup` always has. The G2/G3
|
||
classified-output digest (`AddOpaqueSubmissionGroup`/
|
||
`BuildTransparentSubmissionDigest`) now also folds `GroupKey.FoliageFlags`
|
||
into its hash — previously present in the key but never actually read by
|
||
either digest function, so the fold is correct and symmetric between the
|
||
two functions. **Review fix round 3 (N3) correction:** this does NOT mean a
|
||
classic-vs-packed divergence is caught today. `CompareClassifiedOutput` (the
|
||
method that reads this digest) only runs from
|
||
`RenderScenePViewFrameProductController.BuildAndCompare`, which has no
|
||
production caller anywhere in `src/AcDream.App/` — `FrameRootComposition.cs`
|
||
constructs the controller with a real dispatcher, but nothing ever calls
|
||
`BuildAndCompare` on it — and both of `RenderScenePViewFrameProductTests`'s
|
||
own callers construct the controller without the optional `dispatcher`
|
||
argument, so `_dispatcher` is `null` and `CompareClassifiedOutput` short-
|
||
circuits before reaching the digest at all. The fold is correct and ready
|
||
for the day this oracle is wired to a caller; it catches nothing until then.
|
||
(F2, medium) The delayed-alpha replay
|
||
path (`PrepareDeferredAlphaDraws`) hardcoded `Flags = 1`, dropping bits 1/2
|
||
for any group replayed through it — a trunk instance promoted into the
|
||
alpha-blend group mid-fade (the `#188` translucency-promotion case) would
|
||
stop swaying for the duration of its fade; now `1u | key.FoliageFlags`. (F3)
|
||
`ComputeEntityHasCutoutSubset`'s three call sites (classic, caster, and the
|
||
newly-fixed packed classifier) each allocated a closure over `_meshAdapter`
|
||
per Setup entity per frame; a new context-taking overload passes the mesh
|
||
adapter as an explicit argument to a `static` lambda instead, letting the
|
||
compiler cache one delegate for the method's lifetime. A3's test gap also
|
||
closed: `DirectionalShadowGpuTests` now has a companion test proving
|
||
`WbDrawDispatcher.BindDirectionalShadowReceiver` (made `internal` for this)
|
||
actually emits the `UniformAtmosphericFrame` bind with the exact
|
||
buffer/offset/size a `DirectionalShadowFrameBinding` carries, pairing with
|
||
the existing test that proves that binding carries the caster's real bind
|
||
forward untouched. F4 (the `foliage_wind.glsl` header comment) and F5 (a
|
||
comment at the receiver bind site: the caster's own `AtmosphericFrameBuffer-
|
||
Binding` has its seven ABI v1 members zero/Identity by construction — only
|
||
the two v2 wind members are valid — safe today because `mesh_atmospheric
|
||
.vert` reads this binding solely for wind displacement, a footgun for a
|
||
future v1-reading addition to that shader) also landed. F6, noted rather
|
||
than fixed, applies to BOTH of the classifier's caches: the classic route's
|
||
`EntityClassificationCache`'s `EntityCacheEntry`, and the packed
|
||
production route's `PackedProjectionClassificationEntry`/
|
||
`PackedClassifiedBatch.Key` (`PackedProjectionClassificationCache`). Both
|
||
bake `GroupKey.FoliageFlags` (hence exclusion membership) in at
|
||
classification time and neither is proactively invalidated when
|
||
`FoliageWindExclusions` changes — a newly-excluded or newly-included object
|
||
can show its previous classification until its cache entry is next evicted
|
||
rather than immediately on a pack switch. The two caches differ in HOW they
|
||
eventually recover: `EntityCacheEntry` self-heals per entity, on that
|
||
entity's own next eviction (e.g. a landblock demote/reload);
|
||
`PackedProjectionClassificationCache.BeginFrame`
|
||
(`PackedProjectionClassificationCache.cs:133-137`) instead clears its
|
||
ENTIRE cache in one shot whenever `RenderSceneGeneration` changes. Neither
|
||
mechanism is keyed to a pack switch specifically, so both are staleness
|
||
windows of unknown-but-bounded length, not an immediate reclassification.
|
||
Harmless with the pack off (exclusions are pack-scoped); not worth a
|
||
proactive invalidation sweep for a rarely-changing, pack-scoped list.
|
||
|
||
**Review nit A6 (reviewer-filed, landed round 3):** `ResolveFoliageWind`'s
|
||
`_windMean`/`_windGust` started at 0 and ALWAYS eased toward the weather
|
||
target by clock delta, with no distinction for a graph's first-ever
|
||
advance. Two consequences: a pinned clock (`ACDREAM_SKY_PHASE_SECONDS`,
|
||
the offline pixel gate's determinism pin) has delta 0 on every advance
|
||
after the first, so the wind reached only whatever fraction the first
|
||
(clamped-to-1-second) step produced and sat there forever — every offline
|
||
capture under-represented the motion; live, the first 10 s after a graph
|
||
is constructed (pack selection / login) spun up from dead calm even though
|
||
the weather already IS what it is, because there was no previous frame to
|
||
ease from. Fixed at the root: the first advance
|
||
(`_windFrameSerial == -1`, the constructor sentinel) now SNAPS `_windMean`/
|
||
`_windGust` straight to the target; every later advance eases over
|
||
`WeatherSystem.TransitionSeconds` exactly as before. A new
|
||
`SetWindClockSecondsOverrideForTesting` test-only seam
|
||
(`AtmosphericPostProcessGraph`, `_windClockSecondsOverride` no longer
|
||
`readonly`) lets a hermetic test advance the pinned clock by an exact,
|
||
deterministic amount between two resolves — proving both the first-advance
|
||
snap and that a SECOND advance still eases normally — without a real-time
|
||
`Thread.Sleep`.
|
||
|
||
**Review fix round 4 (2026-08-23): wind decoupled from the shadow gate.**
|
||
The reviewer's offline pixel apparatus caught a real design defect the
|
||
first three rounds' CPU-side reasoning could not see: foliage wind was
|
||
welded to "directional shadows rendered this frame." Evidence: at the
|
||
High preset with `sun-shadow-strength=0` and wind-strength 2 + 1 m
|
||
lean/branch amplitude, wind-on vs wind-off at the same pinned clock
|
||
differed by only 49–65 px — inside the apparatus's own 22 px run-to-run
|
||
noise floor, i.e. no measurable motion at all. A CPU probe independently
|
||
confirmed `ResolveFoliageWind` itself was correct (first advance snaps
|
||
exactly to Clear's 0.25/0.15, the gate is 1, one graph instance) — the
|
||
correct uniform was computed but never reached the world pass. Root
|
||
cause: `DirectionalSunShadowRenderer.Render` left `_currentFrameBinding`
|
||
at its pure `Disabled` (no-buffer) default on both early-out paths
|
||
(`!environment.ShouldRender`, `ResidentWindowUnavailable`);
|
||
`WbDrawDispatcher.PipelinesFor`/`TerrainModernRenderer`'s matching
|
||
selection logic only chose the atmospheric receiver pipeline
|
||
(`mesh_atmospheric`, which alone `#include`s `foliage_wind.glsl`) when
|
||
`TryGetCurrentFrameBinding` returned true; with no buffer it always
|
||
returned false, so the world pass silently ran the plain `mesh_modern`
|
||
pipeline instead — which has no wind code at all. Because the shadow
|
||
gate is `ActiveDayGroupMultiplier = dayGroupPolicy × elevationResponse ×
|
||
strength`, this killed wind every NIGHT (elevation response → 0), at
|
||
user `sun-shadow-strength` 0, and under the portal/login cover — not
|
||
just in the artificial `strength=0` repro.
|
||
Fix (decouple, don't patch): `DirectionalShadowFrameBinding` gained
|
||
`IsBindableFor` ("a real current-frame allocation exists") separate from
|
||
`IsValidFor` ("...and it is Enabled with real shadow content" — the
|
||
volumetric pass still gates on this, unchanged);
|
||
`TryGetCurrentFrameBinding` now returns `IsBindableFor`. When the
|
||
built-in pack supplies an `AtmosphericFrame` binding (declared packs
|
||
never do, so they are unaffected), `Render`'s two early-out paths call a
|
||
new `PublishDisabledReceiverBinding`: it allocates one real ring slice
|
||
and writes a DISABLED `DirectionalShadowUniforms` block — every matrix
|
||
Identity, every control/bias term zero, `TextureAndFlags` all zero (bit
|
||
0 clear is exactly what `directional_shadow_receiver.glsl`'s
|
||
`acdreamDirectionalShadowVisibility` already reads as "no shadow, full
|
||
visibility" via its existing early `return 1.0`), and a UNIT light
|
||
direction `(0,0,1)` so a fragment shader's `normalize()` can never
|
||
produce NaN. `BindDirectionalShadowReceiver` and
|
||
`TerrainModernRenderer`'s shadow-buffer bind now check `Buffer is not
|
||
null` instead of `Enabled`, so this disabled block actually gets bound
|
||
once it is selected.
|
||
|
||
**Review fix round 5 (2026-08-23) correction to the paragraph above:** the
|
||
claim "the flag bit makes it numerically the plain lighting sum" was
|
||
false as written — F1 BLOCKER, found by the reviewer's own offline pixel
|
||
apparatus reading the shader source, not by a repro capture (the disabled
|
||
block's `(0,0,1)` direction differs too little from straight-overhead to
|
||
show up above the apparatus's noise floor at these amplitudes, so the
|
||
mislighting was invisible to that specific test even though it is real).
|
||
Both receiver VERTEX shaders (`mesh_atmospheric.vert`,
|
||
`terrain_atmospheric.vert`) sourced the sun direction used to compute
|
||
`directionalLit`/`vDirectionalLit` from the shadow block's own
|
||
`uShadowLightDirectionAndSource`, unconditionally — not only the
|
||
*visibility* term, which was already correctly flag-gated. Every shadow-
|
||
gated-off frame was lighting outdoor terrain and objects from the
|
||
disabled block's `(0,0,1)` placeholder — straight overhead — regardless
|
||
of the authored sun's actual position. The celestial shadow source
|
||
direction is not the authored light direction either, so publishing it
|
||
instead would not have restored parity. Fixed in the shaders themselves:
|
||
both receiver verts now branch on the SAME flag bit
|
||
(`(uShadowTextureAndFlags.w & 1u) == 0u`) and, when clear, use the EXACT
|
||
plain-pipeline expression (`-uLights[i].dirAndRange.xyz` in
|
||
`mesh_atmospheric.vert`, matching `mesh_modern.vert`;
|
||
`-uLights[0].dirAndRange.xyz` in `terrain_atmospheric.vert`, matching
|
||
`terrain_modern.vert`) instead of the shadow block's direction. Corrected
|
||
statement: with the flag bit clear the receiver shaders take visibility
|
||
1.0 AND fall back to the authored `uLights` direction (round 5), so a
|
||
gated-off frame matches the plain pipeline to within float summation-
|
||
order rounding (review fix round 6, N2: "numerically the plain pipeline"
|
||
overstated it — the direction expression is bit-for-bit, but the SUM is
|
||
not, because the atmospheric fragment's split ambient+point vs
|
||
directional accumulation reassociates the terms relative to the plain
|
||
pipeline's single varying, and terrain's two separate varyings vs the
|
||
plain pipeline's one do the same; measured mean |Δ| 0.007 on the offline
|
||
scene, well under the apparatus's 65 px floor). Wind is unaffected —
|
||
`foliage_wind.glsl` reads only the AtmosphericFrame half of set 3, never
|
||
the shadow block.
|
||
|
||
**Pixel proof after rounds 4–5 (2026-08-23, `eec95535` → `754d59d9`):** the same
|
||
apparatus that found the defect — High preset, `sun-shadow-strength=0` so
|
||
no shadow term can move, same pinned clock, wind on vs off, two captures per
|
||
arm so the repeat pairs measure the run-to-run floor — gives a robust wind
|
||
mask of **13,854 px** on `eec95535` and **14,993 px** on `754d59d9`
|
||
(amplified: strength 2, lean/branch 1 m, flutter 0.5 m) against a 19–65 px
|
||
floor, and **1,402 px** at the default Clear strength; the wind-off frame on
|
||
`754d59d9` matches the pre-round-4 plain pipeline at mean |Δ| 0.007 (round
|
||
4's `eec95535` was 1.77 — the overhead-sun regression); every marked pixel is a treeline tree, hillside tree or shoreline
|
||
bush, and no house, fence, road, lifestone, ground, water or UI pixel moves.
|
||
The first method tried (clock pin 0 s vs 3 s, wind-off pair as the control)
|
||
is recorded as confounded in the note and must not be reused: the treeline
|
||
silhouette carries a bimodal 0-or-~280 px rasterisation churn between runs
|
||
that lands on either side of the subtraction by luck. Tool:
|
||
`tools/vm6/wind-pixel-proof.py`; note:
|
||
`docs/research/2026-08-23-vm6-foliage-wind-pixel-proof.md`.
|
||
|
||
## VM7 — Closeout and merge
|
||
|
||
- Full gates: `tools/run-release-gate.ps1` (hermetic lanes), the AR
|
||
reference matrix re-run for the changed presets, VM0's masked comparison
|
||
on the final binary, the connected lifecycle/reconnect route.
|
||
- Register: rows for the wind direction constant, the render-only
|
||
displacement vs picking, and whatever VM2 decides; TS-52 stays retired.
|
||
- Roadmap: move Campaign VM to shipped; AR's "final owner gate" rows that
|
||
VM0/VM3/VM6 satisfy are marked so in the AR plan.
|
||
- Memory: a `project_visualmaster_campaign.md` digest entry (the wind rule,
|
||
the linear-light decision, the VM0 oracle recipe).
|
||
- Owner: final pack-off / pack-on visual matrix, then merge to main.
|
||
|
||
## What this campaign does NOT do
|
||
|
||
- It does not touch the retail-faithful default path except through VM0's
|
||
evidence and VM1's removal of an un-anchored constant.
|
||
- It does not add normal maps, PBR, SSAO, water, or a second weather system.
|
||
- It does not move trunks' collision, walkability, or anything in Runtime.
|
||
- It does not animate landblock statics, weenies, grass decals that are not
|
||
scenery, or creatures.
|
||
- It does not rebuild the shadow transform buffer (filed, VM5).
|
||
- It does not re-port #226 to the single-pass path unless VM2 proves retail
|
||
ran it.
|
||
|
||
## Proposed owner goal
|
||
|
||
> **Campaign VM — VisualMaster.** Drive
|
||
> `docs/plans/2026-08-22-visualmaster-campaign.md` to shipped on branch
|
||
> `claude/git-sync-status-5fb1d2`: VM0 proves the pack-off path is
|
||
> pixel-identical to `6c79d35c` outside the terrain/detail masks with no
|
||
> production perf or allocation regression; VM1 removes the un-anchored
|
||
> detail fade; VM3 puts the post stack in linear light with a numerically
|
||
> neutral preset; VM4 makes every document truthful; VM5 de-bands the
|
||
> volumetrics; VM6 adds weather-driven foliage wind (scenery-only, cutout
|
||
> subsets, three motions, shadows follow) behind the render pack. Fable plans
|
||
> and verifies, Sonnet implements, Opus dual-lens reviews every slice. Stop
|
||
> me only for: the retail cdb read (VM2), the three visual gates (VM3, VM6,
|
||
> VM7), and the merge. Every closeout claim must name its command, lane and
|
||
> artifact.
|