Round 4 (eec95535) fixed wind but introduced a new lighting bug: the
receiver VERTEX shaders source the sun direction from the shadow block,
not only the shadow visibility term. mesh_atmospheric.vert's
accumulateLights read uShadowLightDirectionAndSource unconditionally for
every directional light; terrain_atmospheric.vert did the same for its
single sun term. Round 4's PublishDisabledReceiverBinding writes
direction (0,0,1) into that block on every shadow-gated-off frame (user
sun-shadow-strength 0 in daylight, indoor/portal cover, night), so every
such frame was lighting outdoor terrain and objects from straight
overhead instead of the authored sun. Publishing the environment's real
direction would not have restored parity either -- the celestial shadow
source direction (sun/moon disc) is not the authored light direction.
F1 (BLOCKER): fixed in the shaders themselves, exact parity with the
plain pipeline. Both receiver verts now branch on the same flag bit
acdreamDirectionalShadowVisibility already reads
((uShadowTextureAndFlags.w & 1u) == 0u) and, when clear, use the EXACT
plain-pipeline expression instead of the shadow block's direction:
-uLights[i].dirAndRange.xyz in mesh_atmospheric.vert (matching
mesh_modern.vert, hoisted out of the light loop as a uniform branch);
-uLights[0].dirAndRange.xyz in terrain_atmospheric.vert (matching
terrain_modern.vert's sunDir/-sunDir form). The (0,0,1) word in the
disabled block stays as the documented normalize()-cannot-NaN guard; its
comment now says so explicitly since it is no longer read as a light
direction when the flag is clear.
F2: RenderPrepared's cascadeCount == 0 return is a third bufferless-
disabled path reachable from a frame that already passed Render's own
two gates (the cascade fitter can still find zero usable cascades) --
publishes the same disabled binding now, via the same
PublishDisabledReceiverBinding helper (re-signatured to take a bare
AtmosphericFrameBufferBinding so all three call sites -- Render's two
early-outs plus this one -- share it).
F3: removed a stray duplicated " -- Closeout and merge" fragment under
the plan's VM7 heading.
F4: corrected the false "the flag bit makes it numerically the plain
lighting sum" claim in the plan's round-4 paragraph and in
WbDrawDispatcher.DirectionalShadowReceivers.cs -- the flag bit alone
only fixed the shadow VISIBILITY term (already correct before round 4);
it took both that AND round 5's light-DIRECTION fallback to actually
match the plain pipeline.
T1: extracted Render's gate prologue (environment evaluate -> two
early-outs -> PublishDisabledReceiverBinding) into internal
EvaluateGateAndPublishDisabledBinding(frame, in input, out environment,
out environmentGateTicks), behaviour-preserving, called by Render before
it touches world/terrain -- the ArgumentNullException.ThrowIfNull(world)/
ThrowIfNull(terrain) calls keep their exact position relative to the
gate. No test in this suite constructs a real WbDrawDispatcher +
TerrainModernRenderer pair (still true), so this extraction is what
makes the gate itself testable; two new tests drive it directly with
PlayerInsideCell: true and with ResidentMaximumReachMeters <=
CameraNearMeters, asserting TryGetCurrentFrameBinding true / IsValidFor
false for both.
T2: proves the actual composition WbDrawDispatcher.PipelinesFor and
TerrainModernRenderer both use -- TryGetCurrentFrameBinding feeding
ShouldSelectReceiverPipeline -- selects the receiver pipeline for the
atmospheric world pass once a disabled binding is published, and still
refuses a non-atmospheric pass name.
T3: shader-source guard (same style as AtmosphericPostProcessGraphTests'
existing shader-text tests) pinning that both receiver verts contain the
flag-gated fallback and reference the same uLights expression the plain
verts use, so a future edit that drops the fallback fails this test
instead of only showing up in a pixel capture.
T4: the (0,0,1) test's doc comment and an inline assertion comment now
say the value is a NaN guard, not a light direction.
Regenerated SPIR-V: mesh_atmospheric.vert and terrain_atmospheric.vert
recompiled to different bytes this time (a real code change, not a
comment); manifest updated to match.
Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,054/0 failed. Core.Tests 4,695/0 failed. RenderPackValidator 30/30.
Full hermetic-filtered solution: 15,282/0 failed across 15 projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
782 lines
47 KiB
Markdown
782 lines
47 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 | **CODE-COMPLETE 2026-08-22** — `87677f9c`, `51178f7c`, closeout; Opus APPROVE; **owner visual gate OWED** (brief below) |
|
||
| 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`; four Opus review rounds (round 3 APPROVE, round 4 narrow re-review of `fccba839`+`eec95535`); pixel proof [note](../research/2026-08-23-vm6-foliage-wind-pixel-proof.md) (13,854 robust wind px vs 19 floor, scenery only); **owner visual gate OWED** (§VM6 Acceptance) |
|
||
| VM7 | Closeout: full gates, register, roadmap, merge | **owner** |
|
||
|
||
Order is VM2 (done) → VM0 (done) → VM1 (done) → VM4 (done) → VM5 (done) → VM3 (code-complete, gate owed) → VM6 (code-complete, gate owed) → VM7. 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 `eec95535`; 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 is numerically the plain pipeline. Wind is unaffected —
|
||
`foliage_wind.glsl` reads only the AtmosphericFrame half of set 3, never
|
||
the shadow block.
|
||
|
||
**Pixel proof after round 4 (2026-08-23, `eec95535`):** 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** (amplified: strength 2, lean/branch 1 m, flutter
|
||
0.5 m) against a **19 px** floor, and **1,402 px** at the default Clear
|
||
strength; 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.
|