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

@ -1222,6 +1222,67 @@ public sealed class AtmosphericPostProcessGraphTests
// ── Campaign VM VM6: foliage wind ────────────────────────────────────
[Fact]
public void FirstAdvanceSnapsExactlyToTheWeatherTargetInsteadOfEasingFromZero()
{
// Campaign VM VM6 review fix round 3 (A6): before this fix, the
// very first advance treated the constructor-default _windMean/
// _windGust (0) as the "previous" strength and eased toward the
// target by a deltaSeconds-based step clamped to 1 second. Under a
// pinned clock (ACDREAM_SKY_PHASE_SECONDS, the determinism pin the
// offline pixel gate uses) that first step is the ONLY one that
// ever happens — deltaSeconds is 0 on every later call because the
// pinned clock never advances — so the wind would sit at whatever
// fraction that one step reached (at most 10% of the target, at
// the 10 s TransitionSeconds rate) forever. The fix snaps exactly
// to the target on the very first advance instead.
var device = new RecordingGpuDevice();
using var graph = Graph(device, "medium", windClockSecondsOverride: 12f);
graph.PrepareWorldTarget(640, 480, 1);
AtmosphericFrameInputs outdoors = Inputs(640, 480) with { Weather = WeatherKind.Storm };
AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, outdoors);
// Storm: mean 1.00, gust 0.75 (FoliageWindByWeather's built-in
// row), times the declared default wind-strength (1.0x) — exact,
// not a fraction of the way there.
Assert.Equal(1.00f, atmospheric.ClockWind.Y, 5);
Assert.Equal(0.75f, atmospheric.ClockWind.Z, 5);
}
[Fact]
public void SecondAdvanceStillEasesTowardTheNewTargetFromTheFirstAdvancesSnappedValue()
{
// Campaign VM VM6 review fix round 3 (A6): the snap-on-first-
// advance fix must not turn EVERY advance into a snap — only the
// very first one. Resolve once at Clear (snaps exactly to Clear's
// target), advance the pinned clock by exactly 1 real second
// (SetWindClockSecondsOverrideForTesting gives deterministic
// control no Thread.Sleep could), then resolve again at Storm on a
// fresh frame.Serial: this is the graph's SECOND advance, so it
// eases at rate = deltaSeconds / TransitionSeconds = 1 / 10 = 10%
// of the way from Clear's snapped value toward Storm's target,
// exactly like every advance did before this fix.
var device = new RecordingGpuDevice();
using var graph = Graph(device, "medium", windClockSecondsOverride: 0f);
graph.PrepareWorldTarget(640, 480, 1);
AtmosphericFrameInputs clearInputs = Inputs(640, 480) with { Weather = WeatherKind.Clear };
AtmosphericFrameUniforms first = RenderAndReadFrameBlock(device, graph, clearInputs);
Assert.Equal(0.25f, first.ClockWind.Y, 5); // Clear mean, snapped exactly
Assert.Equal(0.15f, first.ClockWind.Z, 5); // Clear gust, snapped exactly
graph.SetWindClockSecondsOverrideForTesting(1f);
AtmosphericFrameInputs stormInputs = Inputs(640, 480) with { Weather = WeatherKind.Storm };
AtmosphericFrameUniforms second = RenderAndReadFrameBlock(device, graph, stormInputs);
const float rate = 1f / 10f; // deltaSeconds(1) / TransitionSeconds(10)
float expectedMean = 0.25f + ((1.00f - 0.25f) * rate);
float expectedGust = 0.15f + ((0.75f - 0.15f) * rate);
Assert.Equal(expectedMean, second.ClockWind.Y, 5);
Assert.Equal(expectedGust, second.ClockWind.Z, 5);
}
[Fact]
public void IndoorGatesWindOutputToExactZeroRegardlessOfSmoothedState()
{

View file

@ -189,7 +189,8 @@ public sealed class FoliageWindClassificationTests
{
bool result = FoliageWindClassification.ComputeEntityHasCutoutSubset(
partHasCutout,
static value => value);
context: 0,
static (_, value) => value);
Assert.Equal(expected, result);
}
@ -200,7 +201,8 @@ public sealed class FoliageWindClassificationTests
Assert.False(
FoliageWindClassification.ComputeEntityHasCutoutSubset(
Array.Empty<bool>(),
static value => value));
context: 0,
static (_, value) => value));
}
/// <summary>
@ -224,10 +226,12 @@ public sealed class FoliageWindClassificationTests
bool partAOwnHasCutoutSubset = FoliageWindClassification.ComputeEntityHasCutoutSubset(
partAHasCutoutSubset,
static value => value);
context: 0,
static (_, value) => value);
bool entityScopedHasCutoutSubset = FoliageWindClassification.ComputeEntityHasCutoutSubset(
setupPartsHasCutoutSubset,
static value => value);
context: 0,
static (_, value) => value);
uint flagsFromPartOwnValue = FoliageWindClassification.Classify(
sceneryEntityId,

View file

@ -218,7 +218,8 @@ public class InstanceGroupClearTests
IndexCount: 6,
TextureSlot: Slot(0xAA),
TextureLayer: 0,
Translucency: TranslucencyKind.Opaque);
Translucency: TranslucencyKind.Opaque,
FoliageFlags: 0u);
var cached = new CachedBatch(
key,
Slot(0xAA),
@ -235,6 +236,49 @@ public class InstanceGroupClearTests
out _));
}
/// <summary>
/// Campaign VM VM6 review fix round 3 (N1): this is the guard for the
/// round-2 F1 blocker (the packed classifier's InstanceGroup carried
/// FoliageFlags == 0 no matter what GroupKey.FoliageFlags said, because
/// GetOrCreatePackedGroup's own initializer never copied it). N2a
/// deleted that possibility structurally — CreateGroupFromKey is the
/// ONLY InstanceGroup-from-key construction site left in production,
/// used by BOTH the classic and packed routes — and N2b made
/// GroupKey.FoliageFlags a required constructor argument, so a future
/// caller cannot silently default it to 0 either. Together those two
/// changes are what actually guards F1; this test pins
/// CreateGroupFromKey's own copy so a regression there fails here
/// directly instead of only showing up as a visual "trees don't sway"
/// report.
/// </summary>
[Fact]
public void CreateGroupFromKey_CopiesFoliageFlagsFromTheKey()
{
var key = new GroupKey(
FirstIndex: 10,
BaseVertex: 2,
IndexCount: 18,
TextureSlot: Slot(0x77),
TextureLayer: 0,
Translucency: TranslucencyKind.ClipMap,
FoliageFlags: 0x2u,
CullMode: DatReaderWriter.Enums.CullMode.CounterClockwise);
WbDrawDispatcher.InstanceGroup group =
WbDrawDispatcher.CreateGroupFromKey(key, registration: 5, frame: 9);
Assert.Equal(0x2u, group.FoliageFlags);
Assert.Equal(5, group.Registration);
Assert.Equal(9, group.LastUsedFrame);
Assert.Equal(key.FirstIndex, group.FirstIndex);
Assert.Equal(key.BaseVertex, group.BaseVertex);
Assert.Equal(key.IndexCount, group.IndexCount);
Assert.Equal(key.TextureSlot, group.TextureSlot);
Assert.Equal(key.TextureLayer, group.TextureLayer);
Assert.Equal(key.Translucency, group.Translucency);
Assert.Equal(key.CullMode, group.CullMode);
}
[Fact]
public void FramePrune_RetiresOnlyGroupsAbsentForWholePreviousFrame()
{
@ -314,14 +358,14 @@ public class InstanceGroupClearTests
}
private static AcDream.App.Rendering.Gpu.GpuTextureSlot Slot(uint index) => new(index);
private static GroupKey MakeKey(uint textureSlot) => new(
FirstIndex: 0,
BaseVertex: 0,
IndexCount: 6,
TextureSlot: Slot(textureSlot),
TextureLayer: 0,
Translucency: TranslucencyKind.Opaque);
Translucency: TranslucencyKind.Opaque,
FoliageFlags: 0u);
private static WbDrawDispatcher.InstanceGroup MakeGroup(
TranslucencyKind translucency,

View file

@ -8,27 +8,23 @@ namespace AcDream.App.Tests.Rendering.Wb;
public sealed class PackedDispatcherOracleTests
{
/// <summary>
/// Campaign VM VM6 review fix round 2 (F1 BLOCKER): the production
/// packed classifier (WbDrawDispatcher.PackedOracle.cs's
/// ClassifyPackedBatches/GetOrCreatePackedGroup) is a private instance
/// pipeline reached only through the full RetailPViewPassExecutor →
/// DrawPackedProductionRoute route, which needs a real IGpuDevice,
/// world-pass scope, mesh manager, and compiled pipelines to construct —
/// no test in this suite (or anywhere in the App test project) stands
/// one up, so driving ClassifyPackedBatches/GetOrCreatePackedGroup
/// directly is not a "cheap test." This proves the two halves of the
/// fix that ARE cheaply testable and, chained together, prove the exact
/// claim the review asked for: (1) FoliageWindClassification.Classify —
/// called with the SAME argument shape ClassifyPackedBatches now uses
/// Campaign VM VM6 review fix round 3 (N1): this test does NOT guard
/// the packed classifier itself — CreateGroupFromKey's own test
/// (InstanceGroupClearTests.CreateGroupFromKey_CopiesFoliageFlagsFromTheKey)
/// plus GroupKey.FoliageFlags being a required constructor argument are
/// what actually make F1 (the round-2 blocker: the packed classifier's
/// InstanceGroup silently carried FoliageFlags == 0) impossible to
/// reintroduce. What THIS test proves is the SHARED production step
/// downstream of classification: FoliageWindClassification.Classify,
/// called with the same argument shape ClassifyPackedBatches uses
/// (entity.LocalEntityId, exclusion membership, batch.Translucency,
/// entity-scoped HasCutoutSubset) — resolves a real procedural-scenery
/// entity id (0x8…) to CutoutFoliageFlag (0x2); (2) that flags value,
/// carried on an IndirectGroupInput exactly like GetOrCreatePackedGroup
/// now carries it on InstanceGroup.FoliageFlags, reaches the literal
/// BatchData.flags word through BuildIndirectArrays — the same shared,
/// already-tested production step both the classic and packed group
/// lists feed into (WbDrawDispatcherIndirectBuilderTests pins bit 0;
/// this pins bits 1/2 landing alongside it for a real scenery id).
/// entity-scoped HasCutoutSubset), resolves a real procedural-scenery
/// entity id (0x8…) to CutoutFoliageFlag (0x2); and a group carrying
/// that flags value reaches the literal BatchData.flags word through
/// BuildIndirectArrays — the same already-tested production step both
/// the classic and packed group lists feed into
/// (WbDrawDispatcherIndirectBuilderTests pins bit 0; this pins bits 1/2
/// landing alongside it for a real scenery id).
/// </summary>
[Fact]
public void SceneryCutoutEntityClassificationReachesTheBatchDataFlagsWordAsBit0x2()

View file

@ -336,7 +336,8 @@ public class EntityClassificationCacheTests
IndexCount: indexCount,
TextureSlot: new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot),
TextureLayer: 0,
Translucency: TranslucencyKind.Opaque);
Translucency: TranslucencyKind.Opaque,
FoliageFlags: 0u);
return new CachedBatch(key, new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot), Matrix4x4.Identity);
}
}

View file

@ -426,7 +426,8 @@ public sealed class WbDrawDispatcherBucketingTests
IndexCount: indexCount,
TextureSlot: new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot),
TextureLayer: 0,
Translucency: TranslucencyKind.Opaque);
Translucency: TranslucencyKind.Opaque,
FoliageFlags: 0u);
return new CachedBatch(
key,
new AcDream.App.Rendering.Gpu.GpuTextureSlot(texSlot),