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>
298 lines
12 KiB
C#
298 lines
12 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Rendering.Packs;
|
|
using AcDream.App.Rendering.Wb;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Packs;
|
|
|
|
/// <summary>
|
|
/// Campaign VM VM6: <see cref="FoliageWindModel"/>, the CPU mirror of
|
|
/// <c>foliage_wind.glsl</c>'s <c>acdreamFoliageDisplace</c>. These pin the
|
|
/// motion model's shape (not its exact GLSL-vs-CPU bit-for-bit floats — that
|
|
/// would need a GPU capture) so a change to the shared formula that breaks
|
|
/// an invariant of the design fails fast on the CPU.
|
|
/// </summary>
|
|
public sealed class FoliageWindModelTests
|
|
{
|
|
private static readonly Vector3 WorldPos = new(12f, -7f, 5f);
|
|
private static readonly Vector3 InstanceOrigin = new(10f, -8f, 1f);
|
|
private static readonly Vector4 Amplitude = new(0.25f, 0.15f, 0.05f, 8f); // lean, branch, flutter, canopy
|
|
private static readonly Vector4 CalmWind = new(3.5f, 0f, 0f, 3.9f); // mean=gust=0
|
|
|
|
[Fact]
|
|
public void ZeroFlagsIsIdentityRegardlessOfWindStrength()
|
|
{
|
|
var windyClockWind = new Vector4(3.5f, 1f, 1f, 3.9f);
|
|
|
|
Vector3 result = FoliageWindModel.Displace(
|
|
WorldPos,
|
|
InstanceOrigin,
|
|
batchFlags: 0u,
|
|
windyClockWind,
|
|
Amplitude);
|
|
|
|
Assert.Equal(WorldPos, result);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(FoliageWindClassification.CutoutFoliageFlag)]
|
|
[InlineData(FoliageWindClassification.TrunkFlag)]
|
|
public void CalmWindIsIdentityForAnyFoliageFlags(uint flags)
|
|
{
|
|
Vector3 result = FoliageWindModel.Displace(
|
|
WorldPos,
|
|
InstanceOrigin,
|
|
flags,
|
|
CalmWind,
|
|
Amplitude);
|
|
|
|
AssertApproximatelyEqual(WorldPos, result);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(FoliageWindClassification.CutoutFoliageFlag)]
|
|
[InlineData(FoliageWindClassification.TrunkFlag)]
|
|
public void TheBaseVertexNeverMoves(uint flags)
|
|
{
|
|
// h = clamp((worldPos.z - instanceOrigin.z) / maxHeight, 0, 1) is 0
|
|
// when the vertex sits exactly at the instance origin's height —
|
|
// k = h*h = 0 zeroes lean and branch, and the cutout branch's own
|
|
// h factor zeroes flutter too.
|
|
Vector3 baseVertex = InstanceOrigin with { X = InstanceOrigin.X + 3f };
|
|
var windyClockWind = new Vector4(11f, 1f, 1f, 2.1f);
|
|
|
|
Vector3 result = FoliageWindModel.Displace(
|
|
baseVertex,
|
|
InstanceOrigin,
|
|
flags,
|
|
windyClockWind,
|
|
Amplitude);
|
|
|
|
AssertApproximatelyEqual(baseVertex, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void CanopyTopDisplacementMagnitudeIsBoundedByTheDeclaredAmplitudes()
|
|
{
|
|
// gust = 0 pins s = mean exactly (no gust-envelope overshoot above
|
|
// 1 to reason about), and mean = 1 is the maximum authored strength,
|
|
// so |lean| <= amp.lean, |branch| <= amp.branch, |flutter| <=
|
|
// amp.flutter follow directly from each term's own sin/cos factors
|
|
// being bounded by 1. The triangle inequality then bounds the
|
|
// summed 2-D displacement by amp.lean + 1.35*amp.branch (the extra
|
|
// 0.35 is the perpendicular branch-sway term) + amp.flutter.
|
|
var maxMean = new Vector4(0f, 1f, 0f, 0f);
|
|
float bound = Amplitude.X + (1.35f * Amplitude.Y) + Amplitude.Z + 1e-4f;
|
|
|
|
for (float t = 0f; t < 40f; t += 3.7f)
|
|
{
|
|
for (float xy = -5f; xy <= 5f; xy += 4.3f)
|
|
{
|
|
Vector3 canopyTop = InstanceOrigin with
|
|
{
|
|
X = InstanceOrigin.X + xy,
|
|
Y = InstanceOrigin.Y - xy,
|
|
Z = InstanceOrigin.Z + Amplitude.W, // h = 1
|
|
};
|
|
var clockWind = maxMean with { X = t };
|
|
|
|
Vector3 result = FoliageWindModel.Displace(
|
|
canopyTop,
|
|
InstanceOrigin,
|
|
FoliageWindClassification.CutoutFoliageFlag,
|
|
clockWind,
|
|
Amplitude);
|
|
|
|
Vector2 displacementXY = new(
|
|
result.X - canopyTop.X,
|
|
result.Y - canopyTop.Y);
|
|
Assert.True(
|
|
displacementXY.Length() <= bound,
|
|
$"t={t} xy={xy}: |d|={displacementXY.Length()} exceeds bound {bound}");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void HeightNeverIncreases()
|
|
{
|
|
var rng = new Random(1337);
|
|
for (int i = 0; i < 200; i++)
|
|
{
|
|
var worldPos = new Vector3(
|
|
(float)((rng.NextDouble() * 40) - 20),
|
|
(float)((rng.NextDouble() * 40) - 20),
|
|
(float)(rng.NextDouble() * Amplitude.W));
|
|
var clockWind = new Vector4(
|
|
(float)(rng.NextDouble() * 1000),
|
|
(float)rng.NextDouble(),
|
|
(float)rng.NextDouble(),
|
|
(float)(rng.NextDouble() * MathF.Tau));
|
|
uint flags = (rng.Next(2) == 0)
|
|
? FoliageWindClassification.CutoutFoliageFlag
|
|
: FoliageWindClassification.TrunkFlag;
|
|
|
|
Vector3 result = FoliageWindModel.Displace(
|
|
worldPos,
|
|
InstanceOrigin,
|
|
flags,
|
|
clockWind,
|
|
Amplitude);
|
|
|
|
Assert.True(
|
|
result.Z <= worldPos.Z + 1e-5f,
|
|
$"iteration {i}: z increased from {worldPos.Z} to {result.Z}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TrunkDisplacementIsIndependentOfWorldXyHash()
|
|
{
|
|
// Bit 0x4 (trunk) never enters the cutout branch, so its
|
|
// displacement depends only on height (worldPos.z - origin.z) and
|
|
// the instance-level phase (from instanceOrigin.xy) — never on the
|
|
// vertex's own world x/y, which is exactly what decorrelates leaves
|
|
// on a cutout subset but must NOT vary a trunk's lean.
|
|
float z = InstanceOrigin.Z + (0.5f * Amplitude.W);
|
|
var clockWind = new Vector4(7.25f, 0.8f, 0.6f, 1.1f);
|
|
|
|
Vector3 displacementAt(float x, float y)
|
|
{
|
|
var worldPos = new Vector3(x, y, z);
|
|
Vector3 result = FoliageWindModel.Displace(
|
|
worldPos,
|
|
InstanceOrigin,
|
|
FoliageWindClassification.TrunkFlag,
|
|
clockWind,
|
|
Amplitude);
|
|
return result - worldPos;
|
|
}
|
|
|
|
Vector3 reference = displacementAt(InstanceOrigin.X, InstanceOrigin.Y);
|
|
Vector3 farAway = displacementAt(InstanceOrigin.X + 500f, InstanceOrigin.Y - 300f);
|
|
Vector3 elsewhere = displacementAt(InstanceOrigin.X - 17.3f, InstanceOrigin.Y + 91f);
|
|
|
|
AssertApproximatelyEqual(reference, farAway);
|
|
AssertApproximatelyEqual(reference, elsewhere);
|
|
}
|
|
|
|
[Fact]
|
|
public void CutoutDisplacementVariesWithWorldXyHashButTrunkDoesNot()
|
|
{
|
|
// Same setup as above, but with the cutout bit: the flutter term's
|
|
// per-vertex hash DOES make the displacement depend on world x/y —
|
|
// confirming the trunk test above is a real distinction, not an
|
|
// artifact of degenerate inputs.
|
|
float z = InstanceOrigin.Z + (0.5f * Amplitude.W);
|
|
var clockWind = new Vector4(7.25f, 0.8f, 0.6f, 1.1f);
|
|
|
|
Vector3 displacementAt(float x, float y)
|
|
{
|
|
var worldPos = new Vector3(x, y, z);
|
|
Vector3 result = FoliageWindModel.Displace(
|
|
worldPos,
|
|
InstanceOrigin,
|
|
FoliageWindClassification.CutoutFoliageFlag,
|
|
clockWind,
|
|
Amplitude);
|
|
return result - worldPos;
|
|
}
|
|
|
|
Vector3 reference = displacementAt(InstanceOrigin.X, InstanceOrigin.Y);
|
|
Vector3 farAway = displacementAt(InstanceOrigin.X + 500f, InstanceOrigin.Y - 300f);
|
|
|
|
Assert.NotEqual(reference, farAway);
|
|
}
|
|
|
|
[Fact]
|
|
public void FlutterHashIsRelativeToInstanceOriginSoTranslatingTheWholeTreeDoesNotChangeIt()
|
|
{
|
|
// Review fix round A5: the per-vertex flutter hash is computed from
|
|
// worldPos.xy - instanceOrigin.xy (relative to the tree's own base),
|
|
// not absolute world XY. A tree near the world origin and the exact
|
|
// same tree translated tens of thousands of metres away (a far
|
|
// landblock corner, where fp32 sin() of a large ABSOLUTE coordinate
|
|
// loses precision and produced visibly patterned flutter pre-fix)
|
|
// must produce identical displacement for the identical local leaf
|
|
// offset.
|
|
//
|
|
// ph = dot(instanceOrigin.xy, (0.137, 0.291)) is intentionally
|
|
// instanceOrigin-dependent (it decorrelates neighbouring trees), so
|
|
// this test holds it constant by translating along the direction
|
|
// orthogonal to (0.137, 0.291) — (0.291, -0.137) — which leaves the
|
|
// dot product, and therefore every ph-driven term (lean, branch,
|
|
// gust envelope), unchanged while moving the absolute coordinates
|
|
// by 50,000 units. Only the flutter hash's input changes shape
|
|
// between the old (absolute) and new (relative) implementation, so
|
|
// this test would have failed under the pre-fix code.
|
|
var localOffset = new Vector3(3.7f, -2.1f, 4f); // fixed offset from trunk base to this leaf
|
|
var nearOrigin = InstanceOrigin;
|
|
var translation = new Vector3(0.291f, -0.137f, 0f) * 50_000f;
|
|
var farOrigin = nearOrigin + translation;
|
|
var clockWind = new Vector4(19.5f, 0.8f, 0.6f, 2.3f);
|
|
|
|
Vector3 nearVertex = nearOrigin + localOffset;
|
|
Vector3 farVertex = farOrigin + localOffset;
|
|
|
|
Vector3 nearResult = FoliageWindModel.Displace(
|
|
nearVertex,
|
|
nearOrigin,
|
|
FoliageWindClassification.CutoutFoliageFlag,
|
|
clockWind,
|
|
Amplitude);
|
|
Vector3 farResult = FoliageWindModel.Displace(
|
|
farVertex,
|
|
farOrigin,
|
|
FoliageWindClassification.CutoutFoliageFlag,
|
|
clockWind,
|
|
Amplitude);
|
|
|
|
Vector3 nearDisplacement = nearResult - nearVertex;
|
|
Vector3 farDisplacement = farResult - farVertex;
|
|
|
|
AssertApproximatelyEqual(nearDisplacement, farDisplacement, tolerance: 1e-3f);
|
|
}
|
|
|
|
[Fact]
|
|
public void MidHeightCutoutDisplacementHasAPositiveFloorUnderStormWind()
|
|
{
|
|
// Review fix round: pin that a cutout leaf at mid-canopy height
|
|
// (h = 0.5) actually moves under Storm-strength wind (mean 1.00,
|
|
// gust 0.75 — FoliageWindByWeather's Storm row). Guards against a
|
|
// regression that silently zeroes the whole displacement formula
|
|
// (e.g. an accidental "no motion" early-out, a broken sign, or the
|
|
// A5 relative-XY change degenerating to a constant hash).
|
|
const float floorMetres = 0.01f;
|
|
var stormClockWind = new Vector4(0f, 1.00f, 0.75f, 0f); // mean/gust match Storm
|
|
Vector3 midHeightVertex = InstanceOrigin with
|
|
{
|
|
X = InstanceOrigin.X + 2.4f,
|
|
Y = InstanceOrigin.Y - 1.1f,
|
|
Z = InstanceOrigin.Z + (0.5f * Amplitude.W), // h = 0.5
|
|
};
|
|
|
|
for (float t = 0f; t < 30f; t += 2.9f)
|
|
{
|
|
Vector3 result = FoliageWindModel.Displace(
|
|
midHeightVertex,
|
|
InstanceOrigin,
|
|
FoliageWindClassification.CutoutFoliageFlag,
|
|
stormClockWind with { X = t },
|
|
Amplitude);
|
|
|
|
float magnitude = (result - midHeightVertex).Length();
|
|
Assert.True(
|
|
magnitude > floorMetres,
|
|
$"t={t}: displacement magnitude {magnitude} did not clear the {floorMetres} m floor");
|
|
}
|
|
}
|
|
|
|
private static void AssertApproximatelyEqual(
|
|
Vector3 expected,
|
|
Vector3 actual,
|
|
float tolerance = 1e-4f)
|
|
{
|
|
Assert.True(
|
|
(expected - actual).Length() <= tolerance,
|
|
$"expected {expected}, got {actual} (delta length {(expected - actual).Length()})");
|
|
}
|
|
}
|