refactor(render): one group-creation seam, required foliage key field, first-frame wind snap (Campaign VM VM6 review 3)

Narrow re-review of a82959f1: APPROVE, with follow-ups. All items landed.

N2 (structural): (a) extracted the ONE shared InstanceGroup-from-key
construction seam, WbDrawDispatcher.CreateGroupFromKey(key, registration,
frame) — before this there were two near-identical `new InstanceGroup
{ ... }` initializers (GetOrCreateInstanceGroup and GetOrCreatePackedGroup)
that had already drifted once (the round-2 F1 bug). Both routes call it now;
CreateGroupFromKey's own `new()` is the only production InstanceGroup
construction site repo-wide, same precedent as AppendPackedInstance. (b)
GroupKey.FoliageFlags lost its `= 0u` default and moved before CullMode in
the declaration (CullMode keeps its default, C# requires optional params to
trail required ones), so a `new GroupKey(...)` that omits it is a compile
error. Fixed every real construction site the reorder/requirement touched:
the 2 production sites, ToKey (a reconstruction from InstanceGroup the
review didn't count but the reorder broke), and 5 test sites (one more than
the review's "4" — InstanceGroupClearTests had a second, implicit
target-typed `MakeKey` factory the original count missed). Verified by a
full solution build.

N1: added CreateGroupFromKey_CopiesFoliageFlagsFromTheKey
(InstanceGroupClearTests) — a key carrying FoliageFlags 0x2 in, the created
group's FoliageFlags 0x2 out. That test plus N2b's required field are what
actually guard the round-2 F1 blocker; reworded PackedDispatcherOracleTests'
existing test comment to say what IT proves (the classification-to-
BuildIndirectArrays-to-BatchData.flags path), not that it guards the
classifier.

N3: corrected the plan's round-2 paragraph — folding FoliageFlags into the
G2/G3 digest is correct and symmetric, but CompareClassifiedOutput only
runs from RenderScenePViewFrameProductController.BuildAndCompare, which has
no production caller anywhere in src/AcDream.App/, and both of
RenderScenePViewFrameProductTests's own callers construct the controller
without the optional dispatcher argument — so the fold catches nothing
until that oracle is wired to an actual caller.

N4: the plan's F6 note now names both classification caches — the classic
route's EntityClassificationCache.EntityCacheEntry (self-heals per entity
on its own next eviction) and the packed route's
PackedProjectionClassificationEntry/PackedClassifiedBatch.Key
(PackedProjectionClassificationCache.BeginFrame clears its entire cache in
one shot on a RenderSceneGeneration change) — and notes neither mechanism
is keyed to a pack switch specifically.

N5: deleted the now-unused single-generic ComputeEntityHasCutoutSubset<T>
overload; its 4 test call sites now use the two-generic, zero-alloc
overload with an unused int context and a static (_, value) => value
lambda, so there is exactly one ComputeEntityHasCutoutSubset to keep
correct.

A6 (reviewer-filed): 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. A pinned clock
(ACDREAM_SKY_PHASE_SECONDS, the offline pixel gate's determinism pin) never
advances between calls, so the wind reached only whatever fraction the
first (1-second-clamped) step produced and sat there forever; 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. 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. Added a
SetWindClockSecondsOverrideForTesting seam (_windClockSecondsOverride is no
longer readonly) so a hermetic test can advance the pinned clock by an
exact amount between two resolves without a real-time Thread.Sleep; two new
tests prove the first-advance snap is exact and a second advance still
eases at the normal rate. The three existing indoor/wind-disabled/amplitude
gate tests pass unchanged.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,046/0 failed. Core.Tests 4,695/0 failed. Full hermetic-filtered solution:
15,274/0 failed across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-23 03:03:56 +02:00
parent a82959f1b7
commit fccba8390d
12 changed files with 347 additions and 141 deletions

View file

@ -580,8 +580,19 @@ 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 a content-level (not just count-level) classic-
vs-packed divergence is now caught. (F2, medium) The delayed-alpha replay
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
@ -602,15 +613,45 @@ 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: `EntityClassificationCache`'s `EntityCacheEntry` bakes
`GroupKey.FoliageFlags` (hence exclusion membership) in at classification
time and is not proactively invalidated when `FoliageWindExclusions`
changes — a newly-excluded or newly-included object can show its previous
classification until the entity's cache entry is next evicted (e.g. a
landblock demote/reload) rather than immediately on a pack switch. Harmless
with the pack off (exclusions are pack-scoped) and self-heals on the next
natural cache eviction; not worth a proactive invalidation sweep for a
rarely-changing, pack-scoped list.
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`.
## VM7 — Closeout and merge