feat(render): weather-driven foliage wind for procedural scenery, shadows follow (Campaign VM VM6b)
Procedural-scenery foliage (trees/bushes — entity ids in the ProceduralSceneryIdAllocator's 0x8XXYYIII namespace) sways with weather in mesh_atmospheric.vert and all four directional_shadow_world_* caster vertex shaders, both calling the identical new foliage_wind.glsl include so the shadow moves with the leaf by construction. Classification (FoliageWindClassification, AcDream.App.Rendering.Wb): two new BatchData.flags bits, computed once per (entity, subset) from four inputs — entity id (bit 31 for procedural scenery), the pack's declared FoliageExclusions membership, the subset's TranslucencyKind, and ObjectRenderData.HasCutoutSubset (computed once per mesh at build time, not per frame). Bit 1 marks an alpha-cutout leaf subset; bit 2 marks an opaque trunk subset (only when its own mesh also owns a cutout subset, so rocks stay still). WbDrawDispatcher.ClassifyBatches (world receiver) and AddDirectionalShadowBatches (caster) call this with the same four inputs, so casters and receivers classify identically without needing to share state. Retail's mesh_modern/terrain_modern/mesh_detail pipelines never read these bits, so pack-off output is unaffected. Motion model (foliage_wind.glsl, mirrored bit-for-bit in the new FoliageWindModel for hermetic CPU tests): height-squared-scaled slow lean for every foliage subset, plus branch swing and per-vertex-hash-decorrelated flutter for cutout subsets only. AtmosphericPostProcessGraph.ResolveFoliageWind resolves the wind block once per frame.Serial — advanced by whichever of RenderDirectionalShadows (which runs first) or RenderPostProcess is called first that frame, with the second reading the already-advanced state, which is what keeps the caster and receiver reading byte-identical clock/strength values. The per-day-group mean/gust target (AtmospherePolicyDeclaration. FoliageWindByDayGroup, keyed by the same day-group index convention ActiveDayGroupMultipliers already established: Clear/Cloudy/Overcast/Rainy) eases toward its target over WeatherSystem.TransitionSeconds (10s) so a weather change never snaps; wind-enabled off or indoor instead gates the OUTPUT to an exact zero (not an asymptotic approach) so a settings toggle or cell transition is immediate. The wind clock is a Stopwatch started at graph construction (monotonic, session-relative magnitude for GPU sin() accuracy), overridable by the same ACDREAM_SKY_PHASE_SECONDS pin SkyRenderer already uses, for deterministic offline gates. New settings: wind-enabled, wind-strength, wind-direction-degrees (225° default — no authored retail wind direction exists to read), wind-lean-metres, wind-branch-metres, wind-flutter-metres (0 on Low), wind-canopy-height-metres. Register row IA-25 files this as an intentional, strictly opt-in divergence: retail applies no per-vertex wind displacement to any geometry. Known, accepted limitation: classification is per mesh-subset (one BatchData.flags word per indirect-draw batch), not per entity instance, so the rare case of one mesh subset being reachable from both a procedural-scenery and a non-scenery placement would classify all of that subset's instances alike. Tests: FoliageWindClassificationTests (the full classification matrix), FoliageWindModelTests (identity on non-foliage/calm-wind/base-vertex, canopy-top displacement bound, z-never-increases, trunk has no flutter term), RenderPackAtmospherePolicyEvaluationTests (exact day-group lookup, no interpolation across day-group ids, easing convergence without overshoot or discontinuity), AtmosphericShaderAbiTests (each of the five shaders calls acdreamFoliageDisplace exactly once; mesh_modern/terrain/mesh_detail call it never), and four AtmosphericPostProcessGraphTests additions (indoor/disabled exact-zero gating, settings-to-UBO wiring, same-frame-Serial idempotency — the last proxies the caster/receiver agreement invariant without needing this hermetic harness's WbDrawDispatcher/TerrainModernRenderer dependency chain to exercise RenderDirectionalShadows directly). App hermetic filter: 6015/6017 (the same 2 pre-existing failures as VM6a, confirmed unrelated). Core.Tests hermetic: 4697/4697. RenderPackValidator.Tests: 30/30. Full solution Debug and Release builds green. Shader recompile touched exactly the 5 edited files' .spv (plus manifest); the retail oracle set and every other pack shader are byte-identical. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0930c35d1d
commit
39e8408c7d
34 changed files with 1536 additions and 64 deletions
|
|
@ -1220,6 +1220,115 @@ public sealed class AtmosphericPostProcessGraphTests
|
|||
Assert.Equal(1f, AtmosphericPostProcessGraph.BloomThresholdLinear);
|
||||
}
|
||||
|
||||
// ── Campaign VM VM6: foliage wind ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void IndoorGatesWindOutputToExactZeroRegardlessOfSmoothedState()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var graph = Graph(device, "medium", windClockSecondsOverride: 12f);
|
||||
graph.PrepareWorldTarget(640, 480, 1);
|
||||
// Rainy (index 3 in the built-in table) is the highest declared
|
||||
// mean/gust — the exact target does not matter here, only that the
|
||||
// gate still zeroes the output despite a nonzero smoothed target.
|
||||
AtmosphericFrameInputs indoors = Inputs(640, 480, activeDayGroup: 3) with
|
||||
{
|
||||
IsOutdoor = false,
|
||||
};
|
||||
|
||||
AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, indoors);
|
||||
|
||||
Assert.Equal(0f, atmospheric.ClockWind.Y); // mean
|
||||
Assert.Equal(0f, atmospheric.ClockWind.Z); // gust
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindEnabledFalseGatesWindOutputToExactZero()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var graph = Graph(
|
||||
device,
|
||||
"medium",
|
||||
windClockSecondsOverride: 12f,
|
||||
userSettingOverrides: new Dictionary<string, string> { ["wind-enabled"] = "false" });
|
||||
graph.PrepareWorldTarget(640, 480, 1);
|
||||
AtmosphericFrameInputs outdoors = Inputs(640, 480, activeDayGroup: 3);
|
||||
|
||||
AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, outdoors);
|
||||
|
||||
Assert.Equal(0f, atmospheric.ClockWind.Y);
|
||||
Assert.Equal(0f, atmospheric.ClockWind.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindAmplitudeReflectsTheDeclaredSettingDefaults()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var graph = Graph(device, "medium", windClockSecondsOverride: 5f);
|
||||
graph.PrepareWorldTarget(640, 480, 1);
|
||||
AtmosphericFrameInputs outdoors = Inputs(640, 480);
|
||||
|
||||
AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, outdoors);
|
||||
|
||||
Assert.Equal(new Vector4(0.25f, 0.15f, 0.05f, 8f), atmospheric.WindAmplitude);
|
||||
// 225 degrees, the declared default direction.
|
||||
Assert.Equal(225f * (MathF.PI / 180f), atmospheric.ClockWind.W, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedCallsOnTheSameFrameSerialProduceIdenticalWindBytes()
|
||||
{
|
||||
// RenderDirectionalShadows and RenderPostProcess both resolve the
|
||||
// wind block for frame.Serial, and RenderDirectionalShadows runs
|
||||
// first each frame. This proves the "advance once per Serial, later
|
||||
// callers read the already-advanced state" invariant that makes the
|
||||
// caster and receiver agree — without needing this hermetic
|
||||
// harness's full WbDrawDispatcher/TerrainModernRenderer dependency
|
||||
// chain to exercise RenderDirectionalShadows itself.
|
||||
var device = new RecordingGpuDevice();
|
||||
using var graph = Graph(device, "medium"); // real clock: proves it's the Serial guard, not a frozen override
|
||||
IGpuRenderTarget world = graph.PrepareWorldTarget(640, 480, 1);
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
RecordWorldPass(frame, world);
|
||||
AtmosphericFrameInputs inputs = Inputs(640, 480, activeDayGroup: 3);
|
||||
|
||||
graph.RenderPostProcess(frame, in inputs);
|
||||
AtmosphericFrameUniforms first = ReadLastFrameBlock(device);
|
||||
graph.RenderPostProcess(frame, in inputs);
|
||||
AtmosphericFrameUniforms second = ReadLastFrameBlock(device);
|
||||
frame.End();
|
||||
|
||||
Assert.Equal(first.ClockWind, second.ClockWind);
|
||||
Assert.Equal(first.WindAmplitude, second.WindAmplitude);
|
||||
}
|
||||
|
||||
private static AtmosphericFrameUniforms RenderAndReadFrameBlock(
|
||||
RecordingGpuDevice device,
|
||||
AtmosphericPostProcessGraph graph,
|
||||
in AtmosphericFrameInputs inputs)
|
||||
{
|
||||
IGpuRenderTarget world = graph.PrepareWorldTarget(
|
||||
inputs.ViewportWidth,
|
||||
inputs.ViewportHeight,
|
||||
1);
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
RecordWorldPass(frame, world);
|
||||
graph.RenderPostProcess(frame, in inputs);
|
||||
frame.End();
|
||||
return ReadLastFrameBlock(device);
|
||||
}
|
||||
|
||||
private static AtmosphericFrameUniforms ReadLastFrameBlock(RecordingGpuDevice device)
|
||||
{
|
||||
GpuRecordedUniformBind frameBlock = device
|
||||
.OfKind<GpuRecordedUniformBind>()
|
||||
.Last(call => call.Binding == GpuBindingModel.UniformAtmosphericFrame);
|
||||
return MemoryMarshal.Read<AtmosphericFrameUniforms>(
|
||||
device.RingBytes.Slice(
|
||||
(int)frameBlock.OffsetBytes,
|
||||
AtmosphericFrameUniforms.SizeInBytes));
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string haystack, string needle)
|
||||
{
|
||||
int count = 0;
|
||||
|
|
@ -1235,7 +1344,9 @@ public sealed class AtmosphericPostProcessGraphTests
|
|||
private static AtmosphericPostProcessGraph Graph(
|
||||
RecordingGpuDevice device,
|
||||
string presetId,
|
||||
AtmosphericPostProcessSettings? settings = null)
|
||||
AtmosphericPostProcessSettings? settings = null,
|
||||
float? windClockSecondsOverride = null,
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides = null)
|
||||
{
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
RenderQualityPreset preset = Assert.Single(
|
||||
|
|
@ -1246,7 +1357,9 @@ public sealed class AtmosphericPostProcessGraphTests
|
|||
descriptor,
|
||||
BuiltInAssets(),
|
||||
preset,
|
||||
settings);
|
||||
settings,
|
||||
userSettingOverrides,
|
||||
windClockSecondsOverride);
|
||||
}
|
||||
|
||||
private static RenderPackDescriptor ExternalTierOneDescriptor(
|
||||
|
|
|
|||
|
|
@ -91,6 +91,50 @@ public sealed class AtmosphericShaderAbiTests
|
|||
"uPackSettings[16]");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("mesh_atmospheric.vert")]
|
||||
[InlineData("directional_shadow_world_opaque.vert")]
|
||||
[InlineData("directional_shadow_world_opaque_multiview.vert")]
|
||||
[InlineData("directional_shadow_world_cutout.vert")]
|
||||
[InlineData("directional_shadow_world_cutout_multiview.vert")]
|
||||
public void FoliageWindShadersEachCallAcdreamFoliageDisplaceExactlyOnce(string fileName)
|
||||
{
|
||||
string text = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", fileName));
|
||||
|
||||
Assert.Contains("#include \"foliage_wind.glsl\"", text, StringComparison.Ordinal);
|
||||
int calls = CountOccurrences(text, "acdreamFoliageDisplace(");
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("mesh_modern.vert")]
|
||||
[InlineData("terrain_modern.vert")]
|
||||
[InlineData("terrain_atmospheric.vert")]
|
||||
[InlineData("directional_shadow_terrain.vert")]
|
||||
[InlineData("directional_shadow_terrain_multiview.vert")]
|
||||
[InlineData("mesh_detail.vert")]
|
||||
public void RetailAndTerrainShadersNeverCallAcdreamFoliageDisplace(string fileName)
|
||||
{
|
||||
string text = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", fileName));
|
||||
|
||||
Assert.DoesNotContain("foliage_wind.glsl", text, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("acdreamFoliageDisplace(", text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string text, string token)
|
||||
{
|
||||
int count = 0;
|
||||
int index = 0;
|
||||
while ((index = text.IndexOf(token, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
index += token.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FusedLowShadersRetainTheDeclaredOcclusionAndBloomPixelKernels()
|
||||
{
|
||||
|
|
|
|||
215
tests/AcDream.App.Tests/Rendering/Packs/FoliageWindModelTests.cs
Normal file
215
tests/AcDream.App.Tests/Rendering/Packs/FoliageWindModelTests.cs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
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);
|
||||
}
|
||||
|
||||
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()})");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: <see cref="RenderPackAtmospherePolicyEvaluation.FoliageWind"/>
|
||||
/// (the exact per-day-group mean/gust lookup) and
|
||||
/// <see cref="RenderPackAtmospherePolicyEvaluation.EaseTowardTarget"/> (the
|
||||
/// smoothing step that keeps a day-group change from snapping).
|
||||
/// </summary>
|
||||
public sealed class RenderPackAtmospherePolicyEvaluationTests
|
||||
{
|
||||
[Fact]
|
||||
public void FoliageWindLooksUpExactDayGroupMatchOnly()
|
||||
{
|
||||
FoliageWindDayGroupPoint[] table =
|
||||
[
|
||||
new(0, 0.25, 0.15),
|
||||
new(1, 0.45, 0.30),
|
||||
new(2, 0.60, 0.35),
|
||||
new(3, 0.85, 0.60),
|
||||
];
|
||||
|
||||
(float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(table, 3);
|
||||
|
||||
Assert.Equal(0.85f, mean);
|
||||
Assert.Equal(0.60f, gust);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FoliageWindReturnsZeroForADayGroupAbsentFromTheTable()
|
||||
{
|
||||
FoliageWindDayGroupPoint[] table = [new(0, 0.25, 0.15)];
|
||||
|
||||
(float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(table, 99);
|
||||
|
||||
Assert.Equal(0f, mean);
|
||||
Assert.Equal(0f, gust);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FoliageWindReturnsZeroForANullTable()
|
||||
{
|
||||
(float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(null, 0);
|
||||
|
||||
Assert.Equal(0f, mean);
|
||||
Assert.Equal(0f, gust);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FoliageWindDoesNotInterpolateBetweenDayGroupIndices()
|
||||
{
|
||||
// Day-group ids are not ordered by "how windy" — index 1 sitting
|
||||
// between 0 and 2 in the table must not produce a value between
|
||||
// their mean/gust. This asserts the lookup is an exact match, not
|
||||
// continuous interpolation across the index axis.
|
||||
FoliageWindDayGroupPoint[] table = [new(0, 0.0, 0.0), new(5, 1.0, 1.0)];
|
||||
|
||||
(float meanAtUnlistedMidpoint, float gustAtUnlistedMidpoint) =
|
||||
RenderPackAtmospherePolicyEvaluation.FoliageWind(table, 2);
|
||||
|
||||
Assert.Equal(0f, meanAtUnlistedMidpoint);
|
||||
Assert.Equal(0f, gustAtUnlistedMidpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EaseTowardTargetDoesNotMoveWithZeroDelta()
|
||||
{
|
||||
float result = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
|
||||
current: 0.25f,
|
||||
target: 0.85f,
|
||||
deltaSeconds: 0f,
|
||||
transitionSeconds: 10f);
|
||||
|
||||
Assert.Equal(0.25f, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EaseTowardTargetConvergesWithoutOvershootOrDiscontinuity()
|
||||
{
|
||||
// Sample the same weather change (Clear 0.25 -> Rainy 0.85) many
|
||||
// times over the transition window at a fixed per-tick delta and
|
||||
// assert the sequence is monotonically non-decreasing and never
|
||||
// jumps by more than one tick's proportional share — "no
|
||||
// discontinuity across the delta window."
|
||||
const float start = 0.25f;
|
||||
const float target = 0.85f;
|
||||
const float transitionSeconds = 10f;
|
||||
const float tickSeconds = 0.1f;
|
||||
float current = start;
|
||||
float previous = current;
|
||||
// A fixed-rate-per-tick ease is exponential decay toward the
|
||||
// target, not a linear ramp: after N ticks the remaining gap is
|
||||
// (1 - tickSeconds/transitionSeconds)^N of the original gap. 4000
|
||||
// ticks (400 s of simulated time, 40x the transition window) leaves
|
||||
// a remaining fraction of (0.99)^4000 ~= 4e-18 — comfortably
|
||||
// converged without asserting a false linear-ramp expectation.
|
||||
const int ticks = 4000;
|
||||
|
||||
for (int i = 0; i < ticks; i++)
|
||||
{
|
||||
current = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
|
||||
current,
|
||||
target,
|
||||
tickSeconds,
|
||||
transitionSeconds);
|
||||
|
||||
Assert.True(
|
||||
current >= previous - 1e-6f,
|
||||
$"tick {i}: value regressed from {previous} to {current}");
|
||||
Assert.True(
|
||||
current <= target + 1e-6f,
|
||||
$"tick {i}: value {current} overshot target {target}");
|
||||
previous = current;
|
||||
}
|
||||
|
||||
Assert.True(MathF.Abs(current - target) < 0.01f, $"did not converge: {current}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EaseTowardTargetSnapsInOneStepWhenTransitionSecondsIsZeroOrLess()
|
||||
{
|
||||
float result = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
|
||||
current: 0f,
|
||||
target: 1f,
|
||||
deltaSeconds: 0.001f,
|
||||
transitionSeconds: 0f);
|
||||
|
||||
Assert.Equal(1f, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EaseTowardTargetClampsAnOversizedDeltaInsteadOfOvershooting()
|
||||
{
|
||||
// A delta larger than the transition window (e.g. after a long
|
||||
// pause) must not overshoot past the target.
|
||||
float result = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
|
||||
current: 0f,
|
||||
target: 1f,
|
||||
deltaSeconds: 1000f,
|
||||
transitionSeconds: 10f);
|
||||
|
||||
Assert.Equal(1f, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
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);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x80000000u, true)]
|
||||
[InlineData(0x7FFFFFFFu, false)]
|
||||
[InlineData(0x00000000u, false)]
|
||||
[InlineData(0xFFFFFFFFu, true)]
|
||||
public void IsProceduralSceneryDecodesOnlyBit31(uint entityId, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, FoliageWindClassification.IsProceduralScenery(entityId));
|
||||
}
|
||||
}
|
||||
|
|
@ -488,7 +488,7 @@ public sealed class WbDrawDispatcherBucketingTests
|
|||
// at the top of the per-entity loop body in Draw.
|
||||
var groups = new Dictionary<GroupKey, List<Matrix4x4>>();
|
||||
var sortCenters = new List<Vector3>();
|
||||
void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter)
|
||||
void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter, uint foliageFlags)
|
||||
{
|
||||
if (!groups.TryGetValue(k, out var list))
|
||||
{
|
||||
|
|
@ -497,6 +497,7 @@ public sealed class WbDrawDispatcherBucketingTests
|
|||
}
|
||||
list.Add(m);
|
||||
sortCenters.Add(localSortCenter);
|
||||
_ = foliageFlags;
|
||||
}
|
||||
|
||||
Assert.True(cache.TryGet(EntityId, LandblockId, out var entryHit));
|
||||
|
|
@ -544,7 +545,7 @@ public sealed class WbDrawDispatcherBucketingTests
|
|||
WbDrawDispatcher.ApplyCacheHit(
|
||||
entry,
|
||||
Matrix4x4.Identity,
|
||||
(_, _, center) => observedCenter = center);
|
||||
(_, _, center, _) => observedCenter = center);
|
||||
|
||||
Assert.Equal(authoredCenter, observedCenter);
|
||||
}
|
||||
|
|
@ -787,7 +788,7 @@ public sealed class WbDrawDispatcherBucketingTests
|
|||
const uint EntityId = 100;
|
||||
const int MeshRefCount = 3;
|
||||
|
||||
void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter)
|
||||
void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter, uint foliageFlags)
|
||||
{
|
||||
if (!groups.TryGetValue(k, out var list))
|
||||
{
|
||||
|
|
@ -795,6 +796,7 @@ public sealed class WbDrawDispatcherBucketingTests
|
|||
groups[k] = list;
|
||||
}
|
||||
list.Add(m);
|
||||
_ = foliageFlags;
|
||||
}
|
||||
|
||||
for (int partIdx = 0; partIdx < MeshRefCount; partIdx++)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue