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:
Erik 2026-08-23 00:56:34 +02:00
parent 0930c35d1d
commit 39e8408c7d
34 changed files with 1536 additions and 64 deletions

View file

@ -18,7 +18,12 @@ internal readonly record struct DirectionalShadowPreparedBatch(
GpuTextureSlot TextureSlot,
uint TextureLayer,
CullMode CullMode,
DirectionalShadowCasterMaterial Material);
DirectionalShadowCasterMaterial Material,
// Campaign VM VM6: FoliageWindClassification.CutoutFoliageFlag /
// TrunkFlag — the identical word WbDrawDispatcher.ClassifyBatches
// computed for the matching world-receiver subset, so the caster and
// receiver agree by construction (see foliage_wind.glsl).
uint FoliageFlags = 0u);
internal readonly record struct DirectionalShadowPreparedRun(
int StartCommand,
@ -279,7 +284,8 @@ internal sealed class DirectionalShadowPreparedDraws
uint textureLayer,
CullMode cullMode,
DirectionalShadowCasterMaterial material,
in Matrix4x4 transform)
in Matrix4x4 transform,
uint foliageFlags = 0u)
{
DirectionalShadowTransformSource source = default;
Add(
@ -291,7 +297,8 @@ internal sealed class DirectionalShadowPreparedDraws
cullMode,
material,
in transform,
in source);
in source,
foliageFlags);
}
public void Add(
@ -303,7 +310,8 @@ internal sealed class DirectionalShadowPreparedDraws
CullMode cullMode,
DirectionalShadowCasterMaterial material,
in Matrix4x4 transform,
in DirectionalShadowTransformSource transformSource)
in DirectionalShadowTransformSource transformSource,
uint foliageFlags = 0u)
{
if (!_building)
throw new InvalidOperationException(
@ -331,7 +339,8 @@ internal sealed class DirectionalShadowPreparedDraws
? textureLayer
: 0u,
cullMode,
material),
material,
foliageFlags),
transform,
transformSource);
}
@ -433,7 +442,8 @@ internal sealed class DirectionalShadowPreparedDraws
key.TextureSlot,
key.TextureLayer,
key.CullMode,
key.Material);
key.Material,
key.FoliageFlags);
if (key.Material is DirectionalShadowCasterMaterial.Opaque)
opaqueCommands++;
commandIndex++;
@ -860,7 +870,11 @@ internal sealed class DirectionalShadowPreparedDraws
GpuTextureSlot TextureSlot,
uint TextureLayer,
CullMode CullMode,
DirectionalShadowCasterMaterial Material);
DirectionalShadowCasterMaterial Material,
// Campaign VM VM6: part of the sort/group key (not just the prepared
// batch payload) so two subsets that would otherwise share a group
// never coalesce under different foliage classification.
uint FoliageFlags = 0u);
private readonly record struct DirectionalShadowSourceDraw(
DirectionalShadowDrawKey Key,
@ -889,9 +903,18 @@ internal sealed class DirectionalShadowPreparedDraws
order = x.IndexCount.CompareTo(y.IndexCount);
if (order != 0) return order;
order = x.TextureSlot.Index.CompareTo(y.TextureSlot.Index);
if (order != 0) return order;
order = x.TextureLayer.CompareTo(y.TextureLayer);
// Campaign VM VM6: tie-break on FoliageFlags so entries sharing
// every other key field but differing only in classification
// (the rare case a mesh subset is reachable from both a
// procedural-scenery and a non-scenery placement) still sort
// into one contiguous, exact-key-matched run instead of an
// unstable-sort-dependent scatter. The grouping loop below keys
// on exact DirectionalShadowDrawKey equality regardless.
return order != 0
? order
: x.TextureLayer.CompareTo(y.TextureLayer);
: x.FoliageFlags.CompareTo(y.FoliageFlags);
}
}
}
@ -1171,6 +1194,17 @@ public sealed partial class WbDrawDispatcher
textureLayer = texture.Layer;
}
// Campaign VM VM6: the identical classification
// WbDrawDispatcher.ClassifyBatches computes for the matching
// world-receiver subset, so the caster and receiver agree by
// construction — the shadow moves with the same displaced
// vertex the receiver draws.
uint foliageFlags = FoliageWindClassification.Classify(
candidate.LocalEntityId,
FoliageWindExclusions.Contains(meshRef.GfxObjId),
batch.Translucency,
renderData.HasCutoutSubset);
_directionalShadowDraws.Add(
batch.FirstIndex,
checked((int)batch.BaseVertex),
@ -1180,7 +1214,8 @@ public sealed partial class WbDrawDispatcher
batch.CullMode,
material,
in model,
in transformSource);
in transformSource,
foliageFlags);
}
}
}