diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 3ff5160c..8c8351a4 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-21 | When ACE sends player BoolProperty `68` (`SpellComponentsRequired`) false, acdream presents the retail scarab/prismatic-taper formula even without a directly carried school focus. With component enforcement enabled, retail's exact focus/infusion versus account-customized selection remains intact. | `src/AcDream.App/Spells/SpellComponentRequirementService.cs` | A component-disabled server has no actionable legacy recipe; explicit product direction is that this client/server mode uses the modern scarab/taper component presentation | A custom server could expect retail's legacy recipe to remain visible even though casting consumes no components | `ClientMagicSystem::AreSpellComponentsRequired @ 0x00567B90`; `ClientMagicSystem::GetAppropriateSpellFormula @ 0x00567D50`; `CSpellBase::InqScarabOnlyFormula @ 0x00597050` |
| 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. **While shadows render, the pack's receiver vertex shaders (`mesh_atmospheric.vert`, `terrain_atmospheric.vert`) also take the outdoor directional LIGHTING direction from that same celestial source instead of retail's authored `uLights[0]` direction, so the lit term agrees with the shadow direction; whenever the shadow gate is closed (night, user strength 0, portal cover, indoor) the flag bit is clear and both shaders fall back to the plain pipeline's authored-light expression (Campaign VM VM6 round 5, `754d59d9`).** 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-24 | **Filed 2026-08-22, Campaign AR; amended 2026-09-04, Campaign OVERHAUL S5-c2.** 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. Its active terrain/object projection borrows retail's **prior successfully completed** landscape visibility by exact set reference, then selects ordinary objects by S2's retained retail CELLARRAY intersection and building shells by their outdoor placement `EffectCellId`; missing membership and no completed view fail closed, with no resident/frustum fallback. **While shadows render, the pack's receiver vertex shaders (`mesh_atmospheric.vert`, `terrain_atmospheric.vert`) also take the outdoor directional LIGHTING direction from that same celestial source instead of retail's authored `uLights[0]` direction, so the lit term agrees with the shadow direction; whenever the shadow gate is closed (night, user strength 0, portal cover, indoor) the flag bit is clear and both shaders fall back to the plain pipeline's authored-light expression (Campaign VM VM6 round 5, `754d59d9`).** 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`; `src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs`; pack-only receiver shaders; evidence `docs/research/2026-08-22-dereth-celestial-shadow-sources.md` and OVERHAUL packet §14 | This is the user-requested headline graphics enhancement and is strictly opt-in. Borrowing the prior completed retail landscape product and exact S2 membership narrows pack-on work without inventing another visibility owner. The retail path remains the default and authoritative fallback; pack-off does not build, select, upload, or draw shadow work and does not change `SceneLighting`. One selected source reuses one cascade array, so moon support does not multiply shadow resources. | Pack-on output intentionally differs from retail and deliberately trails camera visibility by one successfully completed world view. A wrong CELLARRAY/EffectCell identity, stale prior-view transaction, or celestial transform would visibly add, omit, or misalign shadows; pack-off output changing would violate the campaign's primary safety contract. | Landscape input: `LScape::draw_check_blocks @0x00505F80`, `LScape::landcell_check @0x005050A0`, `CLandCell::IsInView @0x00532CB0`; celestial: `SkyDesc::GetLighting @0x00500A80`, `GameSky::UseTime @0x005075B0`; installed Region `0x13000000`; cited research notes |
| 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 DAT-classified `AcDream.Core.World.WeatherKind` (Clear/Overcast/Rain/Snow/Storm — the same classification `WeatherState.cs` already derives from the active day group's authored name, not the day group's raw index, which carries no weather meaning by itself) 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 + `FoliageWindByWeather`) | 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` (top-nibble-0x8 entity-id namespace, existing acdream mechanism, not retail) |
---
diff --git a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md
index 3b2e1c2c..7578f8ca 100644
--- a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md
+++ b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md
@@ -681,8 +681,8 @@ Update immediately when a slice changes state. Chat is not the ledger.
| S4-c3a | **LANDED 2026-09-04 after the owner-authorized §21 evidence exception.** Reviewed scratch stack `44e2bc227b` → `b6bf6c131` → `eea5793d2` → `14397b14c` → `359061b82` → `255194e90` → `9cfddf301`; campaign cherry-picks `a86ec73ec` → `8e0c6fb14` → `01674bcc7` → `8bd75ba31` → `b6b015604` → `316193043` → `67c76026e`. The earlier stop remains in history at `06b986622`. | G3/G4 UNPASSED | Final retail lens PASS; production behavior/lifecycle/allocation/scope clean. The narrow independent gate-honesty re-review PASS reproduced the exact first failure (AP-table-boundary `Assert.True`, `rowIndex=-1`, before `Assert.Single`), restored AP-241 byte-identically, and passed the pin 1/1. Fresh campaign: Release 0W/0E; focused class lane 96/96; no-surface 1/1; AP pin 1/1; real allocation 2/2 at 0 B; shader 32/32; `git diff --check` clean. No client launched. NEXT: write c3b's deletion-only contract from the landed code; G3 stays locked until c3b lands. |
| S4-c3b | **LANDED 2026-09-04** — implementation `26e97ba41`, provenance contract `5110bf676`, packet-only correction `3f2f00c9f`; packet §22–§25. Dead classic-group `LocalSortCenters`/`CachedBatch.LocalSortCenter` storage and the alpha camera-parameter/digest chain are deleted. Live per-cell/particle CYpt keys, opaque `SortDistance`, building/private/portal distances, two FIFO lists, all state/barriers, and AP/AD rows remain. | **G3 LEAD SELF-GATE PROVISIONAL PASS; owner acceptance/owner-only rows pending. G4 UNPASSED.** | Retail/deletion lens PASS. Production lens found no code defect and one artifact-provenance omission; packet-only fix round 1 passed its narrow re-review. Fresh campaign: Release 0W/0E; App 132/132; Core 29/29; allocation 2/2 at 0 B; shader 32/32. G3: route 1 13/13, route 2 4/4, route 3 retry 3/3, all exit 0/graceful. The first route-3 attempt stopped before Nanto on registered #462. Exact PNG paths: packet §25. |
| S5-c1 | **LANDED 2026-09-04 after the owner-authorized documentation/evidence exception.** Mandatory stop `efb075619` remains in history; final campaign tip `1718832e2`, reviewed scratch tip `b2d56f9e3`. | G4 UNPASSED | Narrow exception re-review PASS. Clean evidence: hermetic 16,760/16,760; inclusive InstalledDat 385 pass / the same documented 10 fail / 1 skip; both exact `d6592d3ac`, `WorktreeDirty=false`, manifests fully verified. Fresh campaign Release 0W/0E, Core 111/111, visibility 38/38, allocation/AP-116 4/4. Lead graphical portal-haze gate PROVISIONAL PASS, exit 0/graceful, 10 PNGs under `logs/selfgate-20260904-171137-s5c1-particle-visibility`; owner inspection pending. |
-| S5-c2 | **CONTRACTED 2026-09-04; Sonnet implementation next.** Exact prior-completed landscape visibility selects opt-in IA-24 terrain/world shadow casters through S2 CELLARRAY/building placement facts; pack-off remains retail and untouched. | G4 UNPASSED | Packet §14 binding: one borrowed set, zero fallback, arbitrary-instance exactness, no retained topology rebuild on camera-only change, 0-B warmed path, dual sequential review. |
-| S5 | IN FLIGHT — c1 landed/provisionally self-gated; c2 contracted; c3–c5 and closeout remain | G4 | Packet §§7 and 12–14 are binding; never merge main before G4. |
+| S5-c2 | **IMPLEMENTED 2026-09-04; sequential reviews pending.** The opt-in IA-24 path borrows the exact prior-completed landscape set/flag, selects world casters through S2 CELLARRAY or building `EffectCellId`, selects authored terrain slots, and projects arbitrary active instance runs without rebuilding retained topology. Pack-off remains unchanged. | G4 UNPASSED | Packet §14 implementation result: Release 0W/0E; focused production lane 137/137; warmed caster/prepared/terrain selection pins 0 B; all seven required sabotages failed at the named first assertions and were restored. Clean exact-commit official evidence is the remaining implementer return before review. |
+| S5 | IN FLIGHT — c1 landed/provisionally self-gated; c2 implementation candidate awaiting clean evidence + dual review; c3–c5 and closeout remain | G4 | Packet §§7 and 12–14 are binding; never merge main before G4. |
---
diff --git a/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md b/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md
index 003d398c..1fd698e9 100644
--- a/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md
+++ b/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md
@@ -732,3 +732,76 @@ reconstruction, arbitrary-instance correctness, no topology rebuild on camera
motion, transform/GPU flight safety, 0-B steady state, lifecycle/deferral,
allowed scope, counts, and at least three mutation claims. A failed lens gets
one bounded fix contract; a third fix round stops and is written up.
+
+### 14.7 S5-c2 implementation result — clean evidence pending
+
+The bounded implementation is complete for review. The selected atmospheric
+pack now borrows the existing visibility controller's exact prior-completed
+set and completed-view flag by reference. The frame captures that fact before
+the current transaction begins and borrows S2's retained CELLARRAY owner
+through a typed read-only adapter. No second visibility set, cell index, or
+fallback was added.
+
+The retained caster and terrain products now have separate active-selection
+projections. Ordinary casters intersect their non-empty retail CELLARRAY with
+the completed landscape set; building shells use outdoor `EffectCellId`;
+missing membership and no/empty completed views fail closed. The established
+outdoor family remains `low != 0 && low < 0x0100` (including `0x41`), while a
+terrain slot scans only its authored 1..64 cells. Arbitrary selected instances
+inside one material command are emitted as exact contiguous
+`BaseInstance`/`InstanceCount` runs. Active indirect publication advances its
+own sequence; caster, prepared-draw, and terrain topology sequences remain
+unchanged on visibility-only frames. Terrain snapshot publication also marks
+an aborted build invalid so the next frame retries rather than accepting a
+partially written snapshot.
+
+Built-in and declared directional-shadow graphs call the same typed selector
+and pass the same prior product into terrain selection. The existing
+production pack-off integration remains unchanged and proves no active pack
+runtime, shadow resource, or shadow work. IA-24 now states the prior-view/S2
+selection adaptation and keeps the shadow map explicitly non-retail.
+
+Pre-commit automated evidence:
+
+- Release solution build: PASS, 0 warnings / 0 errors.
+- focused visibility/frame/caster/prepared/GPU/terrain/built-in/declared/
+ pack-off/long-cycle lane: 137 passed, 0 failed, 0 skipped;
+- warmed production selection: caster selector, arbitrary-run projection, and
+ terrain projection each measured 0 managed bytes while retained scratch and
+ topology build sequences stayed stable;
+- `git diff --check`: PASS.
+
+The required mutations were each built, run against the named production pin,
+and restored exactly. Their actual first failures were:
+
+1. CELLARRAY → `ParentCellId`:
+ `PriorLandscapeSelection_UsesExactCellArrayAndBuildingEffectCell`, first
+ `Assert.Equal` expected `[201, 202, 203, 205]`, actual `[204, 205]`.
+2. Select every resident terrain range:
+ `PriorLandscapeSelection_ScansExactAuthoredEightByEightCells`, first
+ `Assert.Single` found 3 commands.
+3. Building anchor → placement effect cell:
+ `PriorLandscapeSelection_UsesExactCellArrayAndBuildingEffectCell`, first
+ `Assert.Equal` expected `[201, 202, 203, 205]`, actual
+ `[201, 202, 203, 206]`.
+4. Admit missing membership:
+ the same caster pin's first `Assert.Equal` expected
+ `[201, 202, 203, 205]`, actual `[201, 202, 203, 204, 205]`.
+5. Publish current building scratch instead of the completed product:
+ `BorrowedLandscapeFrame_TracksOnlyCompletedTransactionsByReference`, first
+ `Assert.True` expected `HasCompletedWorldView=true`, actual `false` after
+ the completed empty frame.
+6. Advance retained caster topology during selection:
+ `PriorLandscapeSelection_UsesExactCellArrayAndBuildingEffectCell`, first
+ topology `Assert.Equal` expected `BuildSequence=1`, actual `2`.
+7. Collapse alternating selection into one prefix instance count:
+ `AlternatingCasterSelection_EmitsExactContiguousInstanceRuns`, first
+ active-command-count `Assert.Equal` expected 3, actual 1.
+
+The one bounded implementation commit is made before the official lanes.
+Official hermetic and inclusive InstalledDat evidence will run only from that
+exact clean commit, with `WorktreeDirty=false`, under
+`artifacts/s5-c2-hermetic-20260904` and
+`artifacts/s5-c2-installed-20260904`. Those post-commit artifacts supersede no
+earlier run and will be reported directly; this result does not invent their
+counts.
diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index 1970c8f6..37f1f4cd 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -416,7 +416,9 @@ internal sealed class FrameRootCompositionPhase
live.EquippedChildren),
new RuntimeWorldFrameBuildingSource(
live.LandblockPipeline,
- d.CellVisibility));
+ d.CellVisibility),
+ new RuntimeDirectionalShadowCellMembership(
+ d.PhysicsEngine.ShadowObjects));
var terrainDrawDiagnostics = new TerrainDrawDiagnosticsController(
d.FrameDiagnosticsEnabled,
worldRenderDiagnostics,
diff --git a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
index b43d745f..81b8c275 100644
--- a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
+++ b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
@@ -5,6 +5,7 @@ using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
+using AcDream.App.Rendering.Vfx;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering;
@@ -39,6 +40,7 @@ internal readonly record struct DirectionalSunShadowRenderInput(
bool MeasureGpuTimers = true,
bool MeasureCpuStages = false,
AtmosphericFrameBufferBinding AtmosphericFrame = default,
+ RetailLandscapeVisibilityFrame PriorLandscapeVisibility = default,
// #429 owner-approved pipelining: false keeps the retained caster/draw
// topology this frame (transform refresh only) so the rebuild lands on a
// quieter frame. The prepare seams below re-validate and rebuild anyway
@@ -73,7 +75,8 @@ internal readonly record struct DirectionalShadowTransformChurnDiagnostics(
bool FlightFullDynamicFallback,
bool DenseDirectUpload,
bool DenseFlightReplay,
- DirectionalShadowCasterClassDiagnostics CasterClasses = default);
+ DirectionalShadowCasterClassDiagnostics CasterClasses = default,
+ int ActiveSelectedCasters = 0);
internal readonly record struct DirectionalSunShadowDiagnostics(
DirectionalShadowGateReason GateReason,
@@ -96,7 +99,15 @@ internal readonly record struct DirectionalSunShadowDiagnostics(
int SourceObjectIndex = -1,
uint SourceGfxObjId = 0u,
Vector3 SurfaceToLightDirection = default,
- float LightElevationSin = 0f);
+ float LightElevationSin = 0f,
+ int ResidentWorldCasters = 0,
+ int ActiveWorldCasters = 0,
+ int ResidentWorldInstances = 0,
+ int ActiveWorldInstances = 0,
+ int ResidentWorldCommands = 0,
+ int ActiveWorldCommands = 0,
+ int ResidentTerrainCommands = 0,
+ int ActiveTerrainCommands = 0);
internal static class DirectionalShadowBatchFlags
{
@@ -292,9 +303,9 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
internal DirectionalShadowFrameBinding CurrentFrameBinding => _currentFrameBinding;
///
- /// Topology-only command metadata lives in pack-owned device-local buffers.
- /// It is rebuilt transactionally when the retained CPU product changes and
- /// is never copied through a per-frame ring on a stable scene.
+ /// The bounded active indirect projection lives in pack-owned device-local
+ /// buffers. Selection republishes these buffers without rebuilding the
+ /// retained CPU topology and never copies them through a per-frame ring.
///
internal long RetainedCommandBufferBytes => checked(
(_worldBatchBuffer?.SizeBytes ?? 0L)
@@ -402,10 +413,15 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
world.PrepareDirectionalShadowDraws(
input.Casters,
input.AllowTopologyRebuild);
+ RetailLandscapeVisibilityFrame priorLandscapeVisibility =
+ input.PriorLandscapeVisibility;
DirectionalShadowTerrainPreparedDraws terrainDraws =
- terrain.PrepareDirectionalShadowDraws();
+ terrain.PrepareDirectionalShadowDraws(
+ in priorLandscapeVisibility);
DirectionalShadowMeshGeometry? worldGeometry =
- worldDraws.Commands.IsEmpty ? null : world.GetDirectionalShadowGeometry();
+ worldDraws.ActiveCommands.IsEmpty
+ ? null
+ : world.GetDirectionalShadowGeometry();
DirectionalShadowTerrainGeometry? terrainGeometry =
terrainDraws.Commands.IsEmpty ? null : terrain.GetDirectionalShadowGeometry();
uint transformBindingSizeBytes =
@@ -433,7 +449,7 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
DirectionalShadowCasterClassDiagnostics casterClasses =
CompleteCasterClassDiagnostics(
in casterStats,
- terrainDraws.Commands.Length);
+ terrainDraws.ResidentRanges.Length);
DirectionalShadowTransformPublishStats publishStats =
_transformBuffers.LastStats;
var transformChurn = new DirectionalShadowTransformChurnDiagnostics(
@@ -457,7 +473,8 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
publishStats.UsedFullDynamicFallback,
publishStats.DenseDirectUpload,
publishStats.DenseFlightReplay,
- casterClasses);
+ casterClasses,
+ casterStats.ActiveSelected);
long preparedDrawsAndTransformsTicks = input.MeasureCpuStages
? Stopwatch.GetTimestamp() - cpuStageStarted
: 0L;
@@ -661,7 +678,7 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
PublishDisabledReceiverBinding(frame, atmosphericFrame);
return Disabled(in environment, cpuStages);
}
- if (!worldDraws.Commands.IsEmpty && worldGeometry is null)
+ if (!worldDraws.ActiveCommands.IsEmpty && worldGeometry is null)
throw new ArgumentNullException(nameof(worldGeometry));
if (!terrainDraws.Commands.IsEmpty && terrainGeometry is null)
throw new ArgumentNullException(nameof(terrainGeometry));
@@ -812,9 +829,10 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
int drawsPerCascade = terrainDraws.Commands.IsEmpty ? 0 : 1;
drawsPerCascade = checked(
drawsPerCascade
- + (worldDraws.Commands.IsEmpty
+ + (worldDraws.ActiveCommands.IsEmpty
? 0
- : worldDraws.OpaqueRuns.Length + worldDraws.AlphaCutoutRuns.Length));
+ : worldDraws.ActiveOpaqueRuns.Length
+ + worldDraws.ActiveAlphaCutoutRuns.Length));
long finished = Stopwatch.GetTimestamp();
cpuStages = cpuStages with
{
@@ -833,8 +851,8 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
environment.Strength,
cascadeCount,
checked((MultiviewCascadesEnabled ? 1 : cascadeCount) * drawsPerCascade),
- worldDraws.OpaqueCommandCount,
- worldDraws.AlphaCutoutCommandCount,
+ worldDraws.ActiveOpaqueCommandCount,
+ worldDraws.ActiveAlphaCutoutCommandCount,
terrainDraws.Commands.Length,
worldDraws.BuildSequence,
terrainDraws.BuildSequence,
@@ -848,7 +866,30 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
environment.SourceObjectIndex,
environment.SourceGfxObjId,
environment.SurfaceToLightDirection,
- environment.LightElevationSin);
+ environment.LightElevationSin,
+ ResidentWorldCasters: inputCasterClassesCount(transformChurn),
+ ActiveWorldCasters: transformChurn.ActiveSelectedCasters,
+ ResidentWorldInstances: worldDraws.Stats.PreparedInstances,
+ ActiveWorldInstances: worldDraws.Stats.ActiveInstances,
+ ResidentWorldCommands: worldDraws.Commands.Length,
+ ActiveWorldCommands: worldDraws.ActiveCommands.Length,
+ ResidentTerrainCommands: terrainDraws.ResidentRanges.Length,
+ ActiveTerrainCommands: terrainDraws.Commands.Length);
+
+ static int inputCasterClassesCount(
+ DirectionalShadowTransformChurnDiagnostics churn)
+ {
+ DirectionalShadowCasterClassDiagnostics classes = churn.CasterClasses;
+ return checked(
+ classes.OutdoorStatics
+ + classes.Buildings
+ + classes.AnimatedStatics
+ + classes.LocalPlayers
+ + classes.RemotePlayers
+ + classes.NonPlayerCreatures
+ + classes.OtherLiveDynamics
+ + classes.EquippedChildren);
+ }
}
private PreparedGpuUploads PrepareGpuData(
@@ -856,9 +897,9 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
DirectionalShadowPreparedDraws world,
DirectionalShadowTerrainPreparedDraws terrain)
{
- if (_worldGpuBuildSequence != world.BuildSequence)
+ if (_worldGpuBuildSequence != world.ActiveSelectionSequence)
RebuildWorldGpuData(world);
- if (_terrainGpuBuildSequence != terrain.BuildSequence)
+ if (_terrainGpuBuildSequence != terrain.ActiveSelectionSequence)
RebuildTerrainGpuData(terrain);
return new PreparedGpuUploads(
@@ -874,12 +915,12 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
IGpuBuffer? commands = null;
try
{
- if (!world.Commands.IsEmpty)
+ if (!world.ActiveCommands.IsEmpty)
{
- EnsureBatchCapacity(world.Batches.Length);
- for (int i = 0; i < world.Batches.Length; i++)
+ EnsureBatchCapacity(world.ActiveBatches.Length);
+ for (int i = 0; i < world.ActiveBatches.Length; i++)
{
- DirectionalShadowPreparedBatch batch = world.Batches[i];
+ DirectionalShadowPreparedBatch batch = world.ActiveBatches[i];
_batchScratch[i] = new DirectionalShadowBatchGpuData(
batch.TextureSlot.Index,
0u,
@@ -892,15 +933,15 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
}
ReadOnlySpan batchBytes = MemoryMarshal.AsBytes(
- _batchScratch.AsSpan(0, world.Batches.Length));
+ _batchScratch.AsSpan(0, world.ActiveBatches.Length));
ReadOnlySpan commandBytes = MemoryMarshal.AsBytes(
- world.Commands);
+ world.ActiveCommands);
batches = CreateRetainedBuffer(
- $"directional-shadow-world-batches-{world.BuildSequence}",
+ $"directional-shadow-world-batches-{world.ActiveSelectionSequence}",
batchBytes,
GpuBufferUsage.Storage);
commands = CreateRetainedBuffer(
- $"directional-shadow-world-commands-{world.BuildSequence}",
+ $"directional-shadow-world-commands-{world.ActiveSelectionSequence}",
commandBytes,
GpuBufferUsage.Indirect);
}
@@ -916,7 +957,7 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
IGpuBuffer? previousCommands = _worldCommandBuffer;
_worldBatchBuffer = batches;
_worldCommandBuffer = commands;
- _worldGpuBuildSequence = world.BuildSequence;
+ _worldGpuBuildSequence = world.ActiveSelectionSequence;
previousCommands?.Dispose();
previousBatches?.Dispose();
}
@@ -927,14 +968,14 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
if (!terrain.Commands.IsEmpty)
{
commands = CreateRetainedBuffer(
- $"directional-shadow-terrain-commands-{terrain.BuildSequence}",
+ $"directional-shadow-terrain-commands-{terrain.ActiveSelectionSequence}",
MemoryMarshal.AsBytes(terrain.Commands),
GpuBufferUsage.Indirect);
}
IGpuBuffer? previous = _terrainCommandBuffer;
_terrainCommandBuffer = commands;
- _terrainGpuBuildSequence = terrain.BuildSequence;
+ _terrainGpuBuildSequence = terrain.ActiveSelectionSequence;
previous?.Dispose();
}
@@ -997,7 +1038,7 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
IGpuPipeline? opaquePipeline = null,
IGpuPipeline? cutoutPipeline = null)
{
- if (draws.Commands.IsEmpty)
+ if (draws.ActiveCommands.IsEmpty)
return;
DirectionalShadowMeshGeometry actual = geometry!.Value;
encoder.BindStorageBuffer(
@@ -1013,14 +1054,14 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
DrawWorldRange(
encoder,
uploads.WorldCommands,
- draws.OpaqueRuns,
+ draws.ActiveOpaqueRuns,
cascadeIndex,
opaquePipeline ?? _worldOpaquePipeline,
actual);
DrawWorldRange(
encoder,
uploads.WorldCommands,
- draws.AlphaCutoutRuns,
+ draws.ActiveAlphaCutoutRuns,
cascadeIndex,
cutoutPipeline ?? _worldCutoutPipeline,
actual);
diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs
index 2117d648..137308ae 100644
--- a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs
+++ b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs
@@ -4,6 +4,7 @@ using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Scene;
+using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using AcDream.Plugin.Abstractions.Rendering;
@@ -421,6 +422,12 @@ internal sealed class AtmosphericPostProcessGraph :
!shadowInputsChanged || _shadowRebuildDeferrals >= 2;
ulong casterSequenceBefore = _shadowCasters.BuildSequence;
_shadowCasters.Build(in scene, allowTopologyRebuild);
+ RetailLandscapeVisibilityFrame priorLandscapeVisibility =
+ world.PriorLandscapeVisibility;
+ _shadowCasters.Select(
+ in priorLandscapeVisibility,
+ world.DirectionalShadowCellMembership
+ ?? EmptyDirectionalShadowCellMembership.Instance);
if (_shadowCasters.BuildSequence != casterSequenceBefore)
allowTopologyRebuild = true;
_shadowRebuildDeferrals = allowTopologyRebuild
@@ -463,9 +470,10 @@ internal sealed class AtmosphericPostProcessGraph :
frame.Serial),
MeasureCpuStages: measureCpuStages,
AtmosphericFrame: shadowAtmosphericFrame,
+ PriorLandscapeVisibility: world.PriorLandscapeVisibility,
AllowTopologyRebuild: allowTopologyRebuild);
long environmentFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
- _lastShadowCasterCount = _shadowCasters.Stats.Accepted;
+ _lastShadowCasterCount = _shadowCasters.Stats.ActiveSelected;
_lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0;
_lastShadowDiagnostics = _directionalShadows.Render(
frame,
diff --git a/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs b/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs
index e883456c..affb3d98 100644
--- a/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs
+++ b/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs
@@ -2,6 +2,7 @@ using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Scene;
+using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using AcDream.Plugin.Abstractions.Rendering;
@@ -201,6 +202,12 @@ internal class DeclaredFullscreenRenderPackGraph :
?? throw new InvalidOperationException(
$"Pack '{Descriptor.Id}' has no declared directional-shadow executor.");
_shadowCasters.Build(in scene);
+ RetailLandscapeVisibilityFrame priorLandscapeVisibility =
+ world.PriorLandscapeVisibility;
+ _shadowCasters.Select(
+ in priorLandscapeVisibility,
+ world.DirectionalShadowCellMembership
+ ?? EmptyDirectionalShadowCellMembership.Instance);
AuthoredCelestialShadowSource source = world.CelestialShadowSource;
float elevationStrength = RenderPackAtmospherePolicyEvaluation
.DirectionalShadowFromSin(
@@ -225,8 +232,9 @@ internal class DeclaredFullscreenRenderPackGraph :
world.Camera.Projection,
_shadowCasters,
ResidentMaximumReachMeters:
- world.ResidentStreamingWindow.MaximumReachMeters);
- _lastShadowCasterCount = _shadowCasters.Stats.Accepted;
+ world.ResidentStreamingWindow.MaximumReachMeters,
+ PriorLandscapeVisibility: world.PriorLandscapeVisibility);
+ _lastShadowCasterCount = _shadowCasters.Stats.ActiveSelected;
_lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0;
_lastShadowDiagnostics = renderer.Render(
frame,
diff --git a/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs b/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs
index 39077c3d..577e51b5 100644
--- a/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs
+++ b/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs
@@ -1,3 +1,5 @@
+using AcDream.App.Rendering.Vfx;
+
namespace AcDream.App.Rendering.Scene;
///
@@ -80,7 +82,8 @@ internal readonly record struct DirectionalShadowCasterBuildStats(
int LiveDynamicRootChanges = 0,
int EquippedChildChanges = 0,
bool DensityBulkRefresh = false,
- int BatchedProjectionCopyCalls = 0)
+ int BatchedProjectionCopyCalls = 0,
+ int ActiveSelected = 0)
{
public DirectionalShadowCasterClassDiagnostics CasterClasses { get; init; }
}
@@ -96,6 +99,7 @@ internal sealed class DirectionalShadowCasterFrame
private RenderProjectionRecord[] _outdoorStaticScratch = [];
private RenderProjectionRecord[] _outdoorDynamicScratch = [];
private DirectionalShadowCaster[] _casters = [];
+ private bool[] _selectedCasters = [];
private int[] _refreshCasterSlots = [];
private DirectionalShadowChangedPose[] _changedCasterPoses = [];
private bool[] _changedCasterFlags = [];
@@ -123,9 +127,16 @@ internal sealed class DirectionalShadowCasterFrame
public ulong BuildSequence { get; private set; }
+ /// Per-frame active-selection publication identity. It is
+ /// intentionally independent from retained topology .
+ public ulong SelectionSequence { get; private set; }
+
public ReadOnlySpan Casters =>
_casters.AsSpan(0, _casterCount);
+ internal ReadOnlySpan SelectedCasters =>
+ _selectedCasters.AsSpan(0, _casterCount);
+
internal ReadOnlySpan RefreshCasterSlots =>
_refreshCasterSlots.AsSpan(0, _refreshCasterSlotCount);
@@ -144,6 +155,7 @@ internal sealed class DirectionalShadowCasterFrame
* System.Runtime.CompilerServices.Unsafe.SizeOf()
+ (long)_casters.Length
* System.Runtime.CompilerServices.Unsafe.SizeOf()
+ + _selectedCasters.Length
+ (long)_refreshCasterSlots.Length * sizeof(int)
+ (long)_changedCasterPoses.Length
* System.Runtime.CompilerServices.Unsafe.SizeOf<
@@ -233,6 +245,7 @@ internal sealed class DirectionalShadowCasterFrame
RenderSceneIndex.OutdoorDynamic,
_outdoorDynamicScratch.AsSpan(0, counts.OutdoorDynamic));
EnsureCapacity(ref _casters, checked(staticCount + dynamicCount));
+ EnsureCapacity(ref _selectedCasters, checked(staticCount + dynamicCount));
_casterCount = 0;
int rejectedNotDrawable = 0;
@@ -394,6 +407,67 @@ internal sealed class DirectionalShadowCasterFrame
}
}
+ ///
+ /// Projects the prior-completed retail landscape product onto the retained
+ /// caster topology. This performs no render-scene copy, classification,
+ /// mesh lookup, or topology revision change.
+ ///
+ internal void Select(
+ in RetailLandscapeVisibilityFrame visibility,
+ IDirectionalShadowCellMembership membership)
+ {
+ ArgumentNullException.ThrowIfNull(membership);
+ IReadOnlySet visible = visibility.CellIds
+ ?? RetailLandscapeVisibilityFrame.None.CellIds;
+ int selected = 0;
+ for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++)
+ {
+ ref readonly DirectionalShadowCaster caster =
+ ref _casters[casterIndex];
+ bool active = visibility.HasCompletedWorldView
+ && SelectsCaster(in caster, visible, membership);
+ _selectedCasters[casterIndex] = active;
+ if (active)
+ selected++;
+ }
+
+ SelectionSequence = checked(SelectionSequence + 1);
+ Stats = Stats with { ActiveSelected = selected };
+ }
+
+ private static bool SelectsCaster(
+ in DirectionalShadowCaster caster,
+ IReadOnlySet visible,
+ IDirectionalShadowCellMembership membership)
+ {
+ RenderSourceMetadata source = caster.Projection.Source;
+ if (caster.Kind is DirectionalShadowCasterKind.Building)
+ return IsOutdoorLandCell(source.EffectCellId)
+ && visible.Contains(source.EffectCellId);
+
+ if (!membership.TryGetRetailCellArray(
+ source.LocalEntityId,
+ out IReadOnlyList? cells)
+ || cells.Count == 0)
+ {
+ return false;
+ }
+
+ for (int cellIndex = 0; cellIndex < cells.Count; cellIndex++)
+ {
+ uint cellId = cells[cellIndex];
+ if (IsOutdoorLandCell(cellId) && visible.Contains(cellId))
+ return true;
+ }
+ return false;
+ }
+
+ private static bool IsOutdoorLandCell(uint cellId)
+ {
+ uint low = cellId & 0xFFFFu;
+ return low != 0u && low < 0x0100u;
+ }
+
///
/// Pure pre-check for the deferral gate: would refreshing from the journal
/// demand the dense by-id re-copy? The journal copy is a read; the state
diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadows.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadows.cs
index d8c972ec..5c00da93 100644
--- a/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadows.cs
+++ b/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadows.cs
@@ -1,37 +1,49 @@
using System.Runtime.CompilerServices;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Vfx;
namespace AcDream.App.Rendering;
internal readonly record struct DirectionalShadowTerrainRange(
uint FirstIndex,
- int IndexCount);
+ int IndexCount,
+ uint LandblockId = 0u);
internal readonly record struct DirectionalShadowTerrainGeometry(
IGpuBuffer VertexBuffer,
IGpuBuffer IndexBuffer);
///
-/// The complete resident terrain arena expressed once as indirect commands.
-/// It deliberately has no camera, PView, portal, or cascade input.
+/// The complete resident terrain arena expressed once as retained slot ranges,
+/// plus a separately sequenced active indirect projection.
///
internal sealed class DirectionalShadowTerrainPreparedDraws
{
- private DrawElementsIndirectCommand[] _commands = [];
- private int _count;
+ private DirectionalShadowTerrainRange[] _ranges = [];
+ private DrawElementsIndirectCommand[] _activeCommands = [];
+ private int _rangeCount;
+ private int _activeCount;
private bool _building;
public long SourceFrameSequence { get; private set; }
public ulong BuildSequence { get; private set; }
+ public ulong ActiveSelectionSequence { get; private set; }
+
public ReadOnlySpan Commands =>
- _commands.AsSpan(0, _count);
+ _activeCommands.AsSpan(0, _activeCount);
+
+ public ReadOnlySpan ResidentRanges =>
+ _ranges.AsSpan(0, _rangeCount);
public long RetainedScratchBytes =>
- checked((long)_commands.Length
- * Unsafe.SizeOf());
+ checked(
+ (long)_ranges.Length
+ * Unsafe.SizeOf()
+ + (long)_activeCommands.Length
+ * Unsafe.SizeOf());
public bool TryBegin(long frameSequence, int estimatedCommands)
{
@@ -45,7 +57,7 @@ internal sealed class DirectionalShadowTerrainPreparedDraws
return false;
EnsureCapacity(estimatedCommands);
- _count = 0;
+ _rangeCount = 0;
_building = true;
return true;
}
@@ -57,15 +69,8 @@ internal sealed class DirectionalShadowTerrainPreparedDraws
"Begin a terrain shadow draw build before adding ranges.");
if (range.IndexCount <= 0)
throw new ArgumentOutOfRangeException(nameof(range));
- EnsureCapacity(checked(_count + 1));
- _commands[_count++] = new DrawElementsIndirectCommand
- {
- Count = checked((uint)range.IndexCount),
- InstanceCount = 1,
- FirstIndex = range.FirstIndex,
- BaseVertex = 0,
- BaseInstance = 0,
- };
+ EnsureCapacity(checked(_rangeCount + 1));
+ _ranges[_rangeCount++] = range;
}
public void Complete(long frameSequence)
@@ -78,22 +83,73 @@ internal sealed class DirectionalShadowTerrainPreparedDraws
SourceFrameSequence = frameSequence;
BuildSequence = checked(BuildSequence + 1);
_building = false;
+ RebuildActiveAll();
+ }
+
+ internal void ApplySelection(in RetailLandscapeVisibilityFrame visibility)
+ {
+ IReadOnlySet visible = visibility.CellIds
+ ?? RetailLandscapeVisibilityFrame.None.CellIds;
+ _activeCount = 0;
+ if (visibility.HasCompletedWorldView)
+ {
+ for (int rangeIndex = 0; rangeIndex < _rangeCount; rangeIndex++)
+ {
+ DirectionalShadowTerrainRange range = _ranges[rangeIndex];
+ uint prefix = range.LandblockId & 0xFFFF0000u;
+ bool selected = false;
+ for (uint low = 1u; low <= 64u; low++)
+ {
+ if (visible.Contains(prefix | low))
+ {
+ selected = true;
+ break;
+ }
+ }
+ if (selected)
+ Emit(in range);
+ }
+ }
+ ActiveSelectionSequence = checked(ActiveSelectionSequence + 1);
+ }
+
+ private void RebuildActiveAll()
+ {
+ EnsureCapacity(_rangeCount);
+ _activeCount = 0;
+ for (int rangeIndex = 0; rangeIndex < _rangeCount; rangeIndex++)
+ Emit(in _ranges[rangeIndex]);
+ ActiveSelectionSequence = checked(ActiveSelectionSequence + 1);
+ }
+
+ private void Emit(in DirectionalShadowTerrainRange range)
+ {
+ _activeCommands[_activeCount++] = new DrawElementsIndirectCommand
+ {
+ Count = checked((uint)range.IndexCount),
+ InstanceCount = 1,
+ FirstIndex = range.FirstIndex,
+ BaseVertex = 0,
+ BaseInstance = 0,
+ };
}
public void Abort()
{
- _count = 0;
+ _rangeCount = 0;
+ _activeCount = 0;
_building = false;
}
private void EnsureCapacity(int required)
{
- if (_commands.Length >= required)
+ if (_ranges.Length >= required)
return;
- int capacity = _commands.Length == 0 ? 16 : _commands.Length;
+ int capacity = _ranges.Length == 0 ? 16 : _ranges.Length;
while (capacity < required)
capacity = checked(capacity * 2);
- Array.Resize(ref _commands, capacity);
+ Array.Resize(ref _ranges, capacity);
+ Array.Resize(ref _activeCommands, capacity);
}
}
@@ -101,46 +157,107 @@ public sealed partial class TerrainModernRenderer
{
private readonly DirectionalShadowTerrainPreparedDraws
_directionalShadowTerrainDraws = new();
+ // BeginFrame's established overflow guard/frame counter remains part of
+ // the ordinary renderer lifecycle; shadow topology no longer keys on it.
private long _directionalShadowFrameSequence;
+ private uint[] _directionalShadowSlotLandblocks = [];
+ private uint[] _directionalShadowSlotFirstIndices = [];
+ private int[] _directionalShadowSlotIndexCounts = [];
+ private bool[] _directionalShadowSlotPresent = [];
+ private bool _directionalShadowTopologySnapshotValid;
+ private long _directionalShadowTopologySequence;
internal DirectionalShadowTerrainGeometry GetDirectionalShadowGeometry() => new(
_vertexStore ?? throw new InvalidOperationException("Terrain has no vertex store."),
_indexStore ?? throw new InvalidOperationException("Terrain has no index store."));
///
- /// Builds one all-resident indirect list for the current frame. Repeated
- /// calls by individual cascades return the same retained product.
+ /// Retains exact loaded-slot topology and projects the borrowed prior-view
+ /// selection into a bounded indirect list. Camera-only changes do not
+ /// advance the topology build sequence or rebuild terrain geometry.
///
internal DirectionalShadowTerrainPreparedDraws
- PrepareDirectionalShadowDraws()
+ PrepareDirectionalShadowDraws(
+ in RetailLandscapeVisibilityFrame visibility)
{
- if (!_directionalShadowTerrainDraws.TryBegin(
- _directionalShadowFrameSequence,
- _alloc.LoadedCount))
+ EnsureDirectionalShadowSlotCapacity(_slots.Length);
+ bool topologyChanged = !_directionalShadowTopologySnapshotValid;
+ for (int slot = 0; slot < _slots.Length; slot++)
{
- return _directionalShadowTerrainDraws;
+ SlotData? data = _slots[slot];
+ bool present = data is not null;
+ if (_directionalShadowSlotPresent[slot] != present
+ || present
+ && (_directionalShadowSlotLandblocks[slot] != data!.LandblockId
+ || _directionalShadowSlotFirstIndices[slot] != data.FirstIndex
+ || _directionalShadowSlotIndexCounts[slot] != data.IndexCount))
+ {
+ topologyChanged = true;
+ }
}
- try
+ if (topologyChanged)
{
- for (int slot = 0; slot < _slots.Length; slot++)
+ _directionalShadowTopologySequence = checked(
+ _directionalShadowTopologySequence + 1);
+ _directionalShadowTerrainDraws.TryBegin(
+ _directionalShadowTopologySequence,
+ _alloc.LoadedCount);
+ try
{
- SlotData? data = _slots[slot];
- if (data is null)
- continue;
- var range = new DirectionalShadowTerrainRange(
- data.FirstIndex,
- data.IndexCount);
- _directionalShadowTerrainDraws.Add(in range);
+ for (int slot = 0; slot < _slots.Length; slot++)
+ {
+ SlotData? data = _slots[slot];
+ bool present = data is not null;
+ _directionalShadowSlotPresent[slot] = present;
+ if (!present)
+ {
+ _directionalShadowSlotLandblocks[slot] = 0u;
+ _directionalShadowSlotFirstIndices[slot] = 0u;
+ _directionalShadowSlotIndexCounts[slot] = 0;
+ continue;
+ }
+ _directionalShadowSlotLandblocks[slot] = data!.LandblockId;
+ _directionalShadowSlotFirstIndices[slot] = data.FirstIndex;
+ _directionalShadowSlotIndexCounts[slot] = data.IndexCount;
+ var range = new DirectionalShadowTerrainRange(
+ data.FirstIndex,
+ data.IndexCount,
+ data.LandblockId);
+ _directionalShadowTerrainDraws.Add(in range);
+ }
+ _directionalShadowTerrainDraws.Complete(
+ _directionalShadowTopologySequence);
+ _directionalShadowTopologySnapshotValid = true;
+ }
+ catch
+ {
+ // The snapshot fields are populated while the retained
+ // product is built. If publication fails, force the next
+ // frame to retry even when those fields already match the
+ // live slots; an aborted build is never a valid topology.
+ _directionalShadowTopologySnapshotValid = false;
+ _directionalShadowTerrainDraws.Abort();
+ throw;
}
- _directionalShadowTerrainDraws.Complete(
- _directionalShadowFrameSequence);
- return _directionalShadowTerrainDraws;
- }
- catch
- {
- _directionalShadowTerrainDraws.Abort();
- throw;
}
+
+ _directionalShadowTerrainDraws.ApplySelection(in visibility);
+ return _directionalShadowTerrainDraws;
+ }
+
+ private void EnsureDirectionalShadowSlotCapacity(int required)
+ {
+ if (_directionalShadowSlotPresent.Length >= required)
+ return;
+ int capacity = _directionalShadowSlotPresent.Length == 0
+ ? 16
+ : _directionalShadowSlotPresent.Length;
+ while (capacity < required)
+ capacity = checked(capacity * 2);
+ Array.Resize(ref _directionalShadowSlotLandblocks, capacity);
+ Array.Resize(ref _directionalShadowSlotFirstIndices, capacity);
+ Array.Resize(ref _directionalShadowSlotIndexCounts, capacity);
+ Array.Resize(ref _directionalShadowSlotPresent, capacity);
}
}
diff --git a/src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs b/src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs
index e2e06d5b..a8ed7fda 100644
--- a/src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs
+++ b/src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs
@@ -12,6 +12,21 @@ internal interface IWorldSceneParticleVisibility
void AbortFrame();
}
+///
+/// Borrowed prior-completed retail landscape visibility.
+/// is the controller's one retained completed set, not a reconstructed or
+/// current-camera answer. The borrow is valid through the enhanced-world
+/// prepass that captured it.
+///
+internal readonly record struct RetailLandscapeVisibilityFrame(
+ IReadOnlySet CellIds,
+ bool HasCompletedWorldView)
+{
+ internal static RetailLandscapeVisibilityFrame None { get; } = new(
+ System.Collections.Frozen.FrozenSet.Empty,
+ HasCompletedWorldView: false);
+}
+
///
/// Bridges the retained retail PView result into the next physics update's
/// CObjCell::IsInView particle gate. The controller owns only immutable
@@ -115,6 +130,13 @@ public sealed class ParticleVisibilityController : IWorldSceneParticleVisibility
rangeMultiplier);
}
+ ///
+ /// Borrows the exact prior-completed landscape product for the opt-in
+ /// directional-shadow prepass. No set is copied or reconstructed.
+ ///
+ internal RetailLandscapeVisibilityFrame CaptureCompletedLandscapeVisibility() =>
+ new(_completedCellIds, _hasCompletedWorldView);
+
public void Reset()
{
_buildingCellIds.Clear();
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs
index f8cb8dd5..b7e3db6a 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs
@@ -42,7 +42,9 @@ internal readonly record struct DirectionalShadowPreparationStats(
int RejectedTransparentBatches,
int RejectedFadedParts,
int MissingMeshes,
- int UnresolvedAlphaCutoutTextures);
+ int UnresolvedAlphaCutoutTextures,
+ int ActiveInstances = 0,
+ int ActiveCommands = 0);
internal readonly record struct DirectionalShadowMeshGeometry(
IGpuBuffer VertexBuffer,
@@ -55,6 +57,14 @@ internal readonly record struct DirectionalShadowTransformSource(
bool IsSetupPart,
Matrix4x4 SetupPartTransform)
{
+ public static DirectionalShadowTransformSource Static(int casterIndex) =>
+ new(
+ false,
+ casterIndex,
+ MeshIndex: 0,
+ IsSetupPart: false,
+ SetupPartTransform: default);
+
public static DirectionalShadowTransformSource Dynamic(
int casterIndex,
int meshIndex,
@@ -89,6 +99,9 @@ internal sealed class DirectionalShadowPreparedDraws
private DrawElementsIndirectCommand[] _commands = [];
private DirectionalShadowPreparedBatch[] _batches = [];
private DirectionalShadowPreparedRun[] _runs = [];
+ private DrawElementsIndirectCommand[] _activeCommands = [];
+ private DirectionalShadowPreparedBatch[] _activeBatches = [];
+ private DirectionalShadowPreparedRun[] _activeRuns = [];
private int[] _drawNextInGroup = [];
private int[] _groupHead = [];
private int[] _groupTail = [];
@@ -101,6 +114,8 @@ internal sealed class DirectionalShadowPreparedDraws
private int _sourceCount;
private int _commandCount;
private int _runCount;
+ private int _activeCommandCount;
+ private int _activeRunCount;
private int _dynamicTransformSlotCount;
private int _allDynamicTransformSlotCount;
private int _mappedCasterCount;
@@ -117,6 +132,10 @@ internal sealed class DirectionalShadowPreparedDraws
public ulong BuildSequence { get; private set; }
+ public ulong SourceCasterSelectionSequence { get; private set; }
+
+ public ulong ActiveSelectionSequence { get; private set; }
+
public int LastDynamicTransformRefreshCount { get; private set; }
public bool LastDynamicTransformRefreshWasDense { get; private set; }
@@ -174,6 +193,27 @@ internal sealed class DirectionalShadowPreparedDraws
public ReadOnlySpan AlphaCutoutRuns =>
_runs.AsSpan(OpaqueRunCount, _runCount - OpaqueRunCount);
+ public int ActiveOpaqueCommandCount { get; private set; }
+
+ public int ActiveAlphaCutoutCommandCount =>
+ _activeCommandCount - ActiveOpaqueCommandCount;
+
+ public int ActiveOpaqueRunCount { get; private set; }
+
+ public ReadOnlySpan ActiveCommands =>
+ _activeCommands.AsSpan(0, _activeCommandCount);
+
+ public ReadOnlySpan ActiveBatches =>
+ _activeBatches.AsSpan(0, _activeCommandCount);
+
+ public ReadOnlySpan ActiveOpaqueRuns =>
+ _activeRuns.AsSpan(0, ActiveOpaqueRunCount);
+
+ public ReadOnlySpan ActiveAlphaCutoutRuns =>
+ _activeRuns.AsSpan(
+ ActiveOpaqueRunCount,
+ _activeRunCount - ActiveOpaqueRunCount);
+
public DirectionalShadowPreparationStats Stats { get; private set; }
public long RetainedScratchBytes => checked(
@@ -193,6 +233,9 @@ internal sealed class DirectionalShadowPreparedDraws
+ (long)_commands.Length * Unsafe.SizeOf()
+ (long)_batches.Length * Unsafe.SizeOf()
+ (long)_runs.Length * Unsafe.SizeOf()
+ + (long)_activeCommands.Length * Unsafe.SizeOf()
+ + (long)_activeBatches.Length * Unsafe.SizeOf()
+ + (long)_activeRuns.Length * Unsafe.SizeOf()
+ (long)_drawNextInGroup.Length * sizeof(int)
+ (long)_groupHead.Length * sizeof(int)
+ (long)_groupTail.Length * sizeof(int)
@@ -248,6 +291,8 @@ internal sealed class DirectionalShadowPreparedDraws
_sourceCount = 0;
_commandCount = 0;
_runCount = 0;
+ _activeCommandCount = 0;
+ _activeRunCount = 0;
_dynamicTransformSlotCount = 0;
_allDynamicTransformSlotCount = 0;
if (_mappedCasterCount != 0)
@@ -260,6 +305,8 @@ internal sealed class DirectionalShadowPreparedDraws
_mappedCasterCount = 0;
OpaqueCommandCount = 0;
OpaqueRunCount = 0;
+ ActiveOpaqueCommandCount = 0;
+ ActiveOpaqueRunCount = 0;
Stats = default;
LastDynamicTransformRefreshCount = 0;
LastDynamicTransformRefreshWasDense = false;
@@ -443,6 +490,9 @@ internal sealed class DirectionalShadowPreparedDraws
EnsureCapacity(ref _commands, _sourceCount);
EnsureCapacity(ref _batches, _sourceCount);
EnsureCapacity(ref _runs, _sourceCount);
+ EnsureCapacity(ref _activeCommands, _sourceCount);
+ EnsureCapacity(ref _activeBatches, _sourceCount);
+ EnsureCapacity(ref _activeRuns, _sourceCount);
int maxCasterIndex = -1;
for (int index = 0; index < _sourceCount; index++)
@@ -562,6 +612,152 @@ internal sealed class DirectionalShadowPreparedDraws
PreparedAlphaCutoutCommands = commandIndex - opaqueCommands,
};
_building = false;
+ RebuildActiveAll();
+ }
+
+ ///
+ /// Projects arbitrary selected caster instances into contiguous transform
+ /// runs without changing retained material/mesh topology or its transform
+ /// address space.
+ ///
+ internal void ApplySelection(DirectionalShadowCasterFrame casters)
+ {
+ ArgumentNullException.ThrowIfNull(casters);
+ if (casters.SelectionSequence == 0)
+ return;
+ if (SourceGeneration != casters.Generation
+ || SourceCasterBuildSequence != casters.BuildSequence)
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow selection does not match prepared topology.");
+ }
+ if (SourceCasterSelectionSequence == casters.SelectionSequence)
+ return;
+
+ ApplySelection(casters.SelectedCasters, casters.SelectionSequence);
+ }
+
+ internal void ApplySelection(
+ ReadOnlySpan selected,
+ ulong casterSelectionSequence)
+ {
+ if (casterSelectionSequence == 0)
+ throw new ArgumentOutOfRangeException(nameof(casterSelectionSequence));
+ if (SourceCasterSelectionSequence == casterSelectionSequence)
+ return;
+
+ _activeCommandCount = 0;
+ int activeInstances = 0;
+ for (int commandIndex = 0; commandIndex < _commandCount; commandIndex++)
+ {
+ DrawElementsIndirectCommand command = _commands[commandIndex];
+ int first = checked((int)command.BaseInstance);
+ int end = checked(first + (int)command.InstanceCount);
+ int runStart = -1;
+ for (int transformIndex = first; transformIndex < end; transformIndex++)
+ {
+ int casterIndex = _transformSources[transformIndex].CasterIndex;
+ if ((uint)casterIndex >= (uint)selected.Length)
+ {
+ throw new InvalidOperationException(
+ "Prepared directional-shadow instance has a stale caster slot.");
+ }
+ bool active = selected[casterIndex];
+ if (active && runStart < 0)
+ runStart = transformIndex;
+ if (!active && runStart >= 0)
+ {
+ EmitActive(commandIndex, in command, runStart, transformIndex - runStart);
+ activeInstances += transformIndex - runStart;
+ runStart = -1;
+ }
+ }
+ if (runStart >= 0)
+ {
+ EmitActive(commandIndex, in command, runStart, end - runStart);
+ activeInstances += end - runStart;
+ }
+ }
+
+ BuildActiveRuns();
+ SourceCasterSelectionSequence = casterSelectionSequence;
+ ActiveSelectionSequence = checked(ActiveSelectionSequence + 1);
+ Stats = Stats with
+ {
+ ActiveInstances = activeInstances,
+ ActiveCommands = _activeCommandCount,
+ };
+ }
+
+ private void RebuildActiveAll()
+ {
+ EnsureCapacity(ref _activeCommands, _commandCount);
+ EnsureCapacity(ref _activeBatches, _commandCount);
+ EnsureCapacity(ref _activeRuns, _runCount);
+ _commands.AsSpan(0, _commandCount).CopyTo(_activeCommands);
+ _batches.AsSpan(0, _commandCount).CopyTo(_activeBatches);
+ _activeCommandCount = _commandCount;
+ BuildActiveRuns();
+ SourceCasterSelectionSequence = 0;
+ ActiveSelectionSequence = checked(ActiveSelectionSequence + 1);
+ Stats = Stats with
+ {
+ ActiveInstances = _sourceCount,
+ ActiveCommands = _activeCommandCount,
+ };
+ }
+
+ private void EmitActive(
+ int sourceCommandIndex,
+ in DrawElementsIndirectCommand source,
+ int baseInstance,
+ int instanceCount)
+ {
+ int destination = _activeCommandCount++;
+ _activeCommands[destination] = source with
+ {
+ BaseInstance = checked((uint)baseInstance),
+ InstanceCount = checked((uint)instanceCount),
+ };
+ _activeBatches[destination] = _batches[sourceCommandIndex];
+ }
+
+ private void BuildActiveRuns()
+ {
+ _activeRunCount = 0;
+ ActiveOpaqueCommandCount = 0;
+ while (ActiveOpaqueCommandCount < _activeCommandCount
+ && _activeBatches[ActiveOpaqueCommandCount].Material
+ is DirectionalShadowCasterMaterial.Opaque)
+ {
+ ActiveOpaqueCommandCount++;
+ }
+
+ int runStart = 0;
+ while (runStart < _activeCommandCount)
+ {
+ DirectionalShadowPreparedBatch first = _activeBatches[runStart];
+ int runEnd = runStart + 1;
+ while (runEnd < _activeCommandCount
+ && _activeBatches[runEnd].CullMode == first.CullMode
+ && _activeBatches[runEnd].Material == first.Material)
+ {
+ runEnd++;
+ }
+ _activeRuns[_activeRunCount++] = new DirectionalShadowPreparedRun(
+ runStart,
+ runEnd - runStart,
+ first.CullMode,
+ first.Material);
+ runStart = runEnd;
+ }
+ ActiveOpaqueRunCount = 0;
+ while (ActiveOpaqueRunCount < _activeRunCount
+ && _activeRuns[ActiveOpaqueRunCount].Material
+ is DirectionalShadowCasterMaterial.Opaque)
+ {
+ ActiveOpaqueRunCount++;
+ }
}
public void RefreshDynamicTransforms(
@@ -878,6 +1074,8 @@ internal sealed class DirectionalShadowPreparedDraws
_sourceCount = 0;
_commandCount = 0;
_runCount = 0;
+ _activeCommandCount = 0;
+ _activeRunCount = 0;
_dynamicTransformSlotCount = 0;
_allDynamicTransformSlotCount = 0;
if (_mappedCasterCount != 0)
@@ -890,11 +1088,14 @@ internal sealed class DirectionalShadowPreparedDraws
_mappedCasterCount = 0;
OpaqueCommandCount = 0;
OpaqueRunCount = 0;
+ ActiveOpaqueCommandCount = 0;
+ ActiveOpaqueRunCount = 0;
Stats = default;
SourceGeneration = default;
SourceCasterBuildSequence = 0;
SourceRenderDataAvailabilityVersion = 0;
SourceTranslucencyFadeRevision = 0;
+ SourceCasterSelectionSequence = 0;
LastDynamicTransformRefreshCount = 0;
LastDynamicTransformRefreshWasDense = false;
_retryClassificationNextFrame = false;
@@ -1034,6 +1235,7 @@ public sealed partial class WbDrawDispatcher
translucencyFadeRevision))
{
_directionalShadowDraws.RefreshDynamicTransforms(casters);
+ _directionalShadowDraws.ApplySelection(casters);
return _directionalShadowDraws;
}
// #429 owner-approved pipelining: a deferred frame keeps the retained
@@ -1047,6 +1249,7 @@ public sealed partial class WbDrawDispatcher
&& _directionalShadowDraws.SourceGeneration == casters.Generation)
{
_directionalShadowDraws.RefreshDynamicTransforms(casters);
+ _directionalShadowDraws.ApplySelection(casters);
return _directionalShadowDraws;
}
@@ -1064,6 +1267,7 @@ public sealed partial class WbDrawDispatcher
renderDataAvailabilityVersion,
translucencyFadeRevision))
{
+ _directionalShadowDraws.ApplySelection(casters);
return _directionalShadowDraws;
}
@@ -1168,7 +1372,8 @@ public sealed partial class WbDrawDispatcher
meshIndex,
true,
in partTransform)
- : default;
+ : DirectionalShadowTransformSource.Static(
+ casterIndex);
AddDirectionalShadowBatches(
partData,
in candidate,
@@ -1203,7 +1408,8 @@ public sealed partial class WbDrawDispatcher
meshIndex,
false,
in noSetupPart)
- : default;
+ : DirectionalShadowTransformSource.Static(
+ casterIndex);
AddDirectionalShadowBatches(
renderData,
in candidate,
@@ -1236,6 +1442,7 @@ public sealed partial class WbDrawDispatcher
in stats,
renderDataAvailabilityVersion,
translucencyFadeRevision);
+ _directionalShadowDraws.ApplySelection(casters);
return _directionalShadowDraws;
}
catch
diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
index f5a36b18..d37aebcd 100644
--- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
+++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
@@ -91,6 +91,50 @@ internal readonly record struct WorldRenderFrame(
/// directional-shadow prepass.
///
public AuthoredCelestialShadowSource CelestialShadowSource { get; init; }
+
+ ///
+ /// Pack-on-only borrow of the prior successfully completed retail
+ /// landscape visibility transaction. It deliberately trails the camera
+ /// resolved by this frame.
+ ///
+ public RetailLandscapeVisibilityFrame PriorLandscapeVisibility { get; init; }
+
+ /// Pack-on-only read seam over S2's exact retained CELLARRAY
+ /// owner. The frame never copies or retains registry rows.
+ public IDirectionalShadowCellMembership? DirectionalShadowCellMembership
+ { get; init; }
+}
+
+internal interface IDirectionalShadowCellMembership
+{
+ bool TryGetRetailCellArray(uint entityId, out IReadOnlyList cells);
+}
+
+internal sealed class EmptyDirectionalShadowCellMembership
+ : IDirectionalShadowCellMembership
+{
+ internal static EmptyDirectionalShadowCellMembership Instance { get; } = new();
+
+ public bool TryGetRetailCellArray(
+ uint entityId,
+ out IReadOnlyList cells)
+ {
+ _ = entityId;
+ cells = Array.Empty();
+ return false;
+ }
+}
+
+internal sealed class RuntimeDirectionalShadowCellMembership(
+ ShadowObjectRegistry source) : IDirectionalShadowCellMembership
+{
+ private readonly ShadowObjectRegistry _source = source
+ ?? throw new ArgumentNullException(nameof(source));
+
+ public bool TryGetRetailCellArray(
+ uint entityId,
+ out IReadOnlyList cells) =>
+ _source.TryGetRetailCellArray(entityId, out cells);
}
internal interface IWorldRenderFrameBuilder
@@ -114,6 +158,8 @@ internal interface IWorldFrameRootSource
internal interface IWorldFrameVisibilityPreparation
{
+ RetailLandscapeVisibilityFrame CaptureCompletedLandscapeVisibility();
+
void Begin(in WorldCameraFrame camera, bool waitingForLogin);
void PublishViewProjection(in WorldCameraFrame camera);
@@ -160,6 +206,7 @@ internal sealed class WorldRenderFrameBuilder : IWorldRenderFrameBuilder
private readonly IWorldFrameEnvironmentPreparation _environment;
private readonly IWorldFrameAnimatedEntitySource _animated;
private readonly IWorldFrameBuildingSource _buildings;
+ private readonly IDirectionalShadowCellMembership _directionalShadowCells;
public WorldRenderFrameBuilder(
IWorldFrameCameraSource camera,
@@ -168,7 +215,8 @@ internal sealed class WorldRenderFrameBuilder : IWorldRenderFrameBuilder
IWorldFrameRootSource roots,
IWorldFrameEnvironmentPreparation environment,
IWorldFrameAnimatedEntitySource animated,
- IWorldFrameBuildingSource buildings)
+ IWorldFrameBuildingSource buildings,
+ IDirectionalShadowCellMembership directionalShadowCells)
{
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_visibility = visibility ?? throw new ArgumentNullException(nameof(visibility));
@@ -177,6 +225,8 @@ internal sealed class WorldRenderFrameBuilder : IWorldRenderFrameBuilder
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_animated = animated ?? throw new ArgumentNullException(nameof(animated));
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
+ _directionalShadowCells = directionalShadowCells
+ ?? throw new ArgumentNullException(nameof(directionalShadowCells));
}
public WorldRenderFrame Build(
@@ -184,6 +234,10 @@ internal sealed class WorldRenderFrameBuilder : IWorldRenderFrameBuilder
bool waitingForLogin,
DayGroupData? activeDayGroup)
{
+ RetailLandscapeVisibilityFrame priorLandscapeVisibility =
+ _visibility.CaptureCompletedLandscapeVisibility();
+ if (waitingForLogin)
+ priorLandscapeVisibility = RetailLandscapeVisibilityFrame.None;
WorldCameraFrame camera = _camera.Resolve();
_visibility.Begin(in camera, waitingForLogin);
_settings.Apply(in camera);
@@ -196,7 +250,11 @@ internal sealed class WorldRenderFrameBuilder : IWorldRenderFrameBuilder
roots.ViewerRoot,
roots.ViewerCellId,
in frustum);
- return new WorldRenderFrame(camera, roots, buildings, animated);
+ return new WorldRenderFrame(camera, roots, buildings, animated)
+ {
+ PriorLandscapeVisibility = priorLandscapeVisibility,
+ DirectionalShadowCellMembership = _directionalShadowCells,
+ };
}
}
@@ -350,6 +408,9 @@ internal sealed class RuntimeWorldFrameVisibilityPreparation
_environmentFrustum = environmentFrustum;
}
+ public RetailLandscapeVisibilityFrame CaptureCompletedLandscapeVisibility() =>
+ _particles.CaptureCompletedLandscapeVisibility();
+
public void Begin(in WorldCameraFrame camera, bool waitingForLogin)
{
_selection?.SetViewFrustum(camera.Frustum);
diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs
index 67048994..30d43600 100644
--- a/tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs
@@ -1,5 +1,7 @@
using System.Numerics;
+using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
+using AcDream.App.Rendering.Vfx;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
@@ -106,6 +108,107 @@ public sealed class DirectionalShadowCasterFrameTests
Assert.Equal(2, frame.Stats.IndexCopies);
}
+ [Fact]
+ public void PriorLandscapeSelection_UsesExactCellArrayAndBuildingEffectCell()
+ {
+ const uint visible = 0x12340002u;
+ const uint outdoorAboveTerrainRange = 0x12340041u;
+ RenderProjectionRecord[] statics =
+ [
+ Record(201, RenderProjectionClass.OutdoorStatic),
+ Record(204, RenderProjectionClass.OutdoorStatic, parentCell: visible),
+ Record(205, RenderProjectionClass.OutdoorStatic, building: true,
+ effectCell: visible, buildingAnchor: 0x12340100u),
+ Record(206, RenderProjectionClass.OutdoorStatic, building: true,
+ effectCell: 0u, buildingAnchor: visible),
+ ];
+ RenderProjectionRecord[] dynamics =
+ [
+ Record(202, RenderProjectionClass.LiveDynamicRoot),
+ Record(203, RenderProjectionClass.EquippedChild),
+ ];
+ var source = new QuerySource(statics, dynamics);
+ var frame = new DirectionalShadowCasterFrame();
+ var membership = new RecordingMembership(
+ new Dictionary>
+ {
+ [201] = [0x12340001u, visible],
+ [202] = [visible],
+ [203] = [outdoorAboveTerrainRange],
+ });
+ var visibleCells = new HashSet
+ {
+ visible,
+ outdoorAboveTerrainRange,
+ };
+ var visibility = new RetailLandscapeVisibilityFrame(
+ visibleCells,
+ HasCompletedWorldView: true);
+
+ frame.Build(new RenderSceneQuery(source, Generation));
+ ulong topologySequence = frame.BuildSequence;
+ frame.Select(in visibility, membership);
+
+ ulong[] selected = frame.Casters.ToArray()
+ .Zip(frame.SelectedCasters.ToArray())
+ .Where(static pair => pair.Second)
+ .Select(static pair => pair.First.Projection.Id.RawValue)
+ .ToArray();
+ Assert.Equal([201ul, 202ul, 203ul, 205ul], selected);
+ Assert.Equal(4, frame.Stats.ActiveSelected);
+ Assert.Equal(topologySequence, frame.BuildSequence);
+ Assert.Equal(0x12340100u,
+ frame.Casters.ToArray().Single(c => c.Projection.Id.RawValue == 205)
+ .Projection.Source.BuildingShellAnchorCellId);
+ Assert.DoesNotContain(204ul, selected); // ParentCell is not CELLARRAY.
+ Assert.DoesNotContain(206ul, selected); // Anchor is not placement.
+
+ var noCompletedView = new RetailLandscapeVisibilityFrame(
+ visibleCells,
+ HasCompletedWorldView: false);
+ frame.Build(new RenderSceneQuery(source, Generation));
+ frame.Select(in noCompletedView, membership);
+ Assert.Equal(topologySequence, frame.BuildSequence);
+ Assert.Equal(0, frame.Stats.Classifications);
+ Assert.Equal(0, frame.Stats.ActiveSelected);
+ Assert.DoesNotContain(true, frame.SelectedCasters.ToArray());
+ }
+
+ [Fact]
+ public void WarmPriorLandscapeSelection_AllocatesZeroWithStreamingBoundedScratch()
+ {
+ const uint visible = 0x12340002u;
+ var statics = new RenderProjectionRecord[256];
+ var cells = new Dictionary>(statics.Length);
+ for (int index = 0; index < statics.Length; index++)
+ {
+ uint entityId = checked((uint)(30_000 + index));
+ statics[index] = Record(entityId, RenderProjectionClass.OutdoorStatic);
+ cells.Add(entityId, index % 2 == 0 ? [visible] : [0x12340003u]);
+ }
+ var source = new QuerySource(statics, []);
+ var frame = new DirectionalShadowCasterFrame();
+ RenderSceneQuery query = new(source, Generation);
+ frame.Build(in query);
+ frame.Build(in query);
+ var membership = new RecordingMembership(cells);
+ var visibility = new RetailLandscapeVisibilityFrame(
+ new HashSet { visible },
+ HasCompletedWorldView: true);
+ frame.Select(in visibility, membership);
+ long retainedBytes = frame.RetainedScratchBytes;
+
+ ZeroAllocationProbe.AssertAllocatesNothing(
+ "DirectionalShadowCasterFrame.Select prior completed landscape",
+ () => frame.Select(in visibility, membership),
+ batchSize: 256);
+
+ Assert.Equal(128, frame.Stats.ActiveSelected);
+ Assert.Equal(retainedBytes, frame.RetainedScratchBytes);
+ Assert.Equal(1ul, frame.BuildSequence);
+ Assert.Equal(0, frame.Stats.Classifications);
+ }
+
[Fact]
public void UnchangedSecondFrame_ReusesSortedTopologyWithoutIndexCopiesOrClassification()
{
@@ -575,6 +678,8 @@ public sealed class DirectionalShadowCasterFrameTests
bool building = false,
RenderProjectionFlags extraFlags = RenderProjectionFlags.None,
uint parentCell = 0,
+ uint effectCell = 0,
+ uint buildingAnchor = 0,
ulong? sortKey = null,
RenderCasterIdentityKind casterIdentity =
RenderCasterIdentityKind.Unclassified)
@@ -606,8 +711,8 @@ public sealed class DirectionalShadowCasterFrameTests
: 0,
SourceId: (uint)id,
ParentCellId: parentCell,
- EffectCellId: 0,
- BuildingShellAnchorCellId: 0,
+ EffectCellId: effectCell,
+ BuildingShellAnchorCellId: buildingAnchor,
TransformFingerprint: default,
GeometryFingerprint: default,
AppearanceFingerprint: default),
@@ -619,6 +724,24 @@ public sealed class DirectionalShadowCasterFrameTests
};
}
+ private sealed class RecordingMembership(
+ IReadOnlyDictionary> cellsByEntity)
+ : IDirectionalShadowCellMembership
+ {
+ public bool TryGetRetailCellArray(
+ uint entityId,
+ out IReadOnlyList cells)
+ {
+ if (cellsByEntity.TryGetValue(entityId, out IReadOnlyList? found))
+ {
+ cells = found;
+ return found.Count != 0;
+ }
+ cells = Array.Empty();
+ return false;
+ }
+ }
+
private sealed class QuerySource : IRenderSceneQuerySource
{
private RenderProjectionRecord[] _statics;
diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs
index be4bd238..eaa61678 100644
--- a/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs
@@ -939,6 +939,84 @@ public sealed class DirectionalShadowGpuTests
allocation => allocation.Usage == GpuRingUsage.Indirect);
}
+ [Fact]
+ public void ActiveAlternatingRuns_UploadOnlySelectedTransformAddresses()
+ {
+ using var device = new RecordingGpuDevice();
+ using var renderer = new DirectionalSunShadowRenderer(
+ device,
+ DirectionalShadowPreset.Medium);
+ var world = new DirectionalShadowPreparedDraws();
+ RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(21);
+ Assert.True(world.TryBegin(generation, 44, estimatedInstances: 6));
+ for (int casterIndex = 0; casterIndex < 6; casterIndex++)
+ {
+ Matrix4x4 transform = Matrix4x4.CreateTranslation(casterIndex, 0f, 0f);
+ DirectionalShadowTransformSource source =
+ DirectionalShadowTransformSource.Static(casterIndex);
+ world.Add(
+ 100,
+ 7,
+ 12,
+ GpuTextureSlot.Unassigned,
+ 0,
+ CullMode.CounterClockwise,
+ DirectionalShadowCasterMaterial.Opaque,
+ in transform,
+ in source);
+ }
+ DirectionalShadowPreparationStats stats = default;
+ world.Complete(generation, 44, in stats);
+ world.ApplySelection(
+ [true, false, true, false, true, false],
+ casterSelectionSequence: 1);
+ var terrain = new DirectionalShadowTerrainPreparedDraws();
+ Assert.True(terrain.TryBegin(1, 0));
+ terrain.Complete(1);
+ using IGpuBuffer vertices = Buffer(device, "world-v", GpuBufferUsage.Vertex);
+ using IGpuBuffer indices = Buffer(device, "world-i", GpuBufferUsage.Index);
+ var geometry = new DirectionalShadowMeshGeometry(vertices, indices);
+
+ device.Clear();
+ using IGpuFrame frame = device.BeginFrame();
+ WorldTransformFrameSlice transforms = PublishSharedTransforms(frame, world.Transforms);
+ DirectionalSunShadowDiagnostics diagnostics = renderer.RenderPrepared(
+ frame,
+ EnabledEnvironment(),
+ Matrix4x4.Identity,
+ Matrix4x4.CreatePerspectiveFieldOfView(
+ MathF.PI / 3f,
+ 16f / 9f,
+ 0.1f,
+ 500f),
+ cameraNearMeters: 0.1f,
+ casterDepthPaddingMeters: 48f,
+ world,
+ terrain,
+ geometry,
+ terrainGeometry: null,
+ transforms);
+
+ RecordingGpuBuffer commands = Assert.Single(
+ device.CreatedBuffers,
+ buffer => buffer.Name == "directional-shadow-world-commands-2");
+ Span bytes = stackalloc byte[3 * 20];
+ commands.Read(0, bytes);
+ ReadOnlySpan uploaded =
+ MemoryMarshal.Cast(bytes);
+ Assert.Equal([0u, 2u, 4u],
+ uploaded.ToArray().Select(static command => command.BaseInstance));
+ Assert.All(uploaded.ToArray(),
+ static command => Assert.Equal(1u, command.InstanceCount));
+ Assert.All(
+ device.OfKind(),
+ static draw => Assert.Equal(3u, draw.DrawCount));
+ Assert.Equal(6, diagnostics.ResidentWorldInstances);
+ Assert.Equal(3, diagnostics.ActiveWorldInstances);
+ Assert.Equal(1, diagnostics.ResidentWorldCommands);
+ Assert.Equal(3, diagnostics.ActiveWorldCommands);
+ }
+
[Fact]
public void TopologyRebuild_SwapsRetainedBuffersAndDisposalReleasesTheCurrentSet()
{
diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs
index 776abd6c..608d3c04 100644
--- a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs
@@ -1463,6 +1463,63 @@ public sealed class AtmosphericPostProcessGraphTests
Assert.Equal(first.WindAmplitude, second.WindAmplitude);
}
+ [Fact]
+ public void BuiltInAndDeclaredShadowGraphsUseTheSameTypedPriorVisibilitySelector()
+ {
+ string renderingRoot = Path.Combine(
+ RepositoryRoot(),
+ "src",
+ "AcDream.App",
+ "Rendering",
+ "Packs");
+ string builtIn = File.ReadAllText(
+ Path.Combine(renderingRoot, "AtmosphericPostProcessGraph.cs"));
+ string declared = File.ReadAllText(
+ Path.Combine(renderingRoot, "DeclaredFullscreenRenderPackGraph.cs"));
+
+ AssertGraphUsesTypedSelection(builtIn, "RenderDirectionalShadows(");
+ AssertGraphUsesTypedSelection(declared, "RenderDeclaredDirectionalShadows(");
+
+ string register = File.ReadAllText(Path.Combine(
+ RepositoryRoot(),
+ "docs",
+ "architecture",
+ "retail-divergence-register.md"));
+ string ia24 = Assert.Single(register.Split('\n'), static line =>
+ line.StartsWith("| IA-24 |", StringComparison.Ordinal));
+ Assert.Contains("prior successfully completed", ia24,
+ StringComparison.Ordinal);
+ Assert.Contains("S2's retained retail CELLARRAY", ia24,
+ StringComparison.Ordinal);
+ Assert.Contains("building shells by their outdoor placement `EffectCellId`", ia24,
+ StringComparison.Ordinal);
+ Assert.Contains("pack-off does not build, select, upload, or draw shadow work", ia24,
+ StringComparison.Ordinal);
+
+ static void AssertGraphUsesTypedSelection(string source, string methodName)
+ {
+ int start = source.IndexOf(methodName, StringComparison.Ordinal);
+ Assert.True(start >= 0, $"missing {methodName}");
+ int end = source.IndexOf("public IGpuRenderTarget PrepareWorldTarget", start,
+ StringComparison.Ordinal);
+ Assert.True(end > start, $"could not bound {methodName}");
+ string method = source[start..end];
+ Assert.Contains(
+ "RetailLandscapeVisibilityFrame priorLandscapeVisibility =",
+ method,
+ StringComparison.Ordinal);
+ Assert.Contains("world.PriorLandscapeVisibility", method,
+ StringComparison.Ordinal);
+ Assert.Contains("_shadowCasters.Select(", method, StringComparison.Ordinal);
+ Assert.Contains("world.DirectionalShadowCellMembership", method,
+ StringComparison.Ordinal);
+ Assert.Contains("EmptyDirectionalShadowCellMembership.Instance", method,
+ StringComparison.Ordinal);
+ Assert.Contains("PriorLandscapeVisibility: world.PriorLandscapeVisibility", method,
+ StringComparison.Ordinal);
+ }
+ }
+
private static AtmosphericFrameUniforms RenderAndReadFrameBlock(
RecordingGpuDevice device,
AtmosphericPostProcessGraph graph,
diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/ParticleVisibilityControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/ParticleVisibilityControllerTests.cs
index 4c56fb71..a2f6a047 100644
--- a/tests/AcDream.App.Tests/Rendering/Vfx/ParticleVisibilityControllerTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Vfx/ParticleVisibilityControllerTests.cs
@@ -96,6 +96,56 @@ public sealed class ParticleVisibilityControllerTests
Assert.True(Assert.Single(particles.EnumerateEmitters()).ViewEligible);
}
+ [Fact]
+ public void BorrowedLandscapeFrame_TracksOnlyCompletedTransactionsByReference()
+ {
+ var controller = new ParticleVisibilityController();
+ RetailLandscapeVisibilityFrame first =
+ controller.CaptureCompletedLandscapeVisibility();
+ Assert.False(first.HasCompletedWorldView);
+ Assert.Empty(first.CellIds);
+
+ // A completed null-root safety frame is authoritative and empty.
+ controller.BeginFrame(Vector3.Zero);
+ controller.UseWorldView();
+ controller.CompleteFrame();
+ RetailLandscapeVisibilityFrame completedEmpty =
+ controller.CaptureCompletedLandscapeVisibility();
+ Assert.True(completedEmpty.HasCompletedWorldView);
+ Assert.Empty(completedEmpty.CellIds);
+ Assert.Same(first.CellIds, completedEmpty.CellIds);
+
+ controller.BeginFrame(Vector3.One);
+ controller.UseWorldView();
+ controller.MarkVisibleLandscapeCells([0x12340001u]);
+ RetailLandscapeVisibilityFrame whileBuilding =
+ controller.CaptureCompletedLandscapeVisibility();
+ Assert.Same(completedEmpty.CellIds, whileBuilding.CellIds);
+ Assert.Empty(whileBuilding.CellIds);
+ controller.AbortFrame();
+ RetailLandscapeVisibilityFrame aborted =
+ controller.CaptureCompletedLandscapeVisibility();
+ Assert.Same(completedEmpty.CellIds, aborted.CellIds);
+ Assert.True(aborted.HasCompletedWorldView);
+ Assert.Empty(aborted.CellIds);
+
+ controller.BeginFrame(Vector3.One);
+ controller.UseWorldView();
+ controller.MarkVisibleLandscapeCells([0x12340001u]);
+ controller.CompleteFrame();
+ RetailLandscapeVisibilityFrame replaced =
+ controller.CaptureCompletedLandscapeVisibility();
+ Assert.Same(completedEmpty.CellIds, replaced.CellIds);
+ Assert.Equal([0x12340001u], replaced.CellIds);
+
+ controller.Reset();
+ RetailLandscapeVisibilityFrame reset =
+ controller.CaptureCompletedLandscapeVisibility();
+ Assert.Same(completedEmpty.CellIds, reset.CellIds);
+ Assert.False(reset.HasCompletedWorldView);
+ Assert.Empty(reset.CellIds);
+ }
+
[Fact]
public void CompleteAbortAndResetPublishOnlyCompleteLandscapeTransactions()
{
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs
index 6a7a5dd0..529c9707 100644
--- a/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs
@@ -262,6 +262,63 @@ public sealed class DirectionalShadowPreparedDrawTests
Assert.Single(product.Commands.ToArray());
}
+ [Fact]
+ public void AlternatingCasterSelection_EmitsExactContiguousInstanceRuns()
+ {
+ var product = new DirectionalShadowPreparedDraws();
+ RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(18);
+ Assert.True(product.TryBegin(generation, 33, estimatedInstances: 6));
+ for (int casterIndex = 0; casterIndex < 6; casterIndex++)
+ {
+ Matrix4x4 transform = Matrix4x4.CreateTranslation(casterIndex, 0f, 0f);
+ DirectionalShadowTransformSource source =
+ DirectionalShadowTransformSource.Static(casterIndex);
+ product.Add(
+ firstIndex: 100,
+ baseVertex: 7,
+ indexCount: 12,
+ GpuTextureSlot.Unassigned,
+ textureLayer: 0,
+ CullMode.CounterClockwise,
+ DirectionalShadowCasterMaterial.Opaque,
+ in transform,
+ in source);
+ }
+ DirectionalShadowPreparationStats stats = default;
+ product.Complete(generation, 33, in stats);
+ ulong topologySequence = product.BuildSequence;
+ bool[] alternating = [true, false, true, false, true, false];
+
+ product.ApplySelection(alternating, casterSelectionSequence: 1);
+
+ Assert.Single(product.Commands.ToArray());
+ Assert.Equal(6u, product.Commands[0].InstanceCount);
+ Assert.Equal(3, product.ActiveCommands.Length);
+ Assert.Equal([0u, 2u, 4u],
+ product.ActiveCommands.ToArray().Select(static command => command.BaseInstance));
+ Assert.All(product.ActiveCommands.ToArray(),
+ static command => Assert.Equal(1u, command.InstanceCount));
+ Assert.Equal(3, product.ActiveBatches.Length);
+ Assert.Equal(3, product.Stats.ActiveInstances);
+ Assert.Equal(3, product.Stats.ActiveCommands);
+ Assert.Equal(topologySequence, product.BuildSequence);
+
+ bool[] inverse = [false, true, false, true, false, true];
+ product.ApplySelection(inverse, casterSelectionSequence: 2);
+ Assert.Equal([1u, 3u, 5u],
+ product.ActiveCommands.ToArray().Select(static command => command.BaseInstance));
+ Assert.Equal(topologySequence, product.BuildSequence);
+
+ ulong selectionSequence = 2;
+ ZeroAllocationProbe.AssertAllocatesNothing(
+ "DirectionalShadowPreparedDraws.ApplySelection alternating",
+ () => product.ApplySelection(
+ (selectionSequence & 1ul) == 0ul ? alternating : inverse,
+ ++selectionSequence),
+ batchSize: 256);
+ Assert.Equal(topologySequence, product.BuildSequence);
+ }
+
[Fact]
public void StableTopology_ComposesSlimPoseWithoutMutatingCasterOrAllocating()
{
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowTerrainPreparedDrawTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowTerrainPreparedDrawTests.cs
index aaa88e2c..e4bf309d 100644
--- a/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowTerrainPreparedDrawTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowTerrainPreparedDrawTests.cs
@@ -1,4 +1,6 @@
using AcDream.App.Rendering;
+using AcDream.App.Rendering.Vfx;
+using AcDream.App.Rendering.Wb;
namespace AcDream.App.Tests.Rendering.Wb;
@@ -72,4 +74,50 @@ public sealed class DirectionalShadowTerrainPreparedDrawTests
Assert.Single(product.Commands.ToArray());
Assert.Equal(20u, product.Commands[0].FirstIndex);
}
+
+ [Fact]
+ public void PriorLandscapeSelection_ScansExactAuthoredEightByEightCells()
+ {
+ var product = new DirectionalShadowTerrainPreparedDraws();
+ Assert.True(product.TryBegin(frameSequence: 1, estimatedCommands: 3));
+ DirectionalShadowTerrainRange[] resident =
+ [
+ new(FirstIndex: 0, IndexCount: 384, LandblockId: 0x1111FFFFu),
+ new(FirstIndex: 384, IndexCount: 384, LandblockId: 0x2222FFFFu),
+ new(FirstIndex: 768, IndexCount: 384, LandblockId: 0x3333FFFFu),
+ ];
+ foreach (DirectionalShadowTerrainRange range in resident)
+ product.Add(in range);
+ product.Complete(frameSequence: 1);
+ ulong topologySequence = product.BuildSequence;
+ var visible = new HashSet
+ {
+ 0x11110040u,
+ 0x22220041u, // valid outdoor family, but not this slot's 8x8 cells.
+ };
+ var visibility = new RetailLandscapeVisibilityFrame(
+ visible,
+ HasCompletedWorldView: true);
+
+ product.ApplySelection(in visibility);
+
+ DrawElementsIndirectCommand selected = Assert.Single(product.Commands.ToArray());
+ Assert.Equal(0u, selected.FirstIndex);
+ Assert.Equal(3, product.ResidentRanges.Length);
+ Assert.Equal(topologySequence, product.BuildSequence);
+
+ var empty = new RetailLandscapeVisibilityFrame(
+ new HashSet(),
+ HasCompletedWorldView: true);
+ product.ApplySelection(in empty);
+ Assert.Empty(product.Commands.ToArray());
+ Assert.Equal(topologySequence, product.BuildSequence);
+
+ product.ApplySelection(in visibility);
+ ZeroAllocationProbe.AssertAllocatesNothing(
+ "DirectionalShadowTerrainPreparedDraws.ApplySelection",
+ () => product.ApplySelection(in visibility),
+ batchSize: 256);
+ Assert.Equal(topologySequence, product.BuildSequence);
+ }
}
diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs
index d0cb0e5e..b9da40c2 100644
--- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs
@@ -4,6 +4,7 @@ using AcDream.App.Composition;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
+using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.Tests.Architecture;
using AcDream.App.World;
@@ -46,7 +47,8 @@ public sealed class WorldRenderFrameBuilderTests
new RecordingRoots(calls, roots),
environment,
new RecordingAnimated(calls, animated),
- new RecordingBuildings(calls, new WorldBuildingFrame(null, buildings)));
+ new RecordingBuildings(calls, new WorldBuildingFrame(null, buildings)),
+ new RecordingMembership());
WorldRenderFrame result = builder.Build(
in foundation,
@@ -55,6 +57,7 @@ public sealed class WorldRenderFrameBuilderTests
Assert.Equal(
[
+ "visibility:capture",
"camera",
"visibility:begin",
"settings",
@@ -83,21 +86,24 @@ public sealed class WorldRenderFrameBuilderTests
var environment = new RecordingEnvironment([]);
var animated = new RecordingAnimated([], []);
var buildings = new RecordingBuildings([], default);
+ var membership = new RecordingMembership();
Assert.Throws(() =>
- new WorldRenderFrameBuilder(null!, visibility, settings, roots, environment, animated, buildings));
+ new WorldRenderFrameBuilder(null!, visibility, settings, roots, environment, animated, buildings, membership));
Assert.Throws(() =>
- new WorldRenderFrameBuilder(camera, null!, settings, roots, environment, animated, buildings));
+ new WorldRenderFrameBuilder(camera, null!, settings, roots, environment, animated, buildings, membership));
Assert.Throws(() =>
- new WorldRenderFrameBuilder(camera, visibility, null!, roots, environment, animated, buildings));
+ new WorldRenderFrameBuilder(camera, visibility, null!, roots, environment, animated, buildings, membership));
Assert.Throws(() =>
- new WorldRenderFrameBuilder(camera, visibility, settings, null!, environment, animated, buildings));
+ new WorldRenderFrameBuilder(camera, visibility, settings, null!, environment, animated, buildings, membership));
Assert.Throws(() =>
- new WorldRenderFrameBuilder(camera, visibility, settings, roots, null!, animated, buildings));
+ new WorldRenderFrameBuilder(camera, visibility, settings, roots, null!, animated, buildings, membership));
Assert.Throws(() =>
- new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, null!, buildings));
+ new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, null!, buildings, membership));
Assert.Throws(() =>
- new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, animated, null!));
+ new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, animated, null!, membership));
+ Assert.Throws(() =>
+ new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, animated, buildings, null!));
}
[Fact]
@@ -502,6 +508,12 @@ public sealed class WorldRenderFrameBuilderTests
{
public bool WaitingForLogin { get; private set; }
+ public RetailLandscapeVisibilityFrame CaptureCompletedLandscapeVisibility()
+ {
+ calls.Add("visibility:capture");
+ return RetailLandscapeVisibilityFrame.None;
+ }
+
public void Begin(in WorldCameraFrame camera, bool waitingForLogin)
{
calls.Add("visibility:begin");
@@ -512,6 +524,66 @@ public sealed class WorldRenderFrameBuilderTests
calls.Add("visibility:projection");
}
+ [Fact]
+ public void Build_BorrowsExactPriorCompletedLandscapeBeforeCurrentBegin()
+ {
+ var particles = new ParticleVisibilityController();
+ particles.BeginFrame(Vector3.Zero);
+ particles.UseWorldView();
+ particles.MarkVisibleLandscapeCells([0x12340002u]);
+ particles.CompleteFrame();
+ RetailLandscapeVisibilityFrame ownerBefore =
+ particles.CaptureCompletedLandscapeVisibility();
+ var visibility = new RuntimeWorldFrameVisibilityPreparation(
+ selection: null,
+ particles,
+ terrain: null,
+ reveal: null,
+ environmentFrustum: null);
+ var camera = new FlyCamera();
+ var builder = new WorldRenderFrameBuilder(
+ new RecordingCamera([], CameraFrame(camera)),
+ visibility,
+ new RecordingSettings([]),
+ new RecordingRoots([], default),
+ new RecordingEnvironment([]),
+ new RecordingAnimated([], []),
+ new RecordingBuildings([], default),
+ new RecordingMembership());
+ RenderFrameFoundation foundation = default;
+
+ WorldRenderFrame world = builder.Build(
+ in foundation,
+ waitingForLogin: false,
+ activeDayGroup: null);
+
+ Assert.Same(ownerBefore.CellIds, world.PriorLandscapeVisibility.CellIds);
+ Assert.True(world.PriorLandscapeVisibility.HasCompletedWorldView);
+ Assert.Contains(0x12340002u, world.PriorLandscapeVisibility.CellIds);
+ Assert.Same(
+ ownerBefore.CellIds,
+ particles.CaptureCompletedLandscapeVisibility().CellIds);
+
+ WorldRenderFrame login = builder.Build(
+ in foundation,
+ waitingForLogin: true,
+ activeDayGroup: null);
+ Assert.False(login.PriorLandscapeVisibility.HasCompletedWorldView);
+ Assert.Empty(login.PriorLandscapeVisibility.CellIds);
+ }
+
+ private sealed class RecordingMembership : IDirectionalShadowCellMembership
+ {
+ public bool TryGetRetailCellArray(
+ uint entityId,
+ out IReadOnlyList cells)
+ {
+ _ = entityId;
+ cells = Array.Empty();
+ return false;
+ }
+ }
+
private sealed class RecordingSettings(List calls)
: IWorldFrameSettingsPreview
{