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 {