Opus dual-lens review of the three VM6 commits (0930c35d,39e8408c,6cc5e183) found two blockers and two should-fix issues; all landed here along with the review's nits and documentation corrections. Blockers: - A1: the procedural-scenery classifier tested bit 31 alone instead of the full top nibble (0xF000_0000 == 0x8000_0000), so it also matched LandblockStaticEntityIdAllocator'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; FoliageWindClassification delegates to it. - A2: GroupKey (the receiver's instance-batching key) did not carry FoliageFlags while the caster's dedup key already did, so a scenery instance and a non-scenery instance sharing a mesh subset coalesced into one receiver InstanceGroup whose flags were last-writer-wins — disagreeing with the correctly-keyed caster. GroupKey now carries FoliageFlags, computed before key construction and set exactly once at group creation; the imperative re-stamp is gone, and CachedBatch's now-redundant FoliageFlags field is removed. Should-fix: - A3: the world receiver pass bound UniformAtmosphericFrame only by accident (leftover from the caster pass, which runs first each frame, since Vulkan binding state isn't reset between passes). DirectionalShadowFrameBinding now carries the caster's exact AtmosphericFrameBufferBinding and BindDirectionalShadowReceiver binds it explicitly. - A4: a Setup-composed tree's opaque trunk part never got the trunk flag because HasCutoutSubset is cached per GfxObj part, not per entity. FoliageWindClassification.ComputeEntityHasCutoutSubset now ORs HasCutoutSubset across an entity's resolved sibling parts once per entity, threaded into ClassifyBatches/AddDirectionalShadowBatches via a new optional override parameter. Nits: A5 hashes the per-vertex flutter seed relative to the instance origin instead of absolute world XY (fp32 sin() precision loss at far landblock corners), mirrored in both foliage_wind.glsl and FoliageWindModel; A7 documents the max(maxHeight, 0.5) divide-guard as a deliberate pseudocode divergence; A8 switches FoliageWindExclusions' construction to ToFrozenSet() and softens the "never stale" doc comment to "no slower than one frame behind." Tests added: top-nibble classification (0xFFFFFFFFu now correctly false), GroupKey inequality across entity-driven scenery/landblock- static classification, a caster-batch test proving the same pairing never coalesces, ComputeEntityHasCutoutSubset unit + end-to-end two-part-Setup tests, the caster→receiver AtmosphericFrame binding carry-through, flutter-hash translation invariance relative to instance origin, and a Storm-wind mid-height displacement floor guarding against a "no motion" regression. Docs: plan VM6 body corrected to the five-row WeatherKind table, "bits 1 and 2", "all four" caster shaders, and top-nibble wording throughout; the owner gate checklist's Rain/Storm step; the stale v1-only shader- interface compatibility entry; semantic-bindings-v1.md's v2 members folded into the main 192-byte block; the IA-25 register row's top- nibble wording; AtmosphericFrameInputs.cs's ABI size reference. foliage_wind.glsl's A5 change recompiled exactly the five shaders that include it (mesh_atmospheric.vert, the four directional_shadow_world_* casters) plus the manifest; no other .spv changed. Verify: Release build 0 warnings/0 errors. App hermetic-lane filter 6,041/0 failed (no environment-specific failures this run). RenderPackValidator 30/30. Full hermetic-filtered solution: 15,269/0 failed across 15 projects. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
246 lines
11 KiB
C#
246 lines
11 KiB
C#
using AcDream.App.Rendering.Wb;
|
|
using AcDream.Core.Meshing;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Wb;
|
|
|
|
/// <summary>
|
|
/// Campaign VM VM6: <see cref="FoliageWindClassification"/> — the pure
|
|
/// function <c>WbDrawDispatcher.ClassifyBatches</c> (the world receiver) and
|
|
/// <c>WbDrawDispatcher.AddDirectionalShadowBatches</c> (the directional-
|
|
/// shadow caster) both call with the same four inputs (entity id, exclusion
|
|
/// check, subset translucency, mesh-level HasCutoutSubset), which is what
|
|
/// makes the two agree by construction — see foliage_wind.glsl.
|
|
/// </summary>
|
|
public sealed class FoliageWindClassificationTests
|
|
{
|
|
private const uint ProceduralSceneryEntityId = 0x80010203u; // bit 31 set
|
|
private const uint OrdinaryEntityId = 0x00010203u; // bit 31 clear
|
|
|
|
[Fact]
|
|
public void ProceduralSceneryCutoutSubsetGetsTheCutoutFlag()
|
|
{
|
|
uint flags = FoliageWindClassification.Classify(
|
|
ProceduralSceneryEntityId,
|
|
isExcluded: false,
|
|
TranslucencyKind.ClipMap,
|
|
meshHasCutoutSubset: true);
|
|
|
|
Assert.Equal(FoliageWindClassification.CutoutFoliageFlag, flags);
|
|
}
|
|
|
|
[Fact]
|
|
public void ProceduralSceneryOpaqueSubsetGetsTheTrunkFlagOnlyWhenTheMeshOwnsACutoutSubset()
|
|
{
|
|
uint withCutoutSibling = FoliageWindClassification.Classify(
|
|
ProceduralSceneryEntityId,
|
|
isExcluded: false,
|
|
TranslucencyKind.Opaque,
|
|
meshHasCutoutSubset: true);
|
|
uint withoutCutoutSibling = FoliageWindClassification.Classify(
|
|
ProceduralSceneryEntityId,
|
|
isExcluded: false,
|
|
TranslucencyKind.Opaque,
|
|
meshHasCutoutSubset: false);
|
|
|
|
Assert.Equal(FoliageWindClassification.TrunkFlag, withCutoutSibling);
|
|
Assert.Equal(0u, withoutCutoutSibling); // a rock, not a tree trunk
|
|
}
|
|
|
|
[Fact]
|
|
public void NonProceduralSceneryEntityGetsNeitherBitRegardlessOfMaterial()
|
|
{
|
|
uint cutout = FoliageWindClassification.Classify(
|
|
OrdinaryEntityId,
|
|
isExcluded: false,
|
|
TranslucencyKind.ClipMap,
|
|
meshHasCutoutSubset: true);
|
|
uint opaqueWithCutoutSibling = FoliageWindClassification.Classify(
|
|
OrdinaryEntityId,
|
|
isExcluded: false,
|
|
TranslucencyKind.Opaque,
|
|
meshHasCutoutSubset: true);
|
|
|
|
Assert.Equal(0u, cutout);
|
|
Assert.Equal(0u, opaqueWithCutoutSibling);
|
|
}
|
|
|
|
[Fact]
|
|
public void AnExcludedObjectIdGetsNeitherBitEvenWhenOtherwiseQualifying()
|
|
{
|
|
uint cutout = FoliageWindClassification.Classify(
|
|
ProceduralSceneryEntityId,
|
|
isExcluded: true,
|
|
TranslucencyKind.ClipMap,
|
|
meshHasCutoutSubset: true);
|
|
uint trunk = FoliageWindClassification.Classify(
|
|
ProceduralSceneryEntityId,
|
|
isExcluded: true,
|
|
TranslucencyKind.Opaque,
|
|
meshHasCutoutSubset: true);
|
|
|
|
Assert.Equal(0u, cutout);
|
|
Assert.Equal(0u, trunk);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(TranslucencyKind.AlphaBlend)]
|
|
[InlineData(TranslucencyKind.Additive)]
|
|
[InlineData(TranslucencyKind.InvAlpha)]
|
|
public void OtherMaterialKindsOnProceduralSceneryGetNeitherBit(TranslucencyKind translucency)
|
|
{
|
|
uint flags = FoliageWindClassification.Classify(
|
|
ProceduralSceneryEntityId,
|
|
isExcluded: false,
|
|
translucency,
|
|
meshHasCutoutSubset: true);
|
|
|
|
Assert.Equal(0u, flags);
|
|
}
|
|
|
|
// Review fix round: the classifier tests the full top-nibble 0x8000_0000,
|
|
// NOT bit 31 alone — bit 31 is also set by LandblockStaticEntityIdAllocator's
|
|
// 0xC... namespace (top nibble 1100) and by the synthetic render ids
|
|
// 0xDA11_D0xx (paperdoll) and 0xFFFF_FF01 (portal tunnel). The prior
|
|
// [InlineData(0xFFFFFFFFu, true)] case pinned exactly this bug (0xFFFFFFFFu
|
|
// has bit 31 set but is NOT a procedural-scenery id under the top-nibble
|
|
// rule) — it is corrected to false below.
|
|
[Theory]
|
|
[InlineData(0x80000000u, true)] // top nibble 0x8 exactly
|
|
[InlineData(0x8FFFFFFFu, true)] // top nibble 0x8, every other bit set
|
|
[InlineData(0x7FFFFFFFu, false)] // top nibble 0x7 — bit 31 clear
|
|
[InlineData(0x00000000u, false)]
|
|
[InlineData(0xFFFFFFFFu, false)] // top nibble 0xF — bit 31 set, NOT procedural scenery
|
|
[InlineData(0xC0010203u, false)] // LandblockStaticEntityIdAllocator (top nibble 0xC) — fence/gate/building shell
|
|
[InlineData(0xDA11D012u, false)] // ChargenPreviewEntityBuilder/CreatureAppraisalPresentation synthetic doll id
|
|
[InlineData(0xFFFFFF01u, false)] // PortalTunnelPresentation synthetic id
|
|
public void IsProceduralSceneryDecodesTheFullTopNibbleNotJustBit31(uint entityId, bool expected)
|
|
{
|
|
Assert.Equal(expected, FoliageWindClassification.IsProceduralScenery(entityId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VM VM6 review fix round (A2): GroupKey now carries
|
|
/// FoliageFlags, so two otherwise-identical mesh-subset keys — same
|
|
/// geometry/texture/translucency/cull mode — with different
|
|
/// classification results are UNEQUAL keys, which is what makes a
|
|
/// scenery instance and a non-scenery instance of the same GfxObj land
|
|
/// in two separate WbDrawDispatcher groups instead of coalescing into
|
|
/// one group whose flags depended on whichever entity classified it
|
|
/// last. Flags are derived from real entity ids through
|
|
/// <see cref="FoliageWindClassification.Classify"/> — a procedural-
|
|
/// scenery id (0x8…, a tree's cutout leaf subset) versus a landblock-
|
|
/// static id (0xC…, e.g. a fence with a cutout mesh subset) — matching
|
|
/// the review's exact pairing and its 0x2/0x0 expected words, and the
|
|
/// same pairing <c>DirectionalShadowPreparedDrawTests
|
|
/// .SameSubsetDifferentFoliageClassificationNeverCoalescesIntoOneCasterBatch</c>
|
|
/// proves the caster keeps separate.
|
|
/// </summary>
|
|
[Fact]
|
|
public void GroupKeysWithDifferentFoliageFlagsAreNeverEqualEvenWithIdenticalGeometry()
|
|
{
|
|
const uint proceduralSceneryTreeId = 0x80010203u; // top nibble 0x8
|
|
const uint landblockStaticFenceId = 0xC0010203u; // top nibble 0xC
|
|
|
|
uint sceneryFlags = FoliageWindClassification.Classify(
|
|
proceduralSceneryTreeId,
|
|
isExcluded: false,
|
|
TranslucencyKind.ClipMap,
|
|
meshHasCutoutSubset: true);
|
|
uint landblockStaticFlags = FoliageWindClassification.Classify(
|
|
landblockStaticFenceId,
|
|
isExcluded: false,
|
|
TranslucencyKind.ClipMap,
|
|
meshHasCutoutSubset: true);
|
|
|
|
Assert.Equal(FoliageWindClassification.CutoutFoliageFlag, sceneryFlags); // 0x2
|
|
Assert.Equal(0u, landblockStaticFlags); // 0x0
|
|
|
|
var sceneryKey = new GroupKey(
|
|
FirstIndex: 100,
|
|
BaseVertex: 5,
|
|
IndexCount: 12,
|
|
TextureSlot: new AcDream.App.Rendering.Gpu.GpuTextureSlot(42),
|
|
TextureLayer: 0,
|
|
Translucency: TranslucencyKind.ClipMap,
|
|
CullMode: DatReaderWriter.Enums.CullMode.CounterClockwise,
|
|
FoliageFlags: sceneryFlags);
|
|
var landblockStaticKey = sceneryKey with { FoliageFlags = landblockStaticFlags };
|
|
|
|
// Two otherwise-identical mesh-subset keys, differing only by the
|
|
// entity-driven classification, are unequal — this is what makes
|
|
// WbDrawDispatcher.GetOrCreateInstanceGroup place the two instances
|
|
// in two separate InstanceGroups instead of coalescing them.
|
|
Assert.NotEqual(sceneryKey, landblockStaticKey);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VM VM6 review fix round (A4): ComputeEntityHasCutoutSubset
|
|
/// ORs across every part regardless of WHICH part carries the cutout
|
|
/// subset — this pins the "two-part Setup, part B has cutout" scenario
|
|
/// at the algorithm level, decoupled from ObjectRenderData/mesh-adapter
|
|
/// plumbing.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(new[] { false, false }, false)]
|
|
[InlineData(new[] { true, false }, true)]
|
|
[InlineData(new[] { false, true }, true)] // part B (index 1) has the cutout
|
|
[InlineData(new[] { true, true }, true)]
|
|
public void ComputeEntityHasCutoutSubsetOrsAcrossEveryPart(bool[] partHasCutout, bool expected)
|
|
{
|
|
bool result = FoliageWindClassification.ComputeEntityHasCutoutSubset(
|
|
partHasCutout,
|
|
static value => value);
|
|
|
|
Assert.Equal(expected, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeEntityHasCutoutSubsetIsFalseForAnEmptyPartList()
|
|
{
|
|
Assert.False(
|
|
FoliageWindClassification.ComputeEntityHasCutoutSubset(
|
|
Array.Empty<bool>(),
|
|
static value => value));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VM VM6 review fix round (A4), end to end at the
|
|
/// classification level: a two-part Setup — part A is an opaque trunk
|
|
/// with no cutout batches of its own, part B is the leaves (has a
|
|
/// cutout subset). Classifying part A's opaque subset from its OWN
|
|
/// (false) HasCutoutSubset never yields the trunk flag (the pre-fix
|
|
/// bug); classifying it from the entity-scoped OR that
|
|
/// ComputeEntityHasCutoutSubset computes across BOTH parts correctly
|
|
/// yields TrunkFlag. Both WbDrawDispatcher.ClassifyBatches (receiver)
|
|
/// and AddDirectionalShadowBatches (caster) now pass the entity-scoped
|
|
/// value here instead of the part's own.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TwoPartSetupGivesTheOpaqueTrunkPartTheTrunkFlagWhenAnotherPartHasCutout()
|
|
{
|
|
const uint sceneryEntityId = 0x80010203u;
|
|
bool[] partAHasCutoutSubset = [false]; // the trunk part's OWN mesh has no cutout batches
|
|
bool[] setupPartsHasCutoutSubset = [false, true]; // part A (trunk), part B (leaves)
|
|
|
|
bool partAOwnHasCutoutSubset = FoliageWindClassification.ComputeEntityHasCutoutSubset(
|
|
partAHasCutoutSubset,
|
|
static value => value);
|
|
bool entityScopedHasCutoutSubset = FoliageWindClassification.ComputeEntityHasCutoutSubset(
|
|
setupPartsHasCutoutSubset,
|
|
static value => value);
|
|
|
|
uint flagsFromPartOwnValue = FoliageWindClassification.Classify(
|
|
sceneryEntityId,
|
|
isExcluded: false,
|
|
TranslucencyKind.Opaque,
|
|
partAOwnHasCutoutSubset);
|
|
uint flagsFromEntityScopedValue = FoliageWindClassification.Classify(
|
|
sceneryEntityId,
|
|
isExcluded: false,
|
|
TranslucencyKind.Opaque,
|
|
entityScopedHasCutoutSubset);
|
|
|
|
Assert.Equal(0u, flagsFromPartOwnValue); // the pre-fix bug: no flag at all
|
|
Assert.Equal(FoliageWindClassification.TrunkFlag, flagsFromEntityScopedValue);
|
|
}
|
|
}
|