feat(render): weather-driven foliage wind for procedural scenery, shadows follow (Campaign VM VM6b)
Procedural-scenery foliage (trees/bushes — entity ids in the ProceduralSceneryIdAllocator's 0x8XXYYIII namespace) sways with weather in mesh_atmospheric.vert and all four directional_shadow_world_* caster vertex shaders, both calling the identical new foliage_wind.glsl include so the shadow moves with the leaf by construction. Classification (FoliageWindClassification, AcDream.App.Rendering.Wb): two new BatchData.flags bits, computed once per (entity, subset) from four inputs — entity id (bit 31 for procedural scenery), the pack's declared FoliageExclusions membership, the subset's TranslucencyKind, and ObjectRenderData.HasCutoutSubset (computed once per mesh at build time, not per frame). Bit 1 marks an alpha-cutout leaf subset; bit 2 marks an opaque trunk subset (only when its own mesh also owns a cutout subset, so rocks stay still). WbDrawDispatcher.ClassifyBatches (world receiver) and AddDirectionalShadowBatches (caster) call this with the same four inputs, so casters and receivers classify identically without needing to share state. Retail's mesh_modern/terrain_modern/mesh_detail pipelines never read these bits, so pack-off output is unaffected. Motion model (foliage_wind.glsl, mirrored bit-for-bit in the new FoliageWindModel for hermetic CPU tests): height-squared-scaled slow lean for every foliage subset, plus branch swing and per-vertex-hash-decorrelated flutter for cutout subsets only. AtmosphericPostProcessGraph.ResolveFoliageWind resolves the wind block once per frame.Serial — advanced by whichever of RenderDirectionalShadows (which runs first) or RenderPostProcess is called first that frame, with the second reading the already-advanced state, which is what keeps the caster and receiver reading byte-identical clock/strength values. The per-day-group mean/gust target (AtmospherePolicyDeclaration. FoliageWindByDayGroup, keyed by the same day-group index convention ActiveDayGroupMultipliers already established: Clear/Cloudy/Overcast/Rainy) eases toward its target over WeatherSystem.TransitionSeconds (10s) so a weather change never snaps; wind-enabled off or indoor instead gates the OUTPUT to an exact zero (not an asymptotic approach) so a settings toggle or cell transition is immediate. The wind clock is a Stopwatch started at graph construction (monotonic, session-relative magnitude for GPU sin() accuracy), overridable by the same ACDREAM_SKY_PHASE_SECONDS pin SkyRenderer already uses, for deterministic offline gates. New settings: wind-enabled, wind-strength, wind-direction-degrees (225° default — no authored retail wind direction exists to read), wind-lean-metres, wind-branch-metres, wind-flutter-metres (0 on Low), wind-canopy-height-metres. Register row IA-25 files this as an intentional, strictly opt-in divergence: retail applies no per-vertex wind displacement to any geometry. Known, accepted limitation: classification is per mesh-subset (one BatchData.flags word per indirect-draw batch), not per entity instance, so the rare case of one mesh subset being reachable from both a procedural-scenery and a non-scenery placement would classify all of that subset's instances alike. Tests: FoliageWindClassificationTests (the full classification matrix), FoliageWindModelTests (identity on non-foliage/calm-wind/base-vertex, canopy-top displacement bound, z-never-increases, trunk has no flutter term), RenderPackAtmospherePolicyEvaluationTests (exact day-group lookup, no interpolation across day-group ids, easing convergence without overshoot or discontinuity), AtmosphericShaderAbiTests (each of the five shaders calls acdreamFoliageDisplace exactly once; mesh_modern/terrain/mesh_detail call it never), and four AtmosphericPostProcessGraphTests additions (indoor/disabled exact-zero gating, settings-to-UBO wiring, same-frame-Serial idempotency — the last proxies the caster/receiver agreement invariant without needing this hermetic harness's WbDrawDispatcher/TerrainModernRenderer dependency chain to exercise RenderDirectionalShadows directly). App hermetic filter: 6015/6017 (the same 2 pre-existing failures as VM6a, confirmed unrelated). Core.Tests hermetic: 4697/4697. RenderPackValidator.Tests: 30/30. Full solution Debug and Release builds green. Shader recompile touched exactly the 5 edited files' .spv (plus manifest); the retail oracle set and every other pack shader are byte-identical. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0930c35d1d
commit
39e8408c7d
34 changed files with 1536 additions and 64 deletions
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!),
|
||||
|
|
|
|||
|
|
@ -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<byte> batchBytes = MemoryMarshal.AsBytes(
|
||||
|
|
|
|||
|
|
@ -65,6 +65,65 @@ internal readonly record struct AtmosphericPostProcessSettings(
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: the resolved foliage-wind pack settings — the CPU-side
|
||||
/// counterpart of <c>uAtmosphereWindAmplitude</c> plus the global strength/
|
||||
/// direction/enabled inputs that scale it. Resolution mirrors
|
||||
/// <see cref="AtmosphericPostProcessSettings.FromDescriptor"/> exactly
|
||||
/// (declaration default, then preset override, then user override).
|
||||
/// </summary>
|
||||
internal readonly record struct FoliageWindSettings(
|
||||
bool Enabled,
|
||||
float Strength,
|
||||
float DirectionDegrees,
|
||||
float LeanMetres,
|
||||
float BranchMetres,
|
||||
float FlutterMetres,
|
||||
float CanopyHeightMetres)
|
||||
{
|
||||
internal static FoliageWindSettings FromDescriptor(
|
||||
RenderPackDescriptor descriptor,
|
||||
RenderQualityPreset preset,
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(preset);
|
||||
return new FoliageWindSettings(
|
||||
ReadBool(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindEnabled, true),
|
||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindStrength, 1f),
|
||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindDirectionDegrees, 225f),
|
||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindLeanMetres, 0.25f),
|
||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindBranchMetres, 0.15f),
|
||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindFlutterMetres, 0.05f),
|
||||
Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.WindCanopyHeightMetres, 8f));
|
||||
}
|
||||
|
||||
private static float Read(
|
||||
RenderPackDescriptor descriptor,
|
||||
RenderQualityPreset preset,
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides,
|
||||
RenderSettingSemantic semantic,
|
||||
float fallback)
|
||||
{
|
||||
RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(value =>
|
||||
value.Semantic == semantic);
|
||||
if (setting is null)
|
||||
return fallback;
|
||||
string value = RenderPackSettingResolution.Resolve(setting, preset, userSettingOverrides);
|
||||
return RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded)
|
||||
? encoded
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static bool ReadBool(
|
||||
RenderPackDescriptor descriptor,
|
||||
RenderQualityPreset preset,
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides,
|
||||
RenderSettingSemantic semantic,
|
||||
bool fallback) =>
|
||||
Read(descriptor, preset, userSettingOverrides, semantic, fallback ? 1f : 0f) > 0.5f;
|
||||
}
|
||||
|
||||
internal interface IAtmosphericWorldGraphRuntime : IRenderPackRuntime
|
||||
{
|
||||
IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount);
|
||||
|
|
@ -112,6 +171,26 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
private readonly AtmosphericPostProcessSettings _settings;
|
||||
private readonly float _shadowStrength;
|
||||
private readonly PackSettingsUniforms _packSettings;
|
||||
// Campaign VM VM6: foliage wind. _foliageWindExclusions is resolved once
|
||||
// at construction from the descriptor's declared policy (immutable for
|
||||
// the pack's lifetime, like every other setting here); _windClock is a
|
||||
// monotonic, session-relative (NOT wall-clock, NOT since-boot) seconds
|
||||
// source — see BuildFoliageWindClockAndAmplitude's doc comment for why.
|
||||
// The remaining fields are the per-frame smoothing state: advanced at
|
||||
// most once per frame.Serial regardless of which of
|
||||
// RenderDirectionalShadows/RenderPostProcess runs first that frame, so
|
||||
// the caster and receiver always read byte-identical values within one
|
||||
// frame (see D2/D4 — the shadow must move with the leaf).
|
||||
private readonly FoliageWindSettings _foliageWind;
|
||||
private readonly IReadOnlySet<uint> _foliageWindExclusions;
|
||||
private readonly float? _windClockSecondsOverride;
|
||||
private readonly System.Diagnostics.Stopwatch _windClock =
|
||||
System.Diagnostics.Stopwatch.StartNew();
|
||||
private long _windFrameSerial = -1;
|
||||
private float _windClockSeconds;
|
||||
private float _windLastAdvanceClockSeconds;
|
||||
private float _windMean;
|
||||
private float _windGust;
|
||||
private readonly DirectionalSunShadowRenderer _directionalShadows;
|
||||
private readonly VolumetricShaftRenderer? _volumetric;
|
||||
private readonly bool _fuseLowPostProcess;
|
||||
|
|
@ -150,14 +229,16 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
IRenderPackAssets assets,
|
||||
RenderQualityPreset preset,
|
||||
AtmosphericPostProcessSettings? settings = null,
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides = null)
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides = null,
|
||||
float? windClockSecondsOverride = null)
|
||||
: this(
|
||||
device,
|
||||
descriptor,
|
||||
RenderPackShaderAssets.Validate(descriptor, assets),
|
||||
preset,
|
||||
settings,
|
||||
userSettingOverrides)
|
||||
userSettingOverrides,
|
||||
windClockSecondsOverride)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +248,8 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
ValidatedRenderPackShaderAssets assets,
|
||||
RenderQualityPreset preset,
|
||||
AtmosphericPostProcessSettings? settings = null,
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides = null)
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides = null,
|
||||
float? windClockSecondsOverride = null)
|
||||
{
|
||||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||
Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor));
|
||||
|
|
@ -224,6 +306,14 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
descriptor,
|
||||
preset,
|
||||
userSettingOverrides);
|
||||
_foliageWind = FoliageWindSettings.FromDescriptor(
|
||||
descriptor,
|
||||
preset,
|
||||
userSettingOverrides);
|
||||
_foliageWindExclusions =
|
||||
(descriptor.AtmospherePolicy?.FoliageExclusions
|
||||
?? (IReadOnlyList<uint>)[]).ToHashSet();
|
||||
_windClockSecondsOverride = windClockSecondsOverride;
|
||||
_packSettings = PackSettingsUniforms.Create(
|
||||
descriptor,
|
||||
preset,
|
||||
|
|
@ -323,8 +413,14 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
* _shadowStrength,
|
||||
0f,
|
||||
1f));
|
||||
// Campaign VM VM6: self-contained by construction — set every frame
|
||||
// from the currently-active pack's declared policy so a pack switch
|
||||
// or deactivation can never leave a stale exclusion set applied to
|
||||
// the dispatcher (see FoliageWindExclusions's doc comment).
|
||||
worldMeshes.FoliageWindExclusions = _foliageWindExclusions;
|
||||
bool isOutdoor = world.Roots.RenderSky && !world.Roots.CameraInsideCell;
|
||||
AtmosphericFrameBufferBinding shadowAtmosphericFrame =
|
||||
BuildShadowAtmosphericFrameBinding(frame);
|
||||
BuildShadowAtmosphericFrameBinding(frame, activeDayGroup, isOutdoor);
|
||||
var input = new DirectionalSunShadowRenderInput(
|
||||
environment,
|
||||
world.Camera.Camera.View,
|
||||
|
|
@ -380,12 +476,17 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
/// <see cref="DirectionalSunShadowRenderer.RenderPrepared"/>. Only the
|
||||
/// two appended VM6 members carry real content; the caster shaders never
|
||||
/// read the other seven (sun/weather/policy/inverse-view-projection are
|
||||
/// receiver-only concerns), so this method leaves them zeroed rather
|
||||
/// than duplicating <see cref="RenderPostProcess"/>'s computation.
|
||||
/// receiver-only concerns).
|
||||
/// </summary>
|
||||
private AtmosphericFrameBufferBinding BuildShadowAtmosphericFrameBinding(
|
||||
IGpuFrame frame)
|
||||
IGpuFrame frame,
|
||||
int activeDayGroup,
|
||||
bool isOutdoor)
|
||||
{
|
||||
(Vector4 clockWind, Vector4 windAmplitude) = ResolveFoliageWind(
|
||||
frame.Serial,
|
||||
activeDayGroup,
|
||||
isOutdoor);
|
||||
GpuRingAllocation allocation = frame.AllocateRing(
|
||||
AtmosphericFrameUniforms.SizeInBytes,
|
||||
GpuRingUsage.Uniform);
|
||||
|
|
@ -397,10 +498,8 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
Vector4.Zero,
|
||||
Vector4.Zero,
|
||||
Matrix4x4.Identity,
|
||||
// VM6a: ABI v2 plumbing only. VM6b fills the real weather-driven
|
||||
// wind block here; no caster shader reads it yet.
|
||||
Vector4.Zero,
|
||||
Vector4.Zero);
|
||||
clockWind,
|
||||
windAmplitude);
|
||||
MemoryMarshal.Write(allocation.Data, in uniforms);
|
||||
return new AtmosphericFrameBufferBinding(
|
||||
allocation.Buffer,
|
||||
|
|
@ -408,6 +507,91 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
(uint)AtmosphericFrameUniforms.SizeInBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: resolves this frame's <c>uAtmosphereClockWind</c>/
|
||||
/// <c>uAtmosphereWindAmplitude</c> values, advancing the weather-driven
|
||||
/// smoothing state at most once per <paramref name="frameSerial"/>. Both
|
||||
/// <see cref="RenderDirectionalShadows"/> (which runs first) and
|
||||
/// <see cref="RenderPostProcess"/> call this for the SAME frame, so the
|
||||
/// second caller reads the already-advanced state rather than
|
||||
/// re-advancing it — this is what keeps the caster and receiver's clock
|
||||
/// and smoothed wind strength byte-identical within one frame (D2/D4:
|
||||
/// the shadow must move with the leaf).
|
||||
///
|
||||
/// <para><b>The clock.</b> Not wall time and not "seconds since
|
||||
/// process/system start" — <see cref="_windClock"/> is a
|
||||
/// <see cref="System.Diagnostics.Stopwatch"/> started when this graph
|
||||
/// was constructed, so its magnitude stays small (session-relative) for
|
||||
/// GPU single-precision <c>sin()</c> accuracy over a long play session,
|
||||
/// and it is genuinely monotonic (unlike <c>DateTime.UtcNow</c>).
|
||||
/// <see cref="_windClockSecondsOverride"/> — threaded from
|
||||
/// <c>RuntimeOptions.SkyAnimationPhaseSeconds</c> (<c>ACDREAM_SKY_PHASE_SECONDS</c>),
|
||||
/// the same pin <see cref="AcDream.App.Rendering.Sky.SkyRenderer"/> uses
|
||||
/// for its own animation clock — replaces it when set, which is every
|
||||
/// run but a differential/offline gate's.</para>
|
||||
///
|
||||
/// <para><b>The smoothing.</b> The per-day-group (mean, gust) target
|
||||
/// from <see cref="RenderPackAtmospherePolicyEvaluation.FoliageWind"/>
|
||||
/// eases toward its target using an exponential moving average over
|
||||
/// <see cref="AcDream.Core.World.WeatherSystem.TransitionSeconds"/> (10 s,
|
||||
/// the same authored weather-transition constant retail-parity work
|
||||
/// already established) — so a day-group change never snaps. This state
|
||||
/// keeps evolving even while indoors or disabled; <c>wind-enabled</c>
|
||||
/// off or <c>!isOutdoor</c> instead multiplies the OUTPUT by an exact
|
||||
/// zero gate (never an asymptotic approach), so a settings toggle or a
|
||||
/// cell transition reads exactly zero on the very next frame, and
|
||||
/// resuming outdoors/enabled picks the smoothed state back up without a
|
||||
/// spin-up glitch.</para>
|
||||
/// </summary>
|
||||
private (Vector4 ClockWind, Vector4 WindAmplitude) ResolveFoliageWind(
|
||||
long frameSerial,
|
||||
int activeDayGroup,
|
||||
bool isOutdoor)
|
||||
{
|
||||
float clockSeconds = _windClockSecondsOverride
|
||||
?? (float)_windClock.Elapsed.TotalSeconds;
|
||||
if (_windFrameSerial != frameSerial)
|
||||
{
|
||||
float deltaSeconds = Math.Clamp(
|
||||
clockSeconds - _windLastAdvanceClockSeconds,
|
||||
0f,
|
||||
1f);
|
||||
(float targetMean, float targetGust) = RenderPackAtmospherePolicyEvaluation
|
||||
.FoliageWind(
|
||||
Descriptor.AtmospherePolicy?.FoliageWindByDayGroup,
|
||||
activeDayGroup);
|
||||
targetMean *= _foliageWind.Strength;
|
||||
targetGust *= _foliageWind.Strength;
|
||||
_windMean = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
|
||||
_windMean,
|
||||
targetMean,
|
||||
deltaSeconds,
|
||||
AcDream.Core.World.WeatherSystem.TransitionSeconds);
|
||||
_windGust = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
|
||||
_windGust,
|
||||
targetGust,
|
||||
deltaSeconds,
|
||||
AcDream.Core.World.WeatherSystem.TransitionSeconds);
|
||||
_windClockSeconds = clockSeconds;
|
||||
_windLastAdvanceClockSeconds = clockSeconds;
|
||||
_windFrameSerial = frameSerial;
|
||||
}
|
||||
|
||||
float gate = _foliageWind.Enabled && isOutdoor ? 1f : 0f;
|
||||
float directionRadians = _foliageWind.DirectionDegrees * (MathF.PI / 180f);
|
||||
var clockWind = new Vector4(
|
||||
_windClockSeconds,
|
||||
_windMean * gate,
|
||||
_windGust * gate,
|
||||
directionRadians);
|
||||
var windAmplitude = new Vector4(
|
||||
_foliageWind.LeanMetres,
|
||||
_foliageWind.BranchMetres,
|
||||
_foliageWind.FlutterMetres,
|
||||
_foliageWind.CanopyHeightMetres);
|
||||
return (clockWind, windAmplitude);
|
||||
}
|
||||
|
||||
public IGpuRenderTarget PrepareWorldTarget(
|
||||
int width,
|
||||
int height,
|
||||
|
|
@ -503,6 +687,10 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
float rayStrength = inputs.SunIsOnScreen && inputs.IsOutdoor
|
||||
? Math.Clamp(_settings.SunRayStrength * sunPolicy, 0f, 4f)
|
||||
: 0f;
|
||||
(Vector4 clockWind, Vector4 windAmplitude) = ResolveFoliageWind(
|
||||
frame.Serial,
|
||||
inputs.ActiveDayGroup,
|
||||
inputs.IsOutdoor);
|
||||
var frameUniforms = new AtmosphericFrameUniforms(
|
||||
new Vector4(
|
||||
inputs.SunScreenUv,
|
||||
|
|
@ -526,12 +714,8 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
shadowElevationPolicy,
|
||||
volumetricElevationPolicy),
|
||||
inputs.InverseViewProjection,
|
||||
// Campaign VM VM6a: ABI v2 plumbing only — written here so the
|
||||
// 192-byte block is always fully populated, but mesh_atmospheric.vert
|
||||
// does not read these two members yet (VM6b wires the real
|
||||
// weather-driven values and the shader read together).
|
||||
Vector4.Zero,
|
||||
Vector4.Zero);
|
||||
clockWind,
|
||||
windAmplitude);
|
||||
GpuRingAllocation frameBlock;
|
||||
GpuRingAllocation settingsBlock;
|
||||
GpuRingAllocation fusedSunPassBlock = default;
|
||||
|
|
@ -1679,12 +1863,20 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
}
|
||||
}
|
||||
|
||||
internal sealed class AtmosphericRenderPackRuntimeFactory(IGpuDevice device) :
|
||||
internal sealed class AtmosphericRenderPackRuntimeFactory(
|
||||
IGpuDevice device,
|
||||
float? skyPhaseSecondsOverride = null) :
|
||||
IRenderPackRuntimeFactory
|
||||
{
|
||||
private readonly IGpuDevice _device = device
|
||||
?? throw new ArgumentNullException(nameof(device));
|
||||
|
||||
// Campaign VM VM6: threaded to every built-in-graph instance this
|
||||
// factory produces — see AtmosphericPostProcessGraph.ResolveFoliageWind's
|
||||
// doc comment for why the foliage-wind clock needs this same pin
|
||||
// AcDream.App.Rendering.Sky.SkyRenderer already uses.
|
||||
private readonly float? _skyPhaseSecondsOverride = skyPhaseSecondsOverride;
|
||||
|
||||
public IRenderPackRuntime Build(
|
||||
RenderPackDescriptor descriptor,
|
||||
IRenderPackAssets assets,
|
||||
|
|
@ -1736,7 +1928,8 @@ internal sealed class AtmosphericRenderPackRuntimeFactory(IGpuDevice device) :
|
|||
descriptor,
|
||||
assets,
|
||||
preset,
|
||||
userSettingOverrides: userSettingOverrides);
|
||||
userSettingOverrides: userSettingOverrides,
|
||||
windClockSecondsOverride: _skyPhaseSecondsOverride);
|
||||
}
|
||||
|
||||
bool declaredDirectionalShadowGraph = descriptor.Passes.Count(pass =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
91
src/AcDream.App/Rendering/Packs/FoliageWindModel.cs
Normal file
91
src/AcDream.App/Rendering/Packs/FoliageWindModel.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
|
||||
namespace AcDream.App.Rendering.Packs;
|
||||
|
||||
/// <summary>
|
||||
/// CPU-side mirror of <c>foliage_wind.glsl</c>'s <c>acdreamFoliageDisplace</c>,
|
||||
/// added by Campaign VM VM6.
|
||||
///
|
||||
/// <para>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
|
||||
/// <see cref="AtmosphericColorPipeline"/>. Any change to
|
||||
/// <c>acdreamFoliageDisplace</c> — 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.</para>
|
||||
/// </summary>
|
||||
internal static class FoliageWindModel
|
||||
{
|
||||
private const uint FoliageMask =
|
||||
FoliageWindClassification.CutoutFoliageFlag | FoliageWindClassification.TrunkFlag;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <c>acdreamFoliageDisplace</c> exactly. <paramref name="clockWind"/>
|
||||
/// is (elapsed seconds, wind mean [0..1], wind gust [0..1], wind
|
||||
/// direction radians); <paramref name="amplitude"/> is (lean m, branch
|
||||
/// m, flutter m, max canopy height m) — the same layout as
|
||||
/// <c>uAtmosphereClockWind</c>/<c>uAtmosphereWindAmplitude</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>GLSL <c>fract</c>: always non-negative, matches <c>x - floor(x)</c>.</summary>
|
||||
private static float Frac(float x) => x - MathF.Floor(x);
|
||||
}
|
||||
|
|
@ -44,6 +44,50 @@ internal static class RenderPackAtmospherePolicyEvaluation
|
|||
static value => (float)value,
|
||||
fallback);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: exact day-group lookup for foliage wind — the same
|
||||
/// shape as <see cref="ActiveDayGroupMultiplier"/>'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.
|
||||
/// </summary>
|
||||
internal static (float Mean, float Gust) FoliageWind(
|
||||
IReadOnlyList<FoliageWindDayGroupPoint>? 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: one exponential-moving-average step toward
|
||||
/// <paramref name="target"/>, used to smooth the foliage-wind mean/gust
|
||||
/// strength across a day-group change so it never snaps. Pure and
|
||||
/// stateless — the caller owns <paramref name="current"/> across calls.
|
||||
/// <paramref name="deltaSeconds"/> 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).
|
||||
/// </summary>
|
||||
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<SunElevationResponsePoint>? points,
|
||||
float elevationDegrees,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
91
src/AcDream.App/Rendering/Shaders/foliage_wind.glsl
Normal file
91
src/AcDream.App/Rendering/Shaders/foliage_wind.glsl
Normal file
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable retail-picking descriptor for one static entity part. The cache
|
||||
|
|
|
|||
62
src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs
Normal file
62
src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
using AcDream.Core.Meshing;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: the two <c>BatchData.flags</c> bits that mark a world
|
||||
/// draw batch as swaying foliage — bit 0 remains #226's built-mesh marker
|
||||
/// (see <c>mesh_detail.frag</c>'s <c>vBatchFlags & 1u</c> check) and is
|
||||
/// never touched here. <c>mesh_atmospheric.vert</c> and the four
|
||||
/// <c>directional_shadow_world_*.vert</c> caster shaders read these bits
|
||||
/// through the shared <c>foliage_wind.glsl</c> include
|
||||
/// (<c>acdreamFoliageDisplace</c>'s <c>batchFlags & 0x6u</c> gate); the
|
||||
/// retail <c>mesh_modern</c> pipelines never read them, so setting them does
|
||||
/// not change pack-off output.
|
||||
/// </summary>
|
||||
internal static class FoliageWindClassification
|
||||
{
|
||||
/// <summary>
|
||||
/// Bit 1: the subset is an alpha-cutout/ClipMap material AND its owning
|
||||
/// entity is procedural scenery (see <see cref="Classify"/>). Leaves,
|
||||
/// fronds, bushes, grass tufts.
|
||||
/// </summary>
|
||||
internal const uint CutoutFoliageFlag = 0x2u;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
internal const uint TrunkFlag = 0x4u;
|
||||
|
||||
private const uint SceneryEntityIdBit = 0x8000_0000u;
|
||||
|
||||
/// <summary>
|
||||
/// Pure classification: no allocation, no scan. <paramref name="meshHasCutoutSubset"/>
|
||||
/// is expected to be computed once per GfxObj/Setup mesh and cached with
|
||||
/// the mesh record (<see cref="AcDream.App.Rendering.Wb.ObjectRenderData.HasCutoutSubset"/>),
|
||||
/// not recomputed per call.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bit 31 of the entity id — see <c>ProceduralSceneryIdAllocator</c>'s
|
||||
/// <c>0x8XXYYIII</c> namespace. No other consumer decodes more than this
|
||||
/// one bit.
|
||||
/// </summary>
|
||||
internal static bool IsProceduralScenery(uint entityId) =>
|
||||
(entityId & SceneryEntityIdBit) != 0u;
|
||||
}
|
||||
|
|
@ -40,6 +40,19 @@ namespace AcDream.App.Rendering.Wb
|
|||
public uint VBO { get; set; }
|
||||
public int VertexCount { get; set; }
|
||||
public List<ObjectRenderBatch> Batches { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: true when at least one of <see cref="Batches"/>
|
||||
/// is an alpha-cutout (<c>TranslucencyKind.ClipMap</c>) material —
|
||||
/// computed once here, when the mesh is built/cached, not re-scanned
|
||||
/// per instance or per frame. Used by
|
||||
/// <see cref="FoliageWindClassification.Classify"/> 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).
|
||||
/// </summary>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -483,6 +483,20 @@ public sealed partial class WbDrawDispatcher : IDisposable
|
|||
/// </summary>
|
||||
public bool AlphaToCoverage { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: object/GfxObj ids the active render pack's
|
||||
/// <c>AtmospherePolicyDeclaration.FoliageExclusions</c> 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 <c>AtmosphericPostProcessGraph.RenderDirectionalShadows</c> 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.
|
||||
/// </summary>
|
||||
public IReadOnlySet<uint> FoliageWindExclusions { get; set; } =
|
||||
System.Collections.Frozen.FrozenSet<uint>.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<GroupKey, Matrix4x4, Vector3> appendInstance)
|
||||
Action<GroupKey, Matrix4x4, Vector3, uint> 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 <see cref="InstanceGroup"/> for the given key in
|
||||
/// <c>_groups</c> and appends the per-instance world matrix.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Public mirror of the per-group <see cref="BatchData"/> 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<Matrix4x4> Matrices = new();
|
||||
|
||||
|
|
|
|||
|
|
@ -366,6 +366,14 @@ public enum RenderSettingSemantic
|
|||
VolumetricStrength,
|
||||
VolumetricRayMarchSteps,
|
||||
AutomaticQuality,
|
||||
// Campaign VM VM6: foliage wind.
|
||||
WindEnabled,
|
||||
WindStrength,
|
||||
WindDirectionDegrees,
|
||||
WindLeanMetres,
|
||||
WindBranchMetres,
|
||||
WindFlutterMetres,
|
||||
WindCanopyHeightMetres,
|
||||
}
|
||||
|
||||
/// <summary>One point on a declared sun-elevation response curve.</summary>
|
||||
|
|
@ -378,6 +386,16 @@ public sealed record ActiveDayGroupMultiplier(
|
|||
int ActiveDayGroup,
|
||||
double Multiplier);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed record FoliageWindDayGroupPoint(
|
||||
int ActiveDayGroup,
|
||||
double Mean,
|
||||
double Gust);
|
||||
|
||||
/// <summary>Visible authored-atmosphere interpretation owned by the pack.</summary>
|
||||
public sealed record AtmospherePolicyDeclaration(
|
||||
IReadOnlyList<SunElevationResponsePoint> SunElevationResponse,
|
||||
|
|
@ -404,6 +422,32 @@ public sealed record AtmospherePolicyDeclaration(
|
|||
/// </summary>
|
||||
public IReadOnlyList<SunElevationResponsePoint> VolumetricShaftSunElevationResponse
|
||||
{ get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: exact per-day-group foliage-wind mean/gust targets,
|
||||
/// looked up by the same categorical <c>activeDayGroup</c> id
|
||||
/// <see cref="ActiveDayGroupMultipliers"/> 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.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FoliageWindDayGroupPoint> FoliageWindByDayGroup
|
||||
{ get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>WbDrawDispatcher</c> is the rule.
|
||||
/// </summary>
|
||||
public IReadOnlyList<uint> FoliageExclusions { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[],
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -1220,6 +1220,115 @@ public sealed class AtmosphericPostProcessGraphTests
|
|||
Assert.Equal(1f, AtmosphericPostProcessGraph.BloomThresholdLinear);
|
||||
}
|
||||
|
||||
// ── Campaign VM VM6: foliage wind ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void IndoorGatesWindOutputToExactZeroRegardlessOfSmoothedState()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var graph = Graph(device, "medium", windClockSecondsOverride: 12f);
|
||||
graph.PrepareWorldTarget(640, 480, 1);
|
||||
// Rainy (index 3 in the built-in table) is the highest declared
|
||||
// mean/gust — the exact target does not matter here, only that the
|
||||
// gate still zeroes the output despite a nonzero smoothed target.
|
||||
AtmosphericFrameInputs indoors = Inputs(640, 480, activeDayGroup: 3) with
|
||||
{
|
||||
IsOutdoor = false,
|
||||
};
|
||||
|
||||
AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, indoors);
|
||||
|
||||
Assert.Equal(0f, atmospheric.ClockWind.Y); // mean
|
||||
Assert.Equal(0f, atmospheric.ClockWind.Z); // gust
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindEnabledFalseGatesWindOutputToExactZero()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var graph = Graph(
|
||||
device,
|
||||
"medium",
|
||||
windClockSecondsOverride: 12f,
|
||||
userSettingOverrides: new Dictionary<string, string> { ["wind-enabled"] = "false" });
|
||||
graph.PrepareWorldTarget(640, 480, 1);
|
||||
AtmosphericFrameInputs outdoors = Inputs(640, 480, activeDayGroup: 3);
|
||||
|
||||
AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, outdoors);
|
||||
|
||||
Assert.Equal(0f, atmospheric.ClockWind.Y);
|
||||
Assert.Equal(0f, atmospheric.ClockWind.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindAmplitudeReflectsTheDeclaredSettingDefaults()
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
using var graph = Graph(device, "medium", windClockSecondsOverride: 5f);
|
||||
graph.PrepareWorldTarget(640, 480, 1);
|
||||
AtmosphericFrameInputs outdoors = Inputs(640, 480);
|
||||
|
||||
AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, outdoors);
|
||||
|
||||
Assert.Equal(new Vector4(0.25f, 0.15f, 0.05f, 8f), atmospheric.WindAmplitude);
|
||||
// 225 degrees, the declared default direction.
|
||||
Assert.Equal(225f * (MathF.PI / 180f), atmospheric.ClockWind.W, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedCallsOnTheSameFrameSerialProduceIdenticalWindBytes()
|
||||
{
|
||||
// RenderDirectionalShadows and RenderPostProcess both resolve the
|
||||
// wind block for frame.Serial, and RenderDirectionalShadows runs
|
||||
// first each frame. This proves the "advance once per Serial, later
|
||||
// callers read the already-advanced state" invariant that makes the
|
||||
// caster and receiver agree — without needing this hermetic
|
||||
// harness's full WbDrawDispatcher/TerrainModernRenderer dependency
|
||||
// chain to exercise RenderDirectionalShadows itself.
|
||||
var device = new RecordingGpuDevice();
|
||||
using var graph = Graph(device, "medium"); // real clock: proves it's the Serial guard, not a frozen override
|
||||
IGpuRenderTarget world = graph.PrepareWorldTarget(640, 480, 1);
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
RecordWorldPass(frame, world);
|
||||
AtmosphericFrameInputs inputs = Inputs(640, 480, activeDayGroup: 3);
|
||||
|
||||
graph.RenderPostProcess(frame, in inputs);
|
||||
AtmosphericFrameUniforms first = ReadLastFrameBlock(device);
|
||||
graph.RenderPostProcess(frame, in inputs);
|
||||
AtmosphericFrameUniforms second = ReadLastFrameBlock(device);
|
||||
frame.End();
|
||||
|
||||
Assert.Equal(first.ClockWind, second.ClockWind);
|
||||
Assert.Equal(first.WindAmplitude, second.WindAmplitude);
|
||||
}
|
||||
|
||||
private static AtmosphericFrameUniforms RenderAndReadFrameBlock(
|
||||
RecordingGpuDevice device,
|
||||
AtmosphericPostProcessGraph graph,
|
||||
in AtmosphericFrameInputs inputs)
|
||||
{
|
||||
IGpuRenderTarget world = graph.PrepareWorldTarget(
|
||||
inputs.ViewportWidth,
|
||||
inputs.ViewportHeight,
|
||||
1);
|
||||
using IGpuFrame frame = device.BeginFrame();
|
||||
RecordWorldPass(frame, world);
|
||||
graph.RenderPostProcess(frame, in inputs);
|
||||
frame.End();
|
||||
return ReadLastFrameBlock(device);
|
||||
}
|
||||
|
||||
private static AtmosphericFrameUniforms ReadLastFrameBlock(RecordingGpuDevice device)
|
||||
{
|
||||
GpuRecordedUniformBind frameBlock = device
|
||||
.OfKind<GpuRecordedUniformBind>()
|
||||
.Last(call => call.Binding == GpuBindingModel.UniformAtmosphericFrame);
|
||||
return MemoryMarshal.Read<AtmosphericFrameUniforms>(
|
||||
device.RingBytes.Slice(
|
||||
(int)frameBlock.OffsetBytes,
|
||||
AtmosphericFrameUniforms.SizeInBytes));
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string haystack, string needle)
|
||||
{
|
||||
int count = 0;
|
||||
|
|
@ -1235,7 +1344,9 @@ public sealed class AtmosphericPostProcessGraphTests
|
|||
private static AtmosphericPostProcessGraph Graph(
|
||||
RecordingGpuDevice device,
|
||||
string presetId,
|
||||
AtmosphericPostProcessSettings? settings = null)
|
||||
AtmosphericPostProcessSettings? settings = null,
|
||||
float? windClockSecondsOverride = null,
|
||||
IReadOnlyDictionary<string, string>? userSettingOverrides = null)
|
||||
{
|
||||
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
||||
RenderQualityPreset preset = Assert.Single(
|
||||
|
|
@ -1246,7 +1357,9 @@ public sealed class AtmosphericPostProcessGraphTests
|
|||
descriptor,
|
||||
BuiltInAssets(),
|
||||
preset,
|
||||
settings);
|
||||
settings,
|
||||
userSettingOverrides,
|
||||
windClockSecondsOverride);
|
||||
}
|
||||
|
||||
private static RenderPackDescriptor ExternalTierOneDescriptor(
|
||||
|
|
|
|||
|
|
@ -91,6 +91,50 @@ public sealed class AtmosphericShaderAbiTests
|
|||
"uPackSettings[16]");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("mesh_atmospheric.vert")]
|
||||
[InlineData("directional_shadow_world_opaque.vert")]
|
||||
[InlineData("directional_shadow_world_opaque_multiview.vert")]
|
||||
[InlineData("directional_shadow_world_cutout.vert")]
|
||||
[InlineData("directional_shadow_world_cutout_multiview.vert")]
|
||||
public void FoliageWindShadersEachCallAcdreamFoliageDisplaceExactlyOnce(string fileName)
|
||||
{
|
||||
string text = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", fileName));
|
||||
|
||||
Assert.Contains("#include \"foliage_wind.glsl\"", text, StringComparison.Ordinal);
|
||||
int calls = CountOccurrences(text, "acdreamFoliageDisplace(");
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("mesh_modern.vert")]
|
||||
[InlineData("terrain_modern.vert")]
|
||||
[InlineData("terrain_atmospheric.vert")]
|
||||
[InlineData("directional_shadow_terrain.vert")]
|
||||
[InlineData("directional_shadow_terrain_multiview.vert")]
|
||||
[InlineData("mesh_detail.vert")]
|
||||
public void RetailAndTerrainShadersNeverCallAcdreamFoliageDisplace(string fileName)
|
||||
{
|
||||
string text = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", fileName));
|
||||
|
||||
Assert.DoesNotContain("foliage_wind.glsl", text, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("acdreamFoliageDisplace(", text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string text, string token)
|
||||
{
|
||||
int count = 0;
|
||||
int index = 0;
|
||||
while ((index = text.IndexOf(token, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
index += token.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FusedLowShadersRetainTheDeclaredOcclusionAndBloomPixelKernels()
|
||||
{
|
||||
|
|
|
|||
215
tests/AcDream.App.Tests/Rendering/Packs/FoliageWindModelTests.cs
Normal file
215
tests/AcDream.App.Tests/Rendering/Packs/FoliageWindModelTests.cs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: <see cref="FoliageWindModel"/>, the CPU mirror of
|
||||
/// <c>foliage_wind.glsl</c>'s <c>acdreamFoliageDisplace</c>. These pin the
|
||||
/// motion model's shape (not its exact GLSL-vs-CPU bit-for-bit floats — that
|
||||
/// would need a GPU capture) so a change to the shared formula that breaks
|
||||
/// an invariant of the design fails fast on the CPU.
|
||||
/// </summary>
|
||||
public sealed class FoliageWindModelTests
|
||||
{
|
||||
private static readonly Vector3 WorldPos = new(12f, -7f, 5f);
|
||||
private static readonly Vector3 InstanceOrigin = new(10f, -8f, 1f);
|
||||
private static readonly Vector4 Amplitude = new(0.25f, 0.15f, 0.05f, 8f); // lean, branch, flutter, canopy
|
||||
private static readonly Vector4 CalmWind = new(3.5f, 0f, 0f, 3.9f); // mean=gust=0
|
||||
|
||||
[Fact]
|
||||
public void ZeroFlagsIsIdentityRegardlessOfWindStrength()
|
||||
{
|
||||
var windyClockWind = new Vector4(3.5f, 1f, 1f, 3.9f);
|
||||
|
||||
Vector3 result = FoliageWindModel.Displace(
|
||||
WorldPos,
|
||||
InstanceOrigin,
|
||||
batchFlags: 0u,
|
||||
windyClockWind,
|
||||
Amplitude);
|
||||
|
||||
Assert.Equal(WorldPos, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FoliageWindClassification.CutoutFoliageFlag)]
|
||||
[InlineData(FoliageWindClassification.TrunkFlag)]
|
||||
public void CalmWindIsIdentityForAnyFoliageFlags(uint flags)
|
||||
{
|
||||
Vector3 result = FoliageWindModel.Displace(
|
||||
WorldPos,
|
||||
InstanceOrigin,
|
||||
flags,
|
||||
CalmWind,
|
||||
Amplitude);
|
||||
|
||||
AssertApproximatelyEqual(WorldPos, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(FoliageWindClassification.CutoutFoliageFlag)]
|
||||
[InlineData(FoliageWindClassification.TrunkFlag)]
|
||||
public void TheBaseVertexNeverMoves(uint flags)
|
||||
{
|
||||
// h = clamp((worldPos.z - instanceOrigin.z) / maxHeight, 0, 1) is 0
|
||||
// when the vertex sits exactly at the instance origin's height —
|
||||
// k = h*h = 0 zeroes lean and branch, and the cutout branch's own
|
||||
// h factor zeroes flutter too.
|
||||
Vector3 baseVertex = InstanceOrigin with { X = InstanceOrigin.X + 3f };
|
||||
var windyClockWind = new Vector4(11f, 1f, 1f, 2.1f);
|
||||
|
||||
Vector3 result = FoliageWindModel.Displace(
|
||||
baseVertex,
|
||||
InstanceOrigin,
|
||||
flags,
|
||||
windyClockWind,
|
||||
Amplitude);
|
||||
|
||||
AssertApproximatelyEqual(baseVertex, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanopyTopDisplacementMagnitudeIsBoundedByTheDeclaredAmplitudes()
|
||||
{
|
||||
// gust = 0 pins s = mean exactly (no gust-envelope overshoot above
|
||||
// 1 to reason about), and mean = 1 is the maximum authored strength,
|
||||
// so |lean| <= amp.lean, |branch| <= amp.branch, |flutter| <=
|
||||
// amp.flutter follow directly from each term's own sin/cos factors
|
||||
// being bounded by 1. The triangle inequality then bounds the
|
||||
// summed 2-D displacement by amp.lean + 1.35*amp.branch (the extra
|
||||
// 0.35 is the perpendicular branch-sway term) + amp.flutter.
|
||||
var maxMean = new Vector4(0f, 1f, 0f, 0f);
|
||||
float bound = Amplitude.X + (1.35f * Amplitude.Y) + Amplitude.Z + 1e-4f;
|
||||
|
||||
for (float t = 0f; t < 40f; t += 3.7f)
|
||||
{
|
||||
for (float xy = -5f; xy <= 5f; xy += 4.3f)
|
||||
{
|
||||
Vector3 canopyTop = InstanceOrigin with
|
||||
{
|
||||
X = InstanceOrigin.X + xy,
|
||||
Y = InstanceOrigin.Y - xy,
|
||||
Z = InstanceOrigin.Z + Amplitude.W, // h = 1
|
||||
};
|
||||
var clockWind = maxMean with { X = t };
|
||||
|
||||
Vector3 result = FoliageWindModel.Displace(
|
||||
canopyTop,
|
||||
InstanceOrigin,
|
||||
FoliageWindClassification.CutoutFoliageFlag,
|
||||
clockWind,
|
||||
Amplitude);
|
||||
|
||||
Vector2 displacementXY = new(
|
||||
result.X - canopyTop.X,
|
||||
result.Y - canopyTop.Y);
|
||||
Assert.True(
|
||||
displacementXY.Length() <= bound,
|
||||
$"t={t} xy={xy}: |d|={displacementXY.Length()} exceeds bound {bound}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeightNeverIncreases()
|
||||
{
|
||||
var rng = new Random(1337);
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
var worldPos = new Vector3(
|
||||
(float)((rng.NextDouble() * 40) - 20),
|
||||
(float)((rng.NextDouble() * 40) - 20),
|
||||
(float)(rng.NextDouble() * Amplitude.W));
|
||||
var clockWind = new Vector4(
|
||||
(float)(rng.NextDouble() * 1000),
|
||||
(float)rng.NextDouble(),
|
||||
(float)rng.NextDouble(),
|
||||
(float)(rng.NextDouble() * MathF.Tau));
|
||||
uint flags = (rng.Next(2) == 0)
|
||||
? FoliageWindClassification.CutoutFoliageFlag
|
||||
: FoliageWindClassification.TrunkFlag;
|
||||
|
||||
Vector3 result = FoliageWindModel.Displace(
|
||||
worldPos,
|
||||
InstanceOrigin,
|
||||
flags,
|
||||
clockWind,
|
||||
Amplitude);
|
||||
|
||||
Assert.True(
|
||||
result.Z <= worldPos.Z + 1e-5f,
|
||||
$"iteration {i}: z increased from {worldPos.Z} to {result.Z}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrunkDisplacementIsIndependentOfWorldXyHash()
|
||||
{
|
||||
// Bit 0x4 (trunk) never enters the cutout branch, so its
|
||||
// displacement depends only on height (worldPos.z - origin.z) and
|
||||
// the instance-level phase (from instanceOrigin.xy) — never on the
|
||||
// vertex's own world x/y, which is exactly what decorrelates leaves
|
||||
// on a cutout subset but must NOT vary a trunk's lean.
|
||||
float z = InstanceOrigin.Z + (0.5f * Amplitude.W);
|
||||
var clockWind = new Vector4(7.25f, 0.8f, 0.6f, 1.1f);
|
||||
|
||||
Vector3 displacementAt(float x, float y)
|
||||
{
|
||||
var worldPos = new Vector3(x, y, z);
|
||||
Vector3 result = FoliageWindModel.Displace(
|
||||
worldPos,
|
||||
InstanceOrigin,
|
||||
FoliageWindClassification.TrunkFlag,
|
||||
clockWind,
|
||||
Amplitude);
|
||||
return result - worldPos;
|
||||
}
|
||||
|
||||
Vector3 reference = displacementAt(InstanceOrigin.X, InstanceOrigin.Y);
|
||||
Vector3 farAway = displacementAt(InstanceOrigin.X + 500f, InstanceOrigin.Y - 300f);
|
||||
Vector3 elsewhere = displacementAt(InstanceOrigin.X - 17.3f, InstanceOrigin.Y + 91f);
|
||||
|
||||
AssertApproximatelyEqual(reference, farAway);
|
||||
AssertApproximatelyEqual(reference, elsewhere);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutoutDisplacementVariesWithWorldXyHashButTrunkDoesNot()
|
||||
{
|
||||
// Same setup as above, but with the cutout bit: the flutter term's
|
||||
// per-vertex hash DOES make the displacement depend on world x/y —
|
||||
// confirming the trunk test above is a real distinction, not an
|
||||
// artifact of degenerate inputs.
|
||||
float z = InstanceOrigin.Z + (0.5f * Amplitude.W);
|
||||
var clockWind = new Vector4(7.25f, 0.8f, 0.6f, 1.1f);
|
||||
|
||||
Vector3 displacementAt(float x, float y)
|
||||
{
|
||||
var worldPos = new Vector3(x, y, z);
|
||||
Vector3 result = FoliageWindModel.Displace(
|
||||
worldPos,
|
||||
InstanceOrigin,
|
||||
FoliageWindClassification.CutoutFoliageFlag,
|
||||
clockWind,
|
||||
Amplitude);
|
||||
return result - worldPos;
|
||||
}
|
||||
|
||||
Vector3 reference = displacementAt(InstanceOrigin.X, InstanceOrigin.Y);
|
||||
Vector3 farAway = displacementAt(InstanceOrigin.X + 500f, InstanceOrigin.Y - 300f);
|
||||
|
||||
Assert.NotEqual(reference, farAway);
|
||||
}
|
||||
|
||||
private static void AssertApproximatelyEqual(
|
||||
Vector3 expected,
|
||||
Vector3 actual,
|
||||
float tolerance = 1e-4f)
|
||||
{
|
||||
Assert.True(
|
||||
(expected - actual).Length() <= tolerance,
|
||||
$"expected {expected}, got {actual} (delta length {(expected - actual).Length()})");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.Plugin.Abstractions.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Packs;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: <see cref="RenderPackAtmospherePolicyEvaluation.FoliageWind"/>
|
||||
/// (the exact per-day-group mean/gust lookup) and
|
||||
/// <see cref="RenderPackAtmospherePolicyEvaluation.EaseTowardTarget"/> (the
|
||||
/// smoothing step that keeps a day-group change from snapping).
|
||||
/// </summary>
|
||||
public sealed class RenderPackAtmospherePolicyEvaluationTests
|
||||
{
|
||||
[Fact]
|
||||
public void FoliageWindLooksUpExactDayGroupMatchOnly()
|
||||
{
|
||||
FoliageWindDayGroupPoint[] table =
|
||||
[
|
||||
new(0, 0.25, 0.15),
|
||||
new(1, 0.45, 0.30),
|
||||
new(2, 0.60, 0.35),
|
||||
new(3, 0.85, 0.60),
|
||||
];
|
||||
|
||||
(float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(table, 3);
|
||||
|
||||
Assert.Equal(0.85f, mean);
|
||||
Assert.Equal(0.60f, gust);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FoliageWindReturnsZeroForADayGroupAbsentFromTheTable()
|
||||
{
|
||||
FoliageWindDayGroupPoint[] table = [new(0, 0.25, 0.15)];
|
||||
|
||||
(float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(table, 99);
|
||||
|
||||
Assert.Equal(0f, mean);
|
||||
Assert.Equal(0f, gust);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FoliageWindReturnsZeroForANullTable()
|
||||
{
|
||||
(float mean, float gust) = RenderPackAtmospherePolicyEvaluation.FoliageWind(null, 0);
|
||||
|
||||
Assert.Equal(0f, mean);
|
||||
Assert.Equal(0f, gust);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FoliageWindDoesNotInterpolateBetweenDayGroupIndices()
|
||||
{
|
||||
// Day-group ids are not ordered by "how windy" — index 1 sitting
|
||||
// between 0 and 2 in the table must not produce a value between
|
||||
// their mean/gust. This asserts the lookup is an exact match, not
|
||||
// continuous interpolation across the index axis.
|
||||
FoliageWindDayGroupPoint[] table = [new(0, 0.0, 0.0), new(5, 1.0, 1.0)];
|
||||
|
||||
(float meanAtUnlistedMidpoint, float gustAtUnlistedMidpoint) =
|
||||
RenderPackAtmospherePolicyEvaluation.FoliageWind(table, 2);
|
||||
|
||||
Assert.Equal(0f, meanAtUnlistedMidpoint);
|
||||
Assert.Equal(0f, gustAtUnlistedMidpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EaseTowardTargetDoesNotMoveWithZeroDelta()
|
||||
{
|
||||
float result = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
|
||||
current: 0.25f,
|
||||
target: 0.85f,
|
||||
deltaSeconds: 0f,
|
||||
transitionSeconds: 10f);
|
||||
|
||||
Assert.Equal(0.25f, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EaseTowardTargetConvergesWithoutOvershootOrDiscontinuity()
|
||||
{
|
||||
// Sample the same weather change (Clear 0.25 -> Rainy 0.85) many
|
||||
// times over the transition window at a fixed per-tick delta and
|
||||
// assert the sequence is monotonically non-decreasing and never
|
||||
// jumps by more than one tick's proportional share — "no
|
||||
// discontinuity across the delta window."
|
||||
const float start = 0.25f;
|
||||
const float target = 0.85f;
|
||||
const float transitionSeconds = 10f;
|
||||
const float tickSeconds = 0.1f;
|
||||
float current = start;
|
||||
float previous = current;
|
||||
// A fixed-rate-per-tick ease is exponential decay toward the
|
||||
// target, not a linear ramp: after N ticks the remaining gap is
|
||||
// (1 - tickSeconds/transitionSeconds)^N of the original gap. 4000
|
||||
// ticks (400 s of simulated time, 40x the transition window) leaves
|
||||
// a remaining fraction of (0.99)^4000 ~= 4e-18 — comfortably
|
||||
// converged without asserting a false linear-ramp expectation.
|
||||
const int ticks = 4000;
|
||||
|
||||
for (int i = 0; i < ticks; i++)
|
||||
{
|
||||
current = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
|
||||
current,
|
||||
target,
|
||||
tickSeconds,
|
||||
transitionSeconds);
|
||||
|
||||
Assert.True(
|
||||
current >= previous - 1e-6f,
|
||||
$"tick {i}: value regressed from {previous} to {current}");
|
||||
Assert.True(
|
||||
current <= target + 1e-6f,
|
||||
$"tick {i}: value {current} overshot target {target}");
|
||||
previous = current;
|
||||
}
|
||||
|
||||
Assert.True(MathF.Abs(current - target) < 0.01f, $"did not converge: {current}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EaseTowardTargetSnapsInOneStepWhenTransitionSecondsIsZeroOrLess()
|
||||
{
|
||||
float result = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
|
||||
current: 0f,
|
||||
target: 1f,
|
||||
deltaSeconds: 0.001f,
|
||||
transitionSeconds: 0f);
|
||||
|
||||
Assert.Equal(1f, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EaseTowardTargetClampsAnOversizedDeltaInsteadOfOvershooting()
|
||||
{
|
||||
// A delta larger than the transition window (e.g. after a long
|
||||
// pause) must not overshoot past the target.
|
||||
float result = RenderPackAtmospherePolicyEvaluation.EaseTowardTarget(
|
||||
current: 0f,
|
||||
target: 1f,
|
||||
deltaSeconds: 1000f,
|
||||
transitionSeconds: 10f);
|
||||
|
||||
Assert.Equal(1f, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Meshing;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VM VM6: <see cref="FoliageWindClassification"/> — the pure
|
||||
/// function <c>WbDrawDispatcher.ClassifyBatches</c> (the world receiver) and
|
||||
/// <c>WbDrawDispatcher.AddDirectionalShadowBatches</c> (the directional-
|
||||
/// shadow caster) both call with the same four inputs (entity id, exclusion
|
||||
/// check, subset translucency, mesh-level HasCutoutSubset), which is what
|
||||
/// makes the two agree by construction — see foliage_wind.glsl.
|
||||
/// </summary>
|
||||
public sealed class FoliageWindClassificationTests
|
||||
{
|
||||
private const uint ProceduralSceneryEntityId = 0x80010203u; // bit 31 set
|
||||
private const uint OrdinaryEntityId = 0x00010203u; // bit 31 clear
|
||||
|
||||
[Fact]
|
||||
public void ProceduralSceneryCutoutSubsetGetsTheCutoutFlag()
|
||||
{
|
||||
uint flags = FoliageWindClassification.Classify(
|
||||
ProceduralSceneryEntityId,
|
||||
isExcluded: false,
|
||||
TranslucencyKind.ClipMap,
|
||||
meshHasCutoutSubset: true);
|
||||
|
||||
Assert.Equal(FoliageWindClassification.CutoutFoliageFlag, flags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProceduralSceneryOpaqueSubsetGetsTheTrunkFlagOnlyWhenTheMeshOwnsACutoutSubset()
|
||||
{
|
||||
uint withCutoutSibling = FoliageWindClassification.Classify(
|
||||
ProceduralSceneryEntityId,
|
||||
isExcluded: false,
|
||||
TranslucencyKind.Opaque,
|
||||
meshHasCutoutSubset: true);
|
||||
uint withoutCutoutSibling = FoliageWindClassification.Classify(
|
||||
ProceduralSceneryEntityId,
|
||||
isExcluded: false,
|
||||
TranslucencyKind.Opaque,
|
||||
meshHasCutoutSubset: false);
|
||||
|
||||
Assert.Equal(FoliageWindClassification.TrunkFlag, withCutoutSibling);
|
||||
Assert.Equal(0u, withoutCutoutSibling); // a rock, not a tree trunk
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonProceduralSceneryEntityGetsNeitherBitRegardlessOfMaterial()
|
||||
{
|
||||
uint cutout = FoliageWindClassification.Classify(
|
||||
OrdinaryEntityId,
|
||||
isExcluded: false,
|
||||
TranslucencyKind.ClipMap,
|
||||
meshHasCutoutSubset: true);
|
||||
uint opaqueWithCutoutSibling = FoliageWindClassification.Classify(
|
||||
OrdinaryEntityId,
|
||||
isExcluded: false,
|
||||
TranslucencyKind.Opaque,
|
||||
meshHasCutoutSubset: true);
|
||||
|
||||
Assert.Equal(0u, cutout);
|
||||
Assert.Equal(0u, opaqueWithCutoutSibling);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnExcludedObjectIdGetsNeitherBitEvenWhenOtherwiseQualifying()
|
||||
{
|
||||
uint cutout = FoliageWindClassification.Classify(
|
||||
ProceduralSceneryEntityId,
|
||||
isExcluded: true,
|
||||
TranslucencyKind.ClipMap,
|
||||
meshHasCutoutSubset: true);
|
||||
uint trunk = FoliageWindClassification.Classify(
|
||||
ProceduralSceneryEntityId,
|
||||
isExcluded: true,
|
||||
TranslucencyKind.Opaque,
|
||||
meshHasCutoutSubset: true);
|
||||
|
||||
Assert.Equal(0u, cutout);
|
||||
Assert.Equal(0u, trunk);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TranslucencyKind.AlphaBlend)]
|
||||
[InlineData(TranslucencyKind.Additive)]
|
||||
[InlineData(TranslucencyKind.InvAlpha)]
|
||||
public void OtherMaterialKindsOnProceduralSceneryGetNeitherBit(TranslucencyKind translucency)
|
||||
{
|
||||
uint flags = FoliageWindClassification.Classify(
|
||||
ProceduralSceneryEntityId,
|
||||
isExcluded: false,
|
||||
translucency,
|
||||
meshHasCutoutSubset: true);
|
||||
|
||||
Assert.Equal(0u, flags);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x80000000u, true)]
|
||||
[InlineData(0x7FFFFFFFu, false)]
|
||||
[InlineData(0x00000000u, false)]
|
||||
[InlineData(0xFFFFFFFFu, true)]
|
||||
public void IsProceduralSceneryDecodesOnlyBit31(uint entityId, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, FoliageWindClassification.IsProceduralScenery(entityId));
|
||||
}
|
||||
}
|
||||
|
|
@ -488,7 +488,7 @@ public sealed class WbDrawDispatcherBucketingTests
|
|||
// at the top of the per-entity loop body in Draw.
|
||||
var groups = new Dictionary<GroupKey, List<Matrix4x4>>();
|
||||
var sortCenters = new List<Vector3>();
|
||||
void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter)
|
||||
void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter, uint foliageFlags)
|
||||
{
|
||||
if (!groups.TryGetValue(k, out var list))
|
||||
{
|
||||
|
|
@ -497,6 +497,7 @@ public sealed class WbDrawDispatcherBucketingTests
|
|||
}
|
||||
list.Add(m);
|
||||
sortCenters.Add(localSortCenter);
|
||||
_ = foliageFlags;
|
||||
}
|
||||
|
||||
Assert.True(cache.TryGet(EntityId, LandblockId, out var entryHit));
|
||||
|
|
@ -544,7 +545,7 @@ public sealed class WbDrawDispatcherBucketingTests
|
|||
WbDrawDispatcher.ApplyCacheHit(
|
||||
entry,
|
||||
Matrix4x4.Identity,
|
||||
(_, _, center) => observedCenter = center);
|
||||
(_, _, center, _) => observedCenter = center);
|
||||
|
||||
Assert.Equal(authoredCenter, observedCenter);
|
||||
}
|
||||
|
|
@ -787,7 +788,7 @@ public sealed class WbDrawDispatcherBucketingTests
|
|||
const uint EntityId = 100;
|
||||
const int MeshRefCount = 3;
|
||||
|
||||
void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter)
|
||||
void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter, uint foliageFlags)
|
||||
{
|
||||
if (!groups.TryGetValue(k, out var list))
|
||||
{
|
||||
|
|
@ -795,6 +796,7 @@ public sealed class WbDrawDispatcherBucketingTests
|
|||
groups[k] = list;
|
||||
}
|
||||
list.Add(m);
|
||||
_ = foliageFlags;
|
||||
}
|
||||
|
||||
for (int partIdx = 0; partIdx < MeshRefCount; partIdx++)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue