diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index c7da0c41..7b1f54ec 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 1. Intentional architecture (IA) — 21 active rows (IA-24 filed 2026-08-22 for Campaign AR's opt-in real-time sun/moon directional shadows; IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default) +## 1. Intentional architecture (IA) — 22 active rows (IA-25 filed 2026-08-22 for Campaign VM VM6's opt-in weather-driven foliage wind — a render-only vertex displacement with no authored retail wind direction to read; IA-24 filed 2026-08-22 for Campaign AR's opt-in real-time sun/moon directional shadows; IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -62,6 +62,7 @@ accepted-divergence entries (#96, #49, #50). | IA-22 | **Filed 2026-08-13 (#391, user-directed: "we should only support modern resolutions. Not any old format").** The Config Resolution dropdown offers a CURATED list — the monitor's real mode enumeration filtered to modern widescreen families (16:9/16:10/21:9/32:9, ≥1280 wide, fitting the desktop; `DisplayModeCatalog.Curate`) — and its Defaults value is the desktop's own mode. Retail offered the adapter's complete enumeration including 4:3 legacy modes and authored `800x600` as the row default (`gmConfigUI::InitOptions SetDefaultValue(0x03200258)`; `gmClient::Init @0x004047af` `Device::ForceDisplayResolution(1, 0x320, 0x258)`). | `src/AcDream.App/Rendering/DisplayModeCatalog.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (Resolution row); fixture fallback `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs` (`AvailableResolutions`, 800x600 removed) | Explicit product direction. **Amended 2026-08-16 (#407, Campaign CC gate round 1):** the dropdown now offers `DisplayModeCatalog.WindowedResolutions` — the curated hardware modes UNIONed with the static modern-ladder sizes that fit the desktop — because a WINDOWED pick is a plain Size write needing no video mode, and remote/RDP virtual displays advertise almost no modes (the live RDP display exposed only 1920x1080 + the 2056x1290 desktop, starving the dropdown). The original "an offered mode is supported by construction" invariant now holds for the FULLSCREEN half only: the fullscreen apply still validates against the hardware `Resolutions` list plus `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard, so a fullscreen pick of a windowed-only entry refuses safely (log-and-stay, #388; the #392 apply-result seam is that family's open follow-up) — "Graphics mode not supported" crashes remain unreachable from the dropdown. | A user wanting a genuine legacy 4:3 mode cannot pick it; retail-parity comparisons of the Config tab's list/default will show the deviation. | decomp sites in the Divergence column; ISSUES #391 | | IA-23 | **Filed 2026-08-17 at the night-round review fix round (F8).** `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s not-yet-expired branch renders `"You may buy another landscape house at " + strftime("%c", localtime(timestamp + 0x278d00)) + ". This restriction does not apply to apartments."` — byte-decoded from raw pushed literals at `@0x004a3265`/`@0x004a321d`/`@0x004a3235` (all three text pieces confirmed; a prior filing had wrongly called this "unrecoverable"). This port renders the SAME three pieces, in the same order, with the same expiry-timestamp math, but formats the middle date/time piece with .NET's culture-default `DateTime.ToString()` (no explicit format string) rather than the C runtime's `strftime("%c", ...)` — the two engines do not share a format table, so the RENDERED SHAPE of the date/time differs (e.g. .NET's short numeric date+time vs the CRT's `Ddd Mon DD HH:MM:SS YYYY`-style locale string) even though both express "the process's own locale's full date+time" and use the SAME underlying instant (local time, matching retail's `localtime()`). | `src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs` (`Recompute`'s not-expired branch) | Both are "whatever the process locale says" full date+time strings; no game-logic reads or parses this text back, it is pure chat-scroll presentation, so a differently-shaped (but equally legible) date string carries no functional risk | A retail-side-by-side visual comparison will show a differently formatted date/time (not a byte-identical `strftime("%c")` reproduction) — cosmetic only | `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`; `strftime`/`localtime` CRT calls at `@0x004a322c`/`@0x004a3216` | | IA-24 | **Filed 2026-08-22, Campaign AR.** An explicitly selected atmospheric render pack adds cascaded real-time directional shadows from terrain, trees, buildings, players, monsters, and other retained outdoor casters. The one shadow direction follows the visible authored sun, then the dominant haloed moon (`0x01001F6A`), then the secondary moon (`0x01001F67`); a moon supplies direction only while colour/energy remains retail's single interpolated `SkyTimeOfDay.DirColor × DirBright` channel. Retail renders none of these real-time object-shadow maps and does not expose a second moon light. | `src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs`; `src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs`; pack-only receiver shaders; evidence `docs/research/2026-08-22-dereth-celestial-shadow-sources.md` | This is the user-requested headline graphics enhancement and is strictly opt-in. The retail path remains the default and authoritative fallback; pack-off does not build/select shadow work or change `SceneLighting`. One selected source reuses one cascade array, so moon support does not multiply shadow resources. | Pack-on output intentionally differs from retail. A wrong celestial identity/transform or stale source transition would visibly misalign shadows from the sky; pack-off output changing would violate the campaign's primary safety contract. | `SkyDesc::GetLighting @0x00500A80`; `GameSky::UseTime @0x005075B0`; installed Region `0x13000000`; cited research note | +| IA-25 | **Filed 2026-08-22, Campaign VM VM6.** An explicitly selected atmospheric render pack sways procedural-scenery foliage (trees/bushes — entity ids in the `0x8XXYYIII` `ProceduralSceneryIdAllocator` namespace) in `mesh_atmospheric.vert` and the four `directional_shadow_world_*` caster vertex shaders, driven by a weather-table lean/branch/flutter vertex displacement (`foliage_wind.glsl`, `FoliageWindModel` CPU mirror) whose mean/gust strength is looked up per active day group and eases toward its target over `WeatherSystem.TransitionSeconds` (10 s) so a weather change never snaps. Retail's fixed-function renderer applies no per-vertex wind displacement to any scenery mesh — Dereth's trees are static geometry. Wind direction (`wind-direction-degrees`, default 225°) is a plain pack default: there is no authored retail wind direction to read (no wind data exists in retail at all). Render-only: `WorldPicker` picks the undisplaced mesh, so a swaying leaf can be up to `lean + branch` metres from its pick volume at the moment of a click; foliage subsets are cosmetic scenery, not interactable in retail either. | `src/AcDream.App/Rendering/Shaders/foliage_wind.glsl`; `src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs`; `src/AcDream.App/Rendering/Packs/FoliageWindModel.cs`; `src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs` (`ResolveFoliageWind`); `src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs` (wind settings + `FoliageWindByDayGroup`) | Explicitly opt-in graphics enhancement — the retail path (`mesh_modern`, `terrain_modern`, `mesh_detail`) never reads `BatchData.flags` bits 1/2 and is pixel-identical with the pack off. The classification never touches Runtime/Core physics — the collision BSP is the trunk, and picking against the undisplaced mesh has no gameplay consequence since foliage is not interactable. | Pack-on output intentionally differs from retail (moving foliage where retail has none). A wrong classification bit would sway a non-foliage object or leave a real tree still; a caster/receiver clock or amplitude mismatch would visibly misalign a leaf's shadow from the leaf itself. Pack-off output changing would violate the campaign's primary safety contract. | None — retail applies no vertex wind displacement to any geometry; `ProceduralSceneryIdAllocator` (bit 31 entity-id namespace, existing acdream mechanism, not retail) | --- diff --git a/docs/plans/2026-08-21-atmospheric-rendering.md b/docs/plans/2026-08-21-atmospheric-rendering.md index 47e661c7..a2c0f8b9 100644 --- a/docs/plans/2026-08-21-atmospheric-rendering.md +++ b/docs/plans/2026-08-21-atmospheric-rendering.md @@ -94,6 +94,7 @@ resolution, and cascade count than on output resolution. | Tier | Contents | Prerequisite | Rough incremental GPU cost at 1080p | |---|---|---|---:| | acdream default | Current authoritative retail-faithful rendering | Current mandatory Vulkan/RHI capabilities | 0 ms | +| 0 | **Weather-driven foliage wind** (Campaign VM VM6): procedural-scenery trees/bushes lean, branch-swing, and flutter; their directional-shadow casters apply the identical displacement so the shadow moves with the leaf | Shader ABI v2 (`AtmosphericFrame` gains `uAtmosphereClockWind`/`uAtmosphereWindAmplitude`); the existing `ProceduralSceneryIdAllocator` entity-id namespace for classification | ~0.05 ms (near-zero; ~25 ALU per displaced vertex, zero CPU cost, zero extra draw submissions — an early-out `uint` AND gates every non-foliage vertex) | | 1 | Bloom, ACES filmic tonemap, colour grade, vignette | Main-world colour intermediate and fullscreen passes | 0.35–0.80 ms | | 1 | Screen-space sun rays (crepuscular) | Authored sun screen position plus an occlusion mask; **no shadow maps** | 0.20–0.50 ms | | 2 | **Moving authored sun-and-moon cascaded directional shadows from trees, monsters, players, and houses/buildings** | A second scene pass, selected-celestial view/projection matrices, sampled depth maps, caster pipeline variants | 1.50–3.00 ms | @@ -106,6 +107,15 @@ Tier 1 without shadows. Tier 2 always includes the complete shadow-caster classes; weak-hardware presets reduce range, cascade count, and resolution rather than silently dropping monsters, trees, or buildings. +**Foliage wind (Tier 0, Campaign VM VM6).** Deliberately numbered below Tier +1: it needs no fullscreen pass, no colour intermediate, and no shadow +infrastructure of its own — only the shader ABI v2 clock/wind block and the +per-batch classification bits `BatchData.flags` already carries. It composes +with every other tier (Tier 2's shadow casters read the same displacement so +a swaying tree's shadow tracks it) and, uniquely among these tiers, is cheap +enough that a future default-on consideration is plausible; today it ships +opt-in with the rest of the atmospheric pack, gated by `wind-enabled`. + ## Tier 2 headline: Dereth's moving authored sun-and-moon shadows The [measured renderer state](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#5-renderer-state-relevant-to-atmospheric-work) diff --git a/docs/plans/2026-08-22-visualmaster-campaign.md b/docs/plans/2026-08-22-visualmaster-campaign.md index 630914c9..a48293a4 100644 --- a/docs/plans/2026-08-22-visualmaster-campaign.md +++ b/docs/plans/2026-08-22-visualmaster-campaign.md @@ -472,6 +472,42 @@ direction to read, and the register row says so. indoors — nothing moves; (6) pack off — nothing moves. The owner's words decide; the numbers above are starting points to tune live. +### VM6 outcome (implementation landed 2026-08-22; owner visual gate outstanding) + +Two commits (VM6a shader ABI v2 plumbing, VM6b the feature) shipped the exact +design above. Settings (`BuiltInAtmosphericRenderPack.Settings()`): +`wind-enabled` (bool, default on), `wind-strength` (0–2×, default 1.0), +`wind-direction-degrees` (0–360°, default 225 — no authored retail wind +direction exists to read), `wind-lean-metres` (default 0.25), +`wind-branch-metres` (default 0.15), `wind-flutter-metres` (default 0.05, +forced to 0 on the Low preset), `wind-canopy-height-metres` (default 8). The +Clear/Cloudy/Overcast/Rainy mean/gust rows live in +`AtmospherePolicyDeclaration.FoliageWindByDayGroup`, keyed by the SAME +day-group index convention `ActiveDayGroupMultipliers` already established +two lines above it in `AtmosphericPolicy()` (0 Clear / 1 Cloudy / 2 Overcast; +index 3 Rainy is new). Classification bits live in +`FoliageWindClassification` (`AcDream.App.Rendering.Wb`): bit 1 (`0x2`) +cutout foliage, bit 2 (`0x4`) trunk, computed once per (entity, subset) in +`WbDrawDispatcher.ClassifyBatches` (world receiver) and +`AddDirectionalShadowBatches` (caster) from the identical four inputs +(entity id, `AtmospherePolicyDeclaration.FoliageExclusions` membership, +subset `TranslucencyKind`, and `ObjectRenderData.HasCutoutSubset` — computed +once per mesh, not per frame or instance). The shared displacement lives in +`foliage_wind.glsl`, called once each from `mesh_atmospheric.vert` and all +four `directional_shadow_world_*` (opaque/cutout, base/multiview) caster +vertex shaders, reading the ABI v2 `uAtmosphereClockWind`/ +`uAtmosphereWindAmplitude` members `AtmosphericPostProcessGraph.ResolveFoliageWind` +resolves once per `frame.Serial` (so the caster, which runs first each +frame, and the receiver read byte-identical values). `FoliageWindModel` is +the CPU mirror pinned by hermetic tests. Known limitation, documented and +accepted rather than engineered around: classification is per mesh-subset +(a `BatchData.flags` word shared by every instance drawn in one indirect +command), not per entity instance — the rare case of the exact same mesh +subset being reachable from both a procedural-scenery and a non-scenery +placement would classify all of that subset's instances alike. Register row +IA-25. Owed: the six-step owner visual gate above (implementation is +otherwise code-complete and the automated acceptance criteria pass). + ## VM7 — Closeout and merge - Full gates: `tools/run-release-gate.ps1` (hermetic lanes), the AR diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 7fbb005c..b8cadd25 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -296,7 +296,12 @@ internal sealed class FrameRootCompositionPhase renderPackController = new AcDream.App.Rendering.Packs.RenderPackController( renderPackCatalog.Snapshot, new AcDream.App.Rendering.Packs.AtmosphericRenderPackRuntimeFactory( - host.GpuDevice), + host.GpuDevice, + // Campaign VM VM6: the same ACDREAM_SKY_PHASE_SECONDS pin + // SkyRenderer uses, so the foliage-wind clock is + // deterministic under the same differential/offline + // gates — see ResolveFoliageWind's doc comment. + d.Options.SkyAnimationPhaseSeconds), new AcDream.App.Rendering.Packs.RenderPackReceiverPipelineCoordinator( foundation.Terrain!, live.DrawDispatcher!), diff --git a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs index accb930b..8fc7a684 100644 --- a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs +++ b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs @@ -725,7 +725,11 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS batch.TextureSlot.Index, 0u, batch.TextureLayer, - DirectionalShadowBatchFlags.Encode(batch.Material)); + // Campaign VM VM6: batch.FoliageFlags rides alongside + // the existing alpha-cutout bit in the same word the + // caster shaders read as BatchData.flags. + DirectionalShadowBatchFlags.Encode(batch.Material) + | batch.FoliageFlags); } ReadOnlySpan batchBytes = MemoryMarshal.AsBytes( diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs index 22e1d59d..f0ff1d4f 100644 --- a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs +++ b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs @@ -65,6 +65,65 @@ internal readonly record struct AtmosphericPostProcessSettings( } } +/// +/// Campaign VM VM6: the resolved foliage-wind pack settings — the CPU-side +/// counterpart of uAtmosphereWindAmplitude plus the global strength/ +/// direction/enabled inputs that scale it. Resolution mirrors +/// exactly +/// (declaration default, then preset override, then user override). +/// +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? 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? 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? 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 _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? userSettingOverrides = null) + IReadOnlyDictionary? 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? userSettingOverrides = null) + IReadOnlyDictionary? 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)[]).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 : /// . 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 's computation. + /// receiver-only concerns). /// 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); } + /// + /// Campaign VM VM6: resolves this frame's uAtmosphereClockWind/ + /// uAtmosphereWindAmplitude values, advancing the weather-driven + /// smoothing state at most once per . Both + /// (which runs first) and + /// 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). + /// + /// The clock. Not wall time and not "seconds since + /// process/system start" — is a + /// started when this graph + /// was constructed, so its magnitude stays small (session-relative) for + /// GPU single-precision sin() accuracy over a long play session, + /// and it is genuinely monotonic (unlike DateTime.UtcNow). + /// — threaded from + /// RuntimeOptions.SkyAnimationPhaseSeconds (ACDREAM_SKY_PHASE_SECONDS), + /// the same pin uses + /// for its own animation clock — replaces it when set, which is every + /// run but a differential/offline gate's. + /// + /// The smoothing. The per-day-group (mean, gust) target + /// from + /// eases toward its target using an exponential moving average over + /// (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; wind-enabled + /// off or !isOutdoor 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. + /// + 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 => diff --git a/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs b/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs index f1bfb36f..055df9ae 100644 --- a/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs +++ b/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs @@ -309,6 +309,33 @@ internal static class BuiltInAtmosphericRenderPack null, []) with { Semantic = RenderSettingSemantic.AutomaticQuality }, + // Campaign VM VM6: foliage wind. wind-strength is a global multiplier + // over the per-day-group mean/gust table (AtmospherePolicy below); + // wind-direction-degrees has no authored retail wind direction to + // read (register row: "no authored wind direction" — foliage wind is + // itself a render-only approximation), so it is a plain pack default. + new RenderSettingDeclaration( + "wind-enabled", + "Foliage wind", + RenderSettingKind.Boolean, + "true", + null, + null, + null, + []) + with { Semantic = RenderSettingSemantic.WindEnabled }, + Float("wind-strength", "Foliage wind strength", RenderSettingSemantic.WindStrength, + 1.0, 0, 2, 0.05), + Float("wind-direction-degrees", "Foliage wind direction (degrees)", + RenderSettingSemantic.WindDirectionDegrees, 225, 0, 360, 5), + Float("wind-lean-metres", "Foliage lean amplitude (metres)", + RenderSettingSemantic.WindLeanMetres, 0.25, 0, 1, 0.01), + Float("wind-branch-metres", "Foliage branch-swing amplitude (metres)", + RenderSettingSemantic.WindBranchMetres, 0.15, 0, 1, 0.01), + Float("wind-flutter-metres", "Foliage flutter amplitude (metres)", + RenderSettingSemantic.WindFlutterMetres, 0.05, 0, 0.5, 0.005), + Float("wind-canopy-height-metres", "Foliage canopy height (metres)", + RenderSettingSemantic.WindCanopyHeightMetres, 8, 2, 30, 0.5), ]; private static AtmospherePolicyDeclaration AtmospherePolicy() => new( @@ -342,6 +369,22 @@ internal static class BuiltInAtmosphericRenderPack new SunElevationResponsePoint(70, 0), new SunElevationResponsePoint(90, 0), ], + // Campaign VM VM6: the plan's Clear/Cloudy/Overcast/Rainy rows, + // keyed by the SAME activeDayGroup index convention this pack + // already established two lines above for ActiveDayGroupMultipliers + // (0 brightest/clearest ... 2 dimmest). That table only needed + // three rows; foliage wind needs a fourth (Rainy), so index 3 is + // new here. Dereth's DAT declares many more named day groups than + // these four categories distinguish — an index absent from this + // table (including every index above 3) gets zero wind rather + // than guessing a category. + FoliageWindByDayGroup = + [ + new FoliageWindDayGroupPoint(0, 0.25, 0.15), // Clear + new FoliageWindDayGroupPoint(1, 0.45, 0.30), // Cloudy + new FoliageWindDayGroupPoint(2, 0.60, 0.35), // Overcast + new FoliageWindDayGroupPoint(3, 0.85, 0.60), // Rainy + ], }; private static RenderResourceDeclaration Image( @@ -443,6 +486,10 @@ internal static class BuiltInAtmosphericRenderPack // The renderer recognizes this bounded preset fact; it remains // visible here instead of becoming a hidden cascade constant. new RenderQualitySettingOverride("sun-ray-strength", id == "low" ? "0.4" : "0.55"), + // Campaign VM VM6: Low is lean + branch only, no flutter — the + // per-vertex hash term is the cheapest of the three to drop + // and the least visible at Low's other reduced settings. + new RenderQualitySettingOverride("wind-flutter-metres", id == "low" ? "0" : "0.05"), ], maxMiB * 1024 * 1024, gpuP50, diff --git a/src/AcDream.App/Rendering/Packs/FoliageWindModel.cs b/src/AcDream.App/Rendering/Packs/FoliageWindModel.cs new file mode 100644 index 00000000..a1ab5d3a --- /dev/null +++ b/src/AcDream.App/Rendering/Packs/FoliageWindModel.cs @@ -0,0 +1,91 @@ +using System.Numerics; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Rendering.Packs; + +/// +/// CPU-side mirror of foliage_wind.glsl's acdreamFoliageDisplace, +/// added by Campaign VM VM6. +/// +/// The GLSL is the source of truth for what actually renders — this +/// class exists only so the motion model's shape (identity on non-foliage +/// flags, identity when the wind is calm, zero displacement at the trunk +/// base, bounded displacement at the canopy top, no flutter on a trunk +/// subset, never-increasing height) can be pinned by fast CPU tests instead +/// of a GPU capture, matching the same pattern as +/// . Any change to +/// acdreamFoliageDisplace — the gust envelope constants, the lean/ +/// branch/flutter formulas, the bend-shortening term, the per-vertex hash — +/// MUST be mirrored here in the same commit, or this class silently stops +/// proving what the shader does. +/// +internal static class FoliageWindModel +{ + private const uint FoliageMask = + FoliageWindClassification.CutoutFoliageFlag | FoliageWindClassification.TrunkFlag; + + /// + /// Mirrors acdreamFoliageDisplace exactly. + /// is (elapsed seconds, wind mean [0..1], wind gust [0..1], wind + /// direction radians); is (lean m, branch + /// m, flutter m, max canopy height m) — the same layout as + /// uAtmosphereClockWind/uAtmosphereWindAmplitude. + /// + internal static Vector3 Displace( + Vector3 worldPos, + Vector3 instanceOrigin, + uint batchFlags, + Vector4 clockWind, + Vector4 amplitude) + { + if ((batchFlags & FoliageMask) == 0u) + return worldPos; + + float t = clockWind.X; + float mean = clockWind.Y; + float gust = clockWind.Z; + float dirA = clockWind.W; + + var dir = new Vector2(MathF.Cos(dirA), MathF.Sin(dirA)); + var perp = new Vector2(-dir.Y, dir.X); + + float h = Math.Clamp( + (worldPos.Z - instanceOrigin.Z) / MathF.Max(amplitude.W, 0.5f), + 0f, + 1f); + float k = h * h; + + float ph = Vector2.Dot( + new Vector2(instanceOrigin.X, instanceOrigin.Y), + new Vector2(0.137f, 0.291f)); + + float g = 0.5f + + (0.5f * MathF.Sin((0.05f * t) + ph)) + + (0.25f * MathF.Sin((0.13f * t) + (1.7f * ph))); + float s = mean + (gust * g); + + float lean = k * amplitude.X * s * (0.8f + (0.2f * MathF.Sin((0.35f * t) + ph))); + Vector2 d = dir * lean; + + if ((batchFlags & FoliageWindClassification.CutoutFoliageFlag) != 0u) + { + float branch = k * amplitude.Y * s * MathF.Sin((1.1f * t) + ph + (2.0f * h)); + var worldXY = new Vector2(worldPos.X, worldPos.Y); + float vh = Frac( + MathF.Sin(Vector2.Dot(worldXY, new Vector2(12.9898f, 78.233f))) * 43758.5453f); + float flutter = h * amplitude.Z * s * MathF.Sin((6.0f * t) + (7.0f * vh)); + d += (dir * branch) + + (perp * 0.35f * branch) + + (new Vector2(MathF.Cos(6.2832f * vh), MathF.Sin(6.2832f * vh)) * flutter); + } + + Vector3 p = worldPos; + p.X += d.X; + p.Y += d.Y; + p.Z -= 0.5f * Vector2.Dot(d, d) / MathF.Max(h * amplitude.W, 0.5f); + return p; + } + + /// GLSL fract: always non-negative, matches x - floor(x). + private static float Frac(float x) => x - MathF.Floor(x); +} diff --git a/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs b/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs index 410a7914..533cb8bc 100644 --- a/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs +++ b/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs @@ -44,6 +44,50 @@ internal static class RenderPackAtmospherePolicyEvaluation static value => (float)value, fallback); + /// + /// Campaign VM VM6: exact day-group lookup for foliage wind — the same + /// shape as 's exact-match pattern + /// (day groups are not ordered by "how windy," so there is nothing to + /// interpolate across them). A day group absent from the table returns + /// (0, 0) — no wind is the safe default for an unclassified day group, + /// not full wind. The caller is responsible for smoothing the resolved + /// target over time; this method is a pure, stateless lookup. + /// + internal static (float Mean, float Gust) FoliageWind( + IReadOnlyList? points, + int activeDayGroup) + { + if (points is null) + return (0f, 0f); + foreach (FoliageWindDayGroupPoint point in points) + { + if (point.ActiveDayGroup == activeDayGroup) + return ((float)point.Mean, (float)point.Gust); + } + return (0f, 0f); + } + + /// + /// Campaign VM VM6: one exponential-moving-average step toward + /// , used to smooth the foliage-wind mean/gust + /// strength across a day-group change so it never snaps. Pure and + /// stateless — the caller owns across calls. + /// should already be clamped to a sane + /// per-frame bound by the caller (a huge delta — e.g. after a long + /// pause — would otherwise jump the rate to 1 and snap anyway). + /// + internal static float EaseTowardTarget( + float current, + float target, + float deltaSeconds, + float transitionSeconds) + { + float rate = transitionSeconds <= 0f + ? 1f + : Math.Clamp(deltaSeconds / transitionSeconds, 0f, 1f); + return current + ((target - current) * rate); + } + internal static float VolumetricShaft( IReadOnlyList? points, float elevationDegrees, diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.vert index aa6a9ff7..26a15dc3 100644 --- a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.vert +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.vert @@ -2,6 +2,8 @@ #extension GL_ARB_shader_draw_parameters : require #include "directional_shadow_common.glsl" +#include "atmospheric_common.glsl" +#include "foliage_wind.glsl" layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal; @@ -31,10 +33,19 @@ flat out uint vShadowTextureLayer; void main() { int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; - vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0); + mat4 model = Instances[instanceIndex].transform; + BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB]; + vec4 worldPosition = model * vec4(aPosition, 1.0); + // Campaign VM VM6: weather-driven foliage sway — see foliage_wind.glsl. + // A no-op unless batch.flags carries the cutout (0x2) classification bit. + worldPosition.xyz = acdreamFoliageDisplace( + worldPosition.xyz, + model[3].xyz, + batch.flags, + uAtmosphereClockWind, + uAtmosphereWindAmplitude); gl_Position = uShadowWorldToClip[uRenderPass] * worldPosition; - BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB]; vShadowTexCoord = aTexCoord; vShadowTextureIndex = batch.textureIndex; vShadowTextureLayer = batch.textureLayer; diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.vert index a90ab9ab..4540d742 100644 --- a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.vert +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.vert @@ -3,6 +3,8 @@ #extension GL_EXT_multiview : require #include "directional_shadow_common.glsl" +#include "atmospheric_common.glsl" +#include "foliage_wind.glsl" layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal; @@ -29,9 +31,18 @@ flat out uint vShadowTextureLayer; void main() { int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; - vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0); - gl_Position = uShadowWorldToClip[int(gl_ViewIndex)] * worldPosition; + mat4 model = Instances[instanceIndex].transform; BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB]; + vec4 worldPosition = model * vec4(aPosition, 1.0); + // Campaign VM VM6: weather-driven foliage sway — see foliage_wind.glsl. + // A no-op unless batch.flags carries the cutout (0x2) classification bit. + worldPosition.xyz = acdreamFoliageDisplace( + worldPosition.xyz, + model[3].xyz, + batch.flags, + uAtmosphereClockWind, + uAtmosphereWindAmplitude); + gl_Position = uShadowWorldToClip[int(gl_ViewIndex)] * worldPosition; vShadowTexCoord = aTexCoord; vShadowTextureIndex = batch.textureIndex; vShadowTextureLayer = batch.textureLayer; diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.vert index 4bb615e9..af525c5d 100644 --- a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.vert +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.vert @@ -2,20 +2,44 @@ #extension GL_ARB_shader_draw_parameters : require #include "directional_shadow_common.glsl" +#include "atmospheric_common.glsl" +#include "foliage_wind.glsl" layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal; layout(location = 2) in vec2 aTexCoord; struct InstanceData { mat4 transform; }; +// Campaign VM VM6: this pipeline had no BatchData binding before — an opaque +// caster never needed a texture lookup. It reads batch.flags here purely for +// the foliage-wind trunk classification bit (0x4); textureIndex/textureLayer +// stay unread. +struct BatchData { + uint textureIndex; + uint _pad; + uint textureLayer; + uint flags; +}; layout(std430, binding = 0) readonly buffer InstanceBuffer { InstanceData Instances[]; }; +layout(std430, binding = 1) readonly buffer BatchBuffer { + BatchData Batches[]; +}; +uniform int uDrawIDOffset; uniform int uRenderPass; void main() { int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; - vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0); + mat4 model = Instances[instanceIndex].transform; + BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB]; + vec4 worldPosition = model * vec4(aPosition, 1.0); + worldPosition.xyz = acdreamFoliageDisplace( + worldPosition.xyz, + model[3].xyz, + batch.flags, + uAtmosphereClockWind, + uAtmosphereWindAmplitude); gl_Position = uShadowWorldToClip[uRenderPass] * worldPosition; } diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.vert index 17e85a72..eccf4682 100644 --- a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.vert +++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.vert @@ -3,18 +3,43 @@ #extension GL_EXT_multiview : require #include "directional_shadow_common.glsl" +#include "atmospheric_common.glsl" +#include "foliage_wind.glsl" layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal; layout(location = 2) in vec2 aTexCoord; struct InstanceData { mat4 transform; }; +// Campaign VM VM6: this pipeline had no BatchData binding before — an opaque +// caster never needed a texture lookup. It reads batch.flags here purely for +// the foliage-wind trunk classification bit (0x4); textureIndex/textureLayer +// stay unread. +struct BatchData { + uint textureIndex; + uint _pad; + uint textureLayer; + uint flags; +}; layout(std430, binding = 0) readonly buffer InstanceBuffer { InstanceData Instances[]; }; +layout(std430, binding = 1) readonly buffer BatchBuffer { + BatchData Batches[]; +}; + +uniform int uDrawIDOffset; void main() { int instanceIndex = gl_BaseInstanceARB + gl_InstanceID; - vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0); + mat4 model = Instances[instanceIndex].transform; + BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB]; + vec4 worldPosition = model * vec4(aPosition, 1.0); + worldPosition.xyz = acdreamFoliageDisplace( + worldPosition.xyz, + model[3].xyz, + batch.flags, + uAtmosphereClockWind, + uAtmosphereWindAmplitude); gl_Position = uShadowWorldToClip[int(gl_ViewIndex)] * worldPosition; } diff --git a/src/AcDream.App/Rendering/Shaders/foliage_wind.glsl b/src/AcDream.App/Rendering/Shaders/foliage_wind.glsl new file mode 100644 index 00000000..bd03ee37 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/foliage_wind.glsl @@ -0,0 +1,91 @@ +#ifndef ACDREAM_FOLIAGE_WIND_GLSL +#define ACDREAM_FOLIAGE_WIND_GLSL + +// Campaign VM VM6 (2026-08-22): weather-driven foliage sway for procedural +// scenery (trees/bushes — see ProceduralSceneryIdAllocator, bit 31 of the +// entity id). Shared verbatim between mesh_atmospheric.vert (the world +// receiver) and the four directional_shadow_world_*.vert casters so a +// displaced leaf's shadow moves with it by construction — the caster and +// receiver read the identical uAtmosphereClockWind/uAtmosphereWindAmplitude +// inputs from atmospheric_common.glsl and call this exact function. +// +// batchFlags bit 1 (0x2, FOLIAGE_CUTOUT) marks an alpha-cutout leaf/frond +// subset of a procedural-scenery entity; bit 2 (0x4, FOLIAGE_TRUNK) marks an +// opaque subset of a procedural-scenery entity that also owns a cutout +// subset (a tree trunk, not a rock). Both bits are computed once in +// WbDrawDispatcher.ClassifyBatches / AddDirectionalShadowBatches (host +// C#) — this include never re-derives them from geometry. +// +// Motion model (see docs/plans/2026-08-22-visualmaster-campaign.md's VM6 +// section for the full derivation): the whole tree leans slowly with the +// mean+gust wind, scaled by height-squared so the base never moves; cutout +// subsets additionally get a faster branch swing and a fast, per-vertex +// decorrelated flutter. Bend shortens the vertical extent (p.z -= ...) so a +// bent canopy sinks slightly instead of stretching — the cheap +// length-preserving correction. Normals are NOT rotated: Gouraud on a +// flipped-normal cutout leaf would flicker, and AC's flat-lit foliage does +// not need it. +// +// Budget: ~25 ALU ops per foliage vertex (early-out for every non-foliage +// vertex — one uint AND and a branch), zero CPU cost, zero extra draw +// submissions. See FoliageWindModel (AcDream.App.Rendering.Wb) for the +// bit-exact CPU mirror used by the hermetic conformance tests. +vec3 acdreamFoliageDisplace( + vec3 worldPos, + vec3 instanceOrigin, + uint batchFlags, + vec4 clockWind, + vec4 amp) +{ + if ((batchFlags & 0x6u) == 0u) + return worldPos; + + float t = clockWind.x; + float mean = clockWind.y; + float gust = clockWind.z; + float dirA = clockWind.w; + + vec2 dir = vec2(cos(dirA), sin(dirA)); + vec2 perp = vec2(-dir.y, dir.x); + + // 0 at the instance's own base, 1 at the declared canopy top. Height is + // measured from the instance ORIGIN (the translation column of its + // transform), not the vertex's local Z, so a leaned tree's own leaves + // still measure height from the trunk base correctly. + float h = clamp((worldPos.z - instanceOrigin.z) / max(amp.w, 0.5), 0.0, 1.0); + float k = h * h; + + // Per-tree phase from world position so neighbouring trees fall out of + // step with each other. + float ph = dot(instanceOrigin.xy, vec2(0.137, 0.291)); + + // Gust envelope: two slow sines beat against each other so gusts arrive + // and leave rather than pulsing at one fixed period. + float g = 0.5 + 0.5 * sin(0.05 * t + ph) + 0.25 * sin(0.13 * t + 1.7 * ph); + float s = mean + gust * g; + + // Slow whole-tree lean, height-squared scaled. + float lean = k * amp.x * s * (0.8 + 0.2 * sin(0.35 * t + ph)); + vec2 d = dir * lean; + + if ((batchFlags & 0x2u) != 0u) + { + // Cutout (leaves): branch swing at ~1 Hz with a phase that runs up + // the tree, plus a fast per-vertex flutter decorrelated by a hash of + // the vertex's own world XY so leaves on the same tree do not move + // in lockstep. + float branch = k * amp.y * s * sin(1.1 * t + ph + 2.0 * h); + float vh = fract(sin(dot(worldPos.xy, vec2(12.9898, 78.233))) * 43758.5453); + float flutter = h * amp.z * s * sin(6.0 * t + 7.0 * vh); + d += dir * branch + perp * 0.35 * branch + + vec2(cos(6.2832 * vh), sin(6.2832 * vh)) * flutter; + } + + vec3 p = worldPos; + p.xy += d; + // Bend shortens the vertical extent instead of stretching it. + p.z -= 0.5 * dot(d, d) / max(h * amp.w, 0.5); + return p; +} + +#endif diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert index a9816627..7dd43406 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert @@ -2,6 +2,8 @@ #extension GL_ARB_shader_draw_parameters : require #include "directional_shadow_common.glsl" +#include "atmospheric_common.glsl" +#include "foliage_wind.glsl" layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal; @@ -324,7 +326,21 @@ void main() { ? instanceSelectionLighting[instanceIndex] : vec2(0.0, 1.0); + BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; + vec4 worldPos = model * vec4(aPosition, 1.0); + // Campaign VM VM6: weather-driven foliage sway. acdreamFoliageDisplace is + // a no-op unless b.flags carries the cutout (0x2) or trunk (0x4) + // classification bit — see foliage_wind.glsl. Applied before gl_Position + // so clip distances, lighting, and the fragment stage all see the + // displaced position; the four directional-shadow caster vertex shaders + // call the identical include so the shadow moves with the same vertex. + worldPos.xyz = acdreamFoliageDisplace( + worldPos.xyz, + model[3].xyz, + b.flags, + uAtmosphereClockWind, + uAtmosphereWindAmplitude); gl_Position = uViewProjection * worldPos; // Phase U.3: per-instance clip gate. instanceClipSlot is indexed by the @@ -354,10 +370,11 @@ void main() { : 0u; vTexCoord = aTexCoord; - BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; // Campaign V slice V6e: forward the table SLOT untouched. V2 looked the // handle up here and passed the handle; the lookup now lives at the sample // site in mesh_modern.frag, which is the only form Vulkan can express. + // (b was fetched earlier, before worldPos, so acdreamFoliageDisplace + // could read its flags — Campaign VM VM6.) vTextureIndex = b.textureIndex; vTextureLayer = b.textureLayer; } diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.vert.spv index 6e077a14..573e5db9 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.vert.spv index 028829ea..6c6892f9 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.vert.spv index 8b49cd0f..a1250943 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.vert.spv index 4b3f2f7b..ea2d05cd 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv index 108e15fd..31404c4f 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 85f22c71..e367da29 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -151,7 +151,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "8331583c1d63ee59b3f7898e25b2e9730df98fd9da28273661caad948fb46ae5", + "sourceSha256": "fe67e9bff0e528b08fa699f0be17d1c49660ad63ab34f7ac7dd31f79a2253006", "compiled": true }, { @@ -167,7 +167,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "a4bb3b86283af310a22d3943dd1afadb8a72b42c9e5501efc9abf2b5124a1446", + "sourceSha256": "2a155a7964ff15bd936394b8ffd3a331d5598e140ca36268759520bab86b1bdc", "compiled": true }, { @@ -183,7 +183,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "c2586df5f09518c40d052bbacdfff2c00a82b1eefec4868749d0580571198067", + "sourceSha256": "74b1aa64396dd74185d644de7e9f40626c9422f335da60deb7c606f2a45d92c2", "compiled": true }, { @@ -199,7 +199,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "35b4d524153623691ab523a049212aa35551f96c169c28c08f62d93e31d9e68d", + "sourceSha256": "f15425837f7a8f84337f4493989bcd7cb1af87f916f7678fc016947f68d26be5", "compiled": true }, { @@ -215,7 +215,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "d4f8bc12ee84379ced84f5b703cf8be95798a23a7063773e214bc1eb6ebbb65e", + "sourceSha256": "7e3e49483024145bb71929f715a68087380e5126e15b5bef2c1c5c7d5e2e3516", "compiled": true }, { diff --git a/src/AcDream.App/Rendering/Wb/CachedBatch.cs b/src/AcDream.App/Rendering/Wb/CachedBatch.cs index 3cadb4b1..b4f219db 100644 --- a/src/AcDream.App/Rendering/Wb/CachedBatch.cs +++ b/src/AcDream.App/Rendering/Wb/CachedBatch.cs @@ -27,7 +27,15 @@ internal readonly record struct CachedBatch( Matrix4x4 RestPose, Vector3 LocalSortCenter = default, WbDrawDispatcher.InstanceGroup? Group = null, - long GroupRegistration = 0); + long GroupRegistration = 0, + // Campaign VM VM6: the foliage-wind classification bits ClassifyBatches + // computed for this subset (0 for anything that isn't procedural-scenery + // foliage). Preserved here — not just on the live InstanceGroup — so a + // cache-hit replay that recreates an evicted InstanceGroup (see + // WbDrawDispatcher.ApplyCacheHitDirect's stale-registration path) + // re-stamps the correct flags instead of leaving a freshly-recreated + // group at its zero default. + uint FoliageFlags = 0u); /// /// Immutable retail-picking descriptor for one static entity part. The cache diff --git a/src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs b/src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs new file mode 100644 index 00000000..1270410b --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs @@ -0,0 +1,62 @@ +using AcDream.Core.Meshing; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Campaign VM VM6: the two BatchData.flags bits that mark a world +/// draw batch as swaying foliage — bit 0 remains #226's built-mesh marker +/// (see mesh_detail.frag's vBatchFlags & 1u check) and is +/// never touched here. mesh_atmospheric.vert and the four +/// directional_shadow_world_*.vert caster shaders read these bits +/// through the shared foliage_wind.glsl include +/// (acdreamFoliageDisplace's batchFlags & 0x6u gate); the +/// retail mesh_modern pipelines never read them, so setting them does +/// not change pack-off output. +/// +internal static class FoliageWindClassification +{ + /// + /// Bit 1: the subset is an alpha-cutout/ClipMap material AND its owning + /// entity is procedural scenery (see ). Leaves, + /// fronds, bushes, grass tufts. + /// + internal const uint CutoutFoliageFlag = 0x2u; + + /// + /// Bit 2: the subset is opaque AND its owning entity is procedural + /// scenery AND that same entity owns at least one cutout subset — a + /// tree trunk or branch, not a rock (rocks have no cutout subset). + /// + internal const uint TrunkFlag = 0x4u; + + private const uint SceneryEntityIdBit = 0x8000_0000u; + + /// + /// Pure classification: no allocation, no scan. + /// is expected to be computed once per GfxObj/Setup mesh and cached with + /// the mesh record (), + /// not recomputed per call. + /// + internal static uint Classify( + uint entityId, + bool isExcluded, + TranslucencyKind translucency, + bool meshHasCutoutSubset) + { + if (!IsProceduralScenery(entityId) || isExcluded) + return 0u; + if (translucency == TranslucencyKind.ClipMap) + return CutoutFoliageFlag; + if (translucency == TranslucencyKind.Opaque && meshHasCutoutSubset) + return TrunkFlag; + return 0u; + } + + /// + /// Bit 31 of the entity id — see ProceduralSceneryIdAllocator's + /// 0x8XXYYIII namespace. No other consumer decodes more than this + /// one bit. + /// + internal static bool IsProceduralScenery(uint entityId) => + (entityId & SceneryEntityIdBit) != 0u; +} diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index b83bd585..90874a57 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -40,6 +40,19 @@ namespace AcDream.App.Rendering.Wb public uint VBO { get; set; } public int VertexCount { get; set; } public List Batches { get; set; } = new(); + + /// + /// Campaign VM VM6: true when at least one of + /// is an alpha-cutout (TranslucencyKind.ClipMap) material — + /// computed once here, when the mesh is built/cached, not re-scanned + /// per instance or per frame. Used by + /// to tell a tree's + /// opaque trunk (a procedural-scenery entity whose mesh has a cutout + /// subset) apart from a rock (a procedural-scenery entity whose mesh + /// has none). + /// + public bool HasCutoutSubset { get; set; } + internal GlobalMeshAllocation? GlobalAllocation { get; set; } public bool IsSetup { get; set; } public List<(ulong GfxObjId, Matrix4x4 Transform)> SetupParts { get; set; } = new(); @@ -2154,6 +2167,9 @@ namespace AcDream.App.Rendering.Wb { VertexCount = meshData.Vertices.Length, Batches = renderBatches, + HasCutoutSubset = renderBatches.Any( + static batch => batch.Translucency + == AcDream.Core.Meshing.TranslucencyKind.ClipMap), GlobalAllocation = globalAllocation, ParticleEmitters = meshData.ParticleEmitters, DIDDegrade = meshData.DIDDegrade, diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs index db939f12..fd00b9ba 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs @@ -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); } } } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 6c36b5ea..55febcde 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -483,6 +483,20 @@ public sealed partial class WbDrawDispatcher : IDisposable /// public bool AlphaToCoverage { get; set; } = true; + /// + /// Campaign VM VM6: object/GfxObj ids the active render pack's + /// AtmospherePolicyDeclaration.FoliageExclusions declares — + /// scenery meshes that must never sway even though their entity id and + /// material would otherwise classify them as foliage. Empty (no + /// exclusions) when no atmospheric pack is active. Assigned every frame + /// by AtmosphericPostProcessGraph.RenderDirectionalShadows from + /// the currently-selected pack's declaration — self-contained by + /// construction, so a pack switch or deactivation can never leave a + /// stale exclusion set applied. + /// + public IReadOnlySet FoliageWindExclusions { get; set; } = + System.Collections.Frozen.FrozenSet.Empty; + // Phase U.3: per-instance clip-slot data (binding=3 on the RHI ring). One // uint per instance selecting its CellClip slot. In U.3 this is ALL ZEROS // (every instance → slot 0 → no-clip), so the render is identical to @@ -2292,7 +2306,8 @@ public sealed partial class WbDrawDispatcher : IDisposable TextureIndex: g.TextureSlot.Index, TextureLayer: g.TextureLayer, Translucency: g.Translucency, - CullMode: g.CullMode); + CullMode: g.CullMode, + FoliageFlags: g.FoliageFlags); internal readonly record struct InstanceLayoutCounts( int VisibleInstances, @@ -2981,11 +2996,15 @@ public sealed partial class WbDrawDispatcher : IDisposable internal static void ApplyCacheHit( EntityCacheEntry entry, Matrix4x4 entityWorld, - Action appendInstance) + Action appendInstance) { foreach (var cached in entry.Batches) { - appendInstance(cached.Key, cached.RestPose * entityWorld, cached.LocalSortCenter); + appendInstance( + cached.Key, + cached.RestPose * entityWorld, + cached.LocalSortCenter, + cached.FoliageFlags); } } @@ -3014,7 +3033,7 @@ public sealed partial class WbDrawDispatcher : IDisposable GroupRegistration = group.Registration, }; } - AppendInstanceToGroup(group!, model, cached.LocalSortCenter); + AppendInstanceToGroup(group!, model, cached.LocalSortCenter, cached.FoliageFlags); } } @@ -3125,10 +3144,14 @@ public sealed partial class WbDrawDispatcher : IDisposable /// creates an for the given key in /// _groups and appends the per-instance world matrix. /// - private void AppendInstanceToGroup(GroupKey key, Matrix4x4 model, Vector3 localSortCenter) + private void AppendInstanceToGroup( + GroupKey key, + Matrix4x4 model, + Vector3 localSortCenter, + uint foliageFlags) { InstanceGroup grp = GetOrCreateInstanceGroup(key); - AppendInstanceToGroup(grp, model, localSortCenter); + AppendInstanceToGroup(grp, model, localSortCenter, foliageFlags); } private InstanceGroup GetOrCreateInstanceGroup(GroupKey key) @@ -3164,9 +3187,16 @@ public sealed partial class WbDrawDispatcher : IDisposable private void AppendInstanceToGroup( InstanceGroup grp, Matrix4x4 model, - Vector3 localSortCenter) + Vector3 localSortCenter, + uint foliageFlags) { grp.LastUsedFrame = _groupFrame; + // Campaign VM VM6: re-stamp on every append (idempotent — the value + // is a deterministic function of the mesh subset the group's key + // already identifies) so a stale-registration replay that recreates + // an evicted InstanceGroup (see ApplyCacheHitDirect above) never + // leaves it at its zero default. + grp.FoliageFlags = foliageFlags; grp.Matrices.Add(model); grp.LocalSortCenters.Add(localSortCenter); grp.SubmissionOrders.Add(_nextInstanceSubmissionOrder++); @@ -3351,13 +3381,23 @@ public sealed partial class WbDrawDispatcher : IDisposable AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices grp.SelectionLighting.Add(_currentEntitySelectionLighting); + // Campaign VM VM6: classify from the RAW (pre-#188-promotion) + // batch.Translucency — a mid-fade trunk is still a trunk, it + // just landed in the alpha-blend group instead of opaque. + uint foliageFlags = FoliageWindClassification.Classify( + entity.LocalEntityId, + FoliageWindExclusions.Contains(meshRef.GfxObjId), + batch.Translucency, + renderData.HasCutoutSubset); + grp.FoliageFlags = foliageFlags; collector?.Add(new CachedBatch( key, texSlot, restPose, renderData.SortCenter, grp, - grp.Registration)); + grp.Registration, + foliageFlags)); } return allTexturesReady; } @@ -3565,7 +3605,11 @@ public sealed partial class WbDrawDispatcher : IDisposable uint TextureIndex, uint TextureLayer, TranslucencyKind Translucency, - CullMode CullMode = CullMode.CounterClockwise); + CullMode CullMode = CullMode.CounterClockwise, + // Campaign VM VM6: FoliageWindClassification.CutoutFoliageFlag / + // TrunkFlag, OR'd into BatchDataPublic.Flags alongside #226's bit 0 + // by BuildIndirectArrays. Zero for every non-foliage group. + uint FoliageFlags = 0u); /// /// Public mirror of the per-group uploaded to the SSBO. @@ -3631,7 +3675,10 @@ public sealed partial class WbDrawDispatcher : IDisposable // #226: this is the built-mesh path. Retail DrawMesh passes // curr_detail_surface through RenderMeshSubset for opaque, // ClipMap, alpha, additive and inverse-alpha subsets alike. - Flags = 1u, + // Campaign VM VM6: bits 1/2 (FoliageWindClassification) ride + // in the same word — mesh_modern never reads them, so this + // is a no-op for the retail pack-off path. + Flags = 1u | g.FoliageFlags, }; if (IsOpaque(g.Translucency)) @@ -3798,6 +3845,17 @@ public sealed partial class WbDrawDispatcher : IDisposable public CullMode CullMode; public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes) public int InstanceCount; + + // Campaign VM VM6: foliage-wind classification bits for every + // instance in this group (0x2 cutout / 0x4 trunk / 0 neither), OR'd + // into BatchData.flags alongside #226's bit 0 at BuildIndirectArrays. + // Group-level, not per-instance, because BatchData is read once per + // draw call (Batches[gl_DrawIDARB]) — every instance sharing one + // mesh-subset draw shares its classification. Set by ClassifyBatches + // on a fresh classification and re-stamped by AppendInstanceToGroup + // on every cache-hit replay (see CachedBatch.FoliageFlags). + public uint FoliageFlags; + public float SortDistance; // squared distance from camera to first instance, for opaque sort public readonly List Matrices = new(); diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs index d93fb470..d261948b 100644 --- a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs +++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs @@ -366,6 +366,14 @@ public enum RenderSettingSemantic VolumetricStrength, VolumetricRayMarchSteps, AutomaticQuality, + // Campaign VM VM6: foliage wind. + WindEnabled, + WindStrength, + WindDirectionDegrees, + WindLeanMetres, + WindBranchMetres, + WindFlutterMetres, + WindCanopyHeightMetres, } /// One point on a declared sun-elevation response curve. @@ -378,6 +386,16 @@ public sealed record ActiveDayGroupMultiplier( int ActiveDayGroup, double Multiplier); +/// +/// Explicit mapping from an authored AC day group to the foliage-wind mean +/// and gust strength for that group (both in the declared [0,1] range before +/// the pack's global wind-strength setting scales them). Campaign VM VM6. +/// +public sealed record FoliageWindDayGroupPoint( + int ActiveDayGroup, + double Mean, + double Gust); + /// Visible authored-atmosphere interpretation owned by the pack. public sealed record AtmospherePolicyDeclaration( IReadOnlyList SunElevationResponse, @@ -404,6 +422,32 @@ public sealed record AtmospherePolicyDeclaration( /// public IReadOnlyList VolumetricShaftSunElevationResponse { get; init; } = []; + + /// + /// Campaign VM VM6: exact per-day-group foliage-wind mean/gust targets, + /// looked up by the same categorical activeDayGroup id + /// already keys on (an exact + /// match, not an interpolation across day-group ids — day groups are not + /// ordered by "how windy"). The host smooths the resolved (mean, gust) + /// target toward whatever this table returns over time using the + /// existing weather delta-seconds clock, so a day-group change never + /// snaps. A day group absent from this table gets zero wind. A pack + /// using foliage wind should declare an entry for every day group it + /// cares about distinguishing. + /// + public IReadOnlyList FoliageWindByDayGroup + { get; init; } = []; + + /// + /// Campaign VM VM6: object/GfxObj ids excluded from foliage-wind sway + /// even though their entity id falls in the procedural-scenery + /// namespace and their subset classification would otherwise qualify — + /// the rare scenery object that is cutout-material but not actually + /// foliage (e.g. a cutout rock or fence prop drawn from the scenery + /// table). There is no include list: outside this exclusion set, the + /// classification rule in WbDrawDispatcher is the rule. + /// + public IReadOnlyList FoliageExclusions { get; init; } = []; } /// diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs index 928af0ac..7dbbec94 100644 --- a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs +++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs @@ -42,7 +42,11 @@ public static class RenderPackSpirvValidator or RenderSemanticInput.DirectionalShadowMaps); bool directionalDepth = pass.Semantic == RenderPassSemantic.DirectionalShadowDepth; var access = new AllowedInterface( - StorageBindings: directionalDepth ? [0u] : [], + // Campaign VM VM6: set 0 binding 1 (BatchBuffer) joins binding 0 + // for the directional-shadow-depth pass — the opaque caster now + // reads batch.flags for the trunk classification bit, matching + // the caster PIPELINE VARIANT allow-list below. + StorageBindings: directionalDepth ? [0u, 1u] : [], UniformBindings: [], AllowSampledTable: declaresSampledInput, AllowAtmosphericFrame: directionalDepth || UsesAtmosphericFrame(pass.SemanticInputs), @@ -72,20 +76,26 @@ public static class RenderPackSpirvValidator // clock/wind inputs — the shadow must move with the leaf, and the // shared include is what keeps caster and receiver from drifting // apart. Terrain casters are untouched and keep AllowAtmosphericFrame - // false. + // false. The two opaque caster variants also gain set 0 binding + // 1 (BatchBuffer) — unlike the cutout variants they don't sample + // a texture from it, only read batch.flags for the trunk + // classification bit. RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster => - new([0u], [], false, true, true, false, false), + new([0u, 1u], [], false, true, true, false, false), RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster => - new([0u], [], false, true, true, false, false), + new([0u, 1u], [], false, true, true, false, false), RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster => new([0u, 1u], [], true, true, true, false, false), RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster => new([0u, 1u], [], true, true, true, false, false), RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver => new([], [1u, 2u, 3u], true, false, true, false, true), + // Campaign VM VM6: mesh_atmospheric.vert now reads + // uAtmosphereClockWind/uAtmosphereWindAmplitude for the same + // foliage-wind displacement the casters apply. RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver => new([0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u], [1u], - true, false, true, false, true), + true, true, true, false, true), _ => new( [], [], diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs index 3a0f78ca..7ac82c34 100644 --- a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs @@ -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 { ["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() + .Last(call => call.Binding == GpuBindingModel.UniformAtmosphericFrame); + return MemoryMarshal.Read( + 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? 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( diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericShaderAbiTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericShaderAbiTests.cs index 3338ddce..a1fb54e8 100644 --- a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericShaderAbiTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericShaderAbiTests.cs @@ -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() { diff --git a/tests/AcDream.App.Tests/Rendering/Packs/FoliageWindModelTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/FoliageWindModelTests.cs new file mode 100644 index 00000000..f49562ac --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/FoliageWindModelTests.cs @@ -0,0 +1,215 @@ +using System.Numerics; +using AcDream.App.Rendering.Packs; +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Tests.Rendering.Packs; + +/// +/// Campaign VM VM6: , the CPU mirror of +/// foliage_wind.glsl's acdreamFoliageDisplace. 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. +/// +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()})"); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Packs/RenderPackAtmospherePolicyEvaluationTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackAtmospherePolicyEvaluationTests.cs new file mode 100644 index 00000000..165a39b7 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Packs/RenderPackAtmospherePolicyEvaluationTests.cs @@ -0,0 +1,146 @@ +using AcDream.App.Rendering.Packs; +using AcDream.Plugin.Abstractions.Rendering; + +namespace AcDream.App.Tests.Rendering.Packs; + +/// +/// Campaign VM VM6: +/// (the exact per-day-group mean/gust lookup) and +/// (the +/// smoothing step that keeps a day-group change from snapping). +/// +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); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/FoliageWindClassificationTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/FoliageWindClassificationTests.cs new file mode 100644 index 00000000..9dc43f36 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/FoliageWindClassificationTests.cs @@ -0,0 +1,109 @@ +using AcDream.App.Rendering.Wb; +using AcDream.Core.Meshing; + +namespace AcDream.App.Tests.Rendering.Wb; + +/// +/// Campaign VM VM6: — the pure +/// function WbDrawDispatcher.ClassifyBatches (the world receiver) and +/// WbDrawDispatcher.AddDirectionalShadowBatches (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. +/// +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)); + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs index f0740973..1849f8a7 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs @@ -488,7 +488,7 @@ public sealed class WbDrawDispatcherBucketingTests // at the top of the per-entity loop body in Draw. var groups = new Dictionary>(); var sortCenters = new List(); - 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++)