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
|
|
@ -65,6 +65,65 @@ internal readonly record struct AtmosphericPostProcessSettings(
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: the resolved foliage-wind pack settings — the CPU-side
|
||||
/// counterpart of <c>uAtmosphereWindAmplitude</c> plus the global strength/
|
||||
/// direction/enabled inputs that scale it. Resolution mirrors
|
||||
/// <see cref="AtmosphericPostProcessSettings.FromDescriptor"/> exactly
|
||||
/// (declaration default, then preset override, then user override).
|
||||
/// </summary>
|
||||
internal readonly record struct FoliageWindSettings(
|
||||
bool Enabled,
|
||||
float Strength,
|
||||
float DirectionDegrees,
|
||||
float LeanMetres,
|
||||
float BranchMetres,
|
||||
float FlutterMetres,
|
||||
float CanopyHeightMetres)
|
||||
{
|
||||
internal static FoliageWindSettings FromDescriptor(
|
||||
RenderPackDescriptor descriptor,
|
||||
RenderQualityPreset preset,
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(preset);
|
||||
return new FoliageWindSettings(
|
||||
ReadBool(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindEnabled, true),
|
||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindStrength, 1f),
|
||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindDirectionDegrees, 225f),
|
||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindLeanMetres, 0.25f),
|
||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindBranchMetres, 0.15f),
|
||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindFlutterMetres, 0.05f),
|
||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindCanopyHeightMetres, 8f));
|
||||
}
|
||||
|
||||
private static float Read(
|
||||
RenderPackDescriptor descriptor,
|
||||
RenderQualityPreset preset,
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides,
|
||||
RenderSettingSemantic semantic,
|
||||
float fallback)
|
||||
{
|
||||
RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(value =>
|
||||
value.Semantic == semantic);
|
||||
if (setting is null)
|
||||
return fallback;
|
||||
string value = RenderPackSettingResolution.Resolve(setting, preset, userSettingOverrides);
|
||||
return RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded)
|
||||
? encoded
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static bool ReadBool(
|
||||
RenderPackDescriptor descriptor,
|
||||
RenderQualityPreset preset,
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides,
|
||||
RenderSettingSemantic semantic,
|
||||
bool fallback) =>
|
||||
Read(descriptor, preset, userSettingOverrides, semantic, fallback ? 1f : 0f) > 0.5f;
|
||||
}
|
||||
|
||||
internal interface IAtmosphericWorldGraphRuntime : IRenderPackRuntime
|
||||
{
|
||||
IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount);
|
||||
|
|
@ -112,6 +171,26 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
private readonly AtmosphericPostProcessSettings _settings;
|
||||
private readonly float _shadowStrength;
|
||||
private readonly PackSettingsUniforms _packSettings;
|
||||
// Campaign VM VM6: foliage wind. _foliageWindExclusions is resolved once
|
||||
// at construction from the descriptor's declared policy (immutable for
|
||||
// the pack's lifetime, like every other setting here); _windClock is a
|
||||
// monotonic, session-relative (NOT wall-clock, NOT since-boot) seconds
|
||||
// source — see BuildFoliageWindClockAndAmplitude's doc comment for why.
|
||||
// The remaining fields are the per-frame smoothing state: advanced at
|
||||
// most once per frame.Serial regardless of which of
|
||||
// RenderDirectionalShadows/RenderPostProcess runs first that frame, so
|
||||
// the caster and receiver always read byte-identical values within one
|
||||
// frame (see D2/D4 — the shadow must move with the leaf).
|
||||
private readonly FoliageWindSettings _foliageWind;
|
||||
private readonly IReadOnlySet<uint> _foliageWindExclusions;
|
||||
private readonly float? _windClockSecondsOverride;
|
||||
private readonly System.Diagnostics.Stopwatch _windClock =
|
||||
System.Diagnostics.Stopwatch.StartNew();
|
||||
private long _windFrameSerial = -1;
|
||||
private float _windClockSeconds;
|
||||
private float _windLastAdvanceClockSeconds;
|
||||
private float _windMean;
|
||||
private float _windGust;
|
||||
private readonly DirectionalSunShadowRenderer _directionalShadows;
|
||||
private readonly VolumetricShaftRenderer? _volumetric;
|
||||
private readonly bool _fuseLowPostProcess;
|
||||
|
|
@ -150,14 +229,16 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
IRenderPackAssets assets,
|
||||
RenderQualityPreset preset,
|
||||
AtmosphericPostProcessSettings? settings = null,
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides = null)
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides = null,
|
||||
float? windClockSecondsOverride = null)
|
||||
: this(
|
||||
device,
|
||||
descriptor,
|
||||
RenderPackShaderAssets.Validate(descriptor, assets),
|
||||
preset,
|
||||
settings,
|
||||
userSettingOverrides)
|
||||
userSettingOverrides,
|
||||
windClockSecondsOverride)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +248,8 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
ValidatedRenderPackShaderAssets assets,
|
||||
RenderQualityPreset preset,
|
||||
AtmosphericPostProcessSettings? settings = null,
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides = null)
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides = null,
|
||||
float? windClockSecondsOverride = null)
|
||||
{
|
||||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||
Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor));
|
||||
|
|
@ -224,6 +306,14 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
descriptor,
|
||||
preset,
|
||||
userSettingOverrides);
|
||||
_foliageWind = FoliageWindSettings.FromDescriptor(
|
||||
descriptor,
|
||||
preset,
|
||||
userSettingOverrides);
|
||||
_foliageWindExclusions =
|
||||
(descriptor.AtmospherePolicy?.FoliageExclusions
|
||||
?? (IReadOnlyList<uint>)[]).ToHashSet();
|
||||
_windClockSecondsOverride = windClockSecondsOverride;
|
||||
_packSettings = PackSettingsUniforms.Create(
|
||||
descriptor,
|
||||
preset,
|
||||
|
|
@ -323,8 +413,14 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
* _shadowStrength,
|
||||
0f,
|
||||
1f));
|
||||
// Campaign VM VM6: self-contained by construction — set every frame
|
||||
// from the currently-active pack's declared policy so a pack switch
|
||||
// or deactivation can never leave a stale exclusion set applied to
|
||||
// the dispatcher (see FoliageWindExclusions's doc comment).
|
||||
worldMeshes.FoliageWindExclusions = _foliageWindExclusions;
|
||||
bool isOutdoor = world.Roots.RenderSky && !world.Roots.CameraInsideCell;
|
||||
AtmosphericFrameBufferBinding shadowAtmosphericFrame =
|
||||
BuildShadowAtmosphericFrameBinding(frame);
|
||||
BuildShadowAtmosphericFrameBinding(frame, activeDayGroup, isOutdoor);
|
||||
var input = new DirectionalSunShadowRenderInput(
|
||||
environment,
|
||||
world.Camera.Camera.View,
|
||||
|
|
@ -380,12 +476,17 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
/// <see cref="DirectionalSunShadowRenderer.RenderPrepared"/>. Only the
|
||||
/// two appended VM6 members carry real content; the caster shaders never
|
||||
/// read the other seven (sun/weather/policy/inverse-view-projection are
|
||||
/// receiver-only concerns), so this method leaves them zeroed rather
|
||||
/// than duplicating <see cref="RenderPostProcess"/>'s computation.
|
||||
/// receiver-only concerns).
|
||||
/// </summary>
|
||||
private AtmosphericFrameBufferBinding BuildShadowAtmosphericFrameBinding(
|
||||
IGpuFrame frame)
|
||||
IGpuFrame frame,
|
||||
int activeDayGroup,
|
||||
bool isOutdoor)
|
||||
{
|
||||
(Vector4 clockWind, Vector4 windAmplitude) = ResolveFoliageWind(
|
||||
frame.Serial,
|
||||
activeDayGroup,
|
||||
isOutdoor);
|
||||
GpuRingAllocation allocation = frame.AllocateRing(
|
||||
AtmosphericFrameUniforms.SizeInBytes,
|
||||
GpuRingUsage.Uniform);
|
||||
|
|
@ -397,10 +498,8 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
Vector4.Zero,
|
||||
Vector4.Zero,
|
||||
Matrix4x4.Identity,
|
||||
// VM6a: ABI v2 plumbing only. VM6b fills the real weather-driven
|
||||
// wind block here; no caster shader reads it yet.
|
||||
Vector4.Zero,
|
||||
Vector4.Zero);
|
||||
clockWind,
|
||||
windAmplitude);
|
||||
MemoryMarshal.Write(allocation.Data, in uniforms);
|
||||
return new AtmosphericFrameBufferBinding(
|
||||
allocation.Buffer,
|
||||
|
|
@ -408,6 +507,91 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
(uint)AtmosphericFrameUniforms.SizeInBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: resolves this frame's <c>uAtmosphereClockWind</c>/
|
||||
/// <c>uAtmosphereWindAmplitude</c> values, advancing the weather-driven
|
||||
/// smoothing state at most once per <paramref name="frameSerial"/>. Both
|
||||
/// <see cref="RenderDirectionalShadows"/> (which runs first) and
|
||||
/// <see cref="RenderPostProcess"/> call this for the SAME frame, so the
|
||||
/// second caller reads the already-advanced state rather than
|
||||
/// re-advancing it — this is what keeps the caster and receiver's clock
|
||||
/// and smoothed wind strength byte-identical within one frame (D2/D4:
|
||||
/// the shadow must move with the leaf).
|
||||
///
|
||||
/// <para><b>The clock.</b> Not wall time and not "seconds since
|
||||
/// process/system start" — <see cref="_windClock"/> is a
|
||||
/// <see cref="System.Diagnostics.Stopwatch"/> started when this graph
|
||||
/// was constructed, so its magnitude stays small (session-relative) for
|
||||
/// GPU single-precision <c>sin()</c> accuracy over a long play session,
|
||||
/// and it is genuinely monotonic (unlike <c>DateTime.UtcNow</c>).
|
||||
/// <see cref="_windClockSecondsOverride"/> — threaded from
|
||||
/// <c>RuntimeOptions.SkyAnimationPhaseSeconds</c> (<c>ACDREAM_SKY_PHASE_SECONDS</c>),
|
||||
/// the same pin <see cref="AcDream.App.Rendering.Sky.SkyRenderer"/> uses
|
||||
/// for its own animation clock — replaces it when set, which is every
|
||||
/// run but a differential/offline gate's.</para>
|
||||
///
|
||||
/// <para><b>The smoothing.</b> The per-day-group (mean, gust) target
|
||||
/// from <see cref="RenderPackAtmospherePolicyEvaluation.FoliageWind"/>
|
||||
/// eases toward its target using an exponential moving average over
|
||||
/// <see cref="AcDream.Core.World.WeatherSystem.TransitionSeconds"/> (10 s,
|
||||
/// the same authored weather-transition constant retail-parity work
|
||||
/// already established) — so a day-group change never snaps. This state
|
||||
/// keeps evolving even while indoors or disabled; <c>wind-enabled</c>
|
||||
/// off or <c>!isOutdoor</c> instead multiplies the OUTPUT by an exact
|
||||
/// zero gate (never an asymptotic approach), so a settings toggle or a
|
||||
/// cell transition reads exactly zero on the very next frame, and
|
||||
/// resuming outdoors/enabled picks the smoothed state back up without a
|
||||
/// spin-up glitch.</para>
|
||||
/// </summary>
|
||||
private (Vector4 ClockWind, Vector4 WindAmplitude) ResolveFoliageWind(
|
||||
long frameSerial,
|
||||
int activeDayGroup,
|
||||
bool isOutdoor)
|
||||
{
|
||||
float clockSeconds = _windClockSecondsOverride
|
||||
?? (float)_windClock.Elapsed.TotalSeconds;
|
||||
if (_windFrameSerial != frameSerial)
|
||||
{
|
||||
float deltaSeconds = Math.Clamp(
|
||||
clockSeconds - _windLastAdvanceClockSeconds,
|
||||
0f,
|
||||
1f);
|
||||
(float targetMean, float targetGust) = RenderPackAtmospherePolicyEvaluation
|
||||
.FoliageWind(
|
||||
Descriptor.AtmospherePolicy?.FoliageWindByDayGroup,
|
||||
activeDayGroup);
|
||||
targetMean *= _foliageWind.Strength;
|
||||
targetGust *= _foliageWind.Strength;
|
||||
_windMean = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
|
||||
_windMean,
|
||||
targetMean,
|
||||
deltaSeconds,
|
||||
AcDream.Core.World.WeatherSystem.TransitionSeconds);
|
||||
_windGust = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
|
||||
_windGust,
|
||||
targetGust,
|
||||
deltaSeconds,
|
||||
AcDream.Core.World.WeatherSystem.TransitionSeconds);
|
||||
_windClockSeconds = clockSeconds;
|
||||
_windLastAdvanceClockSeconds = clockSeconds;
|
||||
_windFrameSerial = frameSerial;
|
||||
}
|
||||
|
||||
float gate = _foliageWind.Enabled && isOutdoor ? 1f : 0f;
|
||||
float directionRadians = _foliageWind.DirectionDegrees * (MathF.PI / 180f);
|
||||
var clockWind = new Vector4(
|
||||
_windClockSeconds,
|
||||
_windMean * gate,
|
||||
_windGust * gate,
|
||||
directionRadians);
|
||||
var windAmplitude = new Vector4(
|
||||
_foliageWind.LeanMetres,
|
||||
_foliageWind.BranchMetres,
|
||||
_foliageWind.FlutterMetres,
|
||||
_foliageWind.CanopyHeightMetres);
|
||||
return (clockWind, windAmplitude);
|
||||
}
|
||||
|
||||
public IGpuRenderTarget PrepareWorldTarget(
|
||||
int width,
|
||||
int height,
|
||||
|
|
@ -503,6 +687,10 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
float rayStrength = inputs.SunIsOnScreen && inputs.IsOutdoor
|
||||
? Math.Clamp(_settings.SunRayStrength * sunPolicy, 0f, 4f)
|
||||
: 0f;
|
||||
(Vector4 clockWind, Vector4 windAmplitude) = ResolveFoliageWind(
|
||||
frame.Serial,
|
||||
inputs.ActiveDayGroup,
|
||||
inputs.IsOutdoor);
|
||||
var frameUniforms = new AtmosphericFrameUniforms(
|
||||
new Vector4(
|
||||
inputs.SunScreenUv,
|
||||
|
|
@ -526,12 +714,8 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
shadowElevationPolicy,
|
||||
volumetricElevationPolicy),
|
||||
inputs.InverseViewProjection,
|
||||
// Campaign VM VM6a: ABI v2 plumbing only — written here so the
|
||||
// 192-byte block is always fully populated, but mesh_atmospheric.vert
|
||||
// does not read these two members yet (VM6b wires the real
|
||||
// weather-driven values and the shader read together).
|
||||
Vector4.Zero,
|
||||
Vector4.Zero);
|
||||
clockWind,
|
||||
windAmplitude);
|
||||
GpuRingAllocation frameBlock;
|
||||
GpuRingAllocation settingsBlock;
|
||||
GpuRingAllocation fusedSunPassBlock = default;
|
||||
|
|
@ -1679,12 +1863,20 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
}
|
||||
}
|
||||
|
||||
internal sealed class AtmosphericRenderPackRuntimeFactory(IGpuDevice device) :
|
||||
internal sealed class AtmosphericRenderPackRuntimeFactory(
|
||||
IGpuDevice device,
|
||||
float? skyPhaseSecondsOverride = null) :
|
||||
IRenderPackRuntimeFactory
|
||||
{
|
||||
private readonly IGpuDevice _device = device
|
||||
?? throw new ArgumentNullException(nameof(device));
|
||||
|
||||
// Campaign VM VM6: threaded to every built-in-graph instance this
|
||||
// factory produces — see AtmosphericPostProcessGraph.ResolveFoliageWind's
|
||||
// doc comment for why the foliage-wind clock needs this same pin
|
||||
// AcDream.App.Rendering.Sky.SkyRenderer already uses.
|
||||
private readonly float? _skyPhaseSecondsOverride = skyPhaseSecondsOverride;
|
||||
|
||||
public IRenderPackRuntime Build(
|
||||
RenderPackDescriptor descriptor,
|
||||
IRenderPackAssets assets,
|
||||
|
|
@ -1736,7 +1928,8 @@ internal sealed class AtmosphericRenderPackRuntimeFactory(IGpuDevice device) :
|
|||
descriptor,
|
||||
assets,
|
||||
preset,
|
||||
userSettingOverrides: userSettingOverrides);
|
||||
userSettingOverrides: userSettingOverrides,
|
||||
windClockSecondsOverride: _skyPhaseSecondsOverride);
|
||||
}
|
||||
|
||||
bool declaredDirectionalShadowGraph = descriptor.Passes.Count(pass =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue