diff --git a/AcDream.slnx b/AcDream.slnx
index 34d093fb..892b0ee3 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -16,17 +16,26 @@
+
+
+
+
+
+
+
+
+
@@ -49,6 +58,9 @@
+
+
+
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 240c0622..aa44567d 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -1797,7 +1797,15 @@ tool run closes it).
## #392 — A refused/failed fullscreen enter leaves `fullscreen: true` persisted against a windowed client
-**Status:** OPEN — filed 2026-08-13 from the #376/#388 blast review (M4).
+**Status:** DONE — 2026-08-22. `IRuntimeDisplayWindowTarget` now returns
+the observed native fullscreen post-condition. Both startup and live-save
+controllers reconcile that result back through their own storage boundary,
+so a refused/failed enter immediately restores `fullscreen: false` in the
+retained Config row and `settings.json`; a failed leave likewise preserves
+the true native state. Focused startup, target, persistence, and observer
+tests pin the result seam and the requested→applied publication contract.
+
+**Original filing:**
The save path persists the Full Screen flag BEFORE the apply runs; when
the state-aware apply then refuses (mode not offered / catalog absent) or
the native switch fails, the client stays windowed while settings.json and
@@ -6869,7 +6877,14 @@ it. Do #297 FIRST — #298 depends on it.
`#153` closed 2026-07-30 on the AD-30 hold + arrival StopCompletely +
canonical outbound + reveal-barrier evidence chain). TS-50/TS-51/TS-53 are
tracked in the divergence register.
-- **Deferred visual fidelity:** `#226` retail landscape detail overlay.
+- **Resolved visual fidelity (2026-08-21):** `#226` implements retail's
+ building/EnvCell detail overlay through the existing Building Detail
+ Textures preference. The reachable retail `ChangeRegion` caller disables
+ landscape detail, so no separate landscape-detail item remains queued.
+ The same Track A closeout ports retail's incident-face-averaged shared
+ terrain vertex normals without changing positions, indices, or collision;
+ terrain subdivision was rejected because quantized source samples cannot
+ recover detail and the retail-correct normal interpolation is now present.
- **Deferred frame-pacing fidelity:** `#235`, capped/RDP jump presentation
aliases the retail 30 Hz object clock; uncapped Release presentation is
smooth and physics, collision, and wire state remain correct.
@@ -9190,42 +9205,67 @@ the full 6,558-pass / 5-skip suite remains green.
---
-## #226 — Retail landscape detail-texture overlay is not rendered
+## #226 — Retail building/EnvCell detail-texture overlay is not rendered
-**Status:** OPEN — deferred visual fidelity; the user-visible tiling regression
-in #155 is fixed
+**Status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED 2026-08-21
**Severity:** LOW
**Filed:** 2026-07-20
-**Component:** rendering / terrain material
+**Component:** rendering / building and environment materials
-**Description:** Retail can overlay a high-frequency landscape detail texture,
-faded by viewer distance and gated by the Environment Detail Textures setting.
-acdream now repeats every base/overlay/road surface at its authored
-`TerrainTex.TexTiling`, which fixed the stretched/blurry symptom in #155, but
-does not yet render this separate optional detail layer.
+**Description:** Retail overlays a category-scoped detail texture on building
+shells and interior/EnvCell geometry, faded by viewer distance and gated by
+the Building Detail Textures preference. acdream's existing “Building
+Detail Textures” checkbox persisted that preference but previously had no
+renderer consumer. Outdoor landscape detail is forced off by the reachable
+Sept-2013 retail preference caller and is not this issue's user-visible target.
**Root cause / status:** The earlier #155 investigation conflated two retail
mechanisms. `bb5acab9` ported the behavior that produced the observed mismatch:
`TexMerge::CopyAndTile`/`Merge` pass each source's authored base tiling into the
-terrain composition. The still-missing detail pass is a distinct
-`LScape::GenerateDetailSurfaces`/`ACRender::landPolyDraw` path. The first
-experimental detail-array implementation sampled the wrong neutral/data
-contract and was reverted rather than shipping a darkened ground. TS-52 records
-the current divergence.
+terrain composition. #226 now resolves Dereth category 1/2 detail surfaces,
+uploads their authored texture/tiling with retail wrap/linear sampling, and
+replays building and EnvCell built-mesh subsets with the exact
+`DESTCOLOR + INVSRCALPHA` blend. This includes opaque, ClipMap, straight-alpha,
+additive, and inverse-alpha material subsets; transparent base/detail commands
+remain adjacent in acdream's authoritative shared alpha order with depth writes
+disabled (retail bypasses delayed alpha while detail is installed; retaining
+the accepted queue is the registered bounded ordering seam). The existing
+persisted checkbox is read at draw time. Opaque object replay is restricted to
+coalesced command runs containing a building, with mixed commands filtered per
+instance in the shader. Its depth-equal, non-A2C overlay inherits the exact
+per-sample coverage written by the opaque/A2C base, including ClipMap edges.
+Ordinary objects and landscape remain excluded; the base pass is untouched
+when the option is off. The first experimental
+landscape array used the wrong target, topology, neutral point, and blend and
+was reverted rather than shipping a darkened ground.
**Files:** `src/AcDream.App/Rendering/TerrainAtlas.cs`;
-`src/AcDream.App/Rendering/TerrainModernRenderer.cs`;
-`src/AcDream.App/Rendering/Shaders/terrain_modern.frag`.
+`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs`;
+`src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs`;
+`src/AcDream.App/Rendering/Shaders/mesh_detail.vert`;
+`src/AcDream.App/Rendering/Shaders/mesh_detail.frag`.
**Research:** `docs/research/2026-07-13-retail-terrain-texture-tiling-pseudocode.md`
-covers the now-shipped base contract. The detail symbols cited above must be
-distilled into a dedicated pseudocode note as the first #226 implementation
-step; the reverted experiment remains available in git history.
+covers the already-shipped base contract. The dedicated, corrected detail
+contract is `docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md`;
+its evidence source is
+`docs/research/2026-08-21-terrain-and-atmospheric-rendering-findings.md`.
-**Acceptance:** With retail Environment Detail Textures enabled, close ground
-shows the same high-frequency detail and distance fade without changing base
-color/brightness. Disabling it produces the already-accepted authored base
-tiling.
+**Acceptance:** Toggling the existing “Building Detail Textures” checkbox
+visibly changes nearby building and interior surfaces without a restart.
+Enabled detail is full through 10 m positive view depth, fades linearly to an
+exact no-op at 50 m, and preserves retail's measured slight brightening.
+Disabling it submits no detail replay and preserves the already-accepted base
+render. Landscape, ordinary objects, physics, and collision remain unchanged.
+The automated gates cover the setting gate, data/blend/fade contract,
+built-mesh subset eligibility, opaque command filtering and A2C coverage,
+transparent depth/order seam, Vulkan descriptor
+binding and total/per-stage storage-descriptor limits, shader artifacts, and
+build. The connected Facility Hub A/B/A gate applied the real Config checkbox
+on -> off -> restored-on: nearby static walls/floor changed immediately, the
+restored frame returned to the original-on image (right-wall RGB MAE 2.132
+on/off versus 0.007 on/restored), the persisted preference was observed false
+during B and restored true, and logout was ACE-confirmed graceful.
---
@@ -12052,9 +12092,11 @@ field through `TerrainAtlas`, uploads a layer-indexed table, and applies it in
the modern shader while leaving cell-scale alpha masks unchanged. The user
confirmed the outdoor textures now match the expected scale.
-The optional high-frequency Environment Detail Textures pass is a different
-retail mechanism. It remains deferred under #226/TS-52 and does not keep this
-fixed user-visible regression open.
+The high-frequency detail pass is a different retail mechanism. #226 completed
+its reachable user-visible target on 2026-08-21: building shells and EnvCell
+geometry. Retail's reachable `ChangeRegion` caller passes zero landscape-detail
+surfaces, so the former TS-52 landscape premise is retired and does not keep
+this fixed user-visible regression open.
**Files:** `src/AcDream.App/Rendering/TerrainAtlas.cs`;
`src/AcDream.App/Rendering/TerrainModernRenderer.cs`;
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 2a0d6875..c9928813 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -1,4 +1,4 @@
-# Retail Divergence Register — current through 2026-07-31
+# Retail Divergence Register — current through 2026-08-22
**What this is.** The single auditable register of every known place acdream's
runtime behavior can deviate from the retail client (Sept 2013 EoR build,
@@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 1. Intentional architecture (IA) — 20 active rows (IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default)
+## 1. Intentional architecture (IA) — 21 active rows (IA-24 filed 2026-08-22 for Campaign AR's opt-in real-time sun/moon directional shadows; IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -62,6 +62,9 @@ accepted-divergence entries (#96, #49, #50).
| IA-22 | **Filed 2026-08-13 (#391, user-directed: "we should only support modern resolutions. Not any old format").** The Config Resolution dropdown offers a CURATED list — the monitor's real mode enumeration filtered to modern widescreen families (16:9/16:10/21:9/32:9, ≥1280 wide, fitting the desktop; `DisplayModeCatalog.Curate`) — and its Defaults value is the desktop's own mode. Retail offered the adapter's complete enumeration including 4:3 legacy modes and authored `800x600` as the row default (`gmConfigUI::InitOptions SetDefaultValue(0x03200258)`; `gmClient::Init @0x004047af` `Device::ForceDisplayResolution(1, 0x320, 0x258)`). | `src/AcDream.App/Rendering/DisplayModeCatalog.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (Resolution row); fixture fallback `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs` (`AvailableResolutions`, 800x600 removed) | Explicit product direction. **Amended 2026-08-16 (#407, Campaign CC gate round 1):** the dropdown now offers `DisplayModeCatalog.WindowedResolutions` — the curated hardware modes UNIONed with the static modern-ladder sizes that fit the desktop — because a WINDOWED pick is a plain Size write needing no video mode, and remote/RDP virtual displays advertise almost no modes (the live RDP display exposed only 1920x1080 + the 2056x1290 desktop, starving the dropdown). The original "an offered mode is supported by construction" invariant now holds for the FULLSCREEN half only: the fullscreen apply still validates against the hardware `Resolutions` list plus `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard, so a fullscreen pick of a windowed-only entry refuses safely (log-and-stay, #388; the #392 apply-result seam is that family's open follow-up) — "Graphics mode not supported" crashes remain unreachable from the dropdown. | A user wanting a genuine legacy 4:3 mode cannot pick it; retail-parity comparisons of the Config tab's list/default will show the deviation. | decomp sites in the Divergence column; ISSUES #391 |
| IA-23 | **Filed 2026-08-17 at the night-round review fix round (F8).** `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s not-yet-expired branch renders `"You may buy another landscape house at " + strftime("%c", localtime(timestamp + 0x278d00)) + ". This restriction does not apply to apartments."` — byte-decoded from raw pushed literals at `@0x004a3265`/`@0x004a321d`/`@0x004a3235` (all three text pieces confirmed; a prior filing had wrongly called this "unrecoverable"). This port renders the SAME three pieces, in the same order, with the same expiry-timestamp math, but formats the middle date/time piece with .NET's culture-default `DateTime.ToString()` (no explicit format string) rather than the C runtime's `strftime("%c", ...)` — the two engines do not share a format table, so the RENDERED SHAPE of the date/time differs (e.g. .NET's short numeric date+time vs the CRT's `Ddd Mon DD HH:MM:SS YYYY`-style locale string) even though both express "the process's own locale's full date+time" and use the SAME underlying instant (local time, matching retail's `localtime()`). | `src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs` (`Recompute`'s not-expired branch) | Both are "whatever the process locale says" full date+time strings; no game-logic reads or parses this text back, it is pure chat-scroll presentation, so a differently-shaped (but equally legible) date string carries no functional risk | A retail-side-by-side visual comparison will show a differently formatted date/time (not a byte-identical `strftime("%c")` reproduction) — cosmetic only | `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`; `strftime`/`localtime` CRT calls at `@0x004a322c`/`@0x004a3216` |
+
+| IA-24 | **Filed 2026-08-22, Campaign AR.** An explicitly selected atmospheric render pack adds cascaded real-time directional shadows from terrain, trees, buildings, players, monsters, and other retained outdoor casters. The one shadow direction follows the visible authored sun, then the dominant haloed moon (`0x01001F6A`), then the secondary moon (`0x01001F67`); a moon supplies direction only while colour/energy remains retail's single interpolated `SkyTimeOfDay.DirColor × DirBright` channel. Retail renders none of these real-time object-shadow maps and does not expose a second moon light. | `src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs`; `src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs`; pack-only receiver shaders; evidence `docs/research/2026-08-22-dereth-celestial-shadow-sources.md` | This is the user-requested headline graphics enhancement and is strictly opt-in. The retail path remains the default and authoritative fallback; pack-off does not build/select shadow work or change `SceneLighting`. One selected source reuses one cascade array, so moon support does not multiply shadow resources. | Pack-on output intentionally differs from retail. A wrong celestial identity/transform or stale source transition would visibly misalign shadows from the sky; pack-off output changing would violate the campaign's primary safety contract. | `SkyDesc::GetLighting @0x00500A80`; `GameSky::UseTime @0x005075B0`; installed Region `0x13000000`; cited research note |
+
---
## 2. Adaptation (AD) — 85 active rows (AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal-
@@ -180,7 +183,7 @@ readiness/requeue adaptation. See
| AD-75 | **Filed 2026-08-11 at Campaign OP slice OP3 (D5).** Urgent Assistance (`0x10000206`) and Report Abuse (`0x10000207`) never call `ShellExecuteA` against `http://support.turbine.com/ics/support/ticketnewwizard.asp?style=classic` — the endpoint is dead in 2026. Each button instead ALWAYS emits its own byte-verified retail failure body (the `ShellExecuteA`-failure `MessageBoxA` text, `(Error code %d)` dropped since no real Win32 error ever occurs, the URL kept verbatim) through the interface-text seam (`RetailLogTextType.ClientLocal`) instead of a native `MessageBoxA` popup. | `src/AcDream.Core/Chat/OptionsPanelText.cs` (`UrgentAssistanceUnavailable`/`ReportAbuseUnavailable`); `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (button wiring) | The URL genuinely does not resolve to a live Turbine support endpoint; attempting `ShellExecuteA` would open a browser to a dead page rather than usefully fail. The retained failure TEXT is retail's own (byte-verified), just always shown instead of conditionally on a real launch failure, and routed to acdream's existing interface-text channel rather than a modal OS dialog (retail's own EoR-era mechanism has no acdream analogue for a one-off native `MessageBoxA`). | If Turbine ever revives the endpoint, both buttons would still short-circuit instead of opening it — a silent staleness, not a crash. | `gmGameplayOptionsUI::ListenToElementMessage @0x0049E110`; `ShellExecuteA` call sites `0x0049E154`/`0x0049E1F0`; research doc `2026-08-10-keyboard-config-and-gameplay-tab.md` §4.1/§4.2 |
| AD-76 | **Filed 2026-08-11 at Campaign OP slice OP3 (D5).** In-Game Help Files (`0x10000205`) is authored and clickable but has no handler — clicking it does nothing visible. | `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (button wiring — no callback bound) | Retail's own `KeyStone::OpenHelp` loads a third-party embedded help viewer (`plugins\ACHelpPlugin.dll` via `keystone.dll`) that acdream does not have and cannot port (no DAT-resident help content, no source). Retail ITSELF fails silently with the plugin absent (`KeyStone::m_fnAC2HelpPluginExecute` unresolved) — mirroring that as an inert button is the faithful behavior for "the asset is missing", not an invented stub screen. | A user clicking In-Game Help Files gets no feedback at all, same as retail with the plugin missing — indistinguishable from a dead button unless they already expect the asset-missing case. | `KeyStone::OpenHelp @0x00557010`; `KeyStone::Init @0x00556CF0` (the unresolved plugin function pointer); research doc `2026-08-10-keyboard-config-and-gameplay-tab.md` §4.5 |
| AD-77 | **Filed 2026-08-11 at the Campaign OP OP3 review-fix round (dual-review S4/MUST-FIX 2 — the plan's §5 "out of scope" list explicitly delegated this ruling to the OP3 review).** Retail exposes TWO `gmPanelUI` host variants for the same panel stack — a floating host (`0x2100006E`, `gmFloatyPanelUI`) and a docked host (`0x21000017`) — so a retail user can dock the Options panel (and every other `gmPanelUI` sibling) into a fixed screen position instead of leaving it freely floating. acdream mounts every main panel through `RetailWindowFrame.Mount` + `RetailPanelUiController.RegisterMainPanel` against the floating host ONLY; no code path resolves or mounts `0x21000017` at all. | `src/AcDream.App/UI/RetailUiRuntime.cs` (every `Mount*`/`RegisterMainPanel` call site for a `gmPanelUI` sibling — Character/Inventory/Spellbook/Effects/the four indicator-detail panels/Options); `src/AcDream.App/UI/Layout/RetailWindowFrame.cs` | This predates OP3 — every `gmPanelUI` sibling has shipped floating-only since its own slice landed; OP3 did not introduce the gap, it just added a tenth panel to an already-floating-only cohort. The plan explicitly scoped filing the row to "whichever slice's review deems it a divergence" rather than blocking any one panel's slice on building a docked-host variant no prior panel has either. | A user who expects to dock the Options panel (or any other main panel) the way retail allows cannot — every `gmPanelUI` sibling is floating-only in acdream, client-wide, not an Options-specific gap. | research doc `2026-08-10-options-panel-structure.md` §10.1 (docked/floating host pair); `docs/plans/2026-08-10-options-panel-campaign.md` §5 |
-| AD-78 | **Filed 2026-08-11, user-directed (verbatim: "mark all options that are not implemented now, so I can clearly see what is not implemented"), gate 2 of Campaign OP's follow-up.** Retail dims nothing on any Options-panel row or Configure-Keyboard action row — every retail row drives its own real consumer by construction, so retail has no "does this actually do anything" ambiguity to signal. acdream, by contrast, ships a large honest store-only set (AP-198/AP-199/AP-200/AP-203, TS-73/TS-74/TS-75/TS-76/TS-77/TS-78/TS-79/TS-80, and the Character-tab Group A/D rows) that persist and, where auto-save, send the wire bit, but drive nothing observable client-side. Per explicit user direction, every such row's CAPTION now renders in a shared neutral grey (`UiRenderContext.StoreOnlyCaptionColor`, `(0.5,0.5,0.5,1)` — the SAME value the existing disabled/ghosted convention already used, `UiMenu.TextColorGhosted`) instead of its normal white/DAT-authored color, while the row itself stays fully interactive (click/drag/persist exactly as before — only the caption's paint color changes). No invented marker text is added anywhere (the project's "no user-visible strings outside the DAT" rule stands); the dim IS the marker. **[FA4 fix-round addendum, 2026-08-12 — blast SHOULD-FIX 1 + mechanism SF-8/SF-9: this row's own count had drifted stale THROUGH two campaigns (FA4's D7 un-dim landed 31, but this row still read the pre-FA4 "35"; the fix round then reverted three of FA4's four un-dims — see below — landing at 34). The Character-tab count is now 34 of 50 dimmed / 16 live.]** | `src/AcDream.App/UI/UiRenderContext.cs` (`StoreOnlyCaptionColor`, the one shared constant); `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (21 of 27 rows dimmed — `ApplyLabelAndTooltip`/`SetLabelText`'s `storeOnly` parameter, threaded from each `BindXxxSection` call site); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (**34 of 50 rows dimmed** — `RowSpec.StoreOnly`, derived per-row in the class doc's table, cross-checked against actual shipped consumers rather than the research doc alone. FA4 D7 originally un-dimmed 4 rows — `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests`/`FellowshipShareXP`/`FellowshipShareLoot` — landing at 31. The FA4 FIX ROUND, 2026-08-12, reverted THREE of those four back to dimmed: `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests` per the corrected plan D6 (retail's client reads neither option bit on the fellowship-invite path — both are pure server-side filters with no client consumer, exactly like the two allegiance bits that were always meant to parallel them; the client-side auto-respond interceptor that was their claimed consumer, `RetailUiRuntime.TryAutoRespondToFellowshipInvite`, is deleted outright), and `FellowshipShareLoot` per mechanism review SF-8 (its claimed "second checkbox surface" consumer never actually reads the stored value back — a second EDITOR of a value is not a CONSUMER of it). Only `FellowshipShareXP` survives as genuinely live (the fellowship Create flow reads it as the sent `shareXP` bit) — net ONE row un-dimmed from the pre-FA4 baseline, not four.); `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`BuildActionRow` dims a row when `RetailActionIdentityTable.TryResolve` fails, i.e. `MappedAction` is null — AP-203's set); `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (audited, zero dimmed rows — every row already has a live consumer). | Explicit, unambiguous user direction (this session, gate 2) overriding the earlier per-slice register rows' silence on presentation; the four controllers' own conformance tests (`ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly`, `CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly` + `Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`, `KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite`) pin the exact dimmed set so a future consumer landing without also flipping its row's literal fails the build, not just the eye. | A reviewer comparing a byte-exact retail screenshot to acdream will see caption colors retail never has — this row exists precisely so that divergence is understood as intentional, not a bug. If a row's dim/live classification in the four cited tables ever drifts from its ACTUAL consumer state (a landed consumer whose row was never un-dimmed, or a regressed consumer whose row was never re-dimmed), the caption becomes misleading in the OPPOSITE direction it was built to prevent — treat any report of "this dimmed row visibly does something" or "this live-looking row does nothing" as a real defect, not a rendering nit (see the gate script's own note). **The FA4 fix round is itself an instance of this exact risk materializing** — the register row lagged two code-side count changes across one campaign before this addendum caught up. This row retires only when acdream reaches full retail parity (zero store-only rows remaining), at which point the convention itself — not just its content — should be deleted. | None (acdream-only divergence; retail has no store-only rows to compare against) — `docs/research/2026-08-10-character-options-map.md` §7.1 (Group A/B/C/D split); `docs/research/2026-08-11-campaign-op-test-script.md` (per-tab store-only enumerations this row's dimmed set matches) |
+| AD-78 | **Filed 2026-08-11, user-directed (verbatim: "mark all options that are not implemented now, so I can clearly see what is not implemented"), gate 2 of Campaign OP's follow-up.** Retail dims nothing on any Options-panel row or Configure-Keyboard action row — every retail row drives its own real consumer by construction, so retail has no "does this actually do anything" ambiguity to signal. acdream, by contrast, ships a large honest store-only set (AP-198/AP-199/AP-200/AP-203, TS-73/TS-74/TS-75/TS-76/TS-77/TS-78/TS-79/TS-80, and the Character-tab Group A/D rows) that persist and, where auto-save, send the wire bit, but drive nothing observable client-side. Per explicit user direction, every such row's CAPTION now renders in a shared neutral grey (`UiRenderContext.StoreOnlyCaptionColor`, `(0.5,0.5,0.5,1)` — the SAME value the existing disabled/ghosted convention already used, `UiMenu.TextColorGhosted`) instead of its normal white/DAT-authored color, while the row itself stays fully interactive (click/drag/persist exactly as before — only the caption's paint color changes). No invented marker text is added anywhere (the project's "no user-visible strings outside the DAT" rule stands); the dim IS the marker. **[#226 addendum, 2026-08-21: Building Detail Textures gained a live renderer consumer and is no longer dimmed; Config is now 20 of 27 dimmed.]** **[FA4 fix-round addendum, 2026-08-12 — blast SHOULD-FIX 1 + mechanism SF-8/SF-9: this row's own count had drifted stale THROUGH two campaigns (FA4's D7 un-dim landed 31, but this row still read the pre-FA4 "35"; the fix round then reverted three of FA4's four un-dims — see below — landing at 34). The Character-tab count is now 34 of 50 dimmed / 16 live.]** | `src/AcDream.App/UI/UiRenderContext.cs` (`StoreOnlyCaptionColor`, the one shared constant); `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (20 of 27 rows dimmed — `ApplyLabelAndTooltip`/`SetLabelText`'s `storeOnly` parameter, threaded from each `BindXxxSection` call site); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (**34 of 50 rows dimmed** — `RowSpec.StoreOnly`, derived per-row in the class doc's table, cross-checked against actual shipped consumers rather than the research doc alone. FA4 D7 originally un-dimmed 4 rows — `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests`/`FellowshipShareXP`/`FellowshipShareLoot` — landing at 31. The FA4 FIX ROUND, 2026-08-12, reverted THREE of those four back to dimmed: `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests` per the corrected plan D6 (retail's client reads neither option bit on the fellowship-invite path — both are pure server-side filters with no client consumer, exactly like the two allegiance bits that were always meant to parallel them; the client-side auto-respond interceptor that was their claimed consumer, `RetailUiRuntime.TryAutoRespondToFellowshipInvite`, is deleted outright), and `FellowshipShareLoot` per mechanism review SF-8 (its claimed "second checkbox surface" consumer never actually reads the stored value back — a second EDITOR of a value is not a CONSUMER of it). Only `FellowshipShareXP` survives as genuinely live (the fellowship Create flow reads it as the sent `shareXP` bit) — net ONE row un-dimmed from the pre-FA4 baseline, not four.); `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`BuildActionRow` dims a row when `RetailActionIdentityTable.TryResolve` fails, i.e. `MappedAction` is null — AP-203's set); `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (audited, zero dimmed rows — every row already has a live consumer). | Explicit, unambiguous user direction (this session, gate 2) overriding the earlier per-slice register rows' silence on presentation; the four controllers' own conformance tests (`ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly`, `CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly` + `Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`, `KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite`) pin the exact dimmed set so a future consumer landing without also flipping its row's literal fails the build, not just the eye. | A reviewer comparing a byte-exact retail screenshot to acdream will see caption colors retail never has — this row exists precisely so that divergence is understood as intentional, not a bug. If a row's dim/live classification in the four cited tables ever drifts from its ACTUAL consumer state (a landed consumer whose row was never un-dimmed, or a regressed consumer whose row was never re-dimmed), the caption becomes misleading in the OPPOSITE direction it was built to prevent — treat any report of "this dimmed row visibly does something" or "this live-looking row does nothing" as a real defect, not a rendering nit (see the gate script's own note). **The FA4 fix round is itself an instance of this exact risk materializing** — the register row lagged two code-side count changes across one campaign before this addendum caught up. This row retires only when acdream reaches full retail parity (zero store-only rows remaining), at which point the convention itself — not just its content — should be deleted. | None (acdream-only divergence; retail has no store-only rows to compare against) — `docs/research/2026-08-10-character-options-map.md` §7.1 (Group A/B/C/D split); `docs/research/2026-08-11-campaign-op-test-script.md` (per-tab store-only enumerations this row's dimmed set matches) |
| AD-79 | **MOSTLY RETIRED 2026-08-13 (user-ordered social completion batch):** Friends Add/Remove/Appear-Offline and Squelch add-character/add-account/remove are LIVE (the wire beneath had existed end-to-end since J4.1/FA1 — docs/research/2026-08-13-social-wire-completion.md §4; the panel now publishes the same Runtime commands). REMAINING scope: the Friends "Send Tell" button (`0x10000516`) only, which needs the chat-tell seam. **Original filing — 2026-08-12 at Campaign FA slice FA3, D1 (the plan's "Friends + Squelch pages bind READ-ONLY... their mutation actions are wired only if their wire is already served by ACE and trivially pinnable in-slice — otherwise the action buttons are honest INERT" decision).** The social panel's Friends page authors three buttons (Add/Remove Friend-shaped, `0x10000514`/`0x10000515`/`0x10000516`) plus an "Appear Offline"-shaped checkbox (`0x1000052C`); the Squelch page authors three buttons (`0x10000547`/`0x1000054B`/`0x1000054C`). All seven are built, laid out, and clickable exactly as authored, but carry no click handler — no Friends add/remove/appear-offline wire and no Squelch add/remove/clear wire is implemented this campaign. `gmFriendsUI`/`gmSquelchUI` were also outside lane A/B/C/D's own decompiled scope (only Fellowship/Allegiance were researched), so their real button semantics and wire opcodes are not yet established either — this row covers BOTH "not wired" and "not yet researched." | `src/AcDream.App/UI/Layout/SocialFriendsPageController.cs`; `src/AcDream.App/UI/Layout/SocialSquelchPageController.cs` (both classes' own doc comments cite this row) | FA3 is the panel SHELL slice; D1 sets the bar for which Friends/Squelch actions get wired in-slice at "trivially pinnable," which none of these seven meet without their own wire research. `SocialPanelControllerTests.FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler` pins the INERT contract so a future consumer landing without also removing this row's citation fails nothing silently — the row is the only signal until a follow-up slice wires real handlers. | A user clicking Add/Remove Friend, Appear Offline, or any Squelch button in acdream sees no effect and no feedback — indistinguishable from a dead control unless they already expect the gap. The Friends/Squelch LISTS themselves are live (bound read-only to `RuntimeCommunicationState.Friends`/`.Squelch`) — only the mutation controls are inert. | None (no retail decomp anchor — `gmFriendsUI`/`gmSquelchUI` are outside this campaign's researched scope); `docs/research/2026-08-11-fa-panel-structure.md` §10 (coordinator addendum, the panel discovery that first surfaced these two pages); `docs/plans/2026-08-11-fellowship-allegiance-campaign.md` D1 |
| AD-80 | **Filed 2026-08-12 at Campaign FA slice FA4, D5.** The fellowship page's per-fellow percentage text renders retail's own byte-decoded XP-share table verbatim (1.0/.75/.6/.55/.5/.45/.4/.35/.3111111/.28, default 0.0 — `docs/research/2026-08-11-fa-fellowship-wire.md` §7.2, byte-decoded from the PDB-paired binary because both available decompilers folded the function to a constant). The currently-targeted ACE server computes the ACTUAL distributed XP from a DIFFERENT table (`.3` at 9 fellows instead of `.3111111`, no explicit 10-fellow row, and a wrong out-of-range default of `1.0` instead of `0.0` — `Fellowship.cs:604-632`, lane B §4.3). So a full (9-member) or over-full-in-retail's-table (10-member) fellowship's displayed percentage will not exactly match the XP ACE actually grants. This is a divergence between ACE and RETAIL, not between acdream and retail — acdream's client-side display is retail-faithful — but it is filed here because it is directly user-visible through this panel and a tester comparing "panel says 31.1%" against "server granted 30%" is measuring ACE's bug, not acdream's port. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`EvenSplitPercentTable`, `FormatStatsText`) | The client-side table is byte-verified against the retail binary; re-deriving it to match ACE's (wrong) numbers would make acdream disagree with a REAL retail client observing the same fellowship, which is the opposite of this project's goal. | A tester with a 9- or 10-member fellowship on ACE sees a panel percentage that does not exactly match the XP bonus they actually receive; below 9 members the two agree exactly. The proportional (non-even-split) branch has a SEPARATE, narrower gap: acdream has not ported an `ExperienceToRaiseLevel`-equivalent table, so that branch omits the percentage entirely (level only) rather than computing a wrong number — see AD-81's citation of the same method. | `FellowshipSystem::GetEvenSplitXPPctg @0x005B9BA0` (lane B §7.2); ACE `Fellowship.cs:604-632`; `docs/research/2026-08-11-fa-fellowship-wire.md` §4.3 |
| AD-81 | **Filed 2026-08-12 at Campaign FA slice FA4.** Two retail text-composition primitives the fellowship page's mechanism needs are not ported, so this controller renders their CONTENT as plain numeric composites instead of retail's exact resolved sentence, never invented English: (1) **`StringInfo` variable substitution** — every row field beyond the bare name is a retail `StringInfo` template with embedded variables (`ID_Fellowship_FellowStats` + `ID_Level`/`ID_Experience`; the three `…Status` fields + `ID_Cur`/`ID_Max` — `docs/research/2026-08-11-fa-panel-structure.md` §3.1/§4.1), resolved at runtime through `StringInfo::InqString` → `StringTableMetaLanguage::UnescapeString`, a cross-cutting UI-string engine acdream has never ported (the SAME gap the pre-Campaign-OP Character window recorded, `docs/research/2026-06-25-character-window-faithful-spec.md`: "NOT yet ported — current controller uses canonical AC labels"); this controller instead renders `"{level} {pct}%"` and `"{cur}/{max}"` — the retail-authored NUMBERS, without retail's surrounding words. **AMENDED 2026-08-13:** the no-metalanguage fragment/variable interleave of `StringTable::GetString @0x004300D0` IS now ported as `DatStringResolver.ResolveTemplate` (the AD-85 dialog narrowing), so VERIFIED-token-free templates can resolve exactly; this row's remaining scope is the meta-token engine (`StringTableMetaLanguage::RenderString @0x004302B1` + `StripMetaLetters`) the multi-variable stats templates may need, plus `FormatName`. (2) **`ACCharGenData::FormatName`** — retail's Create flow canonicalizes the typed fellowship name and writes the formatted text back into the entry box before sending (lane B §2.2/§6.2); acdream sends the raw typed text verbatim. Neither gap affects the WIRE — the `0x00A2` builder's `str16L` field is unaffected either way; only the client-side PRESENTATION differs. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`UpdateRow`, `FormatStatsText`, `SetVitals`, the create-button `OnClick`) | Porting `StringTableMetaLanguage` is a cross-cutting UI-string-engine prerequisite, not a fellowship-specific task, and guessing its token syntax without decoding `StringInfo::InqString` would risk silently-wrong substitution rather than an honestly-numeric fallback — exactly the guessing CLAUDE.md's workflow forbids. `FormatName`'s capitalization/character rules are a separate chargen algorithm with no fellowship-specific anchor read yet. | A user sees "12 31%" / "140/140" instead of retail's full sentence, and a typed fellowship name keeps whatever casing/spacing the player typed instead of retail's canonicalized form. The underlying DATA (level, percentage, cur/max, the name itself) is correct in every case — only the surrounding words/formatting are absent. | `StringInfo::InqString @0x0042e490` → `StringTableMetaLanguage::UnescapeString` (unresolved — not yet decoded); `gmFellowshipUI::CreateFellowship @0x0048F730` (the `ACCharGenData::FormatName` call, lane B §2.2); `docs/research/2026-06-25-character-window-faithful-spec.md` (the identical prior finding for the Character window) |
@@ -234,7 +237,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-193 | Character option id `0x34` (`ListenToPKDeathMessages` / "Listen to PK death messages") is mapped to `CharacterOptions2` bit `0x02000000` and modeled as a batched (non-auto-save) option purely on ACE's own enum — the id does not exist in the 2013 EoR PDB (`PlayerOption` there terminates at `TotalNumberOfPlayerOptions_PlayerOption = 0x34`), so neither the mask nor its `IsAutoSaveOption`/`GetDefaultOptionValue` classification is byte-verifiable against our binary. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`HearPkDeathMessages` row) | The user's retail memory (and ACE's own `CharacterOption` enum) both carry this option; shipping wire+store coverage for it is strictly better than omitting the row the Character tab's screenshots show, and ACE never actually reads the bit server-side (`PlayerFactory.cs:659-660` — "possibly was added to Defaults post PDB we have"), so a wrong id/mask/auto-save guess here has zero server-observable consequence either way. | If the final EoR client's real id/mask/auto-save classification ever surfaces (a later PDB, or a byte-level trace against a 2015+ binary), this row's values may be wrong and need correcting — until then treat them as ACE-sourced, not retail-verified. | ACE `PlayerFactory.cs:659-660`, `CharacterOptions2.cs` (`ListenToPKDeathMessages = 0x02000000`); `named-retail/acclient.h:4162-4218` (2013 `PlayerOption` terminates at `0x34`); `docs/research/2026-08-10-set-character-options-wire.md` §8.1 |
| ~~AP-196~~ | **RETIRED 2026-08-11 at Campaign OP slice OP9.** Filed at the OP4 review-fix round (MUST-FIX 3/blast M2) recording that OP4's Group-C re-point deleted only three of the eight re-pointed `GameplaySettings` fields (`AutoTarget`/`AutoRepeatAttack`/`ViewCombatTarget`), leaving `VividTargetingIndicator`/`CoordinatesOnRadar`/`LockUI`/`AcceptLootPermits`/`ToggleRun` behind as WRITE-BEHIND `settings.json` persistence/draft mirrors of the now-authoritative server bit (plus a "two writable copies" default-source change, ADDENDUM historical only). OP9 verified all remaining `GameplaySettings` members — those five plus `ShowTooltips`/`SideBySideVitals`/`SpellDuration`/`AllowGive`/`ShowHelm`/`ShowCloak`/`AdvancedCombatUI`/`UseMouseTurning`, 13 total — already had a live server-bit home in `RuntimeCharacterOptionsState` (11 as OP4 Character-tab rows through `CharacterOptionTable`/`CharacterOptionsPageController`; `LockUI` through `/lockui` + the PlayerDescription `SetUiLocked` convergence, deliberately not a Character-tab row; `UseMouseTurning` through the Gameplay-tab mouse-macro button + the Config tab's Use-Mouse-Turning row — OP9 review NIT 6's channel-attribution correction) and deleted the `GameplaySettings` record outright — the type, the `SettingsStore.LoadGameplay`/`SaveGameplay` plumbing, and `RuntimeSettingsController`'s `Gameplay` property/`SetAcceptLootPermits` write-behind method — closing the "two writable copies" gap for good: there is no longer a second store to diverge from server truth. | `src/AcDream.App/Settings/RuntimeSettingsController.cs`; `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs`; `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | — | — | `docs/research/2026-08-10-character-options-map.md` §7.1/§7.2 (Group C re-point directive); `CharacterOptionTable.cs` |
| AP-197 | **Filed 2026-08-11 at the OP4 review-fix round (SF-1/S4).** "Display Timestamps" hardcodes retail's `PlayerModule` constructor-default format string `"%#H:%M:%S "` rather than reading the PER-CHARACTER override `GenericQualitiesData::InqString(m_pPlayerOptionsData, 1, &m_TimeStampFormat)` carries when the wire's `GenericQualitiesData` string-key `1` is populated — acdream's `PlayerDescription` parser reads and discards that field (wire research doc: "timestamp string (`0x80`) \| read, discarded \| ❌ \| never sent"). | `src/AcDream.Core/Chat/ChatLog.cs` (`FormatTimestampPrefix`); parser site cited at `docs/research/2026-08-10-set-character-options-wire.md:647` | The 2013 client's own constructor default is the only format any fresh/default character would ever show — retail ships no options-panel control that authors a custom one — so hardcoding the one value every real player sees is a safe, honest approximation until a consumer needs the per-character override. | A character whose account somehow carries a non-default persisted timestamp format (a modded/legacy server, or a hypothetical later retail patch exposing a UI for it) sees acdream render the DEFAULT format instead of their stored one — cosmetic only (still a valid H:MM:SS-shaped timestamp), never a wire or data-loss risk. | `PlayerModule::PlayerModule @0x005D51F0` (ctor default literal); `GenericQualitiesData::InqString` call site (wire doc §3.3); `docs/research/2026-08-10-set-character-options-wire.md` U6 |
-| AP-198 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review N1/S2) — the row count was ALWAYS ten (this row's own enumeration always listed ten items); the commit message that said "nine" was the error, now reconciled, and `Render_ScreenBrightness` no longer overloads `Gamma`.** The Config tab's "Graphics Options" + "Rendering Quality Options" sections author ten rows with no acdream renderer consumer: `Render_ScreenBrightness` (its OWN `DisplaySettings.ScreenBrightness` field, range [-1,1] default 0 — NOT the pre-existing `Gamma` multiplier, which has a different unit system and its own live legacy Settings-panel consumer; no gamma-correction pass exists for either), `Render_AutomaticDegrades`, `Render_GraphicsPerformance`, `Render_DegradeDistance`, `Render_LandscapeTextureDetail`, `Render_EnvironmentTextureDetail`, `Render_TextureFiltering`, `Render_LandscapeDrawDistance`, `Render_BuildingDetailTextures`, `Render_MultiPassAlpha`. acdream's world renderer is Vulkan driven by ONE aggregate `QualitySettings`/`QualityPreset` (near/far streaming radii, anisotropic level, alpha-to-coverage, completion budget) — there is no per-feature texture-detail/degrade-distance knob for any of these ten rows to drive. Each round-trips faithfully through `DisplaySettings`/`SettingsStore` and shows retail's own row/label/range (where applicable), with zero observable render effect. **Sub-note, `Render_LandscapeDrawDistance` specifically:** its retail default (`gmConfigUI::InitOptions @0x0049E70D`, `SetDefaultValue(8)`) does not index its own 6-entry `UIPreferences::SetEnumChoices` array (`ID_Graphics_Value_VeryLow`..`Extreme`, `gmClient::InitUIPreferences @0x004041b7`) — reproduced faithfully as an opaque `int` (`DisplaySettings.LandscapeDrawDistance`), not guessed into a clamped index; the Config-tab menu simply shows no highlighted selection at the default. | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindGraphicsSection`/`BindRenderingQualitySection`) | Building ten dead per-feature render knobs into a Vulkan renderer that has no analogous per-feature toggles would be pure UI theater with no correctness payoff; persisting them faithfully keeps the panel honest (every row is clickable, nothing crashes, nothing silently discards a user's choice) while the register makes the "no effect" fact auditable rather than a silent gap a future report would have to re-discover. | A user who changes any of these ten Config-tab controls sees no visual change and, for `LandscapeDrawDistance` specifically, may see no highlighted menu item even after Defaults — both are the CONTRACTED behaviour for this row, not a bug. | `gmConfigUI::InitOptions @0x0049E400`; `gmClient::InitUIPreferences @0x004035b0` (`UIPreferences::AttachPreference`/`SetEnumChoices` calls); `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`QualitySettings`/`ReapplyQualityPreset`) |
+| AP-198 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review N1/S2); NARROWED 2026-08-21 by #226.** The Config tab's "Graphics Options" + "Rendering Quality Options" sections author nine rows that still have no acdream renderer consumer: `Render_ScreenBrightness` (its OWN `DisplaySettings.ScreenBrightness` field, range [-1,1] default 0 — NOT the pre-existing `Gamma` multiplier, which has a different unit system and its own live legacy Settings-panel consumer; no gamma-correction pass exists for either), `Render_AutomaticDegrades`, `Render_GraphicsPerformance`, `Render_DegradeDistance`, `Render_LandscapeTextureDetail`, `Render_EnvironmentTextureDetail`, `Render_TextureFiltering`, `Render_LandscapeDrawDistance`, `Render_MultiPassAlpha`. #226 removed `Render_BuildingDetailTextures` from this row: the existing checkbox now directly gates the retail building/EnvCell detail replay and is no longer caption-dimmed as store-only. acdream's remaining world-quality controls are Vulkan driven by ONE aggregate `QualitySettings`/`QualityPreset` (near/far streaming radii, anisotropic level, alpha-to-coverage, completion budget) — there is no per-feature texture-detail/degrade-distance knob for the nine residual rows to drive. Each residual round-trips faithfully through `DisplaySettings`/`SettingsStore` and shows retail's own row/label/range (where applicable), with zero observable render effect. **Sub-note, `Render_LandscapeDrawDistance` specifically:** its retail default (`gmConfigUI::InitOptions @0x0049E70D`, `SetDefaultValue(8)`) does not index its own 6-entry `UIPreferences::SetEnumChoices` array (`ID_Graphics_Value_VeryLow`..`Extreme`, `gmClient::InitUIPreferences @0x004041b7`) — reproduced faithfully as an opaque `int` (`DisplaySettings.LandscapeDrawDistance`), not guessed into a clamped index; the Config-tab menu simply shows no highlighted selection at the default. | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindGraphicsSection`/`BindRenderingQualitySection`) | Building the nine residual dead per-feature render knobs into a Vulkan renderer that has no analogous controls would be pure UI theater with no correctness payoff; persisting them faithfully keeps the panel honest while the register makes the "no effect" fact auditable. | A user who changes any of these nine residual Config-tab controls sees no visual change and, for `LandscapeDrawDistance` specifically, may see no highlighted menu item even after Defaults — both are the CONTRACTED behaviour for this row, not a bug. Building Detail Textures is explicitly outside this residual and must visibly change eligible building/EnvCell surfaces. | `gmConfigUI::InitOptions @0x0049E400`; `gmClient::InitUIPreferences @0x004035b0` (`UIPreferences::AttachPreference`/`SetEnumChoices` calls); `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`QualitySettings`/`ReapplyQualityPreset`); `docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md` |
| AP-199 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review M2) — the field names and the "gating to zero when disabled" wording were describing an INVERTED, muted-by-default bug, not the shipped behaviour.** The Config tab's "Sound Options" section authors three rows with no acdream consumer: `Sound_SoundFeatures` (Stereo/Mono menu — acdream's OpenAL backend has no channel-count toggle), the Interface Sound toggle+slider trio (`Sound_InterfaceSoundDisabled`/`Sound_InterfaceSoundVolume` — AP-174 already documents this as retail's OWN dead knob, "registered and then never read... interface sounds are scaled by the EFFECT knob"; acdream matches that exact behaviour rather than building a working Interface bus), and `Sound_PlaySoundOnlyWhenActive` (no window-focus-based audio mute subsystem exists). All three round-trip faithfully through the new `AudioSettings.SoundFeatures`/`InterfaceEnabled`/`InterfaceVolume`/`PlaySoundOnlyWhenActive` fields. The Sound and Ambient trios' own toggle+slider pairs are NOT covered by this row — `SfxEnabled`/`AmbientEnabled`/`Sfx`/`Ambient` are LIVE (`RuntimeSettingsController.SaveAudio` now pushes into `OpenAlAudioEngine` on every change; the effective volume is zero only when the corresponding `*Enabled` flag is false — retail's own `SoundManager::effect_sounds_enabled`/`ambient_sounds_enabled` statics default to enabled, so a fresh profile is audible, not muted). | `src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindSoundSection`) | Matches the SAME reasoning AP-174 already established for the Interface knob specifically; Sound Features and Play-Only-When-Active are honest new store-only rows with no existing or planned acdream subsystem to bind (stereo/mono output selection and window-focus audio gating are both out of this campaign's scope). | A user who changes any of these three Config-tab controls sees/hears no change — the CONTRACTED behaviour, matching retail's own Interface-knob precedent for two of the three. | `gmClient::InitUIPreferences @0x004035b0` (`AttachPreference(&Sound_SoundFeatures, ...)`/`&Sound_InterfaceSoundDisabled`/`&Sound_InterfaceSoundVolume`/`&Sound_PlaySoundOnlyWhenActive`); AP-174 (Interface-knob precedent); `SoundManager::InitPrefs @0x005503F0` (`UserPreferences::RegisterPreference` binding the enabled-sense statics) |
| AP-200 | **Filed 2026-08-11 at Campaign OP slice OP6.** The Config tab's "UI Options" section authors `UI_ChatFontFace`/`UI_ChatFontSize` menu rows (retail Windows TrueType face name / a Tiny-Small-Medium-Large-XLarge size-tier enum). These are DELIBERATELY separate NEW fields (`ChatSettings.ChatFontFace`/`ChatFontSizeIndex`) rather than reusing the existing LIVE `ChatSettings.FontSize` (a 10..20pt float acdream's chat panel already renders with) — there is no verified index-to-point mapping from retail's five-tier enum to that float range, and acdream's text rendering has no arbitrary system-font-face swap capability (DAT-baked/bitmap fonts only, not OS TrueType files). Store-only round-trip; `FontSize` is untouched by these two rows. | `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindUiSection`) | Inventing a size-index-to-point mapping without retail evidence would risk silently overwriting `FontSize`'s own already-live, user-visible behaviour with a guessed value; keeping the two concepts separate is the honest choice until a byte-verified mapping (or a font-face-swap capability) exists. | A user who changes either Config-tab font control sees no chat-panel rendering change; the SEPARATE, pre-existing font-size control (wherever acdream currently exposes `ChatSettings.FontSize`) remains the only live one. | `gmClient::InitUIPreferences @0x0040387b`/`@0x00403a1a` (`AttachPreference(&UI_ChatFontFace, ...)`/`&UI_ChatFontSize`, `SetEnumChoices` choice arrays "Arial"/"Tiny".."XLarge") |
| AP-172 | **Filed 2026-08-08 (#354 fix — spell-bar drag reorder).** Retail removes a lifted favorite from `PlayerModule` (+ UI list + wire) the instant a drag starts and the remaining shortcuts visibly slide left to close the gap for the rest of the gesture (`RecvNotice_ItemListBeginDrag` → `RemoveSpellFromMenu`, live). acdream's controller performs the same PlayerModule/wire removal at drag-begin but DEFERS the whole favorite-list's visual rebuild until the drag concludes (drop or off-bar release) — the lifted cell's icon stays visible in its old slot and siblings do not slide until release, instead of reflowing continuously through the gesture. `DropFavorite` compensates by porting retail's own `AddFavorite`-side index adjustment (decrement the target index by one when the lifted item's original index was before it) against the now-intentionally-stale sibling numbering, so the FINAL landed position is byte-identical to retail's in every case exercised (`DragFavoriteOntoAnotherSlot_ThroughTheRealPointerPipeline_ReordersAndSyncsWire`). **NARROWED + CORRECTED 2026-08-08 (drop-ring change).** Correction: this row originally claimed empty-tail-slot drops were "already-live-count-relative and are untouched" — false. The #354 `-1` adjustment sat inside `DropFavorite`, which the empty-cell path also calls, so its live-count-clamped (post-lift-numbered) index was double-corrected: lifting a non-last favorite onto the empty tail landed it second-to-last instead of last. `FavoriteDropIndex` is now THE one landing computation and applies retail's rule exactly — the `-1` is gated on the lifted spell's pre-lift-numbered removal site (retail's `RemoveSpellFromMenu`-return-gated decrement @0x004C7157), which for a live-numbered empty-tail target is retail's `RemoveSpellFromMenu == -1` no-adjustment case (test `SpellFavoriteDrag_DroppedOnTheEmptyTail_AppendsAtTheEnd` fails against the double-correcting code). Narrowing: the mid-drag presentation now includes retail's authored drag-over Accept ring — `SpellCastSubMenu::OnItemListDragOver` @0x004C5990 setting the per-cell authored DragAccept child (element 0x1000045A, `UIElement_UIItem::PostInit` @0x004E1870) to `ItemSlot_DragOver_Accept` (UIStateId 0x10000040 → authored art 0x060011F9) on the hovered cell while a spell drag is live, cleared on leave/drop (`UiCatalogSlot.DragOverAcceptance` → `UiItemSlot.DrawDragAcceptOverlay`), with the ring and the drop sharing `FavoriteDropIndex` so the ring cannot promise a different landing. | `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`BeginFavoriteDrag`, `EndFavoriteDrag`, `DropFavorite`, `Tick` — the `_favoriteDragActive` gate) | `UiRoot`'s subtree-removal safety net (`ClearSubtreeOwnership`, `UiRoot.cs:240-247`) cancels any in-flight drag whose source widget is destroyed, and `Rebuild()` tears down and recreates every cell in the list (`UiItemList.Flush` → `RemoveChild` per cell) rather than incrementally diffing. Left unguarded, the press-time removal's `SpellbookChanged` event would let the very next per-frame `Tick()` (production drives this unconditionally via `RetailUiRuntime.Tick`) destroy the cell driving the gesture and silently cancel the reorder before the user could complete the drop — this was the reported bug. Deferring the rebuild for the gesture's duration is the minimal fix that does not touch the shared `UiRoot` drag machinery every other panel (toolbar/inventory/vendor/paperdoll) also depends on. | A future rewrite that makes `Rebuild()` an incremental per-cell diff (add/remove/reflow one cell) instead of flush-and-recreate-all would make this deferral unnecessary and should retire this row along with it — until then, a player watching their OWN spell bar mid-drag sees the vacated slot's icon linger and siblings snap into place only on release, rather than reflowing live as retail does — and one ring consequence of that frozen bar: when dragging rightward past the source, the Accept ring's SCREEN slot sits one cell right of where the icon finally lands (retail's live-reflowed bar makes them coincide); the ring is on the correct CELL in both — the spell lands immediately before that cell's spell, retail's exact insert-before semantic. No effect on final position, the wire pair sent, or any other panel; cross-window spellbook→favorite drops are live-count-relative and untouched (the empty-tail claim this sentence used to carry was corrected 2026-08-08 — see the Divergence column). | `gmSpellcastingUI::RecvNotice_ItemListBeginDrag` @0x004C7360 (`SpellCastSubMenu::RemoveSpellFromMenu`, immediate live-list removal at lift); `SpellCastSubMenu::AddFavorite` @0x004C7060 (`RemoveSpellFromMenu`'s return value gating the `-1`-if-lifted-before-target `m_numSpells` adjustment before `ItemList_InsertSpellShortcut`); `PlayerModule::AddSpellFavorite` @0x005D43E0 (`InsertPos`); `PlayerModule::RemoveSpellFavorite` @0x005D4910 |
@@ -456,7 +459,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; shared frame drain in `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveEffectFrameController`) | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order |
| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveObjectFrameController` + `LiveEffectFrameController` shared `_particles.Tick` / `_scripts.Tick` tail); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order |
-| TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 |
+| ~~TS-52~~ | **RETIRED 2026-08-21 (#226).** The row's landscape premise was wrong: the reachable Sept-2013 `ChangeRegion` caller passes zero LANDSCAPE detail surfaces and enables the building/environment categories. acdream now resolves those authored category detail textures and tiling, replays eligible building-shell and EnvCell subsets with retail's `DESTCOLOR + INVSRCALPHA` blend and 10–50 m viewer-depth fade, and consumes the existing Building Detail Textures preference. The earlier experimental landscape array remains correctly reverted; there is no missing user-visible landscape pass to track. | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs`; `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs`; `src/AcDream.App/Rendering/Shaders/mesh_detail.vert`; `src/AcDream.App/Rendering/Shaders/mesh_detail.frag` | Retired on measured caller/category evidence and the connected on/off/restored visual gate. | None; disabling the preference submits no detail replay, while enabling it visibly changes nearby building/EnvCell surfaces. Landscape remains unchanged, matching the reachable retail caller. | `docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md`; `LScape::ChangeRegion`; `SmartBox::SetDetailTexturing`; issue #226 |
| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison |
| ~~TS-54~~ | **RETIRED 2026-08-08 (Campaign A slice A4).** The AdminEnvirons stingers now play. `UiSoundController.PlayEnvironCue` maps the change type through `EnvironSoundCueMap` — an EXPLICIT table read case-by-case out of `CPlayerSystem::Handle_Admin__Environs` @ `0x0055DE20` (`0x0055E0C6..0x0055E2C7`), not an offset: codes `0x65..0x72` sit 0x11 below their SoundType but `0x73`/`0x74` have no case at all, so `0x75` lands on `UI_Squeal` (0x84) where arithmetic would give 0x86, and the switch ends at `0x7B`/`UI_Thunder6` with no `0x7C` case. All 21 cases are pinned by conformance tests. The bank itself is no longer a blocker either: the UI sound table's DID is resolved by walking the dats' EnumIDMap chain (`UiSoundTableResolver`, master → slot-7 map → `0x2000004B`), which is how retail finds it — `GetUISoundTable` holds no literal. | retired | — | — | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20`; `SoundManager::PlaySoundFromCenter @ 0x00550950`; `ClientUISystem::GetUISoundTable @ 0x00563FB0`; `docs/research/2026-08-08-audio-retail-music-absence.md` §5 |
| TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) |
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index b74034db..8f475c92 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -2070,6 +2070,8 @@ Native macOS graphical support is not committed by this track. The current
mandatory renderer requires modern OpenGL capabilities beyond Apple's native
OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
+**Future / unscheduled — Campaign AR:** the opt-in [Atmospheric Rendering / Shader Packs campaign](2026-08-21-atmospheric-rendering.md) makes moving authored sun-and-moon directional shadows from trees, monsters, players, and buildings its Tier-2 headline while preserving acdream's current retail-faithful renderer as the default and leaving physics, collision, gameplay, and network behavior unchanged; the project owner assigned Campaign AR on 2026-08-22 without displacing active M4 gameplay work. The [celestial source contract](../research/2026-08-22-dereth-celestial-shadow-sources.md) selects sun, dominant moon, then secondary moon by rendered direction while retaining AC's single authored directional-energy channel; sun rays and volumetrics remain sun-only. The previously referenced #268 + TS-8 package is complete and retired. Stage 1's automated correctness, performance, lifetime, locked-restore, Release, evidence, documentation, and project-owner live gates completed on 2026-08-22 after the opt-in exposure correction. Stage 2 connected, performance, lifetime, package-lifecycle, physical-hardware, and closeout evidence is active.
+
---
## Cross-cutting work tracked in parallel
diff --git a/docs/plans/2026-08-21-atmospheric-rendering.md b/docs/plans/2026-08-21-atmospheric-rendering.md
new file mode 100644
index 00000000..e3220a6f
--- /dev/null
+++ b/docs/plans/2026-08-21-atmospheric-rendering.md
@@ -0,0 +1,931 @@
+# Campaign AR — Atmospheric Rendering / Shader Packs
+
+**Date:** 2026-08-21
+**Status:** STAGE 2 ACTIVE — the approved authored sun-and-moon shadow-source
+extension and every non-physical Stage 1 correctness, performance, lifetime,
+Release, evidence, and documentation gate are complete. After the live
+sun/moon, source-transition, temporal-stability, desktop-performance, and
+exposure correction round, the project owner accepted Stage 1 on 2026-08-22.
+Stage 2 connected, lifetime, package-lifecycle, physical-hardware, and final
+closeout evidence is now active.
+**Phase id:** **Campaign AR** — assigned by the project owner on 2026-08-22
+**Scheduling:** originally held for the post-M7 rendering-polish pass; the
+project owner explicitly authorized implementation on 2026-08-21. This
+owner-directed campaign is now Campaign AR and does not displace the active M4
+gameplay work. The previously referenced #268 + TS-8 stat-chain package is
+already complete and retired, so it is no longer a scheduling dependency.
+
+## Goal
+
+Add an opt-in enhanced-graphics system whose headline feature is **real-time
+directional shadows cast by trees, monsters, players, and buildings as
+Dereth's authored sun and moons move across the sky**. The same system can add
+bloom, filmic tonemapping, colour grading, vignette, sun rays, and later
+volumetric shafts, with useful quality levels on weak through high-end
+hardware. Sun rays and volumetric shafts remain sun-only effects; the approved
+moon scope applies to Tier 2 directional shadows.
+
+Campaign AR executes in two stages. Stage 1 fixed the dense-scene transform
+ceiling and shadow quality, completed authored sun/dominant-moon/secondary-moon
+source selection, and finished every automated gate that did not require the
+project owner's physical-display judgment. The owner accepted the subsequent
+live visual/performance round on 2026-08-22 after the default Atmospheric
+exposure was corrected from 1.0 to 0.80. Stage 2 is active and limited to the
+remaining connected, lifetime, package-lifecycle, physical-hardware, evidence,
+and final owner-acceptance rows; it does not add another renderer feature tier.
+
+Stage 1's dense-scene regression is pinned by the connected failures already
+captured on 2026-08-22: Atmospheric fell back at 68,395, 67,581, and even
+65,538 combined world matrices against the old 65,536-matrix binding ceiling.
+The corrected connected launch must exceed that historical workload without
+persisting acdream-default fallback or splitting the authoritative pose data.
+
+The enhancement is a shader pack, not a rewrite of acdream's renderer or AC's
+art. **acdream's current retail-faithful renderer** remains the default,
+authoritative path. References below to the “default” or “retail-faithful” path
+always mean acdream—not the original retail executable.
+
+The evidence and constraints for this design are recorded in the
+[terrain and atmospheric rendering findings](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md),
+especially [the measured renderer baseline](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#5-renderer-state-relevant-to-atmospheric-work)
+and [the requested tier model](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#6-wanted-work--atmospheric-rendering-user-stated).
+The approved celestial identity, priority, transform, and direction-versus-
+energy contract are pinned by the
+[Dereth celestial shadow-source research](../research/2026-08-22-dereth-celestial-shadow-sources.md).
+
+## Opt-in contract
+
+1. **acdream's current renderer is the default.** With no pack selected, the current render graph,
+ shaders, render targets, submissions, lighting, colours, and screenshots
+ remain authoritative. No enhancement resource or pass is created.
+2. **Selection is explicit.** Installing a pack does not enable it. The user
+ selects one pack and one quality preset in Display settings. `acdream
+ default (retail-faithful)` is always present and cannot be removed.
+3. **One pack owns the enhancement graph.** Packs do not stack. This prevents
+ ambiguous pass ordering, incompatible HDR conventions, and unbounded GPU
+ cost.
+4. **The renderer owns the RHI.** A pack declares assets, semantic pass hooks,
+ capabilities, resources, and quality variants. It never receives Vulkan
+ handles or mutates the authoritative scene, streaming, gameplay, or
+ physics owners.
+5. **Failure returns to acdream's default renderer.** Unsupported capabilities,
+ malformed assets, shader/pipeline candidate-creation failure, or an invalid
+ pass graph disables the complete pack and restores acdream's default path
+ with a visible reason. A half-enabled pack is never rendered. A terminal
+ `VK_ERROR_DEVICE_LOST` cannot render either path on the lost device; it tears
+ down that renderer/device lifetime, and retail remains authoritative while a
+ fresh renderer/device is constructed and the pack is validated again.
+6. **Divergence is honest.** Enhanced screenshots are intentionally not retail
+ parity evidence. The default path remains the comparison oracle and the
+ enhancement choice is recorded in diagnostics and screenshot metadata.
+7. **No scheduling claim.** Rendering phases stay frozen until the M7 polish
+ pass unless the project owner explicitly reprioritizes this work.
+
+## Capability tiers
+
+The costs below are **planning estimates**, not measurements. They are
+incremental GPU p50 targets for a representative discrete GPU at 1920x1080;
+every slice must replace them with physical-hardware measurements. Tier-1
+pixel effects scale with output resolution, so 4K has roughly four times the
+1080p fragment workload. Shadow-map cost depends more on caster count, map
+resolution, and cascade count than on output resolution.
+
+| Tier | Contents | Prerequisite | Rough incremental GPU cost at 1080p |
+|---|---|---|---:|
+| acdream default | Current authoritative retail-faithful rendering | Current mandatory Vulkan/RHI capabilities | 0 ms |
+| 1 | Bloom, ACES filmic tonemap, colour grade, vignette | Main-world colour intermediate and fullscreen passes | 0.35–0.80 ms |
+| 1 | Screen-space sun rays (crepuscular) | Authored sun screen position plus an occlusion mask; **no shadow maps** | 0.20–0.50 ms |
+| 2 | **Moving authored sun-and-moon cascaded directional shadows from trees, monsters, players, and houses/buildings** | A second scene pass, selected-celestial view/projection matrices, sampled depth maps, caster pipeline variants | 1.50–3.00 ms |
+| 2+ | Sun-only volumetric light shafts | Reuse Tier-2 shadow infrastructure only while the selected source is the authored sun, plus authored weather | 0.15–0.40 ms |
+| Later | SSAO and water reflections | Scene depth plus normal inputs and separate designs | Not budgeted here |
+| Out | True PBR | AC lacks authored per-texture normal/roughness/metalness maps | Not planned |
+
+Tier numbers express prerequisites, not a forced bundle. A pack may offer
+Tier 1 without shadows. Tier 2 always includes the complete shadow-caster
+classes; weak-hardware presets reduce range, cascade count, and resolution
+rather than silently dropping monsters, trees, or buildings.
+
+## Tier 2 headline: Dereth's moving authored sun-and-moon shadows
+
+The [measured renderer state](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#5-renderer-state-relevant-to-atmospheric-work)
+already supplies retail's single directional colour/energy channel from
+`SkyStateProvider`. Tier 2 augments it with the visible authored celestial
+positions documented in the
+[Dereth celestial shadow-source research](../research/2026-08-22-dereth-celestial-shadow-sources.md).
+The camera-relative cascaded map selects, in order, the visible above-horizon
+sun (`0x01001348`), dominant haloed moon (`0x01001F6A`), or secondary moon
+(`0x01001F67`). The selected mesh's exact rendered transform supplies shadow
+direction; retail's one interpolated `DirColor * DirBright` channel supplies
+colour/energy. Moon texture brightness and mesh luminosity never manufacture a
+second world light. As those authored bodies move, tree branches, monsters,
+players, houses, and other eligible world geometry cast correspondingly moving
+shadows.
+
+This source selection is an explicit opt-in pack enhancement. It is not a
+claim that the retail executable rendered real-time moon shadows, and it does
+not alter acdream's default retail-faithful scene lighting. Screen-space sun
+rays and volumetric shafts continue to use only the authored sun; they do not
+switch to either moon.
+
+The required behavior is:
+
+- Terrain and opaque world geometry receive shadows. Terrain, buildings,
+ statics, procedural scenery, the local player, remote players, and creatures
+ cast them when resident and visible to the main outdoor world.
+- Foliage and other cutout materials use an alpha-sampling shadow fragment
+ shader. An empty depth fragment shader would turn each tree plane into a
+ solid rectangular shadow.
+- Animated casters reuse the exact per-part transforms already published in
+ the N.5 SSBO. The shadow pass must not create a second animation pose or
+ gameplay entity projection.
+- Cascades follow the camera and are texel-stabilized. Their reach is clamped
+ to the resident two-tier streaming window; the pack does not extend world
+ streaming or issue speculative loads.
+- The celestial directional-shadow pass is outdoor-only. Dungeon and EnvCell lighting remains
+ authored per-cell lighting. Entering an interior retires or idles outdoor
+ shadow work without leaving stale maps on screen.
+- Shadow direction follows the selected visible above-horizon sun, dominant
+ moon, or secondary moon. The authored directional colour/energy remains
+ `DirColor * DirBright`; active day/weather pack policy may soften or reduce
+ it without inventing a second celestial clock, light-energy channel, or
+ weather system. A time with no eligible above-horizon body has no
+ directional shadow; night is not itself a disable condition.
+- Transparent blend materials do not cast an opaque silhouette by default.
+ Only existing opaque and cutout classifications participate until a
+ material-specific transparent-shadow contract is designed.
+
+The pass reuses the retained resident scene. It must not run PView, portal
+traversal, or per-object CPU visibility classification once per cascade.
+Initially, each cascade draws the bounded resident caster set through the
+existing batched/MDI ownership. If that is too expensive, the next permitted
+step is GPU culling—not repeated CPU culling or per-object submissions.
+
+## Pack API surface sketch
+
+The public declarations belong in the BCL-only
+`AcDream.Plugin.Abstractions` assembly. The graphical App supplies the
+implementation and translates the declarations to the Vulkan RHI. Headless
+hosts expose no render-pack registry and never load pack assets.
+
+This is an API shape, not code committed by this design:
+
+```csharp
+public interface IRenderPackPlugin
+{
+ void Register(IRenderPackRegistry registry);
+}
+
+public interface IRenderPackRegistry
+{
+ IDisposable Register(RenderPackDescriptor descriptor, IRenderPackAssets assets);
+}
+
+public interface IRenderPackAssets
+{
+ Stream OpenRead(string assetKey);
+}
+
+public sealed record RenderPackDescriptor(
+ string Id,
+ string DisplayName,
+ Version PackVersion,
+ int PackApiVersion,
+ RenderPackTier HighestTier,
+ IReadOnlyList RequiredCapabilities,
+ IReadOnlyList OptionalCapabilities,
+ IReadOnlyList Resources,
+ IReadOnlyList Passes,
+ IReadOnlyList SceneReplays,
+ IReadOnlyList PipelineVariants,
+ IReadOnlyList QualityPresets,
+ IReadOnlyList Settings,
+ AtmospherePolicyDeclaration? AtmospherePolicy);
+```
+
+A pack declares:
+
+- a stable ID, display name, pack version, and pack-API version;
+- its highest tier and a human-readable feature summary;
+- mandatory and optional GPU capabilities and per-preset limits;
+- shader assets and fixed renderer semantic inputs, including world colour,
+ scene depth, optional normals, selected celestial shadow direction/energy,
+ sun direction/screen position for sun-only effects, active weather, camera
+ matrices, shadow-caster transforms, and frame time;
+- intermediate images/buffers by relative or absolute extent, format class,
+ usage, lifetime, and estimated bytes;
+- passes at renderer-owned hooks such as `ShadowDepthBeforeWorld`,
+ `AtmosphereBeforeToneMap`, `ToneMap`, and
+ `AfterToneMapBeforePrivateViewports`;
+- renderer-owned scene replays such as `OutdoorDirectionalShadowCasters`, with
+ requested cascade views and existing caster/material classes
+ (`Terrain`, `OpaqueWorld`, `AlphaCutoutWorld`, `AnimatedOpaque`, and
+ `AnimatedAlphaCutout`); the renderer resolves those classes from its
+ retained scene and records their existing batched draws;
+- fixed pipeline variants for shadow-caster depth and main-world shadow
+ receivers. A variant names its base semantic (`Terrain`, `WorldMesh`, or
+ `EnvCell`), shader asset, compatible material classes, and declared inputs
+ such as cascade matrices, directional depth maps, and sampler state; it does
+ not replace visibility, batching, mesh ownership, or draw submission code;
+- quality presets, user-visible settings with bounded ranges, and declared
+ incremental GPU/VRAM budgets; and
+- an atmosphere policy: explicit directional-source/sun-elevation response
+ curves and a mapping from AC's categorical `activeDayGroup` values to effect
+ multipliers. These values live in the visible pack declaration, not as hidden
+ renderer constants; AC remains the owner of celestial position, directional
+ energy, and weather state.
+
+The renderer—not the pack—defines descriptor layouts, validates SPIR-V and
+resource declarations, resolves semantic scene-replay and pipeline-variant
+requests, builds pipelines, schedules barriers, owns frame-flight and teardown,
+and supplies immutable frame inputs. Packs cannot add arbitrary draw callbacks,
+read gameplay owners, submit command buffers, retain borrowed frame views, or
+address resources outside their registration. The built-in pack's Tier-2
+caster pass and receiver shaders must be expressible entirely through these
+same public declarations.
+
+### Selection and fail-safe lifecycle
+
+1. Discover manifests and descriptors without creating GPU objects.
+2. Show compatibility and estimated cost in Display settings. Unsupported
+ packs remain visible with the exact missing capability; they cannot be
+ selected.
+3. On explicit selection, validate the whole descriptor, all assets, resource
+ ceilings, hooks, and shader interfaces; then build a complete candidate
+ pipeline set off to the side.
+4. Atomically activate the candidate only after every required object exists.
+ Until then acdream's default path continues rendering.
+5. Persist `pack id + pack version + preset`, never a positional index. If the
+ pack disappears or becomes incompatible, select `acdream default` and
+ retain the failure notice.
+6. On runtime validation or post-recreation candidate failure, withdraw all
+ pack passes/resources at a frame boundary and resume acdream's default
+ renderer. Do not repeatedly retry a failing pack during the session.
+7. Unload and reconnect use the normal render-generation and GPU-flight
+ retirement rules. No pack object may retain a world generation, scene
+ entity, or collectible plugin load context.
+
+In this campaign, **device recreation** means disposing the complete old
+renderer, Vulkan context, and device, then constructing a fresh context/device,
+re-probing capabilities, and validating selection again with retail active
+until the candidate is complete. It does **not** mean live, in-process recovery
+from `VK_ERROR_DEVICE_LOST`; device loss remains terminal to that renderer and
+device lifetime.
+
+The built-in Atmospheric Rendering pack should be the first consumer of this
+same API. It must not receive private renderer shortcuts that third-party packs
+cannot express.
+
+The public v1 authoring surface, manifest schema, shader semantic bindings,
+failure guidance, validator command, and external no-op sample are indexed by
+the [render-pack SDK](../render-packs/README.md).
+
+## Frame-graph placement
+
+With the pack off, the frozen retail graph is unchanged. With a pack selected,
+the renderer builds a separate enhancement graph:
+
+1. Update the existing immutable world frame, including authored sky objects,
+ the retail directional colour/energy channel, and weather.
+2. Outdoors, select the visible above-horizon sun/dominant moon/secondary moon
+ direction and render Tier-2 cascaded shadow depth from the resident caster
+ set.
+3. Render the main world to the pack's world-colour intermediate, using
+ pack-selected pipeline variants to sample the shadow map where requested.
+4. Preserve the established PView, punch/seal depth discipline, shared-alpha
+ ordering, particle ordering, and world transparency boundaries.
+5. Generate screen-space sun occlusion/rays or, only while the shadow source is
+ the sun, shadow-map volumetrics.
+6. Composite rays/shafts **before tonemapping**, so bloom sees them and the
+ filmic curve rolls them off instead of clipping them.
+7. Apply tonemap, colour grade, and vignette to the main world image.
+8. Continue with private portal/paperdoll/appraisal viewports and retained UI
+ on their existing path. They are not accidentally post-processed with the
+ main world.
+
+## Delivery slices and acceptance
+
+The slice labels below are local to this document. They are not phase IDs.
+
+### Pre-moon checkpoint, Stage 1 acceptance, and Stage 2 start — 2026-08-22
+
+Before the approved moon extension, all seven local slices (0–6) had production
+implementations in the current worktree. A source-identical isolated clean
+snapshot closed that sun-only reference-GPU physical matrix, and one physical
+integrated-AMD Auto safe-fallback row was also present. Those artifacts remain
+valid evidence for the exact binaries and sun-only scope they measured; they
+are not moon-alignment, source-transition, current-worktree, or final user-
+acceptance evidence.
+
+Stage 1's automated implementation and validation are complete. The authored
+sun/dominant-moon/secondary-moon resolver and its direction-versus-energy
+handoff are present and covered without inferring physical quality from unit
+tests, screenshots, or historical sun-only rows. The subsequent live ACE round
+covered the owner-reported shadow visibility/configuration, temporal
+pixelation/shimmer, frame-pacing/desktop responsiveness, selection/fullscreen
+regressions, and matched indoor/outdoor exposure. After the exposure correction
+the owner accepted the live result. The exact evidence and limits are recorded
+in the [Stage 1 live-gate report](../research/2026-08-22-atmospheric-stage1-live-gate.md).
+
+Current authored-celestial Stage 1 automated gate (2026-08-22):
+
+- The shader compiler reports **24/24** Vulkan shader pairs ready. Incremental
+ regeneration expands only pack includes and preserves all 18 pre-campaign
+ retail SPIR-V artifacts byte-for-byte; the exact SHA-256 oracle and complete
+ source-manifest checks pass. All selected-celestial binding-6 modules expose
+ the 336-byte ABI v1 layout, including source kind at offset 320.
+- Focused Release validation passes **344/344** App renderer tests, **30/30**
+ standalone SDK/pack-validator tests, **14/14** Core sky-loader tests, and
+ **48/48** MossTank tests. Both external Tier-2 samples embed and validate the
+ current selected-celestial shader ABI without App or Vulkan dependencies.
+- The repository's forced locked restore passes. The complete Release solution,
+ including all source, tests, tools, and SDK samples, builds with **0 warnings
+ and 0 errors** after that restore.
+- The repository-owned fresh-process hermetic gate passes
+ **14,928/14,928** tests with zero skips or failures across 14 assemblies;
+ `AcDream.App.Tests` contributes **5,823/5,823**. Evidence is under
+ `artifacts/atmospheric-rendering/stage1-moon-release-gate/`.
+- The App total includes the 9,500-caster 256-frame zero-managed-allocation
+ steady-state fixture, warmed CPU/GPU sampling allocation gates, the complete
+ 12-cycle Low/Medium/High/retail/resize/failure/recovery/frame-flight/
+ generation convergence fixture, and independent renderer/context/device
+ recreation. These prove the non-physical performance and lifetime contracts;
+ they do not claim physical frame pacing or image quality.
+- A final path audit finds no source changes under `src/AcDream.Runtime`, no
+ physics or collision changes, and no changes to the retail GLSL sources or
+ tracked retail SPIR-V binaries. Pack-off production integration remains the
+ strict authoritative-path oracle.
+
+The command-level record and evidence boundary are in the
+[Stage 1 automated gate report](../research/2026-08-22-atmospheric-stage1-automated-gate.md).
+
+Recorded pre-moon automated checkpoint (not a current moon-scope completion
+claim):
+
+- The repository-owned fresh-process Release test stage passes
+ **14,880/14,880** tests with zero skips or failures across 14 assemblies.
+ `AcDream.App.Tests` contributes **5,783/5,783**; campaign-focused App cases
+ cover descriptor/asset/SPIR-V
+ validation, pack-off/no-op invariants, atomic asynchronous candidate swaps,
+ runtime fallback, declared settings, Tier-1/Tier-2/Tier-2+ graph execution,
+ all headline caster classes, topology caching, exact animated transforms,
+ Low/Medium/High/Auto policy, diagnostics, and the pack UI.
+- Headless plugin-session tests pass **6/6**, including rejection of a
+ render-pack-only request before its DLL is loaded.
+- The SDK validator suite passes **26/26** and builds/validates the external
+ `AcDream.RenderPacks.NoOp`, `AcDream.RenderPacks.AtmosphericTier2`, and
+ `AcDream.RenderPacks.ShadowsOnlyTier2` samples without App or Vulkan
+ references.
+- The production catalog is revisioned rather than frozen at startup. The same
+ composed controller/UI observes external registration, withdrawal, and
+ corrected re-registration; an active withdrawn pack retires at the next
+ frame boundary, persists retail fallback, releases its asset/context owners,
+ and does not retry the removed registration. Runtime package admission now
+ matches the SDK: exactly one public constructible render-pack entry point and
+ at least one live registration, with transactional rollback for malformed,
+ multiple, internal, zero-registration, and partially failing packages.
+- The retained 9,500-caster warmed-frame fixture performs no second-frame
+ scene-index copy, topology rebuild, sort, or classification and allocates
+ zero managed bytes. Animated-static, live-dynamic, and equipped-child root
+ and part transforms refresh through cached IDs/slots with exact float bits.
+- Render and screenshot diagnostics now publish exact accepted counts for
+ terrain commands, outdoor statics, buildings, animated statics, local
+ players, remote players, non-player creatures, other live dynamics, and
+ equipped children. These labels stop at the authoritative evidence boundary:
+ static DAT publication does not distinguish a tree from other outdoor
+ scenery, and create-object render metadata does not distinguish a hostile
+ monster from a non-hostile NPC creature. Diagnostics therefore report
+ `OutdoorStatics` and `NonPlayerCreatures`; they never infer tree or monster
+ identity from a mesh or ID.
+- The recording-RHI long-cycle gate repeatedly crosses Low, Medium, High, and
+ retail selection; resize; injected candidate failure and explicit recovery;
+ both frame-flight slots; render-generation replacement; and final renderer
+ disposal. Pack resources, pipeline-format leases, texture slots, retained
+ transforms, receiver candidates, and registrations converge exactly. A
+ second fixture proves that device recreation is old-renderer/context/device
+ teardown followed by an independent fresh device and activation generation.
+- Therefore the deterministic lifecycle implementation, recording-RHI
+ convergence, and fresh-device recreation definition are locally closed. The
+ executable connected route and its contract assertions are implemented for
+ select/disable/re-enable, exact resize, authored time/weather changes, and
+ fresh-process recreation, but a contract-tested route is not connected-world
+ evidence; its ACE-backed execution and artifacts remain open below.
+- The complete Release solution, including all three SDK samples, the
+ validator, shader compiler/generated manifest, and repository tools, builds
+ with **0 warnings and 0 errors**. The current managed workspace could not
+ repeat the gate's locked-restore stage because it denies NuGet access to the
+ user-profile `NuGet.Config`; the explicit no-restore build and complete test
+ stage above are current, while locked-restore verification remains a
+ closeout-environment requirement rather than being reported as green here.
+
+The corresponding durable source/test entry points are:
+
+- public contracts and SDK:
+ `src/AcDream.Plugin.Abstractions/Rendering/`, `docs/render-packs/`,
+ `tools/RenderPackValidator/`, `samples/AcDream.RenderPacks.*`, and
+ `tests/AcDream.RenderPackValidator.Tests/`;
+- activation, compatibility, Auto, diagnostics, and built-in graph:
+ `src/AcDream.App/Rendering/Packs/` and
+ `tests/AcDream.App.Tests/Rendering/Packs/`;
+- moving authored-celestial cascades, casters, receivers, and retained topology:
+ `src/AcDream.App/Rendering/DirectionalShadow*.cs`,
+ `src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs`,
+ `src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs`,
+ `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs`, and
+ `tests/AcDream.App.Tests/Rendering/DirectionalShadow*` plus
+ `tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs`;
+- retained Display UI and headless boundary:
+ `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs`,
+ `tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs`, and
+ `tests/AcDream.Headless.Tests/HeadlessPluginSessionTests.cs`.
+
+Machine-local offline/physical evidence currently present under
+`artifacts/atmospheric-rendering/` is gate evidence, but it is not a substitute
+for connected-world or project-owner acceptance:
+
+- `smoke-retail-720p/` records the pack-off `retail/off` path with zero pack
+ resources, casters, cascades, or classification calls;
+- `accept-shadow-morning-200m/`, `accept-shadow-afternoon-200m/`, and
+ `accept-shadow-morning-close/` contain fixed-camera moving-sun shadow
+ screenshots plus metadata for 9,498 resident casters and three cascades;
+ these pre-moon captures do not prove moon alignment or source transitions;
+- `volumetric-valid-camera-500m/` contains a High-preset volumetric diagnostic
+ capture; it is not a performance acceptance row;
+- `matrix-clean-snapshot-dense-linear-v20/` is the current complete AMD Radeon
+ RX 9070 XT physical matrix: **30/30 rows pass** across
+ retail/Low/Medium/High/Auto, 1080p/1440p/4K, and capped/uncapped pacing. The
+ actual worktree's 4,397 source files were copied and hash-verified into an
+ isolated clean snapshot at commit `4876c970`; the Release App product version
+ names that exact commit, source and binary identities match, and tracked
+ status is empty. Its 18 active enhanced rows each own exact 2,048-sample
+ CPU/receiver/GPU windows, 9,498 casters, and zero warmed classification calls.
+ Six rows are retail and six 4K Low/Medium/Auto rows are accepted
+ resource-unavailable fail-safe outcomes;
+- `matrix-final-v16-exact-auto/` and the two `current-low-1080p-*-v20/`
+ captures retain the optimization's predecessor/reference trail; the clean
+ snapshot matrix above supersedes them as current reference-adapter evidence;
+- all six retail rows record zero pack resources, passes, casters, cascades,
+ draws, or dispatches. The six unavailable 4K Low/Medium/Auto rows likewise
+ record zero pack work and pass their strict paired-default framebuffer
+ comparisons instead of rendering a half-enabled graph;
+- `igpu-auto-safe-fallback-v7-paired/` records physical Auto behavior on the
+ integrated **AMD Radeon(TM) Graphics** adapter (Vulkan 1.4.315, driver
+ 2.0.353). After 180 stable Low samples, Auto failed safe with the exact reason
+ `GPU p99 19.308 ms (budget 3.000 ms), CPU p99 0.534 ms (budget 0.500 ms),
+ resident GPU bytes 41648404 (budget 67108864)`. The published state is
+ `retail/off`, has zero pack resources or work, and retains that visible
+ reason. Its comparison against the paired time-matched retail artifact
+ `igpu-retail-current-v6-time-matched/`, using `sky-mask.png`, differs in only
+ **56 / 1,536,000 compared pixels**, a **0.003645833% (0.00365%) sky-masked
+ pixel difference**, below the 0.1% gate. This closes physical weak-adapter
+ safe fallback, not active Low performance on that adapter or the remaining
+ GPU matrix;
+- `compare-retained-transform.json` records the retained-transform image
+ comparison used by the 9,500-caster steady-state gate.
+
+Stage 1 project-owner gate:
+
+- **PASS — accepted by the project owner on 2026-08-22.** The acceptance closes
+ Stage 1's physical-display and desktop-performance stop. It authorizes Stage
+ 2; it is not a substitute for Stage 2's connected scenario, long-lifetime,
+ package lifecycle, additional physical-GPU, or final pack-off/pack-on rows.
+
+Stage 2 and closeout gates, now active:
+
+1. Execute the already-implemented connected graphical route and complete its
+ remaining matrix: moving local/remote players, known monster encounters
+ reported under the authoritative `NonPlayerCreatures` category, and equipped
+ children; landblock publication/demotion; clear, overcast and rain;
+ outdoor/interior/dungeon transitions; portal travel and reconnect. Capture
+ the implemented select/disable/re-enable, resize, authored sun/moon source
+ transitions and weather, and
+ fresh-process renderer/context/device-recreation assertions against a real
+ ACE session, proving exact resource convergence and no stale maps/owners.
+ The local recording/device-recreation semantics are closed; this connected
+ execution and its user-visible evidence are not.
+2. Repeat the now-current clean-source RX 9070 XT
+ pack-off/Low/Medium/High/Auto × 1080p/1440p/4K × capped/uncapped matrix on
+ every other supported physical GPU class. The integrated-AMD Auto-to-retail
+ artifact proves weak-adapter safe fallback only; it does not prove active Low
+ or the complete matrix on that adapter. Automated weak-GPU fixtures, one
+ fallback row, and one high-end reference adapter do not prove the remaining
+ physical rows.
+3. Complete the visual matrix for Tier-1 neutral values and private-view/UI
+ isolation; rays at dawn/noon/dusk, behind-camera and occluded states;
+ foliage cutouts; moving animated shadows under sun and moon; indoor gating;
+ source-transition continuity; temporal pixelation/shimmer; bias/cascade seam
+ review; sun-only volumetric weather/occluder behavior; and pack-off
+ restoration.
+4. Exercise external install/select/update/remove/fail/recover flows in the
+ graphical host, then complete the long lifetime run. Automated SDK and
+ lifecycle fixtures do not replace this connected evidence.
+5. Obtain explicit project-owner acceptance of the final pack-off and pack-on
+ visual/performance matrix before changing this document to shipped.
+
+### Slice 0 — Contract, capability probe, and acdream-default no-op
+
+**Implementation:** complete. The BCL-only v1 ABI, live revisioned plugin
+discovery/catalog, retained Display selection, compatibility/cost summaries,
+strict SDK-equivalent entry admission, asynchronous candidate preparation,
+frame-boundary activation/withdrawal/fallback, registration-scoped no-retry,
+stable diagnostics, no-op sample, and headless exclusion are present and
+automated. The checked-in default-path oracle and RX 9070 XT physical pack-off
+rows pass; connected lifetime evidence and the remaining supported physical GPU
+classes stay open.
+
+Define the versioned BCL-only descriptor/registry, manifest fields, semantic
+bindings, pack discovery, Display selection, diagnostics, and atomic
+activation/fallback transaction. Implement a no-op conformance pack only.
+
+**Acceptance:** `acdream default (retail-faithful)` remains selected on clean and upgraded
+installs; pack discovery allocates no GPU resources; the disabled run has the
+same pass list, pipeline set, draw/dispatch counts, deterministic framebuffer
+digests, and resource ledger as the pre-campaign baseline; malformed,
+unsupported, missing, and shader-invalid fixtures all report one precise
+reason and render acdream's default path without partial resources or retry loops; headless
+hosts load no render assemblies or pack assets.
+
+### Slice 1 — Tier-1 world-colour and filmic stack
+
+**Implementation:** complete. The pack-owned main-world target, bloom chain,
+ACES filmic pass, colour grade, vignette, declared neutral settings, resize
+recreation, and private-viewport/UI placement are implemented and automated.
+The RX 9070 XT 1080p/1440p/4K physical budget rows pass. Project-owner visual
+acceptance and the remaining supported physical GPU classes stay open.
+
+Add the main-world intermediate and implement bloom, ACES filmic tonemapping,
+colour grade, and vignette through the pack API. Supply half/quarter-resolution
+variants and preserve private viewports/UI.
+
+**Acceptance:** every effect can be independently set to its neutral value;
+the preset is deterministic across resize/recreate; UI, paperdoll, portal, and
+appraisal surfaces retain their accepted colours; 1080p/1440p/4K captures show
+no clipping, haloing at the world/UI edge, stale frame, or resource leak; the
+slice meets its preset GPU/VRAM budget.
+
+### Slice 2 — Tier-1 screen-space sun rays
+
+**Implementation:** complete. Authored sun projection, the screen-space
+occlusion mask, declared sun/day-group/weather policy, pre-tonemap ray
+composition, and deterministic disabled gates are implemented and automated.
+The connected dawn/noon/dusk, weather, behind-camera, occlusion, and edge-
+flicker visual matrix remains open.
+
+Project the existing authored sun position, build a screen-space occlusion
+mask, and composite weather-driven crepuscular rays before tonemapping. This
+slice deliberately has no shadow-map dependency.
+
+**Acceptance:** clear dawn/dusk produces visible raking rays, noon makes them
+vanish, overcast/rain mutes them, the sun behind the camera or fully occluded
+produces none, and camera edges do not streak or flicker. The pack descriptor's
+sun-elevation and `activeDayGroup` policy deterministically produces those
+states without a second weather/clock owner. The exact same scene with Tier 1
+disabled returns to the Slice-0 digest from acdream's default
+retail-faithful renderer.
+
+### Slice 3 — Tier-2 moving authored sun-and-moon dynamic shadows
+
+**Implementation:** Stage 1 automated and project-owner live gates complete.
+Camera-relative stabilized cascades,
+outdoor gating, opaque and alpha-cutout casters, terrain/world receivers,
+headline caster membership, exact current animated transforms, bounded
+resident replay, GPU-flight ownership, and cached topology/dynamic-transform
+refresh were present at the sun-only checkpoint. The approved authored
+sun/dominant-moon/secondary-moon resolver and direction-versus-energy handoff
+are now present, and focused plus complete fresh-process automated validation
+pass. The project owner's 2026-08-22 live round accepted source alignment,
+temporal stability, desktop responsiveness, and the final exposure correction.
+Per-class diagnostics cover
+terrain commands, outdoor
+statics, buildings, animated statics, local/remote players, non-player
+creatures, other live dynamics, and equipped children without inventing tree
+or hostile-monster identity. Fixed-camera morning/afternoon artifacts are not
+moon evidence; the live-gate result and its exact boundary are recorded in the
+[Stage 1 live-gate report](../research/2026-08-22-atmospheric-stage1-live-gate.md).
+Stage 2 connected and closeout acceptance is active.
+
+This is the campaign's headline slice. Add camera-relative cascades, opaque
+and alpha-cutout caster variants, animated SSBO transforms, shadow receivers,
+texel stabilization, outdoor gating, and weather/authored-directional-energy
+control. Select the exact rendered direction of the visible above-horizon sun,
+dominant haloed moon, or secondary moon according to the
+[celestial source contract](../research/2026-08-22-dereth-celestial-shadow-sources.md),
+while retaining retail's one `DirColor * DirBright` energy channel. Trees,
+monsters, players, houses/buildings, terrain, and ordinary outdoor statics
+participate through existing scene ownership.
+
+**Acceptance:** in fixed-camera and live dawn/noon/dusk/night captures, shadows
+align with and change direction/length under Dereth's authored sun and selected
+dominant/secondary moon; overlap and no-source transitions are stable and do
+not snap to an unrelated body. Walking players and monsters cast and
+self-shadow from their current animated poses; foliage casts leaf/branch
+cutouts rather than rectangles; houses and procedural trees retain shadows
+through landblock publication/demotion without popping outside the chosen
+cascade transition tolerance; indoor/dungeon captures have no outdoor
+celestial directional shadow; portal/reconnect/device recreation leaves zero
+stale maps or owners. Acne, Peter-panning, cascade seams, distant depth-bias
+leaks, temporal pixelation/shimmer, and desktop performance pass the live user
+gate and subsequent Stage 2 matrix; the dense-Arwic CPU submission and GPU
+budgets pass.
+
+### Slice 4 — Quality scaling and automatic compatibility
+
+**Implementation:** complete. Low/Medium/High declarations, capability and
+memory admission, preset cost summaries, retained Display controls, the
+built-in Automatic checkbox, hysteretic Auto, diagnostics, asynchronous
+off-side candidate preparation, and atomic stable-boundary swaps are present
+and automated. The source-identical clean-snapshot RX 9070 XT matrix passes all
+30 current rows after the Low dense-pose CPU optimization. The integrated-AMD
+physical row proves that persistently over-budget Low returns Auto atomically to
+retail with a visible reason and a paired-retail framebuffer match. Active Low
+on that adapter and additional supported-adapter matrices remain open.
+
+Land the Low/Medium/High presets, memory ceilings, capability-based preset
+availability, stable cascade fitting, resize handling, and optional hysteretic
+Auto selection. Auto may change resolution/range only at a stable frame
+boundary and must expose its current choice.
+
+**Acceptance:** every supported preset retains all headline caster classes;
+weak-hardware fixtures select a valid lower preset or fail safely to acdream's
+default renderer;
+changing preset cannot leak, stall the render thread, invalidate streaming, or
+leave mixed-resolution resources; the quality/performance table is populated
+with measured physical-hardware results.
+
+### Slice 5 — Tier-2+ volumetric shafts
+
+**Implementation:** complete for the recorded sun-only scope. The declared
+volumetric pass reuses directional-shadow depth only when the selected source
+is the authored sun, consumes authored sun/weather/indoor inputs, composites
+before tonemapping, and has independent quality/step settings and automated
+failure gates. The reference-GPU low-sun 2,048-sample enabled/neutral A/B passes
+its incremental cost target. Moon selection does not enable moon shafts. The
+connected occluder/weather visual matrix and additional physical GPU classes
+remain open.
+
+Reuse the directional shadow map for world-space light shafts only while its
+source is the authored sun. Drive density, strength, and colour from authored
+sun/weather inputs and composite before tonemapping. A selected moon produces
+directional shadows but no rays or volumetric shafts.
+
+**Acceptance:** shafts respect terrain, trees, houses, and moving creatures;
+clear low sun is strongest, overcast and indoor scenes are muted/off; disabling
+shafts leaves Tier-2 shadow output unchanged; the incremental cost stays within
+the Tier-2+ budget.
+
+### Slice 6 — Pack SDK and campaign closeout
+
+**Implementation:** SDK deliverables complete and the Stage 1 project-owner
+gate passed on 2026-08-22. The v1
+manifest schema, semantic binding table, compatibility/failure guide,
+validator, built-in pack, and three buildable external samples are present and
+automated. Connected graphical package lifecycle, long-run convergence, the
+remaining physical GPU classes, and project-owner acceptance remain open; the
+30-row RX 9070 XT reference matrix is complete.
+
+Publish the manifest/schema, semantic binding table, sample no-op pack,
+Atmospheric pack, compatibility diagnostics, authoring/validation tool, and
+failure-handling guidance. Run the full automated, connected, physical-display,
+performance, lifetime, portal, and screenshot matrix.
+
+**Acceptance:** a clean external sample builds without App or Vulkan
+references; install/select/update/remove/fail/recover flows work; pack-off
+evidence from acdream's default retail-faithful renderer remains authoritative
+and unchanged; all resource ledgers
+converge after long play, reconnect, portal travel, pack disable, and device
+recreation; the project owner accepts the visual matrix before the campaign is
+declared shipped.
+
+## Performance budget and measurement
+
+The [measured pre-campaign baseline](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#performance-baseline-and-the-binding-constraint)
+is **519.7 FPS with CPU/GPU p50 of 1.869/1.096 ms**, and dense towns are
+CPU-submission-bound. Fullscreen work may occupy currently idle GPU time, but
+it is not treated as free. Shadow cascades must protect the CPU submission
+path.
+
+| Preset | Incremental GPU p50 / p99 at 1080p | Incremental render-CPU p50 / p99 | Pack-owned resident GPU memory |
+|---|---:|---:|---:|
+| Low | ≤ 2.0 / 3.0 ms | ≤ 0.15 / 0.50 ms | ≤ 64 MiB |
+| Medium | ≤ 3.25 / 4.50 ms | ≤ 0.25 / 0.75 ms | ≤ 128 MiB |
+| High | ≤ 4.50 / 6.00 ms | ≤ 0.35 / 1.00 ms | ≤ 256 MiB |
+
+The current physical reference matrix is
+`artifacts/atmospheric-rendering/matrix-clean-snapshot-dense-linear-v20/` on an
+AMD Radeon RX 9070 XT (Vulkan 1.4.349, driver 2.0.395). The source-identical
+isolated commit and Release binary both identify `4876c970`; tracked source
+status is empty. All 30 rows pass. At capped 1080p, the exact 2,048-sample
+windows are:
+
+| Selection | Incremental render-CPU p50 / p99 | Inclusive GPU p50 / p99 | Resident pack memory |
+|---|---:|---:|---:|
+| Low | 0.108 / 0.164 ms | 0.900 / 1.002 ms | 39.719 MiB |
+| Medium | 0.116 / 0.144 ms | 1.001 / 1.023 ms | 73.590 MiB |
+| High | 0.121 / 0.145 ms | 1.240 / 1.273 ms | 113.556 MiB |
+| Auto (settled High) | 0.122 / 0.146 ms | 1.211 / 1.222 ms | 113.556 MiB |
+
+Every uncapped active row also passes. Across capped and uncapped active rows,
+the maximum measured p50/p99 and resident memory are: Low 0.108/0.164 ms CPU,
+1.070/1.080 ms GPU, 60.868 MiB; Medium 0.117/0.194 ms CPU, 1.152/1.163 ms GPU,
+103.583 MiB; High 0.121/0.159 ms CPU, 2.237/2.252 ms GPU, 238.141 MiB; and Auto
+0.122/0.166 ms CPU, 1.482/1.494 ms GPU, 145.856 MiB. All six retail rows record
+zero pack resources/work. At 4K, Low needs 131,302,400 bytes and Medium/initial
+Auto need 198,440,960 bytes, so those six capped/uncapped rows correctly report
+`ResourceUnavailable`, create no pack resources/work, and pass their strict
+paired-default framebuffer comparisons. This closes the current reference
+adapter, not the connected receiver A/B route or other supported/weak physical
+GPU classes.
+
+Tier 2+'s separate low-sun physical A/B is recorded in
+`artifacts/atmospheric-rendering/volumetric-performance-ab-1080p.json`. Both
+runs pin High, 1080p uncapped, clear weather, 16.667° sun elevation, the same
+50 m / 180° / 10° camera, 9,498 casters, four cascades, and exact 2,048-sample
+windows. Enabling one volumetric draw over the neutral-strength run adds
+**0.189 ms GPU p50 / 0.219 ms p99**, **0.009 ms CPU p50 / 0.007 ms p99**, and
+4,147,200 resident bytes. The measured GPU p50 passes the Tier-2+ ≤0.40 ms
+reference target; connected weather/occluder behavior and other adapters still
+require their own rows.
+
+acdream's default path has a stricter gate: zero new enhancement passes,
+images, buffers, submissions, or shader variants, with CPU/GPU deltas within
+the existing run-to-run noise envelope and deterministic reference captures
+unchanged. `NoOpRenderPackProductionIntegrationTests` pins the pre-campaign
+pass list, pipeline set, draw/dispatch tuple, framebuffer SHA-256, and complete
+resource ledger; the matrix's six physical retail rows independently record
+zero pack work at all three resolutions and both pacing modes.
+
+Measurement protocol:
+
+- Use existing asynchronous GPU timestamps and frame diagnostics. Never add a
+ `glFinish`/device-idle-style measurement fence to the frame loop.
+- Runtime Auto compares the declared incremental CPU budget with pack-added
+ target-preparation, shadow, post, and volumetric recording only. The complete
+ enhanced main-world receiver recording is retained separately as an absolute
+ CPU diagnostic; it is not itself an incremental delta. GPU accounting remains
+ conservatively inclusive of the complete enhanced receiver pass and every
+ resolved pack pass, exactly once after asynchronous resolution. Identical
+ pack-off/on runs remain the authority for the final receiver CPU delta and
+ the complete physical incremental A/B result.
+- Run capped and uncapped Release builds; record CPU/GPU p50, p95, and p99,
+ FPS, draw/dispatch submissions, shadow-caster count, cascade draw count,
+ transient/retained GPU bytes, and process working/private memory.
+- Compare pack off, Low, Medium, and High with identical camera paths, render
+ resolution, active day group, authored celestial/time keyframe, entity set,
+ and warmed residency.
+- Cover pinned dense Arwic, a foliage-heavy outdoor route, a building cluster,
+ moving-player/monster combat, dawn/noon/dusk/night plus sun/moon/no-source
+ transitions, clear/overcast/rain, a dungeon, portal travel, resize, reconnect,
+ and a long lifetime run.
+- Measure 1920x1080, 2560x1440, and 3840x2160 on each supported physical GPU
+ class. Report—not hide—unavailable presets.
+- No cascade may rerun CPU PView/portal traversal or issue per-object draws.
+ The pass records CPU classification calls and submission counts so this is
+ an enforced gate, not an architectural hope.
+- Pipeline creation and pack validation occur before atomic activation. Normal
+ play may not hitch on first shadow, weather, caster, or quality use.
+
+## Quality scaling for weak hardware
+
+These are starting envelopes to validate, not asset or world guarantees.
+Distances are metres and are always clamped to current resident world data.
+
+| Setting | Bloom/rays | Directional shadows | Volumetric shafts | Approx. depth-map memory at 32-bit depth |
+|---|---|---|---|---:|
+| Off / acdream default | Off | Off | Off | 0 MiB |
+| Low | Quarter resolution | 2 × 768² cascades, about 72 m maximum reach | Off by default | 4.5 MiB |
+| Medium | Half resolution | 3 × 1536² cascades, about 144 m maximum reach | Quarter resolution | 27 MiB |
+| High | Half/full as measured | 4 × 2048² cascades, about 240 m maximum reach | Half resolution | 64 MiB |
+
+Additional scaling rules:
+
+- Prefer reducing cascade count, shadow resolution, reach, bloom/ray
+ resolution, and sample count before removing a feature's semantic
+ correctness.
+- Preserve alpha-tested foliage and animated transforms at every shadow
+ quality. A cheaper preset may look softer or end sooner; it may not turn a
+ tree into a rectangle or freeze a monster's shadow.
+- Clamp resource dimensions and bytes before allocation. A capability probe
+ that cannot support Low disables the pack and explains why.
+- Preset availability uses the selected Vulkan adapter's probed 2-D image and
+ array-layer limits. Optional pack memory receives at most one eighth of its
+ device-local heap, capped at 256 MiB resident and 512 MiB transient; Auto
+ starts at Low when Medium is unavailable and acdream's default remains the fallback if
+ Low cannot fit.
+- Optional Auto quality uses long hysteresis and stable frame-boundary swaps;
+ it never oscillates cascade layouts frame to frame. If Low stays over its
+ declared runtime GPU/CPU/resident budgets for 180 stable samples, Auto
+ retires the complete pack and returns to acdream's default with the measured
+ and declared limits in the visible failure reason.
+- 4K defaults may choose lower post-process resolution because Tier 1 pays
+ approximately four times the 1080p pixel workload.
+
+## Constraints and traps
+
+This list carries forward every item in the findings' measured
+[shadow-specific constraints](../research/2026-08-21-terrain-and-atmospheric-rendering-findings.md#shadow-specific-constraints)
+and adds the current renderer's ownership and lifecycle boundaries.
+
+1. **The renderer is the shipped pass-based Vulkan RHI.** Design against
+ `IGpuDevice` / `IGpuFrame` / `IGpuPassEncoder` and explicit pass/pipeline
+ descriptions. Do not revive an OpenGL backend or build a parallel renderer.
+2. **The current PView graph is authoritative.** Shadow and atmosphere passes
+ consume its retained scene; they do not introduce a competing visibility
+ owner or change punch/seal, shared-alpha, particle, or private-viewport
+ ordering.
+3. **CPU submission is the limiting dimension.** Reusing the full bounded
+ resident caster set is preferable to CPU-reculling it per cascade. GPU
+ culling is the only planned escalation.
+4. **Alpha-tested foliage needs sampling and discard.** Reusing the existing
+ empty `portal_depth` fragment shader would cast solid tree rectangles.
+5. **Animated casters use the existing N.5 SSBO transforms.** A second pose,
+ animation tick, or entity owner is forbidden.
+6. **Indoors has no outdoor celestial directional shadow.** Dungeon/EnvCell
+ authored ambient and local lighting wins; outdoor sun/moon directional
+ shadows, sun rays, and sun shafts are gated off.
+7. **Cascades are camera-relative and streaming-bounded.** They may not use a
+ fixed Dereth-wide extent, request landblocks, retain retired generations, or
+ draw stale portal destinations.
+8. **Depth bias is specified in meaningful eye/world units.** A constant NDC
+ bias spans approximately `b*d²/near` metres of eye depth at distance and can
+ recreate issue #129's door-shaped holes through hills. Bias, normal offset,
+ cascade projection, near/far fitting, and reversed-depth conventions must be
+ tested together at near and far ranges.
+9. **“Shadow” is an overloaded project term.** Existing `shadow_objects` and
+ `CPhysicsObj::add_shadows_to_cells` are collision registration, not light
+ shadows. New names use `DirectionalShadowMap`, `ShadowCaster`, or
+ `CelestialDirectionalShadow`; never generic `ShadowObject`.
+10. **Authored celestial position, directional energy, and weather are
+ inputs.** Do not invent another celestial clock, light-energy channel,
+ weather state, or hard-coded dawn/noon schedule. The directional map uses
+ the visible above-horizon sun/dominant moon/secondary moon's exact rendered
+ direction but retail's single interpolated `DirColor * DirBright` colour/
+ energy channel, per the
+ [celestial source research](../research/2026-08-22-dereth-celestial-shadow-sources.md).
+ Rays and shafts remain sun-only and use the pack's declared sun-elevation
+ curve and categorical `activeDayGroup` mapping. The decomp evidence proves
+ the category reaches the frame, not an authored numeric ray intensity, so
+ the enhancement mapping must remain explicit pack policy.
+11. **Atmosphere ordering is deliberate.** Rays/shafts composite before
+ tonemapping; retained UI and private viewports remain outside main-world
+ post-processing.
+12. **Transparency remains ordered.** The pack cannot flatten the retail
+ world-alpha queue into an unordered shadow/post pass. Truly translucent
+ surfaces cast no opaque shadow until separately designed.
+13. **Generation and GPU-flight lifetimes remain exact.** Pack images,
+ descriptors, and pipelines retire through existing fences and converge on
+ disable, resize, portal, reconnect, reset, failure, and device recreation.
+14. **4K is a distinct performance row.** Tier-1 effects scale with pixels;
+ passing at 1080p is not evidence for 4K.
+15. **Do not repeat closed investigations.** High-res DAT precedence is not
+ dropping overrides, AC detail textures are colour/alpha rather than normal
+ maps, and the engine's historical DOT3 capability does not turn those
+ assets into PBR inputs; these points are already falsified in the findings.
+16. **Caster evidence must not exceed source identity.** Diagnostics separately
+ count terrain commands, outdoor statics, buildings, animated statics,
+ local/remote players, non-player creatures, other live dynamics, and
+ equipped children. Outdoor statics include trees but have no authoritative
+ tree discriminator; non-player creatures include monsters but have no
+ render-only hostile-monster-versus-NPC discriminator. Visual/connected
+ acceptance must name those limits instead of fabricating narrower counts.
+
+## What this does NOT do
+
+- It does **not** change acdream's default retail-faithful rendering path, its
+ expected output, or its authority in fidelity tests.
+- The Atmospheric shader pack does **not** own or depend on #226 detail
+ texturing or the terrain-normal parity correction. Those remain separate
+ Track A ports even though the project owner authorized their implementation
+ in the same worktree.
+- It does **not** add PBR or fabricate normal, roughness, metalness, or material
+ maps that AC's assets do not contain.
+- It does **not** change terrain vertices, collision triangles, walkability,
+ slope response, physics shadow lists, movement, projectiles, or any Runtime
+ physics/collision owner.
+- It does **not** change gameplay rules/state, network messages or ordering, or
+ any Runtime gameplay/network owner.
+- It does **not** extend view distance, streaming radius, landblock residency,
+ or PView visibility to find more shadow casters.
+- It does **not** add an indoor sun or replace authored EnvCell/local lights.
+- It does **not** turn moon texture brightness or mesh luminosity into another
+ world-light energy channel, and it does not produce moon rays or moon shafts.
+- It does **not** post-process retained UI or silently restyle private
+ paperdoll, appraisal, or portal viewports.
+- It does **not** promise that every pack or quality preset runs on unsupported
+ hardware; compatibility failure is explicit and safely returns to acdream's default renderer.
+- Campaign AR does **not** outrank active M4 gameplay work. The project owner's
+ explicit reprioritization authorizes this campaign without changing M4's
+ milestone priority; #268 + TS-8 are already complete and retired.
+
+## Completion gate
+
+The design, Stage 1 implementation, automated validation, and project-owner
+live gate are complete. Stage 2 and closeout are active. The campaign becomes
+**shipped** only after the subsequent
+connected, full physical-hardware, lifetime, and visual gates above pass and
+the project owner accepts both sides of the final matrix:
+
+- **pack off:** unchanged output, performance, ownership, and lifecycle from
+ acdream's authoritative default retail-faithful renderer; and
+- **pack on:** moving authored sun-and-moon directional shadows from trees,
+ monsters, players, and buildings; sun-only rays/shafts; scalable atmosphere,
+ safe compatibility fallback, measured budgets, and clean long-lived resource
+ convergence.
diff --git a/docs/release-gate.md b/docs/release-gate.md
index 583ec863..4aba7774 100644
--- a/docs/release-gate.md
+++ b/docs/release-gate.md
@@ -14,9 +14,9 @@ in a fresh Release process. It does not retry failures. Tests carrying an
explicit non-hermetic `Lane` trait (`InstalledDat`, `PreparedPackage`, `Live`,
`Manual`, `Timing`, `Windows`, `Linux`, or `SystemFont`), `Purpose=Diagnostic`, or
`Status=KnownFailure` are excluded from the hermetic total and run through
-their owned lane instead. The graph currently contains 44 projects,
-including all 13 maintained .NET tools; data-dependent tools are built but are
-not executed as tests.
+their owned lane instead. The graph currently contains 54 projects,
+including all 17 maintained .NET tools and three render-pack SDK samples;
+data-dependent tools and SDK samples are built but are not executed as tests.
Build and dependency policy is repository-owned:
diff --git a/docs/render-packs/README.md b/docs/render-packs/README.md
new file mode 100644
index 00000000..5e9e4263
--- /dev/null
+++ b/docs/render-packs/README.md
@@ -0,0 +1,227 @@
+# Render-pack SDK v1
+
+**Campaign:** Atmospheric Rendering / Shader Packs
+**Phase id:** **Campaign AR**
+**Contract version:** `RenderPackApi.Current == 1`
+
+Render packs are opt-in, declarative graphics extensions. acdream's current
+retail-faithful renderer is always installed, remains the default and
+authoritative comparison path, and is restored as one complete transaction
+when a selected pack cannot run. A
+pack cannot access Vulkan, renderer internals, gameplay state, world streaming,
+or physics.
+
+The public dependency is only
+`AcDream.Plugin.Abstractions`. Do not reference `AcDream.App`, Silk.NET, or a
+Vulkan binding. Three buildable external samples cover the API:
+
+- [`AcDream.RenderPacks.NoOp`](../../samples/AcDream.RenderPacks.NoOp/) is the
+ smallest discovery and activation conformance pack.
+- [`AcDream.RenderPacks.AtmosphericTier2`](../../samples/AcDream.RenderPacks.AtmosphericTier2/)
+ declares the complete semantic atmospheric executor with deliberately
+ renamed pack-owned IDs, embeds all referenced SPIR-V, and demonstrates
+ moving authored sun-and-moon shadows for terrain, trees, buildings, players, and monsters.
+- [`AcDream.RenderPacks.ShadowsOnlyTier2`](../../samples/AcDream.RenderPacks.ShadowsOnlyTier2/)
+ demonstrates that Tier 2 is composable: it requests the same selected-celestial
+ caster/receiver semantics without Tier-1 post-processing or volumetric
+ shafts.
+
+## Quick start
+
+1. Target `.NET 10` and reference `AcDream.Plugin.Abstractions` with runtime
+ copy disabled. The acdream host supplies that assembly.
+2. Add [`plugin.json`](plugin-manifest-v1.schema.json), include
+ `"kinds": ["renderPack"]`, and copy it beside the built entry DLL.
+3. Expose exactly one public, parameterless `IRenderPackPlugin` entry point.
+4. Construct immutable `RenderPackDescriptor` values and register them from
+ `Register`. Registration must only publish declarations; do not open assets,
+ compile shaders, start threads, or allocate native/GPU resources.
+5. Supply shader bytes lazily through `IRenderPackAssets.OpenRead`. Asset keys
+ are forward-slash relative logical paths: never rooted, backslash-based, or
+ `.`/`..` traversals.
+6. Build and run the SDK validator:
+
+ ```powershell
+ dotnet build samples/AcDream.RenderPacks.NoOp/AcDream.RenderPacks.NoOp.csproj -c Release
+ dotnet run --project tools/RenderPackValidator/AcDream.Tools.RenderPackValidator.csproj -c Release -- samples/AcDream.RenderPacks.NoOp/bin/Release/net10.0
+ ```
+
+ Substitute `AcDream.RenderPacks.AtmosphericTier2` in both paths to validate
+ the complete Tier 2/Tier 2+ example and its embedded shader interfaces.
+
+The validator executes the managed registration entry point. Use it only on a
+pack you trust. It loads no App, RHI, or Vulkan assembly and creates no GPU
+objects. It validates the manifest, v1 declarations, referenced asset keys,
+SPIR-V stage/entry point and complete v1 binary interface, managed
+registration, and duplicate pack IDs. Hardware and driver compatibility remain
+client-side activation checks.
+
+To launch a visible offline preview of acdream with the built-in Atmospheric
+pack and a disposable settings profile:
+
+```powershell
+.\tools\launch-atmospheric-preview.ps1 -Preset High
+```
+
+The preview starts `AcDream.App` with audio disabled, clears inherited
+`ACDREAM_*` live/automation/diagnostic state for that child, and leaves the
+user's normal acdream settings untouched. It records the binary identity,
+selected audio mode, and separate stdout/stderr logs beside the disposable
+profile. To exercise OpenAL explicitly, add `-EnableAudio`.
+
+Vertex and fragment asset keys are independent opaque keys; they do not need
+matching basenames or a host shader-directory stem. On explicit selection the
+client opens each declared stream, validates it, copies the bytes into the
+isolated candidate, and creates shader modules from those immutable blobs.
+The pipeline retains neither the stream nor a path into the plugin directory.
+Each stage must be little-endian, word-aligned SPIR-V no larger than 16 MiB.
+
+Shader-visible pack settings are deliberately capped at 64 declarations. The
+public `PackSettings` binding and value encoding are documented in the
+[`semantic binding table`](semantic-bindings-v1.md#packsettings-set-3-binding-8-256-bytes).
+A persisted user override wins the selected preset override, which wins the
+declaration default. Overrides are stored by stable pack ID plus setting ID;
+the host validates the selected descriptor's kind, invariant numeric grammar,
+range, step, and choice list before supplying the resolved scalars. No renderer
+object is exposed to managed code.
+
+When a pack is selected, the retained Config page appends its declared
+Boolean, bounded Float/Integer, and Choice controls under **Graphics
+Enhancements**. Changing packs replaces only that optional tail; retail's 39
+authored Config rows remain unchanged. Numeric controls snap to declared bounds
+and steps, preset changes retain explicit user overrides, and a pack change
+starts with an empty valid override map for the new stable pack identity.
+
+## Manifest
+
+The authoritative machine-readable schema is
+[`plugin-manifest-v1.schema.json`](plugin-manifest-v1.schema.json).
+
+| Field | Meaning |
+|---|---|
+| `id` | Stable lowercase logical plugin ID. It is persisted and must not be localized or reused. |
+| `displayName` | User-visible plugin name. |
+| `version` | Dotted `System.Version`-compatible package version. |
+| `entryDll` | Safe path beneath the plugin directory to the managed entry DLL. |
+| `apiVersion` | General `PluginApi` version. v1 is `1`; this is distinct from `RenderPackApi`. |
+| `dependencies` | Optional plugin IDs that must load first. |
+| `kinds` | Entry-point kinds. Include `renderPack`; omission means legacy `gameplay` only. A hybrid lists both. |
+
+Install one plugin directory containing this manifest, the entry DLL, its
+private managed dependencies, and declared shader assets. Do not redistribute
+`AcDream.Plugin.Abstractions.dll` in that directory: type identity is shared
+from the host.
+
+## Declaration schema
+
+The C# records in `AcDream.Plugin.Abstractions.Rendering` are the public v1
+declaration schema. `RenderPackShaderAbi` publishes the corresponding numeric
+SPIR-V set, binding, block-size, and capacity constants. They are intentionally
+BCL-only and expose no Vulkan handle.
+
+| Declaration | What the pack supplies | What the host owns |
+|---|---|---|
+| `RenderPackDescriptor` | Identity/version, highest tier, capabilities, resources, passes, replays, variants, presets, settings, atmosphere policy | Validation, candidate creation, activation and fallback |
+| `RenderResourceDeclaration` | Logical ID, portable format, extent, usage, lifetime, estimated bytes | Images/buffers, allocation, barriers, frame-flight retirement |
+| `RenderPassDeclaration` | Fixed hook, shader asset keys, semantic inputs, logical resource reads/writes | Render graph order, descriptor layout, pipeline, command recording |
+| `SceneReplayDeclaration` | One supported replay semantic, caster flags, 1–4 views | Resident caster selection and existing batched submissions |
+| `PipelineVariantDeclaration` | Base pipeline semantic, shader assets, compatible material flags, inputs | Visibility, mesh/material ownership, fixed renderer state |
+| `RenderQualityPreset` | Capability requirements, resource/setting overrides, optional execution hints, and p50/p99 CPU/GPU/VRAM ceilings | Availability, explicit selection and stable-boundary swaps |
+| `RenderSettingDeclaration` | Stable ID, kind, default, bounds/choices | Persistence and conditional Display UI |
+| `AtmospherePolicyDeclaration` | Ordered sun and selected-light elevation curves plus explicit `activeDayGroup` multipliers | Authored Dereth clock, celestial source, day group, weather and indoor state |
+
+IDs use `^[a-z][a-z0-9._-]*$`, are case-insensitively unique within each
+declaration kind, and remain stable across updates. A pack must declare at
+least one quality preset and at most 64 settings. The SDK ceiling is 256 MiB pack-owned resident GPU
+memory, 16 MiB per SPIR-V asset, 16,384 pixels per absolute image dimension,
+256 image layers, and four scene-replay views. A physical device may expose a
+lower ceiling or reject a preset whose mandatory capabilities are absent.
+For API v1 the host admits optional pack memory from one eighth of the selected
+adapter's probed device-local heaps, capped at 256 MiB resident and 512 MiB
+transient multisample storage. Presets remain listed with exact limit reasons.
+Auto requires asynchronous timestamps and uses Low when Medium cannot fit. At
+runtime, Auto alone watches the selected preset's declared inclusive-GPU p99,
+pack-added CPU p99, and resident-GPU budgets. If Low remains over any of those
+budgets for 180 stable samples, the whole pack fails safely to acdream's
+default renderer with the measured and declared limits in the failure reason.
+Explicit Low remains selectable and is not silently disabled by the Auto
+performance policy.
+
+A Tier-2 directional-shadow elevation curve must resolve to exactly zero at
+and below the authored 0-degree horizon. Every declared non-positive point
+must therefore have multiplier `0`; if the curve omits an exact 0-degree
+point, its first positive point must also be `0` so endpoint clamping or
+interpolation cannot manufacture a below-horizon directional shadow. The host still
+owns the independent no-selected-light-energy and indoor gates.
+
+The built-in Atmospheric Low preset preserves the complete directional-shadow
+caster set (terrain, opaque and alpha-cutout world geometry, and both animated
+classes). It reduces cost with two 768 x 768 shadow maps and the ordinary
+six-pass, quarter-resolution separable post chain: sun occlusion, sun rays,
+bloom downsample, horizontal blur, vertical blur, and filmic composition. It
+does not remove a caster class or use the fused post-process hint.
+
+`FusedAtmosphericPostProcess` is an optional external-pack Low-preset execution
+hint for the standard atmospheric graph; it is not built-in Low behavior. An
+opting-in shader pack implements the PackPass ABI below: the host feeds scene
+depth directly to sun rays and asks filmic to evaluate the declared bloom
+extraction and separable filter while composing the final image. This reduces
+command recording without disabling rays, bloom, or filmic composition. The
+host never infers the hint from pack identity; unknown hints, non-Low use, and
+incomplete standard graphs fail validation.
+
+`MultiviewDirectionalShadowCascades` is a separate explicit Low-preset
+execution hint. The opting-in pack must implement three multiview caster
+variants. The host records one layered directional-depth
+pass with view mask `0b11`; `gl_ViewIndex` selects the exact two declared Low
+cascade matrices. Terrain, opaque, and alpha-cutout commands retain their
+ordinary pipeline, transform, cull, and cutout semantics. The preset must require
+`MultiviewDirectionalShadowCascades`; unsupported hardware makes that Low preset
+unavailable before allocation. A zero hint retains ordinary per-cascade passes.
+
+Resources are declared in execution order: a pass cannot read a pack resource
+before an earlier pass writes it, and one pass cannot read and write the same
+resource. `WorldColor`, `SceneDepth`, and other renderer semantics are not pack
+resources and are named in `SemanticInputs` instead. API v1 exposes four
+sampled pass-input slots; buffers, `StructuredData`, and storage resources are
+reserved enum values and are rejected until a public binding contract exists.
+Colour image arrays are likewise reserved; v1 arrays are directional-depth
+maps. One declared pass writes at most one attachment. Only `ToneMap` and
+`AfterToneMapBeforePrivateViewports` may write directly to the host surface
+without naming a pack resource.
+
+Every semantic input implies its capability and the descriptor must list that
+capability as required: world colour, scene depth/normals, authored sun/selected-
+celestial/day/weather facts, animation transforms, and directional maps cannot
+be treated as
+optional after a pass unconditionally declares them.
+
+## Lifecycle and versioning
+
+- Discovery calls `Register` but does not open assets or allocate GPU objects.
+- Installing a pack never selects it. The user selects a pack ID, version, and
+ preset; `acdream default (retail-faithful)` is always available.
+- The client validates every declaration and selected asset, builds the full
+ candidate beside the active retail graph, then swaps at a frame boundary.
+- Dispose the registration handle to withdraw the descriptor. The host also
+ withdraws every handle before unloading its collectible plugin context.
+- `PluginApi` versions the general managed plugin ABI. `RenderPackApi` versions
+ these graphics declarations. Additive enum/record support stays compatible;
+ a breaking contract requires a new render-pack API version and explicit
+ compatibility path.
+- Persisted identity is pack ID + pack version + preset ID, never list index.
+ User-authored setting strings are keyed by the same stable pack identity and
+ stable setting ID, never declaration or menu index.
+
+"Device recreation" in the v1 SDK means full teardown of the old renderer,
+graphics context, and device, followed by construction and capability probing
+of a fresh context/device. Retail is authoritative until a fresh pack candidate
+validates and activates. The SDK does not promise live recovery of a pack or
+renderer after `VK_ERROR_DEVICE_LOST`; that error is terminal to the old device
+lifetime.
+
+The complete campaign contract, budgets, and non-goals remain in
+[`2026-08-21-atmospheric-rendering.md`](../plans/2026-08-21-atmospheric-rendering.md).
+The public shader-facing contracts are the
+[`semantic binding table`](semantic-bindings-v1.md) and
+[`compatibility/failure guide`](compatibility-and-failure-v1.md).
diff --git a/docs/render-packs/compatibility-and-failure-v1.md b/docs/render-packs/compatibility-and-failure-v1.md
new file mode 100644
index 00000000..b4a806d4
--- /dev/null
+++ b/docs/render-packs/compatibility-and-failure-v1.md
@@ -0,0 +1,134 @@
+# Render-pack compatibility and failure handling v1
+
+**Campaign phase id:** **TBD**
+
+Compatibility is a declaration and activation result, not a promise inferred
+from a GPU brand. The client keeps unsupported packs visible with one exact
+reason, refuses to select an unavailable preset, and continues rendering the
+authoritative acdream default (retail-faithful) path.
+
+## Author responsibilities
+
+- Declare every mandatory facility in `RequiredCapabilities`. Use
+ `OptionalCapabilities` only when the pack has a deterministic path that does
+ not need it.
+- Gate each preset independently. Low must remain semantically correct; lower
+ shadow resolution or reach rather than silently removing trees, monsters,
+ players, buildings, alpha cutouts, or animated transforms.
+- Keep resource estimates conservative and below the preset and 256 MiB SDK
+ ceilings. The host clamps dimensions and bytes before allocation. Its
+ optional-pack memory policy admits at most one eighth of the selected
+ adapter's probed device-local heap, capped at 256 MiB resident and 512 MiB
+ transient multisample storage; the lower value wins and is printed in an
+ unavailable-preset reason.
+- Use only declared hooks, semantic inputs, resources, scene replays and base
+ pipeline variants. Pack code receives no arbitrary per-frame callback,
+ command buffer, gameplay owner, RHI object, or Vulkan handle.
+- Treat registration as pure declaration publication. `OpenRead` must return a
+ new readable stream for the exact requested key and must not retain a world
+ generation or borrowed frame state.
+- Ship SPIR-V words little-endian, four-byte aligned, no larger than 16 MiB per
+ asset, and compatible with the published v1 semantic binding ABI. Both the
+ SDK and client validate the binary stage, `main` entry point, descriptor
+ allowlist, exact uniform/push layouts, and read-only storage contract before
+ pipeline creation. Vertex and fragment keys are independent logical keys;
+ the selected candidate copies their blobs and never resolves them through
+ the host shader directory.
+- Declare no more than 64 settings and keep their descriptor order stable. The
+ set-3/binding-8 shader mapping is positional: a persisted user override wins
+ the selected-preset override, which wins the declaration default. Boolean
+ becomes 0/1, Choice becomes its zero-based choice index, numeric strings use
+ invariant culture, and unused or defensively invalid slots are zero. The
+ selected descriptor validates every user string against kind, range, step,
+ and choices before activation.
+
+## Client transaction
+
+1. Discover the manifest and descriptor without opening assets or constructing
+ GPU objects.
+2. Compare required capabilities and preset ceilings with the active physical
+ device's probed `maxImageDimension2D`, `maxImageArrayLayers`, device-local
+ heap bytes, and format/timestamp support. An unsupported pack remains
+ installed and its individual presets remain visible with exact
+ needed-versus-provided reasons.
+3. After explicit selection, validate every referenced asset and shader
+ interface, then build every resource and pipeline in an isolated candidate.
+4. Activate the complete candidate at a stable frame boundary. Until that
+ point retail keeps rendering.
+5. If any step fails, retire the candidate through normal GPU-flight fences,
+ record one stable diagnostic, select `acdream default`, and do not retry
+ that pack again during the session.
+
+No half-enabled graph is valid. A missing bloom shader does not leave shadows
+active; a failed shadow pipeline does not leave a world-colour intermediate or
+stale descriptor alive.
+
+Auto is a logical selector rather than an allocated preset. It requires
+asynchronous GPU timestamps, starts at Medium when Medium fits, otherwise
+starts and stays at Low, and never promotes beyond the highest contiguous
+compatible preset. If Low itself cannot fit, Auto fails safely to Retail and
+reports the Low limit that failed.
+
+Runtime Auto decisions use the active preset's declared inclusive-GPU p99,
+pack-added CPU p99, and resident-GPU budgets. An over-budget Medium selection
+can step down to Low; if Low then remains over any declared limit for 180
+stable samples, the host atomically deactivates the complete pack, reports the
+measured and budget values, and enters `FailedToRetail` without a retry loop.
+This performance fallback is Auto-only. Explicit Low remains selectable when
+only timestamp support is missing and is never silently reduced by removing
+terrain, trees, buildings, monsters, players, alpha cutouts, or animated
+casters. The built-in Low preset instead uses two 768 x 768 shadow maps and an
+unfused six-pass, quarter-resolution separable post chain. An ordinary explicit
+Low validation, candidate-build, or runtime failure still follows the complete
+transactional fallback rules above.
+
+## Diagnostic categories
+
+| Category | Example user-facing reason | Recovery |
+|---|---|---|
+| Manifest | `plugin.json does not declare the renderPack kind` | Correct/reinstall the package |
+| Managed ABI | `apiVersion 2 is unsupported; this SDK supports 1..1` | Use a compatible client or rebuild the pack |
+| Pack ABI | `requires render-pack API 2; this client supports 1..1` | Same as above |
+| Capability | `requires unsupported capability DirectionalShadowMaps` | Select a supported preset/device or retail |
+| Declaration | `Pass 'blur' reads resource 'bloom-a' before it is written` | Correct the descriptor |
+| User setting | `user override 'exposure' has invalid Float value '1,5'` | Correct/remove that stable setting-ID override; retail remains active |
+| Asset | `asset 'bloom.frag.spv' is not valid SPIR-V` | Rebuild/reinstall the pack |
+| Shader interface | `AtmosphericFrame block does not match v1` | Recompile against the v1 binding table |
+| Resource ceiling | `preset 'high' exceeds the pack memory ceiling` | Reduce the preset declaration |
+| Auto performance | `Low remained over its declared performance budget for 180 stable samples` | Complete pack falls back to Retail; select explicit Low only after reviewing the measured limits |
+| Candidate build | `pipeline creation failed for 'directional-shadow-world-cutout'` | Driver/asset diagnosis; retail for this session |
+| Runtime/device | `selected pack failed validation on the fresh device` | Retail on the fresh renderer for this session; no retry loop |
+| Removal/update | `selected pack is no longer installed` | Retail, while retaining the notice |
+
+Diagnostics and screenshot metadata record pack ID, pack version, preset ID,
+compatibility result and fallback reason. Enhanced screenshots are not retail
+parity evidence.
+
+## Update and removal
+
+Pack IDs remain stable across compatible updates; increment `PackVersion` and
+manifest `version` together. A preset or setting ID that persists must keep its
+meaning. User values are persisted as invariant strings under the selected
+pack ID and setting ID, so declaration reordering cannot retarget a value. If
+an update removes or changes a persisted setting incompatibly, selection fails
+atomically to retail with the unknown/invalid override reason instead of
+silently applying it elsewhere. If an update removes the selected preset, the client falls back to a
+compatible declared preset only after explicit policy permits it; otherwise it
+selects retail. Removing or unloading a pack first withdraws registrations,
+then retires GPU-flight resources, then releases the collectible load context.
+
+The built-in atmospheric pack's `sun-shadow-*` setting IDs predate the
+selected-celestial source contract. They remain stable persisted identifiers;
+their current labels and semantics apply to directional shadows from whichever
+authored celestial source the renderer selects.
+
+Reconnect, portal travel, resize and world-generation replacement do not
+re-register managed packs. Renderer-owned resources are recreated or retired
+within the same generation/fence rules; pack assets never own gameplay,
+streaming, collision, or physics lifetime.
+
+For v1, device recreation is not an in-place `VK_ERROR_DEVICE_LOST` recovery
+path. The host tears down the complete old renderer, context, and device, then
+constructs and probes a new context/device. The default retail renderer remains
+authoritative while the selected pack is validated as a fresh candidate; a
+failed candidate stays on retail without an automatic retry loop.
diff --git a/docs/render-packs/plugin-manifest-v1.schema.json b/docs/render-packs/plugin-manifest-v1.schema.json
new file mode 100644
index 00000000..258bfa57
--- /dev/null
+++ b/docs/render-packs/plugin-manifest-v1.schema.json
@@ -0,0 +1,68 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "urn:acdream:render-pack:plugin-manifest:v1",
+ "title": "acdream plugin manifest v1",
+ "description": "Manifest shared by gameplay plugins and declarative render packs. A render pack includes renderPack in kinds.",
+ "type": "object",
+ "required": [
+ "id",
+ "displayName",
+ "version",
+ "entryDll",
+ "apiVersion"
+ ],
+ "properties": {
+ "$schema": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ "pattern": "^[a-z][a-z0-9._-]*$",
+ "description": "Stable plugin identity. It is persisted; do not reuse or localize it."
+ },
+ "displayName": {
+ "type": "string",
+ "minLength": 1
+ },
+ "version": {
+ "type": "string",
+ "pattern": "^[0-9]+(?:\\.[0-9]+){1,3}$",
+ "description": "Dotted System.Version-compatible package version."
+ },
+ "entryDll": {
+ "type": "string",
+ "minLength": 5,
+ "maxLength": 512,
+ "pattern": "^(?![A-Za-z]:)(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\]+\\.[dD][lL][lL]$",
+ "description": "Safe forward-slash relative path to the managed entry assembly."
+ },
+ "apiVersion": {
+ "type": "integer",
+ "const": 1,
+ "description": "AcDream.Plugin.Abstractions PluginApi version, not RenderPackApi."
+ },
+ "dependencies": {
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9._-]*$"
+ },
+ "default": []
+ },
+ "kinds": {
+ "type": "array",
+ "minItems": 1,
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "enum": ["gameplay", "renderPack"]
+ },
+ "default": ["gameplay"],
+ "description": "Omitting kinds preserves legacy gameplay-plugin behavior. A render pack must explicitly include renderPack."
+ }
+ },
+ "additionalProperties": true
+}
diff --git a/docs/render-packs/semantic-bindings-v1.md b/docs/render-packs/semantic-bindings-v1.md
new file mode 100644
index 00000000..2d0b1dd6
--- /dev/null
+++ b/docs/render-packs/semantic-bindings-v1.md
@@ -0,0 +1,381 @@
+# Render-pack shader ABI and semantic bindings v1
+
+**Campaign phase id:** **Campaign AR**
+**Render-pack API:** `1`
+
+This is a SPIR-V binary contract over renderer-owned descriptors. It does not
+expose Vulkan descriptor sets, descriptor handles, images, buffers, samplers,
+command buffers, devices, queues, or fences to managed pack code. A pack only
+declares semantic inputs and supplies SPIR-V; the renderer validates the
+interface and binds immutable frame data.
+
+## Semantic execution and stable identity
+
+`RenderResourceSemantic`, `RenderPassSemantic`,
+`RenderPipelineVariantSemantic`, `RenderQualitySemantic`, and
+`RenderSettingSemantic` select renderer-owned execution roles. Pack-owned IDs
+remain stable persistence, UI, graph-edge, and diagnostic keys; the executor
+never recognizes a role by comparing an ID or asset-name string. Custom
+fullscreen declarations retain `Custom` semantics and are executed from their
+declared hooks and edges.
+
+Every non-custom semantic is unique within its declaration kind. The complete
+Tier-2+ atmospheric executor requires its exact v1 pass/resource/pipeline-
+variant roles, hook order, scene replay, and graph edges. A Tier-2 pack may
+instead declare the directional-shadow component plus the technical custom
+`WorldColor` tone-map copy needed to present the HDR world. That profile still
+requires the exact shadow depth resource/pass, five caster/receiver variants,
+headline-caster replay, capabilities, settings, and elevation policy; it does
+not require bloom, rays, grading, vignette, or volumetric shafts. Any other
+partial or malformed semantic graph fails validation even when all pack-owned
+IDs remain syntactically valid.
+
+Directional-shadow declarations use
+`SelectedCelestialDirectionalLight` together with the required
+`AuthoredCelestialDirectionalLight` capability and the descriptor's
+`DirectionalShadowLightElevationResponse`. `SunDirection`,
+`SunElevationResponse`, and `VolumetricShaftSunElevationResponse` remain the
+separate sun-specific atmosphere contract for rays and shafts.
+The public v1 point record remains named `SunElevationResponsePoint` for ABI
+compatibility; points stored in `DirectionalShadowLightElevationResponse` are
+interpreted against the selected celestial light's elevation.
+
+## Fixed descriptor ownership
+
+| Set | Binding | Shader declaration | Owner and use |
+|---:|---:|---|---|
+| 3 | 5 | `AtmosphericFrame` std140 uniform block | Renderer-owned camera/reconstruction, authored sun/day/weather and frame facts |
+| 3 | 6 | `DirectionalShadow` std140 uniform block | Renderer-owned cascade matrices, splits, shadow texture slot, shadow policy, and selected authored celestial direction |
+| 3 | 7 | `PackPass` std140 uniform block | Renderer-resolved pass resource slots, output facts and pass-local parameters |
+| 3 | 8 | `PackSettings` std140 uniform block | Renderer-resolved declaration-order scalar values for the selected preset |
+| 2 | 0 | `sampler2DArray uTextures[]` combined-image-sampler array | Existing global sampled-texture table; index with host-supplied slot IDs and `nonuniformEXT` |
+
+Set 0 remains the renderer's existing storage-buffer set. A declared base
+pipeline variant inherits the exact renderer pipeline ABI it specializes; it
+does not gain arbitrary set-0 storage access. In particular,
+`ShadowCasterTransforms` reuses the renderer's existing per-instance transform
+publication rather than publishing a second animation pose.
+
+Set 1 remains the current retail uniform layout at bindings 1–4. Pack shaders
+must not redeclare or alias it. Set 3 is strictly opt-in: retail pipeline
+layouts contain only sets 0–2, and the host creates no set-3 Vulkan object
+until a validated pack pipeline is activated. Bindings other than those in the
+table are reserved and validation rejects them.
+
+## SPIR-V interface validation
+
+Candidate activation and the standalone authoring validator inspect the
+actual SPIR-V binary before any shader module or pipeline is created. Each
+asset must expose exactly the declared vertex or fragment stage with entry
+point `main`. A fullscreen pass may declare only the sampled table at set 2,
+binding 0 when its declaration supplies a sampled semantic/resource input,
+plus the role-appropriate set-3 blocks. It may not access renderer-private set
+0 or retail set 1. A retained-scene pipeline variant may use only the base
+set-0/set-1 bindings documented for that exact semantic role, plus its allowed
+set-2/set-3 bindings.
+
+Validation checks descriptor type and count, all set-3 uniform-block member
+types, offsets, strides, and total shapes, and any declared push block against
+the exact 96-byte retail layout below. Renderer storage buffers inherited by a
+variant must be read-only; storage images, arbitrary storage descriptors, and
+`OpImageWrite` are forbidden. An absent, malformed, aliased, writable, or
+undeclared interface rejects the whole candidate atomically to retail with a
+specific reason. Validation never exposes or accepts a Vulkan handle.
+
+The binary member layout is validated against matching host structs. The
+checked-in shared render-pack GLSL includes are the byte-offset SSOT; authors
+include those definitions rather than maintaining a private copy. The tables
+below state the same values for review and tool diagnostics.
+
+### `AtmosphericFrame` — set 3, binding 5, 160 bytes
+
+```glsl
+layout(std140, set = 3, binding = 5) uniform AtmosphericFrame {
+ vec4 uAtmosphereSunScreen; // @0: uv.xy, resolved ray strength, elevation degrees
+ vec4 uAtmosphereSunColor; // @16: linear rgb, combined ray-policy multiplier
+ vec4 uAtmosphereViewport; // @32: width, height, 1/width, 1/height
+ vec4 uAtmosphereWeather; // @48: WeatherKind numeric, intensity, delta seconds, outdoor 0/1
+ vec4 uAtmosphereSunDirection; // @64: surface-to-sun xyz, authored direction brightness
+ vec4 uAtmospherePolicy; // @80: day group, group factor, shadow factor, shaft factor
+ mat4 uAtmosphereInverseViewProjection; // @96
+};
+```
+
+`uAtmosphereSunScreen.xy` uses normalized main-world viewport coordinates. The two
+strength fields are host-evaluated authored/policy facts; they do not create a
+second sky or weather owner. `uAtmosphereWeather.x` is numerically integral and must be
+interpreted with this v1 table, not guessed from colour or time:
+
+| Numeric value | Weather kind |
+|---:|---|
+| 0 | Clear |
+| 1 | Overcast |
+| 2 | Rain |
+| 3 | Snow |
+| 4 | Storm |
+
+All other values are reserved. `uAtmosphereWeather.y` is the transition
+intensity in the inclusive range 0–1.
+
+`uAtmosphereSunDirection.xyz` is normalized and points from a lit surface
+toward the authored sun. `uAtmosphereSunScreen.z` is the resolved visible ray
+strength; `uAtmosphereSunColor.w` is the combined ray elevation/day-group/
+weather policy multiplier before a pass's own declared setting. In
+`uAtmospherePolicy`, `.x` is the numerically integral active day group, `.y` is
+that group's declared multiplier, `.z` is the declared directional-shadow
+elevation factor, and `.w` is the declared volumetric-shaft elevation factor.
+Shadow curves interpolate in sine-of-elevation space; shaft curves use
+smoothstep interpolation in elevation-degree space. These are exact values
+from the selected pack's `AtmospherePolicyDeclaration`, not built-in fallback
+curves. An accepted directional-shadow curve resolves to exactly zero at and
+below the authored 0-degree horizon; non-positive points must be zero, and a
+curve without an exact 0-degree point must make its first positive point zero.
+`uAtmosphereInverseViewProjection` reconstructs main-world positions
+from scene depth and the normalized viewport coordinates. Matrix convention
+and depth range match the shared push-block `viewProjection`.
+
+### `PackPass` — set 3, binding 7, 64 bytes
+
+```glsl
+layout(std140, set = 3, binding = 7) uniform PackPass {
+ vec4 uPackParams0; // @0
+ vec4 uPackParams1; // @16
+ vec4 uPackParams2; // @32
+ vec4 uPackParams3; // @48
+};
+```
+
+The active semantic pass defines these sixteen scalar meanings. Unused values
+are zero. A pass cannot reinterpret values owned by a different pass. Sampled
+pass inputs use logical `textureIndexA` through `textureIndexD` in the shared push block;
+binding 7 carries scalar/vector policy and filter parameters, not descriptors.
+
+The optional external-pack Low-preset `FusedAtmosphericPostProcess` execution
+hint uses these fixed values. The built-in Low preset does not declare it:
+
+| Semantic pass | Values |
+|---|---|
+| `SunRays` | `uPackParams1 = (1, logicalMaskWidth, logicalMaskHeight, 0)`; input A is scene depth and the shader reconstructs the declared RGBA8 sun mask before radial integration |
+| `BloomDownsample` / both `BloomBlur` passes | Declared for the standard graph but not recorded for this preset; their threshold, knee, strength, offsets, and weights remain authoritative inputs to filmic |
+| `FilmicComposite` | `uPackParams1.z = 1`; `uPackParams2 = (bloomStrength, threshold, knee, hasVolumetric)`, `uPackParams3.xy = logicalBloomTexelStep`; inputs A/B/C are world color, sun rays, and optional volumetric shafts, and filmic evaluates the full separable bloom kernel before composition |
+
+Zero flags retain the ordinary six-pass atmospheric graph. The built-in Low
+preset uses that zero-flag path; Medium, High, and external packs that do not
+opt in never use this fused ABI.
+
+### Multiview directional-shadow cascades
+
+The optional Low-preset `MultiviewDirectionalShadowCascades` execution
+hint requires the three `*MultiviewDirectionalShadowCaster` variants and the
+matching capability in the Low preset. The host begins one layered depth pass
+with `viewMask = 0b11`; each vertex shader indexes `uShadowWorldToClip` with
+`gl_ViewIndex`. Commands retain exact order, `BaseInstance`, the shared N.5
+world-transform arena, texture index/layer, fixed-function culling, alpha cutoff
+`0.05`, and both fitted cascade matrices. With no hint the host records the
+ordinary one-pass-per-cascade path. Unsupported hardware makes the hinted preset
+unavailable rather than silently selecting an over-budget execution form.
+
+### `DirectionalShadow` — set 3, binding 6, 336 bytes
+
+```glsl
+layout(std140, set = 3, binding = 6) uniform DirectionalShadow {
+ mat4 uShadowWorldToClip[4]; // @0, @64, @128, @192
+ vec4 uShadowSplitFarMeters; // @256
+ vec4 uShadowControl; // @272
+ vec4 uShadowBiasMeters; // @288
+ uvec4 uShadowTextureAndFlags; // @304
+ vec4 uShadowLightDirectionAndSource; // @320
+} directionalShadow;
+```
+
+Field meanings are fixed:
+
+| Field/component | Meaning |
+|---|---|
+| `uShadowWorldToClip[0..3]` | Texel-stabilized world-to-shadow-clip matrices; only the first `cascadeCount` entries are active |
+| `uShadowSplitFarMeters` | Far distance of cascades 0–3 in camera-eye metres |
+| `uShadowControl.x` | Directional shadow strength |
+| `uShadowControl.y` | Filter softness |
+| `uShadowControl.z` | Maximum shadow reach in metres, clamped to resident data |
+| `uShadowControl.w` | Cascade blend width in metres |
+| `uShadowBiasMeters.x` | Constant receiver/caster bias in world metres |
+| `uShadowBiasMeters.y` | Slope-scaled bias in world metres |
+| `uShadowBiasMeters.z` | Normal offset in world metres |
+| `uShadowBiasMeters.w` | Caster depth padding in world metres |
+| `uShadowTextureAndFlags.x` | Directional-depth array slot in set 2 |
+| `uShadowTextureAndFlags.y` | Active cascade count, 1–4 |
+| `uShadowTextureAndFlags.z` | Square shadow-map resolution in pixels |
+| `uShadowTextureAndFlags.w` | Flags; bit 0 means directional shadows are valid/enabled; bits 8–11 carry the fixed receiver PCF radius; remaining v1 bits are reserved and zero |
+| `uShadowLightDirectionAndSource.xyz` | Normalized direction from a lit surface toward the one authored celestial body selected for this shadow frame |
+| `uShadowLightDirectionAndSource.w` | Numerically integral selected-source kind from the table below |
+
+Selected-source kinds are stable ABI values:
+
+| Numeric value | Selected celestial source |
+|---:|---|
+| 0 | None / unavailable; the enabled flag must be clear |
+| 1 | Authored sun |
+| 2 | Dominant authored Dereth moon |
+| 3 | Secondary authored Dereth moon |
+
+All other values are reserved. The selected source is a renderer-owned fact
+resolved from the current immutable Dereth sky frame. A pack does not identify
+sky objects by private index or create a second celestial clock. The host still
+publishes only one directional-depth array: sun and moons are alternative
+sources for the same bounded cascade work, not simultaneous shadow maps.
+
+When producing a cascade, its zero-based cascade index uses the existing
+`uRenderPass` push-constant member. Consumer shaders choose a cascade from the
+eye-space distance and split values. No available selected celestial source, a
+selected source at or below its accepted horizon, no authored directional
+energy, indoors, and portal/login cover clear the enabled bit; shaders must not
+sample stale maps when it is zero. Binding 5 remains sun-specific for sun rays
+and volumetric shafts. Such passes must not substitute the selected moon
+direction for `uAtmosphereSunDirection`; when binding 6 selects a moon they
+treat its shadow map as unrelated to sun-shaft occlusion.
+
+## Shared push constants
+
+Every pipeline retains retail's exact shared 96-byte push-constant range. API
+v1 does not enlarge it:
+
+```glsl
+layout(push_constant) uniform AcdreamPushBlock {
+ mat4 viewProjection; // byte 0
+ int drawIdOffset; // byte 64
+ int lightingMode; // byte 68
+ int renderPass; // byte 72
+ int lightDebug; // byte 76
+ uint textureIndexA; // byte 80
+ uint textureIndexB; // byte 84
+ float paramA; // byte 88
+ float paramB; // byte 92
+} acdreamPush;
+```
+
+Pack shader source may use the logical aliases `uTextureIndexC` and
+`uTextureIndexD`. The host stores their uint slot bits in the existing
+`paramA` and `paramB` words, and the shared Vulkan preamble exposes them as
+`floatBitsToUint(acdreamPush.paramA)` and
+`floatBitsToUint(acdreamPush.paramB)`. This is an exact bit reinterpretation,
+not numeric float conversion. Pack pass scalar/vector parameters belong in
+binding 7, so the two spare retail words are available for these input slots.
+
+Do not reshape existing fields. A future additive growth requires matching
+host/shader layout tests and must remain inside the 128-byte Vulkan guarantee.
+A pack pass receives scalar/vector values through binding 7. Base pipeline
+variants use `viewProjection`, draw offset, texture slots and existing mode
+fields according to that base pipeline's contract.
+
+## PackSettings (set 3, binding 8, 256 bytes)
+
+`RenderSettingDeclaration` values use one fixed declaration-order block:
+
+```glsl
+layout(std140, set = 3, binding = 8) uniform PackSettings {
+ vec4 uPackSettings[16];
+};
+```
+
+The descriptor may declare at most 64 settings, which the authoring validator
+and graphical host both enforce. Setting index `i` is its zero-based position in
+`RenderPackDescriptor.Settings`; it maps to
+`uPackSettings[i / 4][i % 4]`. Declaration order is therefore shader ABI and
+must remain stable within a compatible pack version. The resolved value is one
+IEEE-754 float: Boolean is `0.0` or `1.0`, Choice is its zero-based index in
+`Choices`, and Integer/Float parse with invariant culture before float
+conversion. Integer values are limited to the exactly representable inclusive
+range -16,777,216..16,777,216; Float values must remain finite in float32. A
+matching persisted user override wins a selected-preset `SettingOverride`,
+which wins the declaration default. User values remain invariant strings keyed
+by stable pack ID plus setting ID; before candidate activation the host rejects
+unknown IDs and values that fail kind, range, step, or choice validation. That
+failure retires the complete candidate to retail with an exact reason. The host
+zero-initializes the complete block, so unused slots and any value that fails
+defensive parsing are `0.0`; ordinary descriptor/selection validation prevents
+invalid values from reaching the bind.
+
+Binding 7 remains pass-local host dynamics and filter parameters. It must not
+be overloaded with pack settings: doing so would make the same setting occupy
+different components in different passes and would prevent one stable public
+mapping. This fixed block is the complete v1 contract because it adds no
+descriptor handles, storage buffers, per-frame managed callbacks, or
+pass-specific setting schemas.
+
+## Logical semantic table
+
+The descriptor must list every semantic the shader reads. Listing a semantic
+does not guarantee device support; the corresponding `RenderCapability` must
+also be required when the table says so.
+
+| `RenderSemanticInput` | Logical shader value | Source / lifetime | Capability prerequisite |
+|---|---|---|---|
+| `WorldColor` | Sampled main-world colour slot, excluding retained UI and private viewports | `textureIndexA-D` into set 2; current main-world frame | `MainWorldColorIntermediate` |
+| `SceneDepth` | Sampled main-world depth slot plus reconstruction matrix | `textureIndexA-D` and binding 5; current main-world frame | `SceneDepthSampling` |
+| `SceneNormals` | Sampled main-world normal slot | `textureIndexA-D` into set 2; current main-world frame | `SceneNormalSampling` |
+| `SunDirection` | Normalized authored surface-to-sun direction; no second clock | Binding 5 `uAtmosphereSunDirection`; current immutable world frame | `AuthoredSunDirection` |
+| `SelectedCelestialDirectionalLight` | Normalized direction and stable source kind for the one authored sun/moon selected to cast this frame's directional shadows | Binding 6 `uShadowLightDirectionAndSource`; current immutable world/sky frame | `AuthoredCelestialDirectionalLight` |
+| `SunScreenPosition` | Authored sun projected for the main-world viewport, plus valid/in-front state | Binding 5; current camera/world frame | `AuthoredSunScreenPosition` |
+| `ActiveDayGroup` | AC's categorical group plus descriptor-declared group/elevation multipliers | Binding 5 `uAtmospherePolicy`; current Runtime environment frame | `AuthoredWeather` |
+| `Weather` | Numeric `WeatherKind`, intensity and outdoor state | Binding 5 `uAtmosphereWeather`; current Runtime environment frame | `AuthoredWeather` |
+| `CameraMatrices` | Main-world view-projection and inverse, or directional cascade transforms required by the hook | Push `viewProjection` + binding 5 inverse; binding 6 for cascades | None beyond the hook's feature capability |
+| `ShadowCasterTransforms` | Exact existing per-instance/per-part transforms for retained eligible casters | Inherited base-pipeline set-0 ABI; current retained scene | `AnimatedCasterTransforms` |
+| `DirectionalShadowMaps` | Directional-depth table slot, active cascade count, matrices, splits and valid state | Binding 6 plus set 2; current outdoor shadow frame | `DirectionalShadowMaps` |
+| `FrameTime` | Monotonic frame delta in seconds; never a gameplay clock | Binding 5 `uAtmosphereWeather.z`; current frame | None |
+
+Pack-declared `ResourceReads` are resolved deterministically to the pass input
+slots supplied by the host; v1 exposes up to four sampled inputs through
+`textureIndexA-D`. `DirectionalShadowMaps` uses the binding-6 texture slot and
+does not consume A-D. Slot assignment first walks sampled-image entries in
+`SemanticInputs` declaration order (`WorldColor`, `SceneDepth`, and
+`SceneNormals` when present), then sampled `ResourceReads` declaration order.
+The first input receives A, then B, C, and D. Duplicate inputs are invalid. The
+pack never chooses a global texture-table index. Resource IDs describe graph
+edges, not binding numbers. `ResourceWrites` are render targets chosen by the
+host and are not simultaneously sampled by the same pass.
+
+`RenderResourceKind.Buffer`, `RenderFormatClass.StructuredData`, and
+`RenderResourceUsage.Storage` are reserved for an additive future contract.
+They have no public v1 descriptor binding and the v1 authoring validator
+rejects them instead of accepting an unbindable graph. V1 image arrays are
+reserved for `DirectionalDepth`; ordinary colour intermediates are `Image2D`.
+Each pass writes at most one declared attachment. A zero-write pass is valid
+only at `ToneMap` or `AfterToneMapBeforePrivateViewports`, where the host-owned
+main-world target is implicit.
+
+## Texture-table sampling
+
+Vulkan pack SPIR-V targets the same global table as retail shaders:
+
+```glsl
+#extension GL_EXT_nonuniform_qualifier : require
+layout(set = 2, binding = 0) uniform sampler2DArray uTextures[];
+
+vec4 sample2D(uint slot, vec2 uv) {
+ return texture(uTextures[nonuniformEXT(slot)], vec3(uv, 0.0));
+}
+```
+
+An ordinary 2-D texture is a one-layer array at layer zero. Array resources use
+their declared layer. Directional depth may be sampled as ordinary depth and
+compared/filtered in shader according to the declared shadow policy; the pack
+does not create a private sampler or descriptor. `0xFFFFFFFFu` is the
+unassigned texture-slot sentinel and must be checked before sampling an
+optional input.
+
+## Hooks and availability
+
+| `RenderPassHook` | Inputs valid at the hook | Output boundary |
+|---|---|---|
+| `ShadowDepthBeforeWorld` | Camera/selected celestial source/environment, cascade block, retained caster transforms | Declared directional-depth resources only; outdoor gating applies |
+| `AtmosphereBeforeToneMap` | HDR world colour when required, depth/normals when required, authored atmosphere and earlier declared resources | HDR pack intermediates; rays/shafts composite here |
+| `ToneMap` | HDR world colour and earlier atmosphere resources | Main-world display colour |
+| `AfterToneMapBeforePrivateViewports` | Tonemapped main-world colour and declared resources | Main world only; private viewports and retained UI remain outside |
+
+Pass order is the descriptor order within a hook and never moves backward
+through this table. Discovery does not bind any of these blocks. Bindings exist
+only in the fully validated candidate and retire through normal frame-flight
+fences on fallback, resize, portal, reconnect, unload, or the teardown phase of
+device recreation. Recreation means a complete renderer/context/device
+teardown followed by a fresh context/device; it is not live recovery from
+`VK_ERROR_DEVICE_LOST`.
diff --git a/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md b/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md
new file mode 100644
index 00000000..b8f1cb98
--- /dev/null
+++ b/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md
@@ -0,0 +1,210 @@
+# Retail building and environment detail texturing — #226 port note
+
+**Date:** 2026-08-21
+**Status:** IMPLEMENTED + CONNECTED-VISUAL-VERIFIED
+
+This note is the implementation handoff requested by #226. The measurements
+below come from the already-completed
+[`2026-08-21 terrain and atmospheric rendering findings`](2026-08-21-terrain-and-atmospheric-rendering-findings.md),
+especially §§1–2. They are cited here rather than re-derived. The reachable
+preference/caller chain is also recorded in
+[`2026-07-10 detail texturing`](2026-07-10-detail-texturing.md).
+The A2 terrain-normal verdict and A3 subdivision disposition are recorded in
+the companion [`Terrain fidelity Track A report`](2026-08-21-terrain-fidelity-track-a-report.md).
+
+## User-visible target and reachable caller trace
+
+The issue title used to say “landscape,” but the Sept-2013 retail client does
+not expose live landscape detail through this option:
+
+1. The Options checkbox writes `RenderPrefs.EnvironmentDetailTextures`.
+2. `Render::UpdateFromPreferences` (`0x0054d850`) explicitly changes
+ `Current_Render_LandscapeDetailTextures` to `0` and calls
+ `SmartBox::SetDetailTexturing(smartbox, 0, environmentEnabled)` at
+ `0x0054d9f3`.
+3. `SmartBox::SetDetailTexturing` (`0x00451df0`) forwards
+ `LScape::SetDetailTexturing(lscape, landscape, enabled, enabled, 0)`.
+4. `LScape::ChangeRegion` (`0x00506cb0`) independently installs the same
+ category state: `(0, EnvDetail, EnvDetail, 0)`.
+
+The four positions are landscape (0), building (1), environment/EnvCell (2),
+and ordinary object (3). The only reachable named-retail preference caller
+forces categories 0 and 3 off. `DrawPartCell` also clears ordinary-object
+detail. Therefore #226's scene target is **building shells and interior
+EnvCell geometry**, not outdoor terrain, scenery, creatures, or players. This
+also explains why acdream's existing checkbox is labelled “Building Detail
+Textures.”
+
+## Authored source, size, and sampling
+
+Detail data is reached through
+`Region(0x13000000).TerrainInfo.LandSurfaces.TexMerge.TerrainDesc[category]`:
+
+```text
+SurfaceTextureId = TerrainDesc[category].TerrainTex.DetailTextureId
+tiling = TerrainDesc[category].TerrainTex.DetailTexTiling
+RenderSurfaceId = SurfaceTexture(SurfaceTextureId).Textures[0]
+rgba = decode(RenderSurface(RenderSurfaceId), level 0)
+```
+
+For Dereth, enabled categories 1 and 2 both resolve
+`0x05001787 -> 0x06006D58`, a **256 x 256 A8R8G8B8** texture, with tiling
+**4**. The complete measured Dereth population is three textures across 33
+entries: `0x050012AF -> 0x060037D2` (64 x 64, 29 entries),
+`0x05001786 -> 0x06006D57` (256 x 256, two), and the enabled-category texture
+above (256 x 256, two). See the findings §2 table.
+
+Retail uses wrap addressing in U and V and linear minification,
+magnification, and mip filtering. Detail UV is `baseUv * tiling`. The port
+therefore uploads each live category as a one-layer RGBA8 texture array with a
+full mip chain and the existing repeat/linear world sampler.
+
+## Exact two-pass pseudocode
+
+Retail has both a single-pass multitexture route and a two-pass fallback. The
+Vulkan port uses the fallback because it preserves the already-accepted base
+pass byte-for-byte and expresses the retail framebuffer blend directly.
+
+```text
+enabled = DisplaySettings.BuildingDetailTextures // existing setting; no new option
+
+buildingDetail = load_category(TerrainDesc[1])
+environmentDetail = load_category(TerrainDesc[2])
+
+for each retail built-mesh material subset:
+ draw_existing_base_subset_unchanged()
+
+ if enabled and subset belongs to a building or EnvCell:
+ draw the same subset with its category detail texture
+ // transparent/additive/inverse-alpha: detail follows its base
+ // immediately, before the next delayed-alpha subset
+
+for each replayed fragment:
+ reject ordinary objects / landscape / scenery
+ accept opaque, ClipMap, alpha, additive and inverse-alpha subsets
+
+ zMetres = positive_view_space_depth_in_metres
+ fade = clamp((50 m - zMetres) / (50 m - 10 m), 0, 1)
+ // full through 10 m; linear 10–50 m; exactly zero at/after 50 m
+
+ detail = sample(categoryTexture, baseUv * categoryTiling)
+ src.rgb = detail.rgb * fade
+ src.a = detail.a * fade
+
+ depth test = EQUAL opaque; LESS_OR_EQUAL transparent
+ depth write = preserve base class // ON opaque; OFF transparent
+ alpha-to-coverage = OFF // detail alpha is blend input
+ blend op = ADD
+ source = DEST_COLOR
+ destination = ONE_MINUS_SRC_ALPHA
+```
+
+Scaling **both** RGB and alpha by the fade is load-bearing. The resulting
+framebuffer multiplier is:
+
+```text
+factor = 1 + fade * (detail.rgb - detail.a)
+```
+
+Thus fade zero is an exact no-op and the full-strength neutral point is
+`detail.rgb == detail.a` channel-by-channel. It is not 0.5 gray.
+
+### Built-mesh material coverage and order
+
+The land-polygon `SurfaceType & 4` exclusion does **not** narrow this built-mesh
+port. Named-retail `RenderDeviceD3D::DrawEnvCell` (`0x0059f170`) and
+`DrawBuilding` (`0x0059f2a0`) install `curr_detail_surface` before calling
+`D3DPolyRender::DrawMesh`. `DrawMesh` (`0x0059d4a0`) bypasses delayed-alpha
+queuing while that surface is installed and passes detail enabled to
+`RenderMeshSubset` (`0x0059ca10`) for each material subset. The fallback then
+redraws that exact subset with the detail surface before proceeding. Therefore
+ClipMap, straight-alpha, additive, and inverse-alpha built-mesh subsets are
+included alongside plain opaque ones.
+
+The Vulkan port first filters the opaque object command stream to coalesced
+runs containing at least one category-1 building instance; nonbuilding-only
+commands never reach the detail pipeline. A mixed instanced command remains in
+the replay and `mesh_detail` rejects its ordinary instances individually. The
+accepted opaque path stays batched, while transparent subsets preserve
+immediate base/detail adjacency. Their separate detail pipeline
+keeps depth writes disabled, matching the base subset's accepted depth
+contract. This prevents another shell/object contribution from being
+composited between the base and its detail contribution.
+
+Opaque detail uses depth compare **EQUAL** against the exact geometry just
+written by the base pass. Vulkan depth is per sample, so on MSAA ClipMap edges
+the detail affects only samples whose base alpha-to-coverage mask wrote depth.
+The detail pipeline itself deliberately keeps alpha-to-coverage off: detail
+alpha controls `ONE_MINUS_SRC_ALPHA` in the retail blend and is not the base
+coverage mask. Transparent bases do not write depth, so their adjacent detail
+uses `LESS_OR_EQUAL` with depth writes still off.
+
+One bounded ordering seam is explicit: retail bypasses its delayed-alpha queue
+while `curr_detail_surface` is installed, whereas acdream retains its already-
+authoritative shared alpha-queue order and inserts the detail draw immediately
+after the corresponding base draw. This does not narrow material coverage or
+change base coverage/blend/depth behavior; it avoids making the checkbox
+reorder the default transparent scene. The connected acceptance matrix must
+still exercise overlapping transparent building/EnvCell surfaces.
+
+## Brightening decision
+
+The port keeps retail's `DEST_COLOR + ONE_MINUS_SRC_ALPHA` verbatim. The
+findings measured factors **1.177**, **1.204**, and **1.033** for the three
+Dereth textures; the live Dereth building/environment category uses the
+1.033-factor texture. That slight brightening is intentional retail parity,
+not an acceptance failure.
+
+Changing the destination factor to `ZERO` would be a visual correction rather
+than a port. Exposing both behaviors behind one retail checkbox would also
+make the option ambiguous. If a roughening-corrected material is wanted later,
+it belongs as an explicitly named opt-in enhancement/shader-pack policy with a
+registered divergence. It is not part of #226.
+
+## What the reverted experiment got wrong
+
+The experiment described by `c25d6186` was never committed as renderer code;
+it was reverted from the worktree with `git checkout`. Its useful failure
+record remains in that issue commit. It differed from the verified contract in
+five material ways:
+
+- It targeted outdoor landscape, while the live setting enables building and
+ environment categories and forces landscape off.
+- It built a per-terrain-type texture array, while retail selects one
+ category-scoped surface and scalar tiling for each draw path.
+- It used `base * detail * 2` (`MODULATE2X`) instead of retail's framebuffer
+ blend.
+- It assumed 128 gray was neutral; retail neutral is RGB equal to alpha.
+- Its acceptance prohibited an overall brightness change, although retail's
+ measured blend intentionally brightens these textures.
+
+The old OpenGL-specific array/bindless wiring is also not reusable in the
+current Vulkan-only RHI.
+
+## Corrected acceptance
+
+- With `BuildingDetailTextures=false`, no detail replay is submitted and the
+ current base rendering remains unchanged.
+- With it `true`, toggling the existing Options checkbox **visibly changes
+ buildings and interior/EnvCell surfaces** without a restart. The connected
+ 2026-08-21 Facility Hub A/B/A gate applied the real Config checkbox on ->
+ off -> restored-on and captured the same nearby walls/floor after each
+ transition. Static right-wall mean absolute RGB error was 2.132 for on/off
+ versus 0.007 for original-on/restored-on; the floor row was 3.385 versus
+ 0.013. The persisted setting was observed false during B, restored true,
+ and the session ended with ACE-confirmed graceful logout.
+- Outdoor terrain, ordinary scenery/objects, creatures, and players do not
+ gain this overlay.
+- Every built building/EnvCell material subset is eligible: opaque, ClipMap,
+ straight alpha, additive, and inverse alpha. Transparent base/detail draws
+ remain adjacent in acdream's authoritative shared alpha order.
+- Opaque object replay submits only command runs containing a building; mixed
+ commands are filtered per instance. Depth equality inherits the base pass's
+ per-sample ClipMap coverage without applying A2C to detail alpha.
+- Detail is full through positive view depth 10 m, fades linearly over 10–50
+ m, and is an exact no-op at and beyond 50 m.
+- Category source, 256 x 256 size, tiling 4, repeat addressing, and linear mip
+ sampling match the measured Dereth data.
+- The retail 1.033 live-category brightening is expected. There is no
+ `dst=ZERO` correction mode hidden behind the retail checkbox.
+- Physics, collision, walkability, and geometry are untouched.
diff --git a/docs/research/2026-08-21-terrain-fidelity-track-a-report.md b/docs/research/2026-08-21-terrain-fidelity-track-a-report.md
new file mode 100644
index 00000000..51c4b095
--- /dev/null
+++ b/docs/research/2026-08-21-terrain-fidelity-track-a-report.md
@@ -0,0 +1,75 @@
+# Terrain fidelity Track A report
+
+**Date:** 2026-08-21
+**Status:** REPORT ACCEPTED BY OWNER DIRECTION; A1/A2 IMPLEMENTED; A3 REJECTED
+
+This report answers Track A from the measured
+[`terrain and atmospheric rendering findings`](2026-08-21-terrain-and-atmospheric-rendering-findings.md).
+It cites that evidence rather than repeating its measurements, and it does not
+reopen the findings' three refuted claims. The project owner subsequently
+authorized implementation. No physics or collision behavior changed.
+
+## A1 — #226 detail-texture overlay
+
+The complete source/size/tiling, blend, neutral point, distance units, setting
+gate, material-ordering contract, reverted-experiment analysis, and connected
+A/B/A evidence are in the
+[`#226 retail building/EnvCell detail-texturing port note`](2026-08-21-retail-building-detail-texturing-pseudocode.md).
+
+The report conclusions are:
+
+- The reachable user-visible target is **building shells and interior EnvCell
+ geometry**, not outdoor terrain. The Options preference caller and
+ `LScape::ChangeRegion` both install category state `(landscape=0,
+ building=enabled, environment=enabled, ordinary=0)` through
+ `SmartBox::SetDetailTexturing`.
+- The existing **Building Detail Textures** checkbox is the sole setting gate.
+ No second option was added. Toggling it now visibly changes the connected
+ Facility Hub scene without a restart.
+- The port keeps retail's `DEST_COLOR + ONE_MINUS_SRC_ALPHA` blend verbatim,
+ including the measured slight brightening. `dst=ZERO` would be an opt-in
+ visual correction, not parity; exposing both meanings behind the one retail
+ checkbox would make that preference ambiguous.
+- The reverted experiment targeted landscape, built the wrong texture-array
+ shape, used `base * detail * 2`, assumed 128 gray was neutral, and rejected
+ the brightness change that the measured retail blend actually produces.
+
+## A2 — terrain vertex normals
+
+**Verdict: parity gap. Retail smooths shared terrain vertices.**
+
+The decisive named-retail function is
+`CLandBlockStruct::calc_lighting` at `0x00531700` in
+[`acclient_2013_pseudo_c.txt`](named-retail/acclient_2013_pseudo_c.txt):
+
+1. It zeroes one three-float accumulator for every shared landblock vertex.
+2. From `0x00531774` through `0x005317F6`, it walks every terrain polygon and
+ adds that polygon's plane normal (`CPolygon + 0x20..0x28`) to the
+ accumulator of each of its three vertex IDs.
+3. From `0x00531817` through `0x00531886`, it normalizes every accumulated
+ vector, falling back to `(0, 0, 1)` only for a degenerate sum.
+4. The following sunlight/ambient loop dots those normalized shared-vertex
+ normals with `LScape::sunlight` and writes per-vertex lighting.
+
+That is incident-face normal averaging, not flat per-face shading. The
+WorldBuilder-derived `TerrainUtils.GetNormal` identified in the findings §4
+is therefore a simplified tool path and not the retail oracle.
+
+The approved port is in `LandblockMesh.BuildRetailVertexNormals`. It uses the
+same split hash and exact emitted triangle topology, accumulates each
+normalized incident face normal at the shared 9 x 9 height-sample vertex, and
+normalizes the sum. Tests independently reconstruct the average from emitted
+positions/indices and prove every position and index is unchanged.
+
+This is lighting-only parity: the 81 height samples, 128 triangles, split
+directions, terrain surface, collision triangles, walkability, and physics
+owners are byte-for-byte/topology-equivalent to the prior path.
+
+## A3 — subdivision
+
+**Agree: the standing “not worth doing” recommendation survives.** The
+findings §4 already establishes that the 9 x 9 samples are height-table
+quantized, so subdivision cannot recover missing terrain detail; changing the
+surface would create physics divergence, while coplanar subdivision would only
+interpolate a surface whose retail-correct shared-vertex smoothing is now
+already present. No subdivision work is scheduled.
diff --git a/docs/research/2026-08-22-atmospheric-stage1-automated-gate.md b/docs/research/2026-08-22-atmospheric-stage1-automated-gate.md
new file mode 100644
index 00000000..42edc755
--- /dev/null
+++ b/docs/research/2026-08-22-atmospheric-stage1-automated-gate.md
@@ -0,0 +1,95 @@
+# Campaign AR Stage 1 automated gate
+
+**Date:** 2026-08-22
+**Verdict:** PASS — every Stage 1 gate that does not require physical visual or
+desktop-performance judgment is complete. Those judgments were deliberately
+outside this automated report and subsequently passed in the
+[Stage 1 live-gate report](2026-08-22-atmospheric-stage1-live-gate.md).
+
+## Scope
+
+This report covers the current authored-celestial implementation: the visible
+above-horizon sun, dominant haloed moon, secondary moon, and no-source states;
+the selected source's direction-versus-energy handoff; the 336-byte render-pack
+shadow ABI; the unchanged authoritative retail path; and local deterministic
+performance and lifetime contracts.
+
+It does not itself claim that a physical display proves shadow alignment,
+source-transition continuity, temporal pixelation/shimmer quality, or desktop
+frame pacing. It did not itself begin Campaign AR Stage 2; the subsequent
+project-owner live approval did.
+
+## Results
+
+| Gate | Result |
+|---|---|
+| Shader compilation | 24/24 Vulkan shader pairs ready |
+| Retail shader preservation | all 18 pre-campaign SPIR-V SHA-256 oracles exact; no tracked retail SPIR-V change |
+| Focused App renderer validation | 344/344 passed |
+| Core sky loader | 14/14 passed |
+| SDK and standalone pack validator | 30/30 passed |
+| MossTank plugin regression | 48/48 passed |
+| Forced locked restore | passed for the complete solution graph |
+| Complete Release build after locked restore | passed, 0 warnings, 0 errors |
+| Fresh-process hermetic Release gate | 14,928/14,928 passed, 0 skipped, 0 failed, 14 assemblies |
+| App assembly inside the complete gate | 5,823/5,823 passed |
+
+The release evidence bundle is
+[`artifacts/atmospheric-rendering/stage1-moon-release-gate/`](../../artifacts/atmospheric-rendering/stage1-moon-release-gate/).
+Its `release-gate-summary.json`, TRX files, logs, environment inventory, and
+`SHA256SUMS.txt` are the machine-readable authority for the fresh-process total.
+
+## Performance and lifetime coverage
+
+The complete App gate includes these deterministic contracts:
+
+- `DirectionalShadowCasterFrameTests.WarmStableFrame_AllocatesZero` builds a
+ 9,500-static-caster scene, warms it, then performs 256 stable frames with
+ zero managed bytes, no additional scene-index copy, no classification, and
+ no topology rebuild.
+- `DirectionalShadowCasterFrameTests.WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords`
+ proves dense animated-transform refresh stays allocation-free and does not
+ reread scene records.
+- `AtmosphericCpuStageProfilerTests.WarmedObservationAllocatesNothing` and
+ `AtmosphericGpuTimerSamplingTests.WarmSamplingDecisionsAllocateZero` keep the
+ measurement path allocation-free after warmup.
+- `RenderPackLongCycleConvergenceTests.RepeatedPackResizeFailureGenerationAndFlightCyclesConvergeExactly`
+ repeatedly crosses Low, Medium, High, retail selection, resize, injected
+ failure/recovery, both frame-flight slots, render-generation replacement,
+ and terminal disposal for 12 cycles. Every pack resource, registration,
+ receiver candidate, transform owner, texture slot, and pipeline-format lease
+ returns to its exact baseline.
+- `RenderPackLongCycleConvergenceTests.DeviceRecreationIsFullRendererTeardownThenANewContextAndDevice`
+ proves recreation is complete old-renderer/context/device teardown followed
+ by an independent activation generation on a fresh device.
+
+These are CPU-side and recording-RHI gates. The historical physical AMD rows
+remain valid for their exact pre-moon binaries and stated scope, but they are
+not reused as current sun-and-moon image-quality or desktop-performance proof.
+
+## Authoritative-path and scope audit
+
+- Shader regeneration expands includes and injects pack-only definitions only
+ for pack shaders. Unchanged retail sources retain their existing committed
+ binaries; the source manifest still forces a recompile after a real source
+ edit.
+- The exact pre-campaign retail SPIR-V oracle passes after ordinary shader
+ regeneration.
+- No source file under `src/AcDream.Runtime` changed for this campaign gate.
+- No physics or collision source changed.
+- No retail GLSL source changed.
+- Pack-off production integration continues to require zero enhancement passes,
+ resources, casters, cascades, draws, or dispatches and its pinned framebuffer
+ and resource ledger remain exact.
+
+## Pending project-owner gate
+
+When the desktop is healthy, launch the corrected Release client against ACE
+and stop for the project owner to judge:
+
+- sun, dominant-moon, and secondary-moon shadow alignment;
+- sun-to-moon, moon-to-moon, and no-source transitions;
+- temporal pixelation/shimmer during camera and celestial motion; and
+- desktop smoothness, frame pacing, and FPS behavior.
+
+Stage 2 and campaign closeout remain gated on that explicit approval.
diff --git a/docs/research/2026-08-22-atmospheric-stage1-live-gate.md b/docs/research/2026-08-22-atmospheric-stage1-live-gate.md
new file mode 100644
index 00000000..fb60f52c
--- /dev/null
+++ b/docs/research/2026-08-22-atmospheric-stage1-live-gate.md
@@ -0,0 +1,46 @@
+# Campaign AR Stage 1 live gate
+
+**Date:** 2026-08-22
+**Verdict:** PASS — project-owner accepted; Stage 2 authorized
+
+## Scope
+
+This is the physical-display and desktop-performance stop that followed the
+[Stage 1 automated gate](2026-08-22-atmospheric-stage1-automated-gate.md). It
+records the project owner's live acceptance of the opt-in Atmospheric pack; it
+is not final Campaign AR acceptance.
+
+The owner exercised the Vulkan client against the local ACE server through the
+Stage 1 correction rounds: visible authored sun and moon shadows, selection and
+configuration persistence, temporal texture/shadow shimmer, frame pacing and
+desktop responsiveness, world selection, fullscreen, and final exposure. After
+the exposure correction the owner reported **“Looks good!”** and directed the
+campaign to synchronize with main and proceed autonomously through Stage 2.
+
+The opt-in Atmospheric exposure changed from `1.00` to `0.80`. The retail
+renderer remains the default and authoritative path. Physics, collision,
+gameplay, and network behavior are unchanged.
+
+## Matched exposure evidence
+
+The final comparison pinned time, day group, sky, weather, MSAA, route, and
+camera framing. Its five screenshots and machine-readable metadata are under
+[`artifacts/atmospheric-rendering/live-exposure-comparison-exposure080-20260822-125033/`](../../artifacts/atmospheric-rendering/live-exposure-comparison-exposure080-20260822-125033/).
+
+| Scene/preset | Retail mean luminance | Atmospheric mean luminance | Delta | Atmospheric p95 delta | Saturation delta | Clipped pixels |
+|---|---:|---:|---:|---:|---:|---:|
+| Outdoor / High | 0.1090 | 0.1003 | -8.0% | +6.8% | +29.8% | 0% |
+| Interior / High | 0.2711 | 0.2813 | +3.8% | -3.5% | +5.2% | 0% |
+| Outdoor / Low | 0.1090 | 0.1011 | -7.2% | +8.4% | +30.1% | 0% |
+
+Before the correction, Atmospheric High measured +19.6% outdoors and +24.5%
+indoors. The `0.80` correction removes that overexposure without clipping.
+The final outdoor Atmospheric High capture reports 6,613 shadow casters, four
+cascades, 95,260,912 resident GPU bytes, and 285 performance samples.
+
+## Acceptance boundary
+
+This gate does not fabricate evidence for a second connected remote player,
+portal/reconnect or device-recreation lifecycle, external package flows,
+long-run convergence, or unavailable physical GPU classes. Those remain Stage
+2/closeout rows, followed by the final project-owner acceptance gate.
diff --git a/docs/research/2026-08-22-dereth-celestial-shadow-sources.md b/docs/research/2026-08-22-dereth-celestial-shadow-sources.md
new file mode 100644
index 00000000..f96bf7a5
--- /dev/null
+++ b/docs/research/2026-08-22-dereth-celestial-shadow-sources.md
@@ -0,0 +1,200 @@
+# Dereth celestial shadow sources
+
+**Date:** 2026-08-22
+**Status:** measured retail-DAT and named-retail finding; implementation input
+for Campaign AR
+**Scope:** identify the Dereth sun/moons and define the opt-in pack's dominant
+directional-shadow source. This note does not change the retail rendering path.
+
+## Conclusion
+
+Dereth's Region `0x13000000` consistently authors three moving celestial
+meshes across all 20 day groups:
+
+1. `0x01001348` is the sun disk.
+2. `0x01001F6A` is the large, haloed moon and is the dominant lunar source.
+3. `0x01001F67` is the smaller secondary moon.
+
+Retail does **not** provide a separate lighting colour or intensity for each
+mesh. `SkyDesc::GetLighting` produces one interpolated directional vector,
+colour, and brightness from `SkyTimeOfDay.DirHeading`, `DirPitch`, `DirColor`,
+and `DirBright`. The opt-in atmospheric pack therefore uses the selected
+visible celestial mesh only for shadow **direction**. Colour and energy remain
+the single AC-authored directional-light values.
+
+The deterministic priority is:
+
+1. visible sun whose transformed centre is above the horizon;
+2. visible large/haloed moon whose transformed centre is above the horizon;
+3. visible secondary moon whose transformed centre is above the horizon;
+4. no directional shadow source.
+
+This is a pack enhancement, not a claim that retail cast real-time moon
+shadows.
+
+## Evidence and provenance
+
+The investigation followed the project rendering inventory and used the
+already-loaded retail structures rather than inventing another sky model.
+Evidence came from:
+
+- `artifacts/atmospheric-rendering/sky-heading-dump/client.log`, especially
+ lines 40-88 for Sunny day group 0 and the corresponding repeated entries for
+ all later day groups. The dump records the three IDs, visibility windows,
+ angular sweeps, keyframe directional lighting, and the sun surface.
+- A read-only `DatCollection.Get`/`Get` probe against the
+ installed Asheron's Call DATs, using the same inspection path implemented by
+ `tools/SkyObjectInspect/Program.cs`, for all three `GfxObj` sort centres,
+ polygon geometry, surfaces, and texture chains.
+- `tools/RainMeshProbe/Program.cs` lines 37-49, which names and audits the
+ celestial surface set independently of the shadow implementation.
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt`:
+ `SkyDesc::GetLighting` at `0x00500a80` (around line 261291),
+ `SkyDesc::GetSky` at `0x00501ec0` (around line 262761),
+ `GameSky::CalcFrame` at `0x00506f80` (around line 268650), and
+ `GameSky::UseTime` at `0x005075b0` (around line 269090).
+- `docs/research/2026-04-23-sky-retail-verbatim.md`, especially its recorded
+ directional-light interpolation and `GameSky::UseTime` material updates.
+
+No fresh decompilation was required. The named-retail corpus already answered
+the only question the current code and DAT dump could not answer on their own:
+whether a moon mesh contributes a second retail world light. It does not.
+
+## Installed-DAT characterization
+
+The following values were read from the installed Dereth Region and the three
+referenced `GfxObj`/surface/texture chains. The same three object IDs, windows,
+and sweeps occur in every one of the 20 day groups; only their object index
+changes between seven-object and weather-heavy groups.
+
+| Role | GfxObj | Day window | Angular sweep | Authored `SortCenter` |
+|---|---:|---:|---:|---:|
+| Sun disk | `0x01001348` | `0.1600..0.9400` | `-23 deg..203 deg` | `(1050, 0, 0)` |
+| Secondary moon | `0x01001F67` | `0.0400..0.2100` | `-20 deg..190 deg` | `(1909.46, 1874.78, -0.0000157485)` |
+| Dominant moon + halo | `0x01001F6A` | `0.0000..0.2300` | `-20 deg..190 deg` | `(2066.82, 552.99, 0)` |
+
+The asset chain establishes the visual identities and the dominant-moon
+choice:
+
+| GfxObj | Surface | Surface flags | SurfaceTexture | RenderSurface | Image |
+|---:|---:|---|---:|---:|---|
+| `0x01001348` | `0x080000D1` | Base1Image, Alpha, Additive | `0x050014CD` | `0x0600388D` | 128x128 `PFID_R8G8B8` sun disk |
+| `0x01001F67` | `0x080000D2` | Base1ClipMap | `0x05001A6C` | `0x06003894` | 256x256 `PFID_INDEX16`, palette `0x0400103F` |
+| `0x01001F6A` | `0x080000D6` | Base1ClipMap | `0x05001A6D` | `0x06003898` | 256x256 `PFID_INDEX16`, palette `0x0400103F` |
+| `0x01001F6A` | `0x080000D7` | Base1Image, Alpha, Additive | `0x05001A6E` | `0x06003899` | 128x128 `PFID_R8G8B8` halo |
+
+Every listed surface has authored `Luminosity=1`, `Diffuse=1`, and
+`Translucency=0`. The large moon's primary quad has roughly 2.3 times the
+polygon area of the secondary moon before its still larger additive halo is
+counted. That makes `0x01001F6A` the unambiguous dominant lunar visual when
+both moons are above the horizon.
+
+These installed-DAT facts are characterization evidence, not an ordinary test
+dependency. Unit tests use hand-built `DayGroupData` so clean CI and machines
+without retail DATs remain deterministic.
+
+## Direction and visibility contract
+
+`SkyObjectData.IsVisible(dayFraction)` owns the normal, always-visible, and
+midnight-wrapping window cases. `CurrentAngle(dayFraction)` owns the authored
+arc interpolation, including progress through a wrapping window.
+
+The selected direction must match the sky renderer exactly:
+
+```text
+heading = active SkyObjectReplace.Rotate
+arc = SkyObjectData.CurrentAngle(dayFraction)
+model = RotationZ(-heading) * RotationY(-arc)
+anchor = effective GfxObj.SortCenter
+direction = normalize(TransformNormal(anchor, model))
+```
+
+“Effective” means that an active non-zero replacement `GfxObjId` also supplies
+its own `SortCenter`. A replacement with `Transparent >= 1` makes the object
+ineligible. The replacement lookup follows the renderer's discrete active
+keyframe rule; it does not interpolate replacement fields. A zero, non-finite,
+or below/on-horizon transformed direction is ineligible.
+
+This deliberately does not substitute `SkyTimeOfDay.DirHeading/DirPitch` for
+moon direction. Those values are the one retail world-light direction. The
+moon meshes have separate authored arcs, and the enhancement is specifically
+intended to align moon shadows with the moon the player can see.
+
+## Authored light contribution
+
+Named retail `SkyDesc::GetLighting` interpolates the two surrounding
+`SkyTimeOfDay` records and produces:
+
+```text
+sunVector = DirBright * (
+ cos(DirPitch) * sin(DirHeading),
+ cos(DirPitch) * cos(DirHeading),
+ sin(DirPitch))
+directionalColor = DirColor * length(sunVector)
+```
+
+`length(sunVector)` is `DirBright`. acdream exposes the resulting colour as
+`SkyKeyframe.SunColor`. The pack's scalar authored energy is therefore
+`clamp(max(SunColor.r, SunColor.g, SunColor.b), 0, 1)`.
+
+By contrast, named retail `GameSky::UseTime` sends a celestial replacement's
+`Luminosity`, `MaxBright`, and `Transparent` to the mesh material through
+`SetLuminosity`, `SetDiffusion`, and `SetTranslucency`. It does not install a
+second directional light. Texture brightness and moon surface luminosity must
+not manufacture extra world-light energy.
+
+Weather/day-group reductions, softness, and elevation ramps remain explicit
+render-pack policy. They are not mislabelled as measured retail intensities.
+
+## Parity and safety registration
+
+### Retail behavior
+
+- One interpolated directional world-light channel comes from
+ `SkyTimeOfDay.Dir*`.
+- Celestial meshes follow their own visibility windows and transformed arcs.
+- Replacement luminosity/diffusion/transparency changes mesh material state,
+ not the number of world-directional lights.
+- Retail does not render the Campaign AR cascaded real-time object shadows.
+
+### Opt-in pack enhancement
+
+- The pack chooses the visible sun or dominant visible moon direction for its
+ directional shadow map.
+- Moon direction follows the rendered moon; energy remains the single
+ AC-authored directional channel.
+- Sun wins any overlap when its transformed centre is above the horizon;
+ otherwise the haloed moon wins before the secondary moon.
+- This deviation belongs in the atmospheric render-pack entry of
+ `docs/architecture/retail-divergence-register.md`.
+
+### Unchanged boundaries
+
+- The retail rendering path remains the default and authoritative output.
+- Pack-off frames do not resolve or render celestial shadow work.
+- Existing retail scene lighting remains driven by `SkyStateProvider`; this
+ policy does not replace it.
+- Physics, collision, containment, selection, movement, and DAT geometry are
+ untouched. The selected source is an immutable one-frame rendering fact.
+
+## Deterministic acceptance coverage
+
+`tests/AcDream.App.Tests/Rendering/Packs/AuthoredCelestialShadowSourceResolverTests.cs`
+locks:
+
+- the three verified IDs and priority independent of object-list order;
+- sun overlap, dominant-moon fallback, and secondary-moon fallback;
+- fully transparent and effective replacement behavior;
+- replacement rotation and the exact renderer transform direction;
+- no-visible/no-above-horizon suppression;
+- midnight-wrapping visibility and angle progress; and
+- directional colour-times-brightness energy, including preservation when no
+ celestial source is available.
+
+The test fixture is entirely hand-built. It neither requires nor silently
+substitutes installed retail DAT content.
+
+The complete non-physical verification result, including shader ABI, exact
+retail-binary preservation, performance/lifetime fixtures, locked restore,
+Release build, and fresh-process totals, is recorded in the
+[Campaign AR Stage 1 automated gate report](2026-08-22-atmospheric-stage1-automated-gate.md).
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/AcDream.RenderPacks.AtmosphericTier2.csproj b/samples/AcDream.RenderPacks.AtmosphericTier2/AcDream.RenderPacks.AtmosphericTier2.csproj
new file mode 100644
index 00000000..a9483d83
--- /dev/null
+++ b/samples/AcDream.RenderPacks.AtmosphericTier2/AcDream.RenderPacks.AtmosphericTier2.csproj
@@ -0,0 +1,22 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+
+
+
+
+ false
+ runtime
+
+
+
+
+
+ AcDream.RenderPacks.AtmosphericTier2.Shaders.%(Filename)%(Extension)
+
+
+
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/AtmosphericTier2RenderPack.cs b/samples/AcDream.RenderPacks.AtmosphericTier2/AtmosphericTier2RenderPack.cs
new file mode 100644
index 00000000..0af05b85
--- /dev/null
+++ b/samples/AcDream.RenderPacks.AtmosphericTier2/AtmosphericTier2RenderPack.cs
@@ -0,0 +1,591 @@
+using System.Globalization;
+using System.Reflection;
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.RenderPacks.AtmosphericTier2;
+
+///
+/// Complete external atmospheric-pack example. Every logical ID deliberately
+/// differs from acdream's built-in pack: renderer-owned semantic enums, not
+/// magic ID strings, bind the fixed atmospheric executor.
+///
+public sealed class AtmosphericTier2RenderPack : IRenderPackPlugin, IRenderPackAssets
+{
+ private const string ShaderPrefix = "shaders/";
+ private const string EmbeddedShaderPrefix =
+ "AcDream.RenderPacks.AtmosphericTier2.Shaders.";
+
+ private static RenderPackDescriptor Descriptor { get; } = new RenderPackDescriptor(
+ "sample.atmospheric-tier2",
+ "Atmospheric Tier 2 SDK Sample",
+ new Version(1, 0, 0),
+ RenderPackApi.Current,
+ RenderPackTier.Tier2Plus,
+ [
+ RenderCapability.MainWorldColorIntermediate,
+ RenderCapability.FullscreenPasses,
+ RenderCapability.SceneDepthSampling,
+ RenderCapability.AuthoredSunDirection,
+ RenderCapability.AuthoredSunScreenPosition,
+ RenderCapability.AuthoredWeather,
+ RenderCapability.DirectionalShadowMaps,
+ RenderCapability.OutdoorDirectionalShadowCasterReplay,
+ RenderCapability.AnimatedCasterTransforms,
+ RenderCapability.AlphaCutoutShadowCasters,
+ RenderCapability.AuthoredCelestialDirectionalLight,
+ ],
+ [RenderCapability.GpuTimestampQueries],
+ Resources(),
+ Passes(),
+ SceneReplays(),
+ PipelineVariants(),
+ QualityPresets(),
+ Settings(),
+ AtmospherePolicy())
+ {
+ FeatureSummary = "Filmic HDR atmosphere and moving sun-and-moon cascaded shadows "
+ + "from terrain, trees, buildings, players, and monsters, with "
+ + "optional volumetric shafts.",
+ };
+
+ public void Register(IRenderPackRegistry registry)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+ registry.Register(Descriptor, this);
+ }
+
+ public Stream OpenRead(string assetKey)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(assetKey);
+ if (!assetKey.StartsWith(ShaderPrefix, StringComparison.Ordinal)
+ || assetKey.Length == ShaderPrefix.Length
+ || assetKey.Contains('\\')
+ || assetKey.Contains("..", StringComparison.Ordinal))
+ {
+ throw new FileNotFoundException(
+ "The atmospheric sample exposes only declared embedded shader assets.",
+ assetKey);
+ }
+
+ string fileName = assetKey[ShaderPrefix.Length..];
+ if (fileName.Contains('/'))
+ throw new FileNotFoundException("Nested shader paths are not declared.", assetKey);
+
+ string resourceName = EmbeddedShaderPrefix + fileName;
+ return Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName)
+ ?? throw new FileNotFoundException("The declared shader asset is missing.", assetKey);
+ }
+
+ private static IReadOnlyList Resources() =>
+ [
+ Image("cinematic-world-buffer", RenderResourceSemantic.MainWorldHdr,
+ RenderFormatClass.HdrColor, 1.0, 32L * 1024 * 1024),
+ Image("glow-stage-one", RenderResourceSemantic.BloomPing,
+ RenderFormatClass.HdrColor, 0.5, 8L * 1024 * 1024),
+ Image("glow-stage-two", RenderResourceSemantic.BloomPong,
+ RenderFormatClass.HdrColor, 0.5, 8L * 1024 * 1024),
+ Image("solar-visibility", RenderResourceSemantic.SunOcclusionMask,
+ RenderFormatClass.SingleChannel, 0.25, 2L * 1024 * 1024),
+ Image("scattered-sunlight", RenderResourceSemantic.SunRays,
+ RenderFormatClass.HdrColor, 0.25, 2L * 1024 * 1024),
+ new RenderResourceDeclaration(
+ "directional-shadow-depth",
+ RenderResourceKind.Image2DArray,
+ RenderFormatClass.DirectionalDepth,
+ new RenderExtentDeclaration(
+ RenderExtentMode.AbsolutePixels,
+ 1024,
+ 1024,
+ Layers: 2),
+ SizeBytes: 0,
+ RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment,
+ RenderResourceLifetime.ActivePack,
+ EstimatedResidentBytes: 8L * 1024 * 1024)
+ { Semantic = RenderResourceSemantic.DirectionalShadowDepth },
+ Image("participating-air", RenderResourceSemantic.VolumetricShafts,
+ RenderFormatClass.HdrColor, 0.25, 2L * 1024 * 1024),
+ ];
+
+ private static IReadOnlyList Passes() =>
+ [
+ Pass(
+ "record-directional-shadow-casters",
+ RenderPassSemantic.DirectionalShadowDepth,
+ RenderPassHook.ShadowDepthBeforeWorld,
+ "shadow-pass.vert.spv",
+ "shadow-pass.frag.spv",
+ [
+ RenderSemanticInput.CameraMatrices,
+ RenderSemanticInput.SelectedCelestialDirectionalLight,
+ RenderSemanticInput.ShadowCasterTransforms,
+ RenderSemanticInput.ActiveDayGroup,
+ RenderSemanticInput.Weather,
+ ],
+ [],
+ ["directional-shadow-depth"]),
+ Pass(
+ "measure-solar-visibility",
+ RenderPassSemantic.SunOcclusion,
+ RenderPassHook.AtmosphereBeforeToneMap,
+ "solar-visibility.vert.spv",
+ "solar-visibility.frag.spv",
+ [
+ RenderSemanticInput.SceneDepth,
+ RenderSemanticInput.SunScreenPosition,
+ RenderSemanticInput.ActiveDayGroup,
+ RenderSemanticInput.Weather,
+ ],
+ [],
+ ["solar-visibility"]),
+ Pass(
+ "scatter-visible-sunlight",
+ RenderPassSemantic.SunRays,
+ RenderPassHook.AtmosphereBeforeToneMap,
+ "sun-scatter.vert.spv",
+ "sun-scatter.frag.spv",
+ [RenderSemanticInput.SunScreenPosition, RenderSemanticInput.FrameTime],
+ ["solar-visibility"],
+ ["scattered-sunlight"]),
+ Pass(
+ "integrate-lit-air",
+ RenderPassSemantic.VolumetricShafts,
+ RenderPassHook.AtmosphereBeforeToneMap,
+ "lit-air.vert.spv",
+ "lit-air.frag.spv",
+ [
+ RenderSemanticInput.SceneDepth,
+ RenderSemanticInput.CameraMatrices,
+ RenderSemanticInput.SunDirection,
+ RenderSemanticInput.DirectionalShadowMaps,
+ RenderSemanticInput.ActiveDayGroup,
+ RenderSemanticInput.Weather,
+ ],
+ ["directional-shadow-depth"],
+ ["participating-air"]),
+ Pass(
+ "extract-highlight-energy",
+ RenderPassSemantic.BloomDownsample,
+ RenderPassHook.AtmosphereBeforeToneMap,
+ "highlight-extract.vert.spv",
+ "highlight-extract.frag.spv",
+ [RenderSemanticInput.WorldColor],
+ ["scattered-sunlight", "participating-air"],
+ ["glow-stage-one"]),
+ Pass(
+ "spread-glow-sideways",
+ RenderPassSemantic.BloomBlurHorizontal,
+ RenderPassHook.AtmosphereBeforeToneMap,
+ "glow-filter.vert.spv",
+ "glow-filter.frag.spv",
+ [RenderSemanticInput.FrameTime],
+ ["glow-stage-one"],
+ ["glow-stage-two"]),
+ Pass(
+ "spread-glow-upwards",
+ RenderPassSemantic.BloomBlurVertical,
+ RenderPassHook.AtmosphereBeforeToneMap,
+ "glow-filter.vert.spv",
+ "glow-filter.frag.spv",
+ [RenderSemanticInput.FrameTime],
+ ["glow-stage-two"],
+ ["glow-stage-one"]),
+ Pass(
+ "compose-cinematic-colour",
+ RenderPassSemantic.FilmicComposite,
+ RenderPassHook.ToneMap,
+ "cinematic-composite.vert.spv",
+ "cinematic-composite.frag.spv",
+ [RenderSemanticInput.WorldColor, RenderSemanticInput.FrameTime],
+ ["glow-stage-one", "scattered-sunlight", "participating-air"],
+ []),
+ ];
+
+ private static IReadOnlyList SceneReplays() =>
+ [
+ new SceneReplayDeclaration(
+ "replay-every-outdoor-shadow-caster",
+ RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters,
+ RenderCasterClass.Terrain
+ | RenderCasterClass.OpaqueWorld
+ | RenderCasterClass.AlphaCutoutWorld
+ | RenderCasterClass.AnimatedOpaque
+ | RenderCasterClass.AnimatedAlphaCutout,
+ ViewCount: 4),
+ ];
+
+ private static IReadOnlyList PipelineVariants() =>
+ [
+ Variant(
+ "landscape-shadow-writer",
+ RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.Terrain,
+ "landscape-shadow.vert.spv",
+ "landscape-shadow.frag.spv",
+ RenderMaterialClass.Opaque,
+ [RenderSemanticInput.CameraMatrices]),
+ Variant(
+ "solid-object-shadow-writer",
+ RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.WorldMesh,
+ "solid-shadow.vert.spv",
+ "solid-shadow.frag.spv",
+ RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque,
+ [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]),
+ Variant(
+ "cutout-object-shadow-writer",
+ RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.WorldMesh,
+ "cutout-shadow.vert.spv",
+ "cutout-shadow.frag.spv",
+ RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout,
+ [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]),
+ Variant(
+ "landscape-shadow-reader",
+ RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver,
+ RenderPipelineBaseSemantic.Terrain,
+ "landscape-lit.vert.spv",
+ "landscape-lit.frag.spv",
+ RenderMaterialClass.Opaque,
+ [RenderSemanticInput.DirectionalShadowMaps,
+ RenderSemanticInput.SelectedCelestialDirectionalLight]),
+ Variant(
+ "object-shadow-reader",
+ RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver,
+ RenderPipelineBaseSemantic.WorldMesh,
+ "object-lit.vert.spv",
+ "object-lit.frag.spv",
+ RenderMaterialClass.Opaque
+ | RenderMaterialClass.AlphaCutout
+ | RenderMaterialClass.AnimatedOpaque
+ | RenderMaterialClass.AnimatedAlphaCutout,
+ [RenderSemanticInput.DirectionalShadowMaps,
+ RenderSemanticInput.SelectedCelestialDirectionalLight]),
+ ];
+
+ private static IReadOnlyList QualityPresets() =>
+ [
+ Preset(
+ "economy",
+ "Economy",
+ RenderQualitySemantic.Low,
+ maxMiB: 64,
+ gpuP50: 2.0,
+ gpuP99: 3.0,
+ cpuP50: 0.15,
+ cpuP99: 0.50,
+ shadowResolution: 1024,
+ cascades: 2,
+ shadowReachMetres: 72,
+ postScale: 0.25),
+ Preset(
+ "balanced",
+ "Balanced",
+ RenderQualitySemantic.Medium,
+ maxMiB: 128,
+ gpuP50: 3.25,
+ gpuP99: 4.50,
+ cpuP50: 0.25,
+ cpuP99: 0.75,
+ shadowResolution: 1536,
+ cascades: 3,
+ shadowReachMetres: 144,
+ postScale: 0.5),
+ Preset(
+ "cinematic",
+ "Cinematic",
+ RenderQualitySemantic.High,
+ maxMiB: 256,
+ gpuP50: 4.50,
+ gpuP99: 6.00,
+ cpuP50: 0.35,
+ cpuP99: 1.00,
+ shadowResolution: 2048,
+ cascades: 4,
+ shadowReachMetres: 240,
+ postScale: 0.5),
+ Preset(
+ "adaptive",
+ "Adaptive",
+ RenderQualitySemantic.Automatic,
+ maxMiB: 128,
+ gpuP50: 3.25,
+ gpuP99: 4.50,
+ cpuP50: 0.25,
+ cpuP99: 0.75,
+ shadowResolution: 1536,
+ cascades: 3,
+ shadowReachMetres: 144,
+ postScale: 0.5)
+ with
+ {
+ SettingOverrides =
+ [
+ new RenderQualitySettingOverride("quality-governor", "true"),
+ new RenderQualitySettingOverride("air-shaft-strength", "0.35"),
+ new RenderQualitySettingOverride("air-march-steps", "40"),
+ new RenderQualitySettingOverride("moving-shadow-opacity", "0.72"),
+ new RenderQualitySettingOverride("moving-shadow-range", "144"),
+ new RenderQualitySettingOverride("shadow-filter-samples", "9"),
+ new RenderQualitySettingOverride("sun-scatter-strength", "0.55"),
+ ],
+ AutoEligible = false,
+ },
+ ];
+
+ private static IReadOnlyList Settings() =>
+ [
+ Float("highlight-glow", "Highlight glow", RenderSettingSemantic.BloomStrength,
+ 0.65, 0, 2, 0.05),
+ Float("filmic-mix", "Filmic tone-map mix", RenderSettingSemantic.FilmicStrength,
+ 1.0, 0, 1, 0.05),
+ Float("scene-exposure", "Scene exposure", RenderSettingSemantic.Exposure,
+ 1.0, 0.25, 4, 0.05),
+ Float("colour-saturation", "Colour saturation", RenderSettingSemantic.GradeSaturation,
+ 1.0, 0, 2, 0.05),
+ Float("colour-contrast", "Colour contrast", RenderSettingSemantic.GradeContrast,
+ 1.0, 0.5, 2, 0.05),
+ Float("frame-vignette", "Frame vignette", RenderSettingSemantic.VignetteStrength,
+ 0.12, 0, 1, 0.01),
+ Float("sun-scatter-strength", "Sun-scatter strength", RenderSettingSemantic.SunRayStrength,
+ 0.55, 0, 2, 0.05),
+ Float("moving-shadow-opacity", "Moving-shadow opacity",
+ RenderSettingSemantic.DirectionalShadowStrength, 0.72, 0, 1, 0.02),
+ Integer("moving-shadow-range", "Moving-shadow range (metres)",
+ RenderSettingSemantic.DirectionalShadowReachMetres, 240, 16, 240, 1),
+ Choice("shadow-filter-samples", "Shadow filter samples",
+ RenderSettingSemantic.DirectionalShadowPcfTaps, "9", ["1", "9", "25"]),
+ Float("air-shaft-strength", "Volumetric-air strength",
+ RenderSettingSemantic.VolumetricStrength, 0.35, 0, 1, 0.01),
+ Integer("air-march-steps", "Volumetric ray-march steps",
+ RenderSettingSemantic.VolumetricRayMarchSteps, 40, 8, 64, 8),
+ new RenderSettingDeclaration(
+ "quality-governor",
+ "Automatic quality",
+ RenderSettingKind.Boolean,
+ "false",
+ Minimum: null,
+ Maximum: null,
+ Step: null,
+ Choices: [])
+ { Semantic = RenderSettingSemantic.AutomaticQuality },
+ ];
+
+ private static AtmospherePolicyDeclaration AtmospherePolicy() => new(
+ [
+ new SunElevationResponsePoint(-90, 0),
+ new SunElevationResponsePoint(-3, 0),
+ new SunElevationResponsePoint(4, 1),
+ new SunElevationResponsePoint(22, 0.75),
+ new SunElevationResponsePoint(55, 0),
+ new SunElevationResponsePoint(90, 0),
+ ],
+ [
+ new ActiveDayGroupMultiplier(0, 1.0),
+ new ActiveDayGroupMultiplier(1, 0.35),
+ new ActiveDayGroupMultiplier(2, 0.20),
+ ])
+ {
+ DirectionalShadowLightElevationResponse =
+ [
+ new SunElevationResponsePoint(-90, 0),
+ new SunElevationResponsePoint(1, 0),
+ new SunElevationResponsePoint(12, 1),
+ new SunElevationResponsePoint(90, 1),
+ ],
+ VolumetricShaftSunElevationResponse =
+ [
+ new SunElevationResponsePoint(-90, 0),
+ new SunElevationResponsePoint(0, 0),
+ new SunElevationResponsePoint(6, 1),
+ new SunElevationResponsePoint(18, 1),
+ new SunElevationResponsePoint(70, 0),
+ new SunElevationResponsePoint(90, 0),
+ ],
+ };
+
+ private static RenderResourceDeclaration Image(
+ string id,
+ RenderResourceSemantic semantic,
+ RenderFormatClass format,
+ double scale,
+ long estimatedBytes) => new(
+ id,
+ RenderResourceKind.Image2D,
+ format,
+ new RenderExtentDeclaration(RenderExtentMode.RelativeToMainWorld, scale, scale),
+ SizeBytes: 0,
+ RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment,
+ RenderResourceLifetime.ActivePack,
+ estimatedBytes)
+ { Semantic = semantic };
+
+ private static RenderPassDeclaration Pass(
+ string id,
+ RenderPassSemantic semantic,
+ RenderPassHook hook,
+ string vertex,
+ string fragment,
+ IReadOnlyList semanticInputs,
+ IReadOnlyList reads,
+ IReadOnlyList writes) => new(
+ id,
+ hook,
+ ShaderPrefix + vertex,
+ ShaderPrefix + fragment,
+ semanticInputs,
+ reads,
+ writes)
+ { Semantic = semantic };
+
+ private static PipelineVariantDeclaration Variant(
+ string id,
+ RenderPipelineVariantSemantic semantic,
+ RenderPipelineBaseSemantic baseSemantic,
+ string vertex,
+ string fragment,
+ RenderMaterialClass materials,
+ IReadOnlyList inputs) => new(
+ id,
+ baseSemantic,
+ ShaderPrefix + vertex,
+ ShaderPrefix + fragment,
+ materials,
+ inputs)
+ { Semantic = semantic };
+
+ private static RenderQualityPreset Preset(
+ string id,
+ string displayName,
+ RenderQualitySemantic semantic,
+ long maxMiB,
+ double gpuP50,
+ double gpuP99,
+ double cpuP50,
+ double cpuP99,
+ int shadowResolution,
+ int cascades,
+ int shadowReachMetres,
+ double postScale) => new(
+ id,
+ displayName,
+ [RenderCapability.DirectionalShadowMaps],
+ [
+ AbsoluteOverride(
+ "directional-shadow-depth",
+ shadowResolution,
+ cascades,
+ 4L * shadowResolution * shadowResolution * cascades),
+ RelativeOverride("glow-stage-one", postScale),
+ RelativeOverride("glow-stage-two", postScale),
+ RelativeOverride("solar-visibility", id == "economy" ? 0.25 : 0.5),
+ RelativeOverride("scattered-sunlight", id == "economy" ? 0.25 : 0.5),
+ RelativeOverride("participating-air", id == "cinematic" ? 0.5 : 0.25),
+ ],
+ [
+ new RenderQualitySettingOverride("quality-governor", "false"),
+ new RenderQualitySettingOverride(
+ "air-shaft-strength",
+ id == "economy" ? "0" : "0.35"),
+ new RenderQualitySettingOverride(
+ "air-march-steps",
+ semantic switch
+ {
+ RenderQualitySemantic.Low => "24",
+ RenderQualitySemantic.High => "56",
+ _ => "40",
+ }),
+ new RenderQualitySettingOverride("moving-shadow-opacity", "0.72"),
+ new RenderQualitySettingOverride(
+ "moving-shadow-range",
+ shadowReachMetres.ToString(CultureInfo.InvariantCulture)),
+ new RenderQualitySettingOverride(
+ "shadow-filter-samples",
+ semantic switch
+ {
+ RenderQualitySemantic.Low => "1",
+ RenderQualitySemantic.High => "25",
+ _ => "9",
+ }),
+ new RenderQualitySettingOverride(
+ "sun-scatter-strength",
+ id == "economy" ? "0.4" : "0.55"),
+ ],
+ maxMiB * 1024 * 1024,
+ gpuP50,
+ gpuP99,
+ cpuP50,
+ cpuP99)
+ { Semantic = semantic };
+
+ private static RenderQualityResourceOverride AbsoluteOverride(
+ string id,
+ int resolution,
+ int layers,
+ long bytes) => new(
+ id,
+ new RenderExtentDeclaration(
+ RenderExtentMode.AbsolutePixels,
+ resolution,
+ resolution,
+ layers),
+ SizeBytes: 0,
+ EstimatedResidentBytes: bytes);
+
+ private static RenderQualityResourceOverride RelativeOverride(string id, double scale) => new(
+ id,
+ new RenderExtentDeclaration(RenderExtentMode.RelativeToMainWorld, scale, scale),
+ SizeBytes: 0,
+ EstimatedResidentBytes: 0);
+
+ private static RenderSettingDeclaration Float(
+ string id,
+ string displayName,
+ RenderSettingSemantic semantic,
+ double defaultValue,
+ double min,
+ double max,
+ double step) => new(
+ id,
+ displayName,
+ RenderSettingKind.Float,
+ defaultValue.ToString(CultureInfo.InvariantCulture),
+ min,
+ max,
+ step,
+ [])
+ { Semantic = semantic };
+
+ private static RenderSettingDeclaration Integer(
+ string id,
+ string displayName,
+ RenderSettingSemantic semantic,
+ int defaultValue,
+ int min,
+ int max,
+ int step) => new(
+ id,
+ displayName,
+ RenderSettingKind.Integer,
+ defaultValue.ToString(CultureInfo.InvariantCulture),
+ min,
+ max,
+ step,
+ [])
+ { Semantic = semantic };
+
+ private static RenderSettingDeclaration Choice(
+ string id,
+ string displayName,
+ RenderSettingSemantic semantic,
+ string defaultValue,
+ IReadOnlyList choices) => new(
+ id,
+ displayName,
+ RenderSettingKind.Choice,
+ defaultValue,
+ null,
+ null,
+ null,
+ choices)
+ { Semantic = semantic };
+}
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cinematic-composite.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cinematic-composite.frag.spv
new file mode 100644
index 00000000..8950e78a
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cinematic-composite.frag.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cinematic-composite.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cinematic-composite.vert.spv
new file mode 100644
index 00000000..c493caf5
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cinematic-composite.vert.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cutout-shadow.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cutout-shadow.frag.spv
new file mode 100644
index 00000000..16f7b307
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cutout-shadow.frag.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cutout-shadow.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cutout-shadow.vert.spv
new file mode 100644
index 00000000..6e077a14
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/cutout-shadow.vert.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/glow-filter.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/glow-filter.frag.spv
new file mode 100644
index 00000000..081ba8dd
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/glow-filter.frag.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/glow-filter.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/glow-filter.vert.spv
new file mode 100644
index 00000000..c493caf5
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/glow-filter.vert.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/highlight-extract.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/highlight-extract.frag.spv
new file mode 100644
index 00000000..ac62a9dc
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/highlight-extract.frag.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/highlight-extract.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/highlight-extract.vert.spv
new file mode 100644
index 00000000..c493caf5
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/highlight-extract.vert.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-lit.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-lit.frag.spv
new file mode 100644
index 00000000..bbf43111
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-lit.frag.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-lit.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-lit.vert.spv
new file mode 100644
index 00000000..d672dcb9
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-lit.vert.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-shadow.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-shadow.frag.spv
new file mode 100644
index 00000000..86b5c126
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-shadow.frag.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-shadow.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-shadow.vert.spv
new file mode 100644
index 00000000..c79fd88c
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/landscape-shadow.vert.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/lit-air.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/lit-air.frag.spv
new file mode 100644
index 00000000..d782ffe1
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/lit-air.frag.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/lit-air.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/lit-air.vert.spv
new file mode 100644
index 00000000..c493caf5
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/lit-air.vert.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/object-lit.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/object-lit.frag.spv
new file mode 100644
index 00000000..78335661
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/object-lit.frag.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/object-lit.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/object-lit.vert.spv
new file mode 100644
index 00000000..108e15fd
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/object-lit.vert.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/shadow-pass.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/shadow-pass.frag.spv
new file mode 100644
index 00000000..86b5c126
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/shadow-pass.frag.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/shadow-pass.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/shadow-pass.vert.spv
new file mode 100644
index 00000000..8b49cd0f
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/shadow-pass.vert.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solar-visibility.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solar-visibility.frag.spv
new file mode 100644
index 00000000..a5a50473
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solar-visibility.frag.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solar-visibility.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solar-visibility.vert.spv
new file mode 100644
index 00000000..c493caf5
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solar-visibility.vert.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solid-shadow.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solid-shadow.frag.spv
new file mode 100644
index 00000000..86b5c126
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solid-shadow.frag.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solid-shadow.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solid-shadow.vert.spv
new file mode 100644
index 00000000..8b49cd0f
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/solid-shadow.vert.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/sun-scatter.frag.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/sun-scatter.frag.spv
new file mode 100644
index 00000000..1dcc705f
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/sun-scatter.frag.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/sun-scatter.vert.spv b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/sun-scatter.vert.spv
new file mode 100644
index 00000000..c493caf5
Binary files /dev/null and b/samples/AcDream.RenderPacks.AtmosphericTier2/Shaders/sun-scatter.vert.spv differ
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/packages.neutral.lock.json b/samples/AcDream.RenderPacks.AtmosphericTier2/packages.neutral.lock.json
new file mode 100644
index 00000000..3924e2e2
--- /dev/null
+++ b/samples/AcDream.RenderPacks.AtmosphericTier2/packages.neutral.lock.json
@@ -0,0 +1,10 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "acdream.plugin.abstractions": {
+ "type": "Project"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/AcDream.RenderPacks.AtmosphericTier2/plugin.json b/samples/AcDream.RenderPacks.AtmosphericTier2/plugin.json
new file mode 100644
index 00000000..65900a1c
--- /dev/null
+++ b/samples/AcDream.RenderPacks.AtmosphericTier2/plugin.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "../../docs/render-packs/plugin-manifest-v1.schema.json",
+ "id": "sample.atmospheric-tier2",
+ "displayName": "Atmospheric Tier 2 SDK Sample",
+ "version": "1.0.0",
+ "entryDll": "AcDream.RenderPacks.AtmosphericTier2.dll",
+ "apiVersion": 1,
+ "dependencies": [],
+ "kinds": ["renderPack"]
+}
diff --git a/samples/AcDream.RenderPacks.NoOp/AcDream.RenderPacks.NoOp.csproj b/samples/AcDream.RenderPacks.NoOp/AcDream.RenderPacks.NoOp.csproj
new file mode 100644
index 00000000..b32cadf4
--- /dev/null
+++ b/samples/AcDream.RenderPacks.NoOp/AcDream.RenderPacks.NoOp.csproj
@@ -0,0 +1,19 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+
+
+
+
+ false
+ runtime
+
+
+
+
+
+
diff --git a/samples/AcDream.RenderPacks.NoOp/NoOpRenderPack.cs b/samples/AcDream.RenderPacks.NoOp/NoOpRenderPack.cs
new file mode 100644
index 00000000..302d8551
--- /dev/null
+++ b/samples/AcDream.RenderPacks.NoOp/NoOpRenderPack.cs
@@ -0,0 +1,54 @@
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.RenderPacks.NoOp;
+
+///
+/// Minimal external render-pack entry point. It declares no resources, passes,
+/// pipeline variants, shaders, or GPU capabilities, so selecting it is a
+/// renderer no-op while still exercising discovery and atomic activation.
+///
+public sealed class NoOpRenderPack : IRenderPackPlugin, IRenderPackAssets
+{
+ private static RenderPackDescriptor Descriptor { get; } = new(
+ "sample.no-op-render-pack",
+ "No-op Render Pack Sample",
+ new Version(1, 0, 0),
+ RenderPackApi.Current,
+ RenderPackTier.Tier1,
+ RequiredCapabilities: [],
+ OptionalCapabilities: [],
+ Resources: [],
+ Passes: [],
+ SceneReplays: [],
+ PipelineVariants: [],
+ QualityPresets:
+ [
+ new RenderQualityPreset(
+ "conformance",
+ "Conformance",
+ RequiredCapabilities: [],
+ ResourceOverrides: [],
+ SettingOverrides: [],
+ MaxResidentGpuBytes: 0,
+ MaxIncrementalGpuMillisecondsP50: 0,
+ MaxIncrementalGpuMillisecondsP99: 0,
+ MaxIncrementalCpuMillisecondsP50: 0,
+ MaxIncrementalCpuMillisecondsP99: 0),
+ ],
+ Settings: [],
+ AtmospherePolicy: null)
+ {
+ FeatureSummary = "No visual changes; exercises render-pack discovery and atomic activation.",
+ };
+
+ public void Register(IRenderPackRegistry registry)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+ registry.Register(Descriptor, this);
+ }
+
+ public Stream OpenRead(string assetKey) =>
+ throw new FileNotFoundException(
+ "The no-op conformance pack declares no assets.",
+ assetKey);
+}
diff --git a/samples/AcDream.RenderPacks.NoOp/packages.neutral.lock.json b/samples/AcDream.RenderPacks.NoOp/packages.neutral.lock.json
new file mode 100644
index 00000000..3924e2e2
--- /dev/null
+++ b/samples/AcDream.RenderPacks.NoOp/packages.neutral.lock.json
@@ -0,0 +1,10 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "acdream.plugin.abstractions": {
+ "type": "Project"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/AcDream.RenderPacks.NoOp/plugin.json b/samples/AcDream.RenderPacks.NoOp/plugin.json
new file mode 100644
index 00000000..9573e41a
--- /dev/null
+++ b/samples/AcDream.RenderPacks.NoOp/plugin.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "../../docs/render-packs/plugin-manifest-v1.schema.json",
+ "id": "sample.no-op-render-pack",
+ "displayName": "No-op Render Pack Sample",
+ "version": "1.0.0",
+ "entryDll": "AcDream.RenderPacks.NoOp.dll",
+ "apiVersion": 1,
+ "dependencies": [],
+ "kinds": ["renderPack"]
+}
diff --git a/samples/AcDream.RenderPacks.ShadowsOnlyTier2/AcDream.RenderPacks.ShadowsOnlyTier2.csproj b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/AcDream.RenderPacks.ShadowsOnlyTier2.csproj
new file mode 100644
index 00000000..0aa92060
--- /dev/null
+++ b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/AcDream.RenderPacks.ShadowsOnlyTier2.csproj
@@ -0,0 +1,22 @@
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+
+
+
+ false
+ runtime
+
+
+
+
+
+ AcDream.RenderPacks.ShadowsOnlyTier2.Shaders.%(Filename)%(Extension)
+
+
+
diff --git a/samples/AcDream.RenderPacks.ShadowsOnlyTier2/ShadowsOnlyTier2RenderPack.cs b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/ShadowsOnlyTier2RenderPack.cs
new file mode 100644
index 00000000..8751dffe
--- /dev/null
+++ b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/ShadowsOnlyTier2RenderPack.cs
@@ -0,0 +1,231 @@
+using System.Reflection;
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.RenderPacks.ShadowsOnlyTier2;
+
+///
+/// Minimal Tier-2 moving celestial-shadow pack. The custom tone-map pass is only the
+/// technical HDR output copy; no Tier-2+ atmosphere post stack is declared.
+///
+public sealed class ShadowsOnlyTier2RenderPack : IRenderPackPlugin, IRenderPackAssets
+{
+ private const string ShaderPrefix = "shaders/";
+ private const string EmbeddedPrefix =
+ "AcDream.RenderPacks.ShadowsOnlyTier2.Shaders.";
+
+ private static RenderPackDescriptor Descriptor { get; } = new(
+ "sample.shadows-only-tier2",
+ "Shadows-Only Tier 2 SDK Sample",
+ new Version(1, 0, 0),
+ RenderPackApi.Current,
+ RenderPackTier.Tier2,
+ [
+ RenderCapability.MainWorldColorIntermediate,
+ RenderCapability.FullscreenPasses,
+ RenderCapability.AuthoredWeather,
+ RenderCapability.DirectionalShadowMaps,
+ RenderCapability.OutdoorDirectionalShadowCasterReplay,
+ RenderCapability.AnimatedCasterTransforms,
+ RenderCapability.AlphaCutoutShadowCasters,
+ RenderCapability.AuthoredCelestialDirectionalLight,
+ ],
+ [RenderCapability.GpuTimestampQueries],
+ [ShadowResource()],
+ Passes(),
+ [CasterReplay()],
+ Variants(),
+ [Preset()],
+ Settings(),
+ Policy())
+ {
+ FeatureSummary = "Moving sun-and-moon shadows from terrain, trees, buildings, players, "
+ + "and monsters without bloom, rays, grading, vignette, or volumetric shafts.",
+ };
+
+ public void Register(IRenderPackRegistry registry)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+ registry.Register(Descriptor, this);
+ }
+
+ public Stream OpenRead(string assetKey)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(assetKey);
+ if (!assetKey.StartsWith(ShaderPrefix, StringComparison.Ordinal)
+ || assetKey.Length == ShaderPrefix.Length
+ || assetKey.Contains('\\')
+ || assetKey.Contains("..", StringComparison.Ordinal))
+ {
+ throw new FileNotFoundException("Only declared embedded shaders are available.", assetKey);
+ }
+ string fileName = assetKey[ShaderPrefix.Length..];
+ if (fileName.Contains('/'))
+ throw new FileNotFoundException("Nested shader paths are not declared.", assetKey);
+ return Assembly.GetExecutingAssembly().GetManifestResourceStream(EmbeddedPrefix + fileName)
+ ?? throw new FileNotFoundException("The declared shader asset is missing.", assetKey);
+ }
+
+ private static RenderResourceDeclaration ShadowResource() => new(
+ "directional-shadow-depth",
+ RenderResourceKind.Image2DArray,
+ RenderFormatClass.DirectionalDepth,
+ new RenderExtentDeclaration(RenderExtentMode.AbsolutePixels, 1024, 1024, Layers: 2),
+ SizeBytes: 0,
+ RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment,
+ RenderResourceLifetime.ActivePack,
+ EstimatedResidentBytes: 8L * 1024 * 1024)
+ {
+ Semantic = RenderResourceSemantic.DirectionalShadowDepth,
+ };
+
+ private static IReadOnlyList Passes() =>
+ [
+ new RenderPassDeclaration(
+ "record-directional-shadow-casters",
+ RenderPassHook.ShadowDepthBeforeWorld,
+ Shader("shadow-pass.vert.spv"),
+ Shader("shadow-pass.frag.spv"),
+ [
+ RenderSemanticInput.CameraMatrices,
+ RenderSemanticInput.SelectedCelestialDirectionalLight,
+ RenderSemanticInput.ShadowCasterTransforms,
+ RenderSemanticInput.ActiveDayGroup,
+ RenderSemanticInput.Weather,
+ ],
+ [],
+ ["directional-shadow-depth"])
+ {
+ Semantic = RenderPassSemantic.DirectionalShadowDepth,
+ },
+ new RenderPassDeclaration(
+ "copy-hdr-world-to-output",
+ RenderPassHook.ToneMap,
+ Shader("glow-filter.vert.spv"),
+ Shader("glow-filter.frag.spv"),
+ [RenderSemanticInput.WorldColor],
+ [],
+ []),
+ ];
+
+ private static SceneReplayDeclaration CasterReplay() => new(
+ "replay-all-headline-casters",
+ RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters,
+ RenderCasterClass.Terrain
+ | RenderCasterClass.OpaqueWorld
+ | RenderCasterClass.AlphaCutoutWorld
+ | RenderCasterClass.AnimatedOpaque
+ | RenderCasterClass.AnimatedAlphaCutout,
+ ViewCount: 4);
+
+ private static IReadOnlyList Variants() =>
+ [
+ Variant("terrain-caster", RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.Terrain, "landscape-shadow",
+ RenderMaterialClass.Opaque, [RenderSemanticInput.CameraMatrices]),
+ Variant("opaque-caster", RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.WorldMesh, "solid-shadow",
+ RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque,
+ [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]),
+ Variant("cutout-caster", RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.WorldMesh, "cutout-shadow",
+ RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout,
+ [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]),
+ Variant("terrain-receiver", RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver,
+ RenderPipelineBaseSemantic.Terrain, "landscape-lit",
+ RenderMaterialClass.Opaque,
+ [RenderSemanticInput.DirectionalShadowMaps,
+ RenderSemanticInput.SelectedCelestialDirectionalLight]),
+ Variant("world-receiver", RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver,
+ RenderPipelineBaseSemantic.WorldMesh, "object-lit",
+ RenderMaterialClass.Opaque | RenderMaterialClass.AlphaCutout
+ | RenderMaterialClass.AnimatedOpaque | RenderMaterialClass.AnimatedAlphaCutout,
+ [RenderSemanticInput.DirectionalShadowMaps,
+ RenderSemanticInput.SelectedCelestialDirectionalLight]),
+ ];
+
+ private static PipelineVariantDeclaration Variant(
+ string id,
+ RenderPipelineVariantSemantic semantic,
+ RenderPipelineBaseSemantic baseSemantic,
+ string shaderStem,
+ RenderMaterialClass materials,
+ IReadOnlyList inputs) => new(
+ id,
+ baseSemantic,
+ Shader(shaderStem + ".vert.spv"),
+ Shader(shaderStem + ".frag.spv"),
+ materials,
+ inputs)
+ {
+ Semantic = semantic,
+ };
+
+ private static RenderQualityPreset Preset() => new(
+ "balanced",
+ "Balanced",
+ [],
+ [],
+ [],
+ MaxResidentGpuBytes: 64L * 1024 * 1024,
+ MaxIncrementalGpuMillisecondsP50: 2.0,
+ MaxIncrementalGpuMillisecondsP99: 3.0,
+ MaxIncrementalCpuMillisecondsP50: 0.2,
+ MaxIncrementalCpuMillisecondsP99: 0.5)
+ {
+ Semantic = RenderQualitySemantic.Medium,
+ };
+
+ private static IReadOnlyList Settings() =>
+ [
+ Float("shadow-opacity", "Shadow opacity",
+ RenderSettingSemantic.DirectionalShadowStrength, "0.72", 0, 1, 0.02),
+ Integer("shadow-reach", "Shadow reach (metres)",
+ RenderSettingSemantic.DirectionalShadowReachMetres, "144", 16, 240, 1),
+ new RenderSettingDeclaration(
+ "shadow-filter", "Shadow filter samples", RenderSettingKind.Choice,
+ "9", null, null, null, ["1", "9", "25"])
+ {
+ Semantic = RenderSettingSemantic.DirectionalShadowPcfTaps,
+ },
+ ];
+
+ private static RenderSettingDeclaration Float(
+ string id, string displayName, RenderSettingSemantic semantic,
+ string value, double minimum, double maximum, double step) => new(
+ id, displayName, RenderSettingKind.Float, value,
+ minimum, maximum, step, [])
+ {
+ Semantic = semantic,
+ };
+
+ private static RenderSettingDeclaration Integer(
+ string id, string displayName, RenderSettingSemantic semantic,
+ string value, double minimum, double maximum, double step) => new(
+ id, displayName, RenderSettingKind.Integer, value,
+ minimum, maximum, step, [])
+ {
+ Semantic = semantic,
+ };
+
+ private static AtmospherePolicyDeclaration Policy() => new(
+ [
+ new SunElevationResponsePoint(-90, 1),
+ new SunElevationResponsePoint(90, 1),
+ ],
+ [
+ new ActiveDayGroupMultiplier(0, 1),
+ new ActiveDayGroupMultiplier(1, 0.35),
+ new ActiveDayGroupMultiplier(2, 0.20),
+ ])
+ {
+ DirectionalShadowLightElevationResponse =
+ [
+ new SunElevationResponsePoint(-90, 0),
+ new SunElevationResponsePoint(1, 0),
+ new SunElevationResponsePoint(12, 1),
+ new SunElevationResponsePoint(90, 1),
+ ],
+ };
+
+ private static string Shader(string fileName) => ShaderPrefix + fileName;
+}
diff --git a/samples/AcDream.RenderPacks.ShadowsOnlyTier2/packages.neutral.lock.json b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/packages.neutral.lock.json
new file mode 100644
index 00000000..3924e2e2
--- /dev/null
+++ b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/packages.neutral.lock.json
@@ -0,0 +1,10 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "acdream.plugin.abstractions": {
+ "type": "Project"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/AcDream.RenderPacks.ShadowsOnlyTier2/plugin.json b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/plugin.json
new file mode 100644
index 00000000..62569dd7
--- /dev/null
+++ b/samples/AcDream.RenderPacks.ShadowsOnlyTier2/plugin.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "../../docs/render-packs/plugin-manifest-v1.schema.json",
+ "id": "sample.shadows-only-tier2",
+ "displayName": "Shadows-Only Tier 2 SDK Sample",
+ "version": "1.0.0",
+ "entryDll": "AcDream.RenderPacks.ShadowsOnlyTier2.dll",
+ "apiVersion": 1,
+ "dependencies": [],
+ "kinds": ["renderPack"]
+}
diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index 7494efc3..7fbb005c 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -51,7 +51,9 @@ internal sealed record FrameRootDependencies(
LiveEntityAnimationRuntimeView Animations,
UpdateFrameClock UpdateClock,
GameFrameGraphSlot FrameGraphs,
- Action Log)
+ Action Log,
+ AcDream.App.Rendering.Packs.DeferredRenderPackDiagnosticsSource?
+ RenderPackDiagnostics = null)
{
public RuntimeLocalPlayerMovementState PlayerController =>
Runtime.MovementOwner;
@@ -234,6 +236,7 @@ internal sealed class FrameRootCompositionPhase
ref bool bindingsOwnedByScope)
{
FrameRootDependencies d = _dependencies;
+ bindings = new FrameRootRuntimeBindings();
WorldRenderFoundation foundation = world.Foundation;
// Campaign V slice V6h: the frame root's raw-GL render graph fork was
// deleted at slice V11. The graph is the clear pass, the private-
@@ -285,6 +288,35 @@ internal sealed class FrameRootCompositionPhase
renderFrameLivePreparation);
Fault(FrameRootCompositionPoint.RenderResourcesCreated);
+ AcDream.App.Rendering.Packs.RenderPackController? renderPackController = null;
+ AcDream.App.Rendering.Packs.RenderPackSelectionBinding? renderPackSelection = null;
+ AcDream.App.Rendering.Packs.AtmosphericFrameInputState? atmosphericInputs = null;
+ if (settings.RenderPacks is { } renderPackCatalog)
+ {
+ renderPackController = new AcDream.App.Rendering.Packs.RenderPackController(
+ renderPackCatalog.Snapshot,
+ new AcDream.App.Rendering.Packs.AtmosphericRenderPackRuntimeFactory(
+ host.GpuDevice),
+ new AcDream.App.Rendering.Packs.RenderPackReceiverPipelineCoordinator(
+ foundation.Terrain!,
+ live.DrawDispatcher!),
+ AcDream.App.Rendering.Packs.ThreadPoolRenderPackPreparationScheduler.Instance,
+ renderPackCatalog);
+ bindings.Adopt("render-pack controller", renderPackController);
+ renderPackSelection = new AcDream.App.Rendering.Packs.RenderPackSelectionBinding(
+ d.Settings,
+ renderPackController,
+ d.Log);
+ bindings.Adopt("render-pack selection", renderPackSelection);
+ if (d.RenderPackDiagnostics is { } renderPackDiagnostics)
+ {
+ bindings.Adopt(
+ "render-pack diagnostics",
+ renderPackDiagnostics.BindOwned(renderPackController));
+ }
+ atmosphericInputs = new AcDream.App.Rendering.Packs.AtmosphericFrameInputState();
+ }
+
var renderWeatherFrame = new RenderWeatherFrameController(
d.WorldTime,
d.Weather);
@@ -463,7 +495,8 @@ internal sealed class FrameRootCompositionPhase
worldScenePasses,
d.RenderRange,
worldSceneDiagnostics,
- live.WorldAvailability);
+ live.WorldAvailability,
+ atmosphericInputs);
// The world renderer runs INSIDE the frame's one backbuffer pass,
// which this phase opens, publishes on the scope, and closes.
worldSceneRenderer =
@@ -474,14 +507,70 @@ internal sealed class FrameRootCompositionPhase
(d.Graphics as VulkanGameWindowGraphics)?.WorldPassScopeCore
?? throw new InvalidOperationException(
"The Vulkan world phase requires the Vulkan graphics handle."),
- worldSceneRenderer);
+ worldSceneRenderer,
+ renderPackController,
+ atmosphericInputs,
+ renderPackSelection is null
+ ? null
+ : renderPackSelection.ApplyAtFrameBoundary,
+ live.RenderSceneShadow,
+ live.DrawDispatcher,
+ foundation.Terrain);
}
Fault(FrameRootCompositionPoint.WorldRendererCreated);
- bindings = new FrameRootRuntimeBindings();
WorldLifecycleAutomationController? lifecycleAutomation = null;
if (interaction.RetainedUi?.Screenshots is { } screenshots
&& d.Options.AutomationArtifactDirectory is { } artifactDirectory)
{
+ AcDream.UI.Abstractions.Panels.Settings.RenderPackSelectionSettings?
+ automationLastEnhancedSelection = null;
+
+ (bool Succeeded, string Error) SaveAutomationRenderPackSelection(
+ AcDream.UI.Abstractions.Panels.Settings.RenderPackSelectionSettings selection)
+ {
+ d.Settings.SaveDisplay(d.Settings.Display with
+ {
+ RenderPack = selection,
+ });
+ return d.Settings.Display.RenderPack == selection
+ ? (true, string.Empty)
+ : (false, $"render-pack selection '{selection.PresetId}' was not persisted");
+ }
+
+ (bool Succeeded, string Error) SelectAutomationRenderPack(string preset)
+ {
+ var selection = string.Equals(
+ preset,
+ "retail",
+ StringComparison.Ordinal)
+ ? AcDream.UI.Abstractions.Panels.Settings
+ .RenderPackSelectionSettings.Retail
+ : new AcDream.UI.Abstractions.Panels.Settings
+ .RenderPackSelectionSettings(
+ AcDream.App.Rendering.Packs
+ .BuiltInAtmosphericRenderPack.Id,
+ "1.0.0",
+ preset);
+ return SaveAutomationRenderPackSelection(selection);
+ }
+
+ (bool Succeeded, string Error) DisableAutomationRenderPack()
+ {
+ var current = d.Settings.Display.RenderPack;
+ if (!current.IsRetail)
+ automationLastEnhancedSelection = current;
+ return SaveAutomationRenderPackSelection(
+ AcDream.UI.Abstractions.Panels.Settings
+ .RenderPackSelectionSettings.Retail);
+ }
+
+ (bool Succeeded, string Error) ReenableAutomationRenderPack()
+ {
+ return automationLastEnhancedSelection is { } selection
+ ? SaveAutomationRenderPackSelection(selection)
+ : (false, "render-pack re-enable requires a prior enhanced selection");
+ }
+
var resourceSnapshots =
new WorldLifecycleResourceSnapshotSource(
live.WorldState,
@@ -515,7 +604,86 @@ internal sealed class FrameRootCompositionPhase
resourceSnapshots.Capture,
screenshots,
artifactDirectory,
- message => d.Log("[UI-PROBE] " + message));
+ message => d.Log("[UI-PROBE] " + message),
+ () => renderPackController?.MinimumPerformanceSampleCount ?? 0,
+ () =>
+ {
+ if (renderPackController is null)
+ {
+ return (
+ false,
+ "render-pack performance automation is unavailable");
+ }
+ bool reset = renderPackController.TryResetPerformanceEvidence(
+ out string error);
+ return (reset, error);
+ },
+ () => renderPackController?.Snapshot.State ==
+ AcDream.App.Rendering.Packs.RenderPackActivationState.FailedToRetail,
+ getRenderPackStatus: () =>
+ {
+ AcDream.App.Rendering.Packs.RenderPackActivationSnapshot snapshot =
+ renderPackController?.Snapshot
+ ?? new AcDream.App.Rendering.Packs.RenderPackActivationSnapshot(
+ AcDream.App.Rendering.Packs.RenderPackActivationState.Retail,
+ AcDream.UI.Abstractions.Panels.Settings
+ .RenderPackSelectionSettings.Retail,
+ ActivePackDisplayName: null,
+ Reason: null,
+ ActivationGeneration: 0);
+ var state = snapshot.State switch
+ {
+ AcDream.App.Rendering.Packs.RenderPackActivationState.Retail =>
+ AcDream.App.UI.Testing
+ .RetailUiAutomationRenderPackState.Retail,
+ AcDream.App.Rendering.Packs.RenderPackActivationState.CandidatePending =>
+ AcDream.App.UI.Testing
+ .RetailUiAutomationRenderPackState.CandidatePending,
+ AcDream.App.Rendering.Packs.RenderPackActivationState.Active =>
+ AcDream.App.UI.Testing
+ .RetailUiAutomationRenderPackState.Active,
+ AcDream.App.Rendering.Packs.RenderPackActivationState.FailedToRetail =>
+ AcDream.App.UI.Testing
+ .RetailUiAutomationRenderPackState.FailedToRetail,
+ _ => throw new ArgumentOutOfRangeException(),
+ };
+ return new AcDream.App.UI.Testing
+ .RetailUiAutomationRenderPackStatus(
+ state,
+ snapshot.Selection.PackId,
+ snapshot.Selection.PresetId,
+ snapshot.ActivationGeneration,
+ snapshot.Reason);
+ },
+ selectRenderPack: SelectAutomationRenderPack,
+ disableRenderPack: DisableAutomationRenderPack,
+ reenableRenderPack: ReenableAutomationRenderPack,
+ getFramebufferSize: () =>
+ {
+ var size = d.Window.FramebufferSize;
+ return (size.X, size.Y);
+ },
+ resizeFramebuffer: (width, height) =>
+ {
+ if (d.Settings.Display.Fullscreen)
+ {
+ return (
+ false,
+ "automation framebuffer resize requires windowed mode");
+ }
+ string resolution = $"{width}x{height}";
+ d.Settings.SaveDisplay(d.Settings.Display with
+ {
+ Resolution = resolution,
+ });
+ return string.Equals(
+ d.Settings.Display.Resolution,
+ resolution,
+ StringComparison.Ordinal)
+ ? (true, string.Empty)
+ : (false, $"framebuffer resize '{resolution}' was not persisted");
+ },
+ requestClientClose: d.Window.Close);
bindings.Adopt(
"world lifecycle automation owner",
lifecycleAutomation);
diff --git a/src/AcDream.App/Composition/HostInputCameraComposition.cs b/src/AcDream.App/Composition/HostInputCameraComposition.cs
index 7be703e7..93bd8cf9 100644
--- a/src/AcDream.App/Composition/HostInputCameraComposition.cs
+++ b/src/AcDream.App/Composition/HostInputCameraComposition.cs
@@ -52,7 +52,10 @@ internal sealed record HostInputCameraDependencies(
LocalPlayerModeState LocalPlayerMode,
ChaseCameraInputState ChaseCameraInput,
PointerPositionState PointerPosition,
- IRenderFrameDiagnosticLog RenderDiagnosticLog);
+ IRenderFrameDiagnosticLog RenderDiagnosticLog,
+ float? InitialOrbitDistanceMeters = null,
+ float? InitialOrbitYawDegrees = null,
+ float? InitialOrbitPitchDegrees = null);
///
/// The construction seam every backend differs at. Campaign V slice V6h widened
@@ -101,7 +104,10 @@ internal interface IHostInputCameraCompositionFactory
IKeyboardSource keyboard,
IMouseSource mouse,
KeyBindings bindings);
- CameraController CreateCameraController();
+ CameraController CreateCameraController(
+ float? initialOrbitDistanceMeters,
+ float? initialOrbitYawDegrees,
+ float? initialOrbitPitchDegrees);
IFramebufferCameraTarget CreateCameraTarget(CameraController camera);
CameraPointerInputController CreateCameraPointerInput(
IReadOnlyList mice,
@@ -311,7 +317,10 @@ internal sealed class HostInputCameraCompositionPhase :
Fault(HostInputCameraCompositionPoint.CameraInputBound);
}
- CameraController camera = _factory.CreateCameraController();
+ CameraController camera = _factory.CreateCameraController(
+ _dependencies.InitialOrbitDistanceMeters,
+ _dependencies.InitialOrbitYawDegrees,
+ _dependencies.InitialOrbitPitchDegrees);
_publication.PublishCameraController(camera);
Fault(HostInputCameraCompositionPoint.CameraPublished);
_dependencies.FramebufferResize.BindCamera(
diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
index b44ded67..d74658b3 100644
--- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
+++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
@@ -92,7 +92,10 @@ internal sealed record InteractionRetainedUiDependencies(
// needed. gmMapUI::Update @0x004a1eb0 reads GameTime::current_game_time
// every 5s — MapPageController owns that cadence, this just supplies the
// current reading.
- Func CurrentCalendar)
+ Func CurrentCalendar,
+ AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null,
+ Func?
+ RenderPackDiagnostics = null)
{
public RuntimeActionState Actions => Runtime.ActionOwner;
@@ -643,7 +646,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
screenshots = new FrameScreenshotController(
d.BackbufferReader,
Path.Combine(artifactDirectory, "screenshots"),
- ProbeLog);
+ ProbeLog,
+ d.RenderPackDiagnostics);
}
checkpoint(InteractionRetainedUiCompositionPoint.UiProbeCreated);
@@ -949,7 +953,53 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
LoadDisplay: () => d.Settings.Display,
SaveDisplay: d.Settings.SaveDisplay,
LoadAudio: () => d.Settings.Audio,
- SaveAudio: d.Settings.SaveAudio),
+ SaveAudio: d.Settings.SaveAudio,
+ LoadRenderPackChoices: d.RenderPackCatalog is null
+ ? null
+ : () => d.RenderPackCatalog.Snapshot().Entries
+ .Select(entry =>
+ new ConfigOptionsPageController.RenderPackChoice(
+ entry.Descriptor.Id,
+ entry.Descriptor.DisplayName,
+ entry.Descriptor.PackVersion.ToString(),
+ entry.IsCompatible,
+ entry.IncompatibilityReason,
+ entry.Descriptor.QualityPresets
+ .Select(preset =>
+ {
+ entry.PresetIncompatibilityReasons.TryGetValue(
+ preset.Id,
+ out string? reason);
+ return new ConfigOptionsPageController.RenderPackPresetChoice(
+ preset.Id,
+ preset.DisplayName,
+ entry.IsCompatible && reason is null,
+ reason ?? entry.IncompatibilityReason)
+ {
+ SettingOverrides = preset.SettingOverrides,
+ MaxResidentGpuBytes = preset.MaxResidentGpuBytes,
+ MaxIncrementalGpuMillisecondsP50 =
+ preset.MaxIncrementalGpuMillisecondsP50,
+ MaxIncrementalGpuMillisecondsP99 =
+ preset.MaxIncrementalGpuMillisecondsP99,
+ MaxIncrementalCpuMillisecondsP50 =
+ preset.MaxIncrementalCpuMillisecondsP50,
+ MaxIncrementalCpuMillisecondsP99 =
+ preset.MaxIncrementalCpuMillisecondsP99,
+ };
+ })
+ .ToArray())
+ {
+ FeatureSummary = entry.Descriptor.FeatureSummary,
+ Settings = entry.Descriptor.Settings,
+ })
+ .ToArray(),
+ LoadRenderPackCatalogRevision: d.RenderPackCatalog is null
+ ? null
+ : () => d.RenderPackCatalog.Revision,
+ LoadRenderPackFailureNotice: d.RenderPackDiagnostics is null
+ ? null
+ : () => d.RenderPackDiagnostics().FailureReason),
// Campaign FA slice FA3: the social panel's own bindings —
// FA2's typed Fellowship/Allegiance snapshot readers off the
// GameRuntime views, plus J4.1's Friends/Squelch owners
diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
index 185032e5..87f8b1cd 100644
--- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
+++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs
@@ -792,6 +792,67 @@ internal sealed class DeferredWorldLifecycleAutomationRuntime
!_deactivated && _target?.IsWorldViewportVisible == true;
public int PortalMaterializationCount =>
!_deactivated ? _target?.PortalMaterializationCount ?? 0 : 0;
+ public int RenderPackPerformanceSampleCount =>
+ !_deactivated ? _target?.RenderPackPerformanceSampleCount ?? 0 : 0;
+ public bool RenderPackFailedToRetail =>
+ !_deactivated && _target?.RenderPackFailedToRetail == true;
+ public RetailUiAutomationRenderPackStatus RenderPackStatus =>
+ !_deactivated
+ ? _target?.RenderPackStatus
+ ?? RetailUiAutomationRenderPackStatus.Retail
+ : RetailUiAutomationRenderPackStatus.Retail;
+ public int FramebufferWidth =>
+ !_deactivated ? _target?.FramebufferWidth ?? 0 : 0;
+ public int FramebufferHeight =>
+ !_deactivated ? _target?.FramebufferHeight ?? 0 : 0;
+
+ public bool TrySelectRenderPack(string presetId, out string error)
+ {
+ if (!_deactivated && _target is { } target)
+ return target.TrySelectRenderPack(presetId, out error);
+ error = "world lifecycle automation is not bound";
+ return false;
+ }
+
+ public bool TryDisableRenderPack(out string error)
+ {
+ if (!_deactivated && _target is { } target)
+ return target.TryDisableRenderPack(out error);
+ error = "world lifecycle automation is not bound";
+ return false;
+ }
+
+ public bool TryReenableRenderPack(out string error)
+ {
+ if (!_deactivated && _target is { } target)
+ return target.TryReenableRenderPack(out error);
+ error = "world lifecycle automation is not bound";
+ return false;
+ }
+
+ public bool TryResizeFramebuffer(int width, int height, out string error)
+ {
+ if (!_deactivated && _target is { } target)
+ return target.TryResizeFramebuffer(width, height, out error);
+ error = "world lifecycle automation is not bound";
+ return false;
+ }
+
+ public bool TryResetRenderPackPerformance(out string error)
+ {
+ if (!_deactivated && _target is { } target)
+ return target.TryResetRenderPackPerformance(out error);
+ error = "world lifecycle automation is not bound";
+ return false;
+ }
+
+ public bool TryRequestClientClose(out string error)
+ {
+ if (!_deactivated && _target is { } target)
+ return target.TryRequestClientClose(out error);
+ error = "world lifecycle automation is not bound";
+ return false;
+ }
public IDisposable Bind(IRetailUiAutomationRuntime target)
{
diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index 60246361..aa9391f8 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -74,7 +74,9 @@ internal sealed record LivePresentationDependencies(
DeferredRenderFrameDiagnosticsSource? DevFrameDiagnostics,
DeferredRenderFrameDiagnosticsSource UiFrameDiagnostics,
Action Log,
- Action? Toast)
+ Action? Toast,
+ AcDream.App.Rendering.Packs.IRenderPackDiagnosticsSnapshotSource?
+ RenderPackDiagnostics = null)
{
public SelectionState Selection => Runtime.ActionOwner.Selection;
@@ -448,7 +450,8 @@ internal sealed class LivePresentationCompositionPhase
LiveRenderProjectionJournal? liveRenderProjections =
renderSceneShadow?.BindLiveRuntime(
liveEntities,
- new GpuWorldRenderTraversalOrderSource(worldState));
+ new GpuWorldRenderTraversalOrderSource(worldState),
+ d.PlayerIdentity);
Fault(LivePresentationCompositionPoint.CanonicalRuntimeCreated);
bindings.Adopt(
@@ -803,7 +806,9 @@ internal sealed class LivePresentationCompositionPhase
d.TranslucencyFades,
selectionScene,
d.RetailAlphaQueue,
- alphaScratchBudgets.DispatcherBytes),
+ alphaScratchBudgets.DispatcherBytes,
+ foundation.TerrainAtlas?.BuildingDetailTexture ?? default,
+ () => d.Settings.DisplayPreview.BuildingDetailTextures),
static value => value.Dispose());
var selectionQuery = new WorldSelectionQuery(
liveEntities,
@@ -1238,9 +1243,11 @@ internal sealed class LivePresentationCompositionPhase
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope."),
foundation.MeshAdapter!.MeshManager!,
- envCellFrustum),
+ envCellFrustum,
+ foundation.TerrainAtlas?.EnvironmentDetailTexture ?? default,
+ () => d.Settings.DisplayPreview.BuildingDetailTextures),
static value => value.Dispose());
- // The three pipelines ARE its program, built at construction — the
+ // The four pipelines ARE its program, built at construction — the
// raw-GL arm's separate Initialize(Shader) step was deleted at V11.
Fault(LivePresentationCompositionPoint.EnvironmentCellsCreated);
@@ -1484,7 +1491,8 @@ internal sealed class LivePresentationCompositionPhase
new SilkRenderFrameTitleSink(d.Window),
d.RenderDiagnosticLog,
d.Options.UiProbeDump,
- resourceDiagnostics);
+ resourceDiagnostics,
+ d.RenderPackDiagnostics);
if (d.DevFrameDiagnostics is { } devFrameDiagnostics)
{
bindings.Adopt(
diff --git a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs
index e0863994..15e7df79 100644
--- a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs
+++ b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs
@@ -1,4 +1,8 @@
using AcDream.App.Settings;
+using AcDream.App.Plugins;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Packs;
+using AcDream.UI.Abstractions.Panels.Settings;
using Silk.NET.Input;
namespace AcDream.App.Composition;
@@ -18,11 +22,22 @@ namespace AcDream.App.Composition;
/// keybinds.json (not retail's .keymap format — register row AP-202).
///
internal sealed record SettingsDevToolsResult(
- AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality);
+ AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality)
+{
+ internal RenderPackCatalogSource? RenderPacks { get; init; }
+
+ internal RenderPackSelectionSettings RenderPackSelection { get; init; } =
+ RenderPackSelectionSettings.Retail;
+}
internal sealed record SettingsDevToolsDependencies(
RuntimeSettingsController Settings,
- IRuntimeSettingsStartupTarget StartupTarget);
+ IRuntimeSettingsStartupTarget StartupTarget)
+{
+ internal BufferedRenderPackRegistry? RenderPacks { get; init; }
+
+ internal IGpuDevice? GpuDevice { get; init; }
+}
///
/// Production Phase 3: applies the resolved startup display/audio settings.
@@ -51,6 +66,19 @@ internal sealed class SettingsDevToolsCompositionPhase :
ArgumentNullException.ThrowIfNull(content);
_dependencies.Settings.ApplyStartup(_dependencies.StartupTarget);
- return new SettingsDevToolsResult(_dependencies.Settings.ResolvedQuality);
+ RenderPackCatalogSource? renderPacks = null;
+ if (_dependencies.RenderPacks is { } registry
+ && _dependencies.GpuDevice is { } gpu)
+ {
+ renderPacks = new RenderPackCatalogSource(
+ registry,
+ RenderPackCapabilityResolver.Resolve(gpu.Capabilities));
+ }
+
+ return new SettingsDevToolsResult(_dependencies.Settings.ResolvedQuality)
+ {
+ RenderPacks = renderPacks,
+ RenderPackSelection = _dependencies.Settings.Display.RenderPack,
+ };
}
}
diff --git a/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs b/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs
index a96508e8..a7091979 100644
--- a/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs
+++ b/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs
@@ -82,8 +82,23 @@ internal sealed class VulkanHostInputCameraCompositionFactory
KeyBindings bindings) =>
InputDispatcher.CreateDetached(keyboard, mouse, bindings);
- public CameraController CreateCameraController() =>
- new(new OrbitCamera(), new FlyCamera());
+ public CameraController CreateCameraController(
+ float? initialOrbitDistanceMeters,
+ float? initialOrbitYawDegrees,
+ float? initialOrbitPitchDegrees)
+ {
+ var orbit = new OrbitCamera();
+ if (initialOrbitDistanceMeters is { } distance)
+ orbit.Distance = distance;
+ if (initialOrbitYawDegrees is { } yaw)
+ orbit.Yaw = DegreesToRadians(yaw);
+ if (initialOrbitPitchDegrees is { } pitch)
+ orbit.Pitch = DegreesToRadians(pitch);
+ return new CameraController(orbit, new FlyCamera());
+ }
+
+ private static float DegreesToRadians(float degrees) =>
+ degrees * (MathF.PI / 180f);
public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) =>
new CameraFramebufferTarget(camera);
diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs
index 8ecdbd0c..76b0055a 100644
--- a/src/AcDream.App/Composition/WorldRenderComposition.cs
+++ b/src/AcDream.App/Composition/WorldRenderComposition.cs
@@ -78,7 +78,10 @@ internal interface IGameWindowWorldRenderPublication
internal interface IWorldRenderCompositionFactory
{
WorldRegionData LoadRegion(IDatReaderWriter dats);
- void InitializeEnvironment(WorldEnvironmentController environment, Region region);
+ void InitializeEnvironment(
+ WorldEnvironmentController environment,
+ Region region,
+ IDatReaderWriter dats);
///
/// Campaign V slice V6i-2: the terrain atlas built through
/// . The raw-GL arm this
@@ -188,11 +191,13 @@ internal sealed class RetailWorldRenderCompositionFactory
public void InitializeEnvironment(
WorldEnvironmentController environment,
- Region region)
+ Region region,
+ IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(environment);
ArgumentNullException.ThrowIfNull(region);
- environment.Initialize(region);
+ ArgumentNullException.ThrowIfNull(dats);
+ environment.Initialize(region, dats);
}
public TerrainAtlas AcquireBackendNeutralTerrainAtlas(
@@ -452,7 +457,10 @@ internal sealed class WorldRenderCompositionPhase
WorldRegionData region = _factory.LoadRegion(content.Dats);
Fault(WorldRenderCompositionPoint.RegionLoaded);
- _factory.InitializeEnvironment(_dependencies.Environment, region.Region);
+ _factory.InitializeEnvironment(
+ _dependencies.Environment,
+ region.Region,
+ content.Dats);
Fault(WorldRenderCompositionPoint.EnvironmentInitialized);
// Campaign V slice V6i-2: the atlas builds through IGpuDevice on
diff --git a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs
index f6b9ada9..a2588d5b 100644
--- a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs
+++ b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs
@@ -1,6 +1,9 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
+using System.Text.Json;
+using AcDream.App.Rendering.Packs;
+
namespace AcDream.App.Diagnostics;
///
@@ -22,6 +25,7 @@ internal sealed class FrameScreenshotController
private readonly Func _readRgba;
private readonly string _directory;
private readonly Action _log;
+ private readonly Func? _renderPackMetadata;
private readonly Queue _pending = new();
private readonly Dictionary _status =
new(StringComparer.OrdinalIgnoreCase);
@@ -29,13 +33,15 @@ internal sealed class FrameScreenshotController
internal FrameScreenshotController(
Func readRgba,
string directory,
- Action? log = null)
+ Action? log = null,
+ Func? renderPackMetadata = null)
{
_readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba));
_directory = string.IsNullOrWhiteSpace(directory)
? throw new ArgumentException("A screenshot directory is required.", nameof(directory))
: Path.GetFullPath(directory);
_log = log ?? (_ => { });
+ _renderPackMetadata = renderPackMetadata;
}
public bool TryRequest(string name, out string error)
@@ -85,6 +91,27 @@ internal sealed class FrameScreenshotController
string temporaryPath = path + ".tmp";
using (Image image = Image.LoadPixelData(flipped, width, height))
image.SaveAsPng(temporaryPath);
+
+ string? metadataPath = null;
+ string? temporaryMetadataPath = null;
+ if (_renderPackMetadata is not null)
+ {
+ metadataPath = Path.Combine(_directory, name + ".metadata.json");
+ temporaryMetadataPath = metadataPath + ".tmp";
+ var metadata = new FrameScreenshotMetadata(
+ SchemaVersion: 1,
+ Width: width,
+ Height: height,
+ RenderPack: _renderPackMetadata());
+ File.WriteAllBytes(
+ temporaryMetadataPath,
+ JsonSerializer.SerializeToUtf8Bytes(
+ metadata,
+ new JsonSerializerOptions { WriteIndented = true }));
+ }
+
+ if (metadataPath is not null && temporaryMetadataPath is not null)
+ File.Move(temporaryMetadataPath, metadataPath, overwrite: true);
File.Move(temporaryPath, path, overwrite: true);
_status[name] = new CaptureStatus(CaptureState.Complete);
@@ -93,6 +120,9 @@ internal sealed class FrameScreenshotController
}
catch (Exception exception)
{
+ TryDelete(Path.Combine(_directory, name + ".png.tmp"));
+ TryDelete(Path.Combine(_directory, name + ".metadata.json.tmp"));
+ TryDelete(Path.Combine(_directory, name + ".metadata.json"));
string message = $"screenshot '{name}' failed: {exception.Message}";
_status[name] = new CaptureStatus(CaptureState.Failed, message);
_log($"[world-gate] screenshot-failed name={name} error={exception.Message}");
@@ -100,6 +130,22 @@ internal sealed class FrameScreenshotController
}
}
+ private static void TryDelete(string path)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch (Exception error) when (error is IOException
+ or UnauthorizedAccessException
+ or ArgumentException
+ or NotSupportedException)
+ {
+ // Preserve the primary capture error. The next artifact directory
+ // teardown reports any file that could not be cleaned.
+ }
+ }
+
internal static byte[] FlipRows(byte[] pixels, int width, int height)
{
int stride = checked(width * 4);
@@ -249,3 +295,9 @@ internal sealed class FrameScreenshotController
}
}
+
+internal sealed record FrameScreenshotMetadata(
+ int SchemaVersion,
+ int Width,
+ int Height,
+ RenderPackDiagnosticsSnapshot RenderPack);
diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs
index 0ac7241e..3122834f 100644
--- a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs
+++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs
@@ -240,6 +240,22 @@ internal sealed class WorldLifecycleAutomationController :
private readonly Func
_getTransitOwnership;
private readonly Func _getPortalMaterializationCount;
+ private readonly Func _getRenderPackPerformanceSampleCount;
+ private readonly Func _getRenderPackFailedToRetail;
+ private readonly Func
+ _getRenderPackStatus;
+ private readonly Func
+ _selectRenderPack;
+ private readonly Func<(bool Succeeded, string Error)>?
+ _disableRenderPack;
+ private readonly Func<(bool Succeeded, string Error)>?
+ _reenableRenderPack;
+ private readonly Func<(int Width, int Height)> _getFramebufferSize;
+ private readonly Func
+ _resizeFramebuffer;
+ private readonly Func<(bool Succeeded, string Error)>
+ _resetRenderPackPerformance;
+ private readonly Action? _requestClientClose;
private readonly Func
_captureResources;
private readonly FrameScreenshotController _screenshots;
@@ -248,6 +264,7 @@ internal sealed class WorldLifecycleAutomationController :
private readonly object _requestOwner = new();
private readonly object _sync = new();
private readonly Queue _requests = [];
+ private string? _lastEnabledRenderPackPreset;
private int _sequence;
private bool _disposed;
@@ -260,7 +277,17 @@ internal sealed class WorldLifecycleAutomationController :
Func captureResources,
FrameScreenshotController screenshots,
string artifactDirectory,
- Action? log = null)
+ Action? log = null,
+ Func? getRenderPackPerformanceSampleCount = null,
+ Func<(bool Succeeded, string Error)>? resetRenderPackPerformance = null,
+ Func? getRenderPackFailedToRetail = null,
+ Func? getRenderPackStatus = null,
+ Func? selectRenderPack = null,
+ Func<(bool Succeeded, string Error)>? disableRenderPack = null,
+ Func<(bool Succeeded, string Error)>? reenableRenderPack = null,
+ Func<(int Width, int Height)>? getFramebufferSize = null,
+ Func? resizeFramebuffer = null,
+ Action? requestClientClose = null)
{
_getReveal = getReveal ?? throw new ArgumentNullException(nameof(getReveal));
_getEnvironmentOwnership = getEnvironmentOwnership
@@ -270,6 +297,21 @@ internal sealed class WorldLifecycleAutomationController :
?? throw new ArgumentNullException(nameof(getTransitOwnership));
_getPortalMaterializationCount = getPortalMaterializationCount
?? throw new ArgumentNullException(nameof(getPortalMaterializationCount));
+ _getRenderPackPerformanceSampleCount =
+ getRenderPackPerformanceSampleCount ?? (() => 0);
+ _getRenderPackFailedToRetail = getRenderPackFailedToRetail ?? (() => false);
+ _getRenderPackStatus = getRenderPackStatus
+ ?? (() => RetailUiAutomationRenderPackStatus.Retail);
+ _selectRenderPack = selectRenderPack
+ ?? (_ => (false, "render-pack selection automation is unavailable"));
+ _disableRenderPack = disableRenderPack;
+ _reenableRenderPack = reenableRenderPack;
+ _getFramebufferSize = getFramebufferSize ?? (() => (0, 0));
+ _resizeFramebuffer = resizeFramebuffer
+ ?? ((_, _) => (false, "framebuffer resize automation is unavailable"));
+ _resetRenderPackPerformance = resetRenderPackPerformance
+ ?? (() => (false, "render-pack performance automation is unavailable"));
+ _requestClientClose = requestClientClose;
_captureResources = captureResources ?? throw new ArgumentNullException(nameof(captureResources));
_screenshots = screenshots ?? throw new ArgumentNullException(nameof(screenshots));
_artifactDirectory = string.IsNullOrWhiteSpace(artifactDirectory)
@@ -281,6 +323,113 @@ internal sealed class WorldLifecycleAutomationController :
public bool IsWorldReady => _getReveal().IsReady;
public bool IsWorldViewportVisible => _getReveal().WorldViewportObserved;
public int PortalMaterializationCount => _getPortalMaterializationCount();
+ public int RenderPackPerformanceSampleCount =>
+ _getRenderPackPerformanceSampleCount();
+ public bool RenderPackFailedToRetail => _getRenderPackFailedToRetail();
+ public RetailUiAutomationRenderPackStatus RenderPackStatus =>
+ _getRenderPackStatus();
+ public int FramebufferWidth => _getFramebufferSize().Width;
+ public int FramebufferHeight => _getFramebufferSize().Height;
+
+ public bool TrySelectRenderPack(string presetId, out string error)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(presetId);
+ string normalized = presetId.ToLowerInvariant();
+ if (normalized == "off")
+ normalized = "retail";
+ if (normalized is not ("retail" or "low" or "medium" or "high" or "auto"))
+ {
+ error = $"unknown render-pack preset '{presetId}'";
+ return false;
+ }
+
+ (bool succeeded, string selectionError) = _selectRenderPack(normalized);
+ if (succeeded && normalized != "retail")
+ _lastEnabledRenderPackPreset = normalized;
+ error = selectionError;
+ return succeeded;
+ }
+
+ public bool TryDisableRenderPack(out string error)
+ {
+ if (_disableRenderPack is not null)
+ {
+ (bool succeeded, string disableError) = _disableRenderPack();
+ error = disableError;
+ return succeeded;
+ }
+ RetailUiAutomationRenderPackStatus current = RenderPackStatus;
+ if (current.State == RetailUiAutomationRenderPackState.Active
+ && !string.Equals(current.PackId, "retail", StringComparison.OrdinalIgnoreCase))
+ {
+ _lastEnabledRenderPackPreset = current.PresetId;
+ }
+ return TrySelectRenderPack("retail", out error);
+ }
+
+ public bool TryReenableRenderPack(out string error)
+ {
+ if (_reenableRenderPack is not null)
+ {
+ (bool succeeded, string reenableError) = _reenableRenderPack();
+ error = reenableError;
+ return succeeded;
+ }
+ if (string.IsNullOrWhiteSpace(_lastEnabledRenderPackPreset))
+ {
+ error = "render-pack re-enable requires a prior active enhanced selection";
+ return false;
+ }
+ return TrySelectRenderPack(_lastEnabledRenderPackPreset, out error);
+ }
+
+ public bool TryResizeFramebuffer(int width, int height, out string error)
+ {
+ if (width < 320 || height < 240 || width > 8192 || height > 8192)
+ {
+ error = "automation framebuffer size must be within 320x240 and 8192x8192";
+ return false;
+ }
+ (bool succeeded, string resizeError) = _resizeFramebuffer(width, height);
+ error = resizeError;
+ return succeeded;
+ }
+
+ public bool TryResetRenderPackPerformance(out string error)
+ {
+ // A terminal fallback owns no enhanced evidence. Treat reset as an
+ // idempotent no-op so a reset/wait/screenshot automation sequence can
+ // report the unavailable preset instead of stopping before capture.
+ if (RenderPackFailedToRetail)
+ {
+ error = string.Empty;
+ return true;
+ }
+ (bool succeeded, string resetError) = _resetRenderPackPerformance();
+ error = resetError;
+ return succeeded;
+ }
+
+ public bool TryRequestClientClose(out string error)
+ {
+ if (_requestClientClose is null)
+ {
+ error = "client-close automation is unavailable";
+ return false;
+ }
+
+ try
+ {
+ _requestClientClose();
+ error = string.Empty;
+ return true;
+ }
+ catch (Exception exception)
+ {
+ error = $"client-close automation failed: {exception.Message}";
+ return false;
+ }
+ }
public bool TryRequestCheckpoint(
string name,
diff --git a/src/AcDream.App/Plugins/BufferedRenderPackRegistry.cs b/src/AcDream.App/Plugins/BufferedRenderPackRegistry.cs
new file mode 100644
index 00000000..0b034187
--- /dev/null
+++ b/src/AcDream.App/Plugins/BufferedRenderPackRegistry.cs
@@ -0,0 +1,171 @@
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.App.Plugins;
+
+///
+/// Pre-device render-pack discovery buffer. Registration only retains the
+/// immutable declaration and lazy asset source; it deliberately never calls
+/// or touches the renderer. This lets
+/// plugins register before the window/GPU exists without weakening the retail
+/// no-op contract.
+///
+internal sealed class BufferedRenderPackRegistry : IRenderPackRegistry, IDisposable
+{
+ private readonly object _sync = new();
+ private readonly Dictionary _registrations =
+ new(StringComparer.OrdinalIgnoreCase);
+ private long _revision;
+ private long _nextRegistrationId;
+ private bool _disposed;
+
+ ///
+ /// Monotonic catalog generation. Consumers use to
+ /// invalidate their cached view and consume the new snapshot at a safe
+ /// frame/UI boundary; no renderer path polls the registry per frame.
+ ///
+ internal long Revision
+ {
+ get
+ {
+ lock (_sync)
+ return _revision;
+ }
+ }
+
+ internal event Action? Changed;
+
+ internal IReadOnlyList Snapshot()
+ {
+ lock (_sync)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ return _registrations.Values
+ .OrderBy(static value => value.Descriptor.Id, StringComparer.OrdinalIgnoreCase)
+ .Select(static value => new BufferedRenderPackRegistration(
+ value.Descriptor,
+ value.Assets,
+ value.RegistrationId))
+ .ToArray();
+ }
+ }
+
+ public IDisposable Register(
+ RenderPackDescriptor descriptor,
+ IRenderPackAssets assets)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(assets);
+
+ Registration registration;
+ long revision;
+ lock (_sync)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_registrations.ContainsKey(descriptor.Id))
+ {
+ throw new InvalidOperationException(
+ $"A render pack with id '{descriptor.Id}' is already registered.");
+ }
+
+ registration = new Registration(
+ this,
+ descriptor,
+ assets,
+ checked(++_nextRegistrationId));
+ _registrations.Add(descriptor.Id, registration);
+ revision = checked(++_revision);
+ }
+
+ PublishChanged(revision);
+ return registration;
+ }
+
+ public void Dispose()
+ {
+ Registration[] registrations;
+ long? revision = null;
+ lock (_sync)
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ registrations = _registrations.Values.ToArray();
+ _registrations.Clear();
+ if (registrations.Length != 0)
+ revision = checked(++_revision);
+ }
+
+ foreach (Registration registration in registrations)
+ registration.WithdrawFromOwner();
+ if (revision is { } changedRevision)
+ PublishChanged(changedRevision);
+ }
+
+ private void Withdraw(Registration registration)
+ {
+ long? revision = null;
+ lock (_sync)
+ {
+ if (_registrations.TryGetValue(
+ registration.Descriptor.Id,
+ out Registration? active)
+ && ReferenceEquals(active, registration))
+ {
+ _registrations.Remove(registration.Descriptor.Id);
+ revision = checked(++_revision);
+ }
+ }
+
+ if (revision is { } changedRevision)
+ PublishChanged(changedRevision);
+ }
+
+ private void PublishChanged(long revision)
+ {
+ Delegate[] subscribers = Changed?.GetInvocationList() ?? [];
+ foreach (Delegate subscriber in subscribers)
+ {
+ try { ((Action)subscriber)(revision); }
+ catch
+ {
+ // Registration ownership must not be corrupted by a UI or
+ // controller observer. The next explicit snapshot still sees
+ // the authoritative revision and contents.
+ }
+ }
+ }
+
+ private sealed class Registration : IDisposable
+ {
+ private BufferedRenderPackRegistry? _owner;
+
+ internal Registration(
+ BufferedRenderPackRegistry owner,
+ RenderPackDescriptor descriptor,
+ IRenderPackAssets assets,
+ long registrationId)
+ {
+ _owner = owner;
+ Descriptor = descriptor;
+ Assets = assets;
+ RegistrationId = registrationId;
+ }
+
+ internal RenderPackDescriptor Descriptor { get; }
+
+ internal IRenderPackAssets Assets { get; }
+
+ internal long RegistrationId { get; }
+
+ public void Dispose() =>
+ Interlocked.Exchange(ref _owner, null)?.Withdraw(this);
+
+ internal void WithdrawFromOwner() =>
+ Interlocked.Exchange(ref _owner, null);
+ }
+}
+
+internal sealed record BufferedRenderPackRegistration(
+ RenderPackDescriptor Descriptor,
+ IRenderPackAssets Assets,
+ long RegistrationId);
diff --git a/src/AcDream.App/Plugins/GraphicalPluginSession.cs b/src/AcDream.App/Plugins/GraphicalPluginSession.cs
index a968028c..e7b1f007 100644
--- a/src/AcDream.App/Plugins/GraphicalPluginSession.cs
+++ b/src/AcDream.App/Plugins/GraphicalPluginSession.cs
@@ -1,6 +1,7 @@
using AcDream.Core.Plugins;
using AcDream.Platform;
using AcDream.Plugin.Abstractions;
+using AcDream.Plugin.Abstractions.Rendering;
using AcDream.Runtime.Session;
namespace AcDream.App.Plugins;
@@ -44,7 +45,8 @@ internal sealed class GraphicalPluginSession : IDisposable
IReadOnlyList? allowList,
string sessionId,
IPluginHost host,
- SessionStatusWriter statusWriter)
+ SessionStatusWriter statusWriter,
+ IRenderPackRegistry? renderPacks = null)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
@@ -53,7 +55,11 @@ internal sealed class GraphicalPluginSession : IDisposable
var plugins = new PluginSession(
host,
- status => Report(statusWriter, sessionId, status));
+ status => Report(statusWriter, sessionId, status),
+ renderPacks,
+ renderPacks is null
+ ? [PluginKind.Gameplay]
+ : [PluginKind.Gameplay, PluginKind.RenderPack]);
return new GraphicalPluginSession(
plugins,
[
diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs
index d62cbb7b..32c3fecf 100644
--- a/src/AcDream.App/Program.cs
+++ b/src/AcDream.App/Program.cs
@@ -149,6 +149,15 @@ if (runtimeOptions.DevTools)
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
+using var renderPackRegistry = new AcDream.App.Plugins.BufferedRenderPackRegistry();
+using IDisposable atmosphericPackRegistration = renderPackRegistry.Register(
+ AcDream.App.Rendering.Packs.BuiltInAtmosphericRenderPack.Descriptor,
+ AcDream.App.Rendering.Packs.BuiltInAtmosphericRenderPack.CreateAssets(
+ Path.Combine(
+ AppContext.BaseDirectory,
+ "Rendering",
+ "Shaders",
+ "spv")));
// Constructed here and handed to both sides: GameWindow binds it to the live
// session's Runtime owners, the plugin host exposes it to plugins.
using var automation = new AcDream.App.Plugins.AppAutomationSurface();
@@ -158,7 +167,8 @@ using var window = new GameWindow(
worldEvents,
uiRegistry,
graphicalPlatform,
- automation);
+ automation,
+ renderPackRegistry);
var host = new AppPluginHost(
new SerilogAdapter(Log.Logger),
worldGameState,
@@ -171,7 +181,8 @@ GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
runtimeOptions.Plugins,
runtimeOptions.SessionId ?? "app",
host,
- window.StatusWriter);
+ window.StatusWriter,
+ renderPackRegistry);
window.StartPluginHosting(pluginSession);
try
diff --git a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
index aa279667..c8a4c2ce 100644
--- a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
+++ b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
@@ -194,14 +194,14 @@ internal sealed class RhiCompositeTextureArrayBackend : ICompositeTextureArrayBa
}
///
- /// The GL backend reads GL_MAX_ARRAY_TEXTURE_LAYERS. The pinned
- /// has no array-layer field and §3.3 is
- /// frozen, so this reports Vulkan's guaranteed maxImageArrayLayers
- /// minimum of 256. That is not a limitation in practice:
+ /// The selected Vulkan adapter's probed maxImageArrayLayers. This is
+ /// normally far above the cache's own bound:
/// caps every
/// array at 64, so the true device limit is never the binding constraint.
///
- public int MaximumArrayLayers => 256;
+ public int MaximumArrayLayers => checked((int)Math.Min(
+ _device.Capabilities.MaxImageArrayLayers,
+ (uint)int.MaxValue));
public CompositeTextureArrayResource Create(int width, int height, int capacity)
{
diff --git a/src/AcDream.App/Rendering/DirectionalShadowCascadeFitter.cs b/src/AcDream.App/Rendering/DirectionalShadowCascadeFitter.cs
new file mode 100644
index 00000000..72a861dc
--- /dev/null
+++ b/src/AcDream.App/Rendering/DirectionalShadowCascadeFitter.cs
@@ -0,0 +1,286 @@
+using System.Numerics;
+
+namespace AcDream.App.Rendering;
+
+internal readonly record struct DirectionalShadowCascadeFitInput(
+ Matrix4x4 CameraView,
+ Matrix4x4 CameraProjection,
+ Vector3 SurfaceToLightDirection,
+ DirectionalShadowQuality Quality,
+ float CameraNearMeters = 0.1f,
+ float PracticalSplitLambda = 0.65f,
+ float CasterDepthPaddingMeters = 48f,
+ float ResidentMaximumReachMeters = float.PositiveInfinity);
+
+internal readonly record struct DirectionalShadowCascade(
+ int Index,
+ float SplitNearMeters,
+ float SplitFarMeters,
+ Matrix4x4 LightView,
+ Matrix4x4 LightProjection,
+ Matrix4x4 WorldToShadowClip,
+ Vector2 StabilizedLightSpaceCenter,
+ float HalfExtentMeters,
+ float TexelWorldSize,
+ float CasterDepthPaddingMeters,
+ DirectionalShadowWorldBias Bias);
+
+///
+/// Pure camera-relative cascade fitting. It receives no scene/PView callback,
+/// so fitting N cascades cannot trigger N CPU visibility traversals.
+///
+internal static class DirectionalShadowCascadeFitter
+{
+ private const float RadiusQuantizationMeters = 1f / 16f;
+
+ public static int Fit(
+ in DirectionalShadowCascadeFitInput input,
+ Span destination)
+ {
+ Validate(in input, destination.Length);
+ if (!Matrix4x4.Invert(input.CameraView, out Matrix4x4 inverseView))
+ throw new ArgumentException("Camera view matrix is not invertible.", nameof(input));
+ if (!Matrix4x4.Invert(input.CameraProjection, out Matrix4x4 inverseProjection))
+ throw new ArgumentException("Camera projection matrix is not invertible.", nameof(input));
+
+ Vector3 lightDirection = Vector3.Normalize(input.SurfaceToLightDirection);
+ float maximumReach = MathF.Min(
+ input.Quality.MaximumReachMeters,
+ input.ResidentMaximumReachMeters);
+ if (maximumReach <= input.CameraNearMeters)
+ return 0;
+ float splitNear = input.CameraNearMeters;
+ Span corners = stackalloc Vector3[8];
+ for (int cascadeIndex = 0;
+ cascadeIndex < input.Quality.CascadeCount;
+ cascadeIndex++)
+ {
+ float splitFar = PracticalSplit(
+ input.CameraNearMeters,
+ maximumReach,
+ cascadeIndex + 1,
+ input.Quality.CascadeCount,
+ input.PracticalSplitLambda);
+ BuildFrustumSliceCorners(
+ inverseView,
+ inverseProjection,
+ splitNear,
+ splitFar,
+ corners);
+ destination[cascadeIndex] = FitCascade(
+ cascadeIndex,
+ splitNear,
+ splitFar,
+ corners,
+ lightDirection,
+ input.Quality.MapResolution,
+ input.CasterDepthPaddingMeters,
+ input.Quality.BiasPolicy);
+ splitNear = splitFar;
+ }
+
+ return input.Quality.CascadeCount;
+ }
+
+ internal static float PracticalSplit(
+ float nearMeters,
+ float farMeters,
+ int splitIndex,
+ int splitCount,
+ float lambda)
+ {
+ if (!float.IsFinite(nearMeters)
+ || !float.IsFinite(farMeters)
+ || nearMeters <= 0f
+ || farMeters <= nearMeters)
+ {
+ throw new ArgumentOutOfRangeException(nameof(farMeters));
+ }
+ if (splitCount <= 0 || splitIndex <= 0 || splitIndex > splitCount)
+ throw new ArgumentOutOfRangeException(nameof(splitIndex));
+ if (!float.IsFinite(lambda) || lambda < 0f || lambda > 1f)
+ throw new ArgumentOutOfRangeException(nameof(lambda));
+
+ float fraction = (float)splitIndex / splitCount;
+ float logarithmic = nearMeters * MathF.Pow(farMeters / nearMeters, fraction);
+ float uniform = nearMeters + (farMeters - nearMeters) * fraction;
+ return lambda * logarithmic + (1f - lambda) * uniform;
+ }
+
+ private static DirectionalShadowCascade FitCascade(
+ int index,
+ float splitNear,
+ float splitFar,
+ ReadOnlySpan corners,
+ Vector3 surfaceToLight,
+ int mapResolution,
+ float depthPadding,
+ in DirectionalShadowBiasPolicy biasPolicy)
+ {
+ Vector3 center = Vector3.Zero;
+ for (int i = 0; i < corners.Length; i++)
+ center += corners[i];
+ center /= corners.Length;
+
+ float radius = 0f;
+ for (int i = 0; i < corners.Length; i++)
+ radius = MathF.Max(radius, Vector3.Distance(center, corners[i]));
+ radius = MathF.Ceiling(radius / RadiusQuantizationMeters)
+ * RadiusQuantizationMeters;
+ radius = MathF.Max(radius, RadiusQuantizationMeters);
+
+ Vector3 up = StableLightUp(surfaceToLight);
+ Matrix4x4 lightRotation = Matrix4x4.CreateLookAt(
+ Vector3.Zero,
+ -surfaceToLight,
+ up);
+
+ Vector3 lightCenter = Vector3.Transform(center, lightRotation);
+ float texelWorldSize = (2f * radius) / mapResolution;
+ float snappedX = SnapToTexel(lightCenter.X, texelWorldSize);
+ float snappedY = SnapToTexel(lightCenter.Y, texelWorldSize);
+
+ float minZ = float.PositiveInfinity;
+ float maxZ = float.NegativeInfinity;
+ for (int i = 0; i < corners.Length; i++)
+ {
+ float z = Vector3.Transform(corners[i], lightRotation).Z;
+ minZ = MathF.Min(minZ, z);
+ maxZ = MathF.Max(maxZ, z);
+ }
+
+ // Move the light eye toward the selected celestial source. The
+ // receiver slice then lies
+ // between depthPadding and span+depthPadding metres in front of it,
+ // while the far extension admits casters behind the slice as well.
+ float eyeAxis = maxZ + depthPadding;
+ Vector3 eye = surfaceToLight * eyeAxis;
+ Matrix4x4 lightView = Matrix4x4.CreateLookAt(
+ eye,
+ eye - surfaceToLight,
+ up);
+ float nearPlane = 0.1f;
+ float farPlane = MathF.Max(
+ nearPlane + 0.1f,
+ (maxZ - minZ) + 2f * depthPadding);
+ Matrix4x4 lightProjection = Matrix4x4.CreateOrthographicOffCenter(
+ snappedX - radius,
+ snappedX + radius,
+ snappedY - radius,
+ snappedY + radius,
+ nearPlane,
+ farPlane);
+
+ return new DirectionalShadowCascade(
+ index,
+ splitNear,
+ splitFar,
+ lightView,
+ lightProjection,
+ lightView * lightProjection,
+ new Vector2(snappedX, snappedY),
+ radius,
+ texelWorldSize,
+ depthPadding,
+ biasPolicy.Resolve(texelWorldSize));
+ }
+
+ private static void BuildFrustumSliceCorners(
+ Matrix4x4 inverseView,
+ Matrix4x4 inverseProjection,
+ float nearMeters,
+ float farMeters,
+ Span destination)
+ {
+ int cursor = 0;
+ for (int depthIndex = 0; depthIndex < 2; depthIndex++)
+ {
+ float distance = depthIndex == 0 ? nearMeters : farMeters;
+ for (int yIndex = 0; yIndex < 2; yIndex++)
+ {
+ float y = yIndex == 0 ? -1f : 1f;
+ for (int xIndex = 0; xIndex < 2; xIndex++)
+ {
+ float x = xIndex == 0 ? -1f : 1f;
+ Vector4 viewCorner = Vector4.Transform(
+ new Vector4(x, y, 1f, 1f),
+ inverseProjection);
+ if (MathF.Abs(viewCorner.W) <= 1e-6f)
+ throw new ArgumentException("Camera projection produced a corner at infinity.");
+ Vector3 view = new(
+ viewCorner.X / viewCorner.W,
+ viewCorner.Y / viewCorner.W,
+ viewCorner.Z / viewCorner.W);
+ float viewDepth = MathF.Abs(view.Z);
+ if (viewDepth <= 1e-6f)
+ throw new ArgumentException("Camera projection produced zero view depth.");
+ view *= distance / viewDepth;
+ destination[cursor++] = Vector3.Transform(view, inverseView);
+ }
+ }
+ }
+ }
+
+ private static float SnapToTexel(float value, float texelWorldSize) =>
+ MathF.Round(value / texelWorldSize, MidpointRounding.AwayFromZero)
+ * texelWorldSize;
+
+ ///
+ /// Uses Duff's numerically stable revision of Frisvad's orthonormal basis.
+ /// The selected celestial source occupies the accepted upper hemisphere,
+ /// where this basis varies continuously through the exact zenith. The old
+ /// 0.95 dot-product branch rotated the cascade basis abruptly, while
+ /// projected world-up merely moved that discontinuity to exact zenith.
+ ///
+ internal static Vector3 StableLightUp(Vector3 surfaceToLight)
+ {
+ surfaceToLight = Vector3.Normalize(surfaceToLight);
+ float sign = MathF.CopySign(1f, surfaceToLight.Z);
+ float a = -1f / (sign + surfaceToLight.Z);
+ float b = surfaceToLight.X * surfaceToLight.Y * a;
+ return Vector3.Normalize(new Vector3(
+ b,
+ sign + surfaceToLight.Y * surfaceToLight.Y * a,
+ -surfaceToLight.Y));
+ }
+
+ private static void Validate(
+ in DirectionalShadowCascadeFitInput input,
+ int destinationLength)
+ {
+ DirectionalShadowQuality quality = input.Quality;
+ if (quality.CascadeCount <= 0 || quality.CascadeCount > 4)
+ throw new ArgumentOutOfRangeException(nameof(input), "Cascade count must be in [1,4].");
+ if (destinationLength < quality.CascadeCount)
+ throw new ArgumentException("Destination cannot hold every configured cascade.");
+ if (quality.MapResolution <= 0
+ || !float.IsFinite(quality.MaximumReachMeters)
+ || quality.MaximumReachMeters <= input.CameraNearMeters)
+ {
+ throw new ArgumentOutOfRangeException(nameof(input), "Shadow quality dimensions are invalid.");
+ }
+ if (!float.IsFinite(input.CameraNearMeters) || input.CameraNearMeters <= 0f)
+ throw new ArgumentOutOfRangeException(nameof(input), "Camera near distance must be positive.");
+ if (float.IsNaN(input.ResidentMaximumReachMeters)
+ || input.ResidentMaximumReachMeters < 0f)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(input),
+ "Resident shadow reach must be nonnegative or positive infinity.");
+ }
+ if (!float.IsFinite(input.PracticalSplitLambda)
+ || input.PracticalSplitLambda < 0f
+ || input.PracticalSplitLambda > 1f)
+ {
+ throw new ArgumentOutOfRangeException(nameof(input), "Split lambda must be in [0,1].");
+ }
+ if (!float.IsFinite(input.CasterDepthPaddingMeters)
+ || input.CasterDepthPaddingMeters <= 0f)
+ {
+ throw new ArgumentOutOfRangeException(nameof(input), "Caster depth padding must be positive.");
+ }
+ float lightLength = input.SurfaceToLightDirection.Length();
+ if (!float.IsFinite(lightLength) || lightLength <= 1e-6f)
+ throw new ArgumentOutOfRangeException(nameof(input), "Light direction must be finite and nonzero.");
+ }
+}
diff --git a/src/AcDream.App/Rendering/DirectionalShadowQuality.cs b/src/AcDream.App/Rendering/DirectionalShadowQuality.cs
new file mode 100644
index 00000000..7c8d6c6d
--- /dev/null
+++ b/src/AcDream.App/Rendering/DirectionalShadowQuality.cs
@@ -0,0 +1,362 @@
+using AcDream.App.Rendering.Packs;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering;
+
+///
+/// User-visible quality rows for Dereth's selected celestial directional shadows. Presets may
+/// reduce count, resolution, reach, and filtering cost; they never remove a
+/// headline caster class.
+///
+internal enum DirectionalShadowPreset : byte
+{
+ Low,
+ Medium,
+ High,
+}
+
+[Flags]
+internal enum DirectionalShadowSemantics : ushort
+{
+ None = 0,
+ Terrain = 1 << 0,
+ TreesAndOutdoorStatics = 1 << 1,
+ Buildings = 1 << 2,
+ Players = 1 << 3,
+ Monsters = 1 << 4,
+ AnimatedTransforms = 1 << 5,
+ AlphaCutoutCasters = 1 << 6,
+
+ Headline = Terrain
+ | TreesAndOutdoorStatics
+ | Buildings
+ | Players
+ | Monsters
+ | AnimatedTransforms
+ | AlphaCutoutCasters,
+}
+
+///
+/// Converts shadow-map texel footprint into receiver-side offsets expressed in
+/// metres. No term is an NDC constant: the projection may change without
+/// silently changing the amount of world geometry displaced.
+///
+internal readonly record struct DirectionalShadowBiasPolicy(
+ float ConstantTexels,
+ float SlopeTexels,
+ float NormalTexels,
+ float MinimumMeters,
+ float MaximumMeters)
+{
+ public DirectionalShadowWorldBias Resolve(float texelWorldSize)
+ {
+ if (!float.IsFinite(texelWorldSize) || texelWorldSize <= 0f)
+ throw new ArgumentOutOfRangeException(nameof(texelWorldSize));
+ if (!float.IsFinite(MinimumMeters)
+ || !float.IsFinite(MaximumMeters)
+ || MinimumMeters < 0f
+ || MaximumMeters < MinimumMeters)
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow bias bounds must be finite, non-negative, and ordered.");
+ }
+
+ var minimumMeters = MinimumMeters;
+ var maximumMeters = MaximumMeters;
+ return new DirectionalShadowWorldBias(
+ ConstantDepthMeters: Math.Clamp(
+ ConstantTexels * texelWorldSize,
+ minimumMeters,
+ maximumMeters),
+ SlopeDepthMeters: Math.Clamp(
+ SlopeTexels * texelWorldSize,
+ minimumMeters,
+ maximumMeters),
+ NormalOffsetMeters: Math.Clamp(
+ NormalTexels * texelWorldSize,
+ minimumMeters,
+ maximumMeters));
+ }
+}
+
+internal readonly record struct DirectionalShadowWorldBias(
+ float ConstantDepthMeters,
+ float SlopeDepthMeters,
+ float NormalOffsetMeters);
+
+internal readonly record struct DirectionalShadowQuality(
+ DirectionalShadowPreset Preset,
+ int CascadeCount,
+ int MapResolution,
+ float MaximumReachMeters,
+ int PcfRadiusTexels,
+ long ApproximateDepthMapBytes,
+ double IncrementalGpuP50BudgetMilliseconds,
+ double IncrementalGpuP99BudgetMilliseconds,
+ double IncrementalCpuP50BudgetMilliseconds,
+ double IncrementalCpuP99BudgetMilliseconds,
+ long PackResidentGpuByteBudget,
+ DirectionalShadowSemantics Semantics,
+ DirectionalShadowBiasPolicy BiasPolicy)
+{
+ private const long MiB = 1024L * 1024L;
+
+ public static DirectionalShadowQuality For(DirectionalShadowPreset preset) =>
+ preset switch
+ {
+ DirectionalShadowPreset.Low => Create(
+ preset,
+ cascades: 2,
+ // The physical integrated-GPU row funds Low's cheaper
+ // quarter-resolution separable post path by reducing only
+ // texel density. Both cascades and every semantic caster
+ // class remain present.
+ resolution: 768,
+ reachMeters: 72f,
+ pcfRadius: 0,
+ gpuP50: 2.0,
+ gpuP99: 3.0,
+ cpuP50: 0.15,
+ cpuP99: 0.50,
+ residentBudget: 64L * MiB,
+ bias: new DirectionalShadowBiasPolicy(
+ 0.45f, 1.25f, 1.0f, 0.001f, 0.35f)),
+ DirectionalShadowPreset.Medium => Create(
+ preset,
+ cascades: 3,
+ resolution: 1536,
+ reachMeters: 144f,
+ pcfRadius: 1,
+ gpuP50: 3.25,
+ gpuP99: 4.50,
+ cpuP50: 0.25,
+ cpuP99: 0.75,
+ residentBudget: 128L * MiB,
+ bias: new DirectionalShadowBiasPolicy(
+ 0.40f, 1.15f, 0.9f, 0.001f, 0.30f)),
+ DirectionalShadowPreset.High => Create(
+ preset,
+ cascades: 4,
+ resolution: 2048,
+ reachMeters: 240f,
+ pcfRadius: 2,
+ gpuP50: 4.50,
+ gpuP99: 6.00,
+ cpuP50: 0.35,
+ cpuP99: 1.00,
+ residentBudget: 256L * MiB,
+ bias: new DirectionalShadowBiasPolicy(
+ 0.35f, 1.0f, 0.8f, 0.001f, 0.25f)),
+ _ => throw new ArgumentOutOfRangeException(nameof(preset), preset, null),
+ };
+
+ private static DirectionalShadowQuality Create(
+ DirectionalShadowPreset preset,
+ int cascades,
+ int resolution,
+ float reachMeters,
+ int pcfRadius,
+ double gpuP50,
+ double gpuP99,
+ double cpuP50,
+ double cpuP99,
+ long residentBudget,
+ DirectionalShadowBiasPolicy bias) =>
+ new(
+ preset,
+ cascades,
+ resolution,
+ reachMeters,
+ pcfRadius,
+ checked((long)cascades * resolution * resolution * sizeof(float)),
+ gpuP50,
+ gpuP99,
+ cpuP50,
+ cpuP99,
+ residentBudget,
+ DirectionalShadowSemantics.Headline,
+ bias);
+}
+
+internal enum DirectionalShadowGateReason : byte
+{
+ Enabled,
+ PackDisabled,
+ PortalOrLoginCover,
+ Indoor,
+ NoVisibleCelestial,
+ SelectedLightBelowHorizon,
+ SelectedLightHasNoEnergy,
+ AtmosphereSuppressed,
+ ResidentWindowUnavailable,
+}
+
+///
+/// Visible policy owned by the selected atmospheric pack. AC continues to own
+/// the sky and weather inputs; these values only map them to enhancement
+/// strength and softness.
+///
+internal readonly record struct DirectionalShadowAtmospherePolicy(
+ float MinimumLightElevationSin,
+ float FullStrengthLightElevationSin,
+ float ClearStrength,
+ float OvercastStrength,
+ float RainStrength,
+ float SnowStrength,
+ float StormStrength,
+ float ClearSoftness,
+ float OvercastSoftness,
+ float RainSoftness,
+ float SnowSoftness,
+ float StormSoftness)
+{
+ public static DirectionalShadowAtmospherePolicy BuiltIn { get; } = new(
+ MinimumLightElevationSin: MathF.Sin(MathF.PI / 180f),
+ FullStrengthLightElevationSin: MathF.Sin(12f * MathF.PI / 180f),
+ ClearStrength: 1.0f,
+ OvercastStrength: 0.65f,
+ RainStrength: 0.45f,
+ SnowStrength: 0.60f,
+ StormStrength: 0.25f,
+ ClearSoftness: 1.0f,
+ OvercastSoftness: 1.5f,
+ RainSoftness: 1.8f,
+ SnowSoftness: 1.6f,
+ StormSoftness: 2.0f);
+
+ public float StrengthFor(WeatherKind weather) => weather switch
+ {
+ WeatherKind.Clear => ClearStrength,
+ WeatherKind.Overcast => OvercastStrength,
+ WeatherKind.Rain => RainStrength,
+ WeatherKind.Snow => SnowStrength,
+ WeatherKind.Storm => StormStrength,
+ _ => throw new ArgumentOutOfRangeException(nameof(weather), weather, null),
+ };
+
+ public float SoftnessFor(WeatherKind weather) => weather switch
+ {
+ WeatherKind.Clear => ClearSoftness,
+ WeatherKind.Overcast => OvercastSoftness,
+ WeatherKind.Rain => RainSoftness,
+ WeatherKind.Snow => SnowSoftness,
+ WeatherKind.Storm => StormSoftness,
+ _ => throw new ArgumentOutOfRangeException(nameof(weather), weather, null),
+ };
+}
+
+internal readonly record struct DirectionalShadowEnvironmentInput(
+ bool PackEnabled,
+ bool PortalOrLoginCoverVisible,
+ bool PlayerInsideCell,
+ AuthoredCelestialShadowSource Source,
+ AtmosphereSnapshot Atmosphere,
+ float ActiveDayGroupMultiplier = 1f);
+
+internal readonly record struct DirectionalShadowEnvironmentState(
+ DirectionalShadowGateReason Reason,
+ System.Numerics.Vector3 SurfaceToLightDirection,
+ float LightElevationSin,
+ float Strength,
+ float SoftnessMultiplier,
+ AuthoredCelestialShadowSourceKind SourceKind =
+ AuthoredCelestialShadowSourceKind.None,
+ int SourceObjectIndex = -1,
+ uint SourceGfxObjId = 0u)
+{
+ public bool ShouldRender => Reason is DirectionalShadowGateReason.Enabled;
+}
+
+internal static class DirectionalShadowEnvironmentGate
+{
+ private const float MinimumDirectionalEnergy = 1e-5f;
+
+ public static DirectionalShadowEnvironmentState Evaluate(
+ in DirectionalShadowEnvironmentInput input,
+ in DirectionalShadowAtmospherePolicy policy)
+ {
+ if (!input.PackEnabled)
+ return Disabled(DirectionalShadowGateReason.PackDisabled);
+ if (input.PortalOrLoginCoverVisible)
+ return Disabled(DirectionalShadowGateReason.PortalOrLoginCover);
+ if (input.PlayerInsideCell)
+ return Disabled(DirectionalShadowGateReason.Indoor);
+
+ if (!input.Source.IsAvailable)
+ return Disabled(DirectionalShadowGateReason.NoVisibleCelestial);
+
+ System.Numerics.Vector3 surfaceToLight =
+ input.Source.SurfaceToLightDirection;
+ float elevation = input.Source.ElevationSin;
+ if (!float.IsFinite(elevation)
+ || elevation <= policy.MinimumLightElevationSin)
+ {
+ return new DirectionalShadowEnvironmentState(
+ DirectionalShadowGateReason.SelectedLightBelowHorizon,
+ surfaceToLight,
+ elevation,
+ 0f,
+ 1f,
+ input.Source.Kind,
+ input.Source.ObjectIndex,
+ input.Source.GfxObjId);
+ }
+
+ float energy = input.Source.AuthoredEnergy;
+ if (!float.IsFinite(energy)
+ || energy <= MinimumDirectionalEnergy)
+ {
+ return new DirectionalShadowEnvironmentState(
+ DirectionalShadowGateReason.SelectedLightHasNoEnergy,
+ surfaceToLight,
+ elevation,
+ 0f,
+ 1f,
+ input.Source.Kind,
+ input.Source.ObjectIndex,
+ input.Source.GfxObjId);
+ }
+
+ float elevationSpan = MathF.Max(
+ 1e-5f,
+ policy.FullStrengthLightElevationSin - policy.MinimumLightElevationSin);
+ float elevationStrength = Math.Clamp(
+ (elevation - policy.MinimumLightElevationSin) / elevationSpan,
+ 0f,
+ 1f);
+ float weatherStrength = policy.StrengthFor(input.Atmosphere.Kind);
+ float atmosphereProgress = Math.Clamp(input.Atmosphere.Intensity, 0f, 1f);
+ float dayGroupStrength = Math.Clamp(input.ActiveDayGroupMultiplier, 0f, 1f);
+ float strength = elevationStrength
+ * Math.Clamp(energy, 0f, 1f)
+ * weatherStrength
+ * atmosphereProgress
+ * dayGroupStrength;
+ if (!float.IsFinite(strength) || strength <= 0f)
+ {
+ return new DirectionalShadowEnvironmentState(
+ DirectionalShadowGateReason.AtmosphereSuppressed,
+ surfaceToLight,
+ elevation,
+ 0f,
+ policy.SoftnessFor(input.Atmosphere.Kind),
+ input.Source.Kind,
+ input.Source.ObjectIndex,
+ input.Source.GfxObjId);
+ }
+
+ return new DirectionalShadowEnvironmentState(
+ DirectionalShadowGateReason.Enabled,
+ surfaceToLight,
+ elevation,
+ Math.Clamp(strength, 0f, 1f),
+ MathF.Max(1f, policy.SoftnessFor(input.Atmosphere.Kind)),
+ input.Source.Kind,
+ input.Source.ObjectIndex,
+ input.Source.GfxObjId);
+ }
+
+ private static DirectionalShadowEnvironmentState Disabled(
+ DirectionalShadowGateReason reason) =>
+ new(reason, System.Numerics.Vector3.UnitZ, 0f, 0f, 1f);
+}
diff --git a/src/AcDream.App/Rendering/DirectionalShadowReceiver.cs b/src/AcDream.App/Rendering/DirectionalShadowReceiver.cs
new file mode 100644
index 00000000..0e023b9a
--- /dev/null
+++ b/src/AcDream.App/Rendering/DirectionalShadowReceiver.cs
@@ -0,0 +1,154 @@
+using System.Numerics;
+using AcDream.App.Rendering.Gpu;
+
+namespace AcDream.App.Rendering;
+
+///
+/// The ordinary, non-ref view of the directional-shadow allocation produced for
+/// one frame. The serial prevents a ring slice from leaking into a later frame.
+///
+internal readonly record struct DirectionalShadowFrameBinding(
+ long FrameSerial,
+ bool Enabled,
+ IGpuBuffer? Buffer,
+ uint OffsetBytes,
+ uint SizeBytes,
+ GpuTextureSlot TextureSlot,
+ int CascadeCount)
+{
+ internal static DirectionalShadowFrameBinding Disabled => default;
+
+ internal bool IsValidFor(IGpuFrame frame) =>
+ Enabled
+ && Buffer is not null
+ && FrameSerial == frame.Serial
+ && SizeBytes == DirectionalShadowUniforms.SizeInBytes
+ && TextureSlot.IsAssigned
+ && CascadeCount is >= 2 and <= 4;
+}
+
+///
+/// Receiver-side seam. A pack runtime may publish this source at a stable frame
+/// boundary without exposing the producer's target or ref-struct allocation.
+///
+internal interface IDirectionalShadowReceiverSource
+{
+ DirectionalShadowPipelineShaders PipelineShaders { get; }
+
+ bool TryGetCurrentFrameBinding(
+ IGpuFrame frame,
+ out DirectionalShadowFrameBinding binding);
+}
+
+internal readonly record struct DirectionalShadowPipelineShaders(
+ GpuShaderSet TerrainCaster,
+ GpuShaderSet WorldOpaqueCaster,
+ GpuShaderSet WorldAlphaCutoutCaster,
+ GpuShaderSet TerrainReceiver,
+ GpuShaderSet WorldReceiver)
+{
+ internal DirectionalShadowMultiviewPipelineShaders? MultiviewCasters { get; init; }
+
+ internal static DirectionalShadowPipelineShaders Local { get; } = new(
+ new GpuShaderSet("directional_shadow_terrain"),
+ new GpuShaderSet("directional_shadow_world_opaque"),
+ new GpuShaderSet("directional_shadow_world_cutout"),
+ new GpuShaderSet("terrain_atmospheric"),
+ new GpuShaderSet("mesh_atmospheric"))
+ {
+ MultiviewCasters = new DirectionalShadowMultiviewPipelineShaders(
+ new GpuShaderSet("directional_shadow_terrain_multiview"),
+ new GpuShaderSet("directional_shadow_world_opaque_multiview"),
+ new GpuShaderSet("directional_shadow_world_cutout_multiview")),
+ };
+}
+
+internal readonly record struct DirectionalShadowMultiviewPipelineShaders(
+ GpuShaderSet TerrainCaster,
+ GpuShaderSet WorldOpaqueCaster,
+ GpuShaderSet WorldAlphaCutoutCaster);
+
+internal readonly record struct DirectionalShadowCascadeBlend(
+ int PrimaryCascade,
+ int SecondaryCascade,
+ float SecondaryWeight,
+ bool WithinShadowReach);
+
+/// CPU mirror of receiver-only cascade and world-metre bias policy.
+internal static class DirectionalShadowReceiverPolicy
+{
+ internal const string AtmosphericWorldPassName = "atmospheric-world-hdr";
+
+ internal static bool ShouldSelectReceiverPipeline(
+ string passName,
+ bool sourcePresent,
+ bool bindingValid) =>
+ sourcePresent
+ && bindingValid
+ && string.Equals(
+ passName,
+ AtmosphericWorldPassName,
+ StringComparison.Ordinal);
+
+ internal static DirectionalShadowCascadeBlend SelectCascade(
+ float cameraDistanceMeters,
+ Vector4 splitFarMeters,
+ int cascadeCount,
+ float blendWidthMeters)
+ {
+ if (!float.IsFinite(cameraDistanceMeters) || cameraDistanceMeters < 0f)
+ throw new ArgumentOutOfRangeException(nameof(cameraDistanceMeters));
+ if (cascadeCount is < 2 or > 4)
+ throw new ArgumentOutOfRangeException(nameof(cascadeCount));
+ if (!float.IsFinite(blendWidthMeters) || blendWidthMeters < 0f)
+ throw new ArgumentOutOfRangeException(nameof(blendWidthMeters));
+
+ Span splits = stackalloc float[4]
+ {
+ splitFarMeters.X,
+ splitFarMeters.Y,
+ splitFarMeters.Z,
+ splitFarMeters.W,
+ };
+ for (int i = 0; i < cascadeCount; i++)
+ {
+ if (!float.IsFinite(splits[i])
+ || splits[i] <= 0f
+ || (i > 0 && splits[i] < splits[i - 1]))
+ {
+ throw new ArgumentException(
+ "Directional-shadow split distances must be finite, positive, and monotonic.",
+ nameof(splitFarMeters));
+ }
+ }
+
+ int primary = 0;
+ while (primary < cascadeCount && cameraDistanceMeters > splits[primary])
+ primary++;
+ if (primary == cascadeCount)
+ return new DirectionalShadowCascadeBlend(cascadeCount - 1, cascadeCount - 1, 0f, false);
+
+ if (primary == cascadeCount - 1 || blendWidthMeters <= 0f)
+ return new DirectionalShadowCascadeBlend(primary, primary, 0f, true);
+
+ float blendStart = MathF.Max(0f, splits[primary] - blendWidthMeters);
+ float t = Math.Clamp(
+ (cameraDistanceMeters - blendStart) / MathF.Max(blendWidthMeters, 1e-6f),
+ 0f,
+ 1f);
+ float smooth = t * t * (3f - 2f * t);
+ return new DirectionalShadowCascadeBlend(primary, primary + 1, smooth, true);
+ }
+
+ internal static float ReceiverBiasMeters(
+ in DirectionalShadowWorldBias bias,
+ float normalDotSurfaceToLight) =>
+ bias.ConstantDepthMeters
+ + bias.SlopeDepthMeters * (1f - Math.Clamp(normalDotSurfaceToLight, 0f, 1f));
+
+ internal static bool ShouldSample(
+ bool bindingEnabled,
+ bool indoor,
+ bool hasSelectedCelestialDirectionalLight) =>
+ bindingEnabled && !indoor && hasSelectedCelestialDirectionalLight;
+}
diff --git a/src/AcDream.App/Rendering/DirectionalShadowTransformBufferSet.cs b/src/AcDream.App/Rendering/DirectionalShadowTransformBufferSet.cs
new file mode 100644
index 00000000..9ecc4e32
--- /dev/null
+++ b/src/AcDream.App/Rendering/DirectionalShadowTransformBufferSet.cs
@@ -0,0 +1,439 @@
+using System.Numerics;
+using System.Runtime.InteropServices;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Wb;
+
+namespace AcDream.App.Rendering;
+
+internal readonly record struct DirectionalShadowTransformPublishStats(
+ bool TopologyUploaded,
+ int DynamicMatricesUpdated,
+ int DynamicRangesUpdated,
+ long BytesWritten,
+ int CurrentChangedMatrices = 0,
+ int PendingReplayMatrices = 0,
+ bool UsedFullDynamicFallback = false,
+ bool DenseDirectUpload = false,
+ bool DenseFlightReplay = false);
+
+///
+/// Pack-owned transform storage indexed by the RHI frame-flight slot. A slot is
+/// handed back only after its prior GPU submission retires, so stable topology
+/// can keep every static matrix in mapped storage and update only the exact
+/// dynamic indices already refreshed by .
+/// No animation, scene lookup, or pose derivation happens here.
+///
+internal sealed class DirectionalShadowTransformBufferSet : IDisposable
+{
+ private readonly IGpuDevice _device;
+ private SlotState[] _slots = [];
+ private ulong _denseTopologyBuildSequence;
+ private ulong _denseRevision = 1;
+ private bool _disposed;
+
+ internal DirectionalShadowTransformBufferSet(IGpuDevice device)
+ {
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ if (!device.Capabilities.SupportsPersistentlyMappedRings)
+ {
+ throw new NotSupportedException(
+ "Directional-shadow retained transforms require persistently mapped host-writable buffers.");
+ }
+ }
+
+ internal long RetainedGpuBytes
+ {
+ get
+ {
+ long total = 0;
+ for (int i = 0; i < _slots.Length; i++)
+ total = checked(total + (_slots[i].Buffer?.SizeBytes ?? 0L));
+ return total;
+ }
+ }
+
+ internal int BufferCount
+ {
+ get
+ {
+ int count = 0;
+ for (int i = 0; i < _slots.Length; i++)
+ {
+ if (_slots[i].Buffer is not null)
+ count++;
+ }
+ return count;
+ }
+ }
+
+ internal long RetainedScratchBytes
+ {
+ get
+ {
+ long bytes = checked((long)_slots.Length
+ * System.Runtime.CompilerServices.Unsafe.SizeOf());
+ for (int index = 0; index < _slots.Length; index++)
+ bytes = checked(bytes + (_slots[index].Pending?.RetainedBytes ?? 0L));
+ return bytes;
+ }
+ }
+
+ internal DirectionalShadowTransformPublishStats LastStats { get; private set; }
+
+ internal WorldTransformFrameSlice Publish(
+ IGpuFrame frame,
+ ulong topologyBuildSequence,
+ ReadOnlySpan transforms,
+ ReadOnlySpan dynamicTransformSlots)
+ {
+ return Publish(
+ frame,
+ topologyBuildSequence,
+ transforms,
+ dynamicTransformSlots,
+ dynamicTransformSlots,
+ denseRefresh: false);
+ }
+
+ internal WorldTransformFrameSlice Publish(
+ IGpuFrame frame,
+ ulong topologyBuildSequence,
+ ReadOnlySpan transforms,
+ ReadOnlySpan dynamicTransformSlots,
+ ReadOnlySpan allDynamicTransformSlots)
+ {
+ return Publish(
+ frame,
+ topologyBuildSequence,
+ transforms,
+ dynamicTransformSlots,
+ allDynamicTransformSlots,
+ denseRefresh: false);
+ }
+
+ internal WorldTransformFrameSlice Publish(
+ IGpuFrame frame,
+ ulong topologyBuildSequence,
+ ReadOnlySpan transforms,
+ ReadOnlySpan dynamicTransformSlots,
+ ReadOnlySpan allDynamicTransformSlots,
+ bool denseRefresh,
+ uint bindingSizeBytes = 0)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(frame);
+ if (topologyBuildSequence == 0)
+ throw new ArgumentOutOfRangeException(nameof(topologyBuildSequence));
+ uint requiredInstances = checked((uint)transforms.Length);
+ if (bindingSizeBytes == 0)
+ {
+ bindingSizeBytes = WorldTransformCapacityPolicy.ResolveBindingSizeBytes(
+ requiredInstances,
+ _device.Capabilities.MaxStorageBufferRangeBytes);
+ }
+ WorldTransformCapacityPolicy.ValidateBindingSizeBytes(
+ bindingSizeBytes,
+ requiredInstances,
+ _device.Capabilities.MaxStorageBufferRangeBytes);
+ if (!denseRefresh)
+ ValidateDynamicSlots(dynamicTransformSlots, transforms.Length);
+ ResetDenseRevisionForTopology(topologyBuildSequence);
+ if (denseRefresh)
+ {
+ ValidateDynamicSlots(allDynamicTransformSlots, transforms.Length);
+ if (_denseRevision == ulong.MaxValue)
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow dense transform revision was exhausted.");
+ }
+ _denseRevision++;
+ for (int index = 0; index < _slots.Length; index++)
+ _slots[index].Pending?.Clear();
+ }
+ EnsureSlotCapacity(frame.SlotIndex);
+ ref SlotState slot = ref _slots[frame.SlotIndex];
+ bool matchingSlot = slot.Buffer is not null
+ && slot.TopologyBuildSequence == topologyBuildSequence
+ && slot.TransformCount == transforms.Length
+ && slot.Buffer.SizeBytes >= bindingSizeBytes;
+ bool denseFlightReplay = matchingSlot
+ && slot.ConsumedDenseRevision != _denseRevision;
+ int pendingReplayMatrices = matchingSlot
+ ? slot.Pending?.Count ?? 0
+ : 0;
+ if (!denseRefresh)
+ {
+ MarkPendingChanges(
+ topologyBuildSequence,
+ transforms.Length,
+ dynamicTransformSlots);
+ }
+ int contentBytes = checked(transforms.Length * 64);
+ int allocationBytes = checked((int)bindingSizeBytes);
+
+ if (slot.Buffer is null
+ || slot.TopologyBuildSequence != topologyBuildSequence
+ || slot.TransformCount != transforms.Length
+ || slot.Buffer.SizeBytes < allocationBytes)
+ {
+ IGpuBuffer? candidate = null;
+ try
+ {
+ candidate = _device.CreateBuffer(new GpuBufferDescription(
+ $"directional-shadow-transforms-slot-{frame.SlotIndex}-build-{topologyBuildSequence}",
+ allocationBytes,
+ GpuBufferUsage.Storage | GpuBufferUsage.TransferDestination,
+ GpuMemoryResidency.HostWritable));
+ if (!candidate.HostWritesAreCoherent)
+ {
+ throw new NotSupportedException(
+ "Directional-shadow retained transforms require coherent "
+ + "host-writable Vulkan memory. The pack will fail safe "
+ + "on this adapter rather than expose unflushed pose data.");
+ }
+ if (!transforms.IsEmpty)
+ {
+ candidate.Upload(0, MemoryMarshal.AsBytes(transforms));
+ frame.PublishHostStorageWrites(candidate);
+ }
+ }
+ catch
+ {
+ candidate?.Dispose();
+ throw;
+ }
+
+ IGpuBuffer? previous = slot.Buffer;
+ PendingTransformSet pending = slot.Pending
+ ?? new PendingTransformSet(transforms.Length);
+ pending.EnsureCapacity(transforms.Length);
+ pending.Clear();
+ slot = new SlotState(
+ candidate,
+ topologyBuildSequence,
+ transforms.Length,
+ pending,
+ _denseRevision);
+ previous?.Dispose();
+ LastStats = new DirectionalShadowTransformPublishStats(
+ TopologyUploaded: true,
+ DynamicMatricesUpdated: 0,
+ DynamicRangesUpdated: 0,
+ BytesWritten: contentBytes,
+ CurrentChangedMatrices: dynamicTransformSlots.Length,
+ PendingReplayMatrices: 0,
+ DenseDirectUpload: denseRefresh);
+ }
+ else
+ {
+ PendingTransformSet pending = slot.Pending
+ ?? throw new InvalidOperationException(
+ "A retained directional-shadow flight slot has no pending-change owner.");
+ bool directDenseUpload = denseRefresh || denseFlightReplay;
+ ReadOnlySpan slotsToUpload = directDenseUpload
+ ? allDynamicTransformSlots
+ : pending.GetSorted();
+ if (directDenseUpload && !denseRefresh)
+ ValidateDynamicSlots(allDynamicTransformSlots, transforms.Length);
+ int ranges = UploadDynamicRanges(
+ slot.Buffer,
+ transforms,
+ slotsToUpload,
+ out long bytesWritten);
+ if (ranges != 0)
+ frame.PublishHostStorageWrites(slot.Buffer);
+ LastStats = new DirectionalShadowTransformPublishStats(
+ TopologyUploaded: false,
+ DynamicMatricesUpdated: slotsToUpload.Length,
+ DynamicRangesUpdated: ranges,
+ BytesWritten: bytesWritten,
+ CurrentChangedMatrices: dynamicTransformSlots.Length,
+ PendingReplayMatrices: directDenseUpload ? 0 : pendingReplayMatrices,
+ DenseDirectUpload: denseRefresh,
+ DenseFlightReplay: denseFlightReplay && !denseRefresh);
+ pending.Clear();
+ slot = slot with { ConsumedDenseRevision = _denseRevision };
+ }
+
+ IGpuBuffer buffer = slot.Buffer
+ ?? throw new InvalidOperationException(
+ "The retained directional-shadow transform buffer was not published.");
+ return new WorldTransformFrameSlice(
+ frame.Serial,
+ buffer,
+ BaseOffsetBytes: 0,
+ checked((uint)buffer.SizeBytes),
+ FirstInstance: 0,
+ checked((uint)transforms.Length));
+ }
+
+ private void ResetDenseRevisionForTopology(ulong topologyBuildSequence)
+ {
+ if (_denseTopologyBuildSequence == topologyBuildSequence)
+ return;
+ _denseTopologyBuildSequence = topologyBuildSequence;
+ _denseRevision = 1;
+ for (int index = 0; index < _slots.Length; index++)
+ _slots[index].Pending?.Clear();
+ }
+
+ private void MarkPendingChanges(
+ ulong topologyBuildSequence,
+ int transformCount,
+ ReadOnlySpan dynamicTransformSlots)
+ {
+ if (dynamicTransformSlots.IsEmpty)
+ return;
+ for (int index = 0; index < _slots.Length; index++)
+ {
+ ref SlotState candidate = ref _slots[index];
+ if (candidate.Buffer is null
+ || candidate.TopologyBuildSequence != topologyBuildSequence
+ || candidate.TransformCount != transformCount)
+ {
+ continue;
+ }
+ PendingTransformSet pending = candidate.Pending
+ ??= new PendingTransformSet(transformCount);
+ pending.EnsureCapacity(transformCount);
+ pending.Mark(dynamicTransformSlots);
+ }
+ }
+
+ private static int UploadDynamicRanges(
+ IGpuBuffer buffer,
+ ReadOnlySpan transforms,
+ ReadOnlySpan slots,
+ out long bytesWritten)
+ {
+ bytesWritten = 0;
+ int ranges = 0;
+ int cursor = 0;
+ while (cursor < slots.Length)
+ {
+ int start = slots[cursor];
+ int end = start + 1;
+ cursor++;
+ while (cursor < slots.Length && slots[cursor] == end)
+ {
+ end++;
+ cursor++;
+ }
+
+ ReadOnlySpan values = transforms.Slice(start, end - start);
+ ReadOnlySpan bytes = MemoryMarshal.AsBytes(values);
+ buffer.Upload(checked((long)start * 64L), bytes);
+ bytesWritten = checked(bytesWritten + bytes.Length);
+ ranges++;
+ }
+ return ranges;
+ }
+
+ private static void ValidateDynamicSlots(
+ ReadOnlySpan slots,
+ int transformCount)
+ {
+ int previous = -1;
+ for (int i = 0; i < slots.Length; i++)
+ {
+ int current = slots[i];
+ if ((uint)current >= (uint)transformCount)
+ {
+ throw new InvalidOperationException(
+ $"Dynamic shadow transform slot {current} is outside the "
+ + $"{transformCount}-matrix retained product.");
+ }
+ if (current <= previous)
+ {
+ throw new InvalidOperationException(
+ "Dynamic shadow transform slots must be strictly increasing.");
+ }
+ previous = current;
+ }
+ }
+
+ private void EnsureSlotCapacity(int slotIndex)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(slotIndex);
+ if (_slots.Length > slotIndex)
+ return;
+ int capacity = _slots.Length == 0 ? 2 : _slots.Length;
+ while (capacity <= slotIndex)
+ capacity = checked(capacity * 2);
+ Array.Resize(ref _slots, capacity);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ for (int i = 0; i < _slots.Length; i++)
+ {
+ _slots[i].Buffer?.Dispose();
+ _slots[i] = default;
+ }
+ LastStats = default;
+ _denseTopologyBuildSequence = 0;
+ _denseRevision = 0;
+ }
+
+ private record struct SlotState(
+ IGpuBuffer? Buffer,
+ ulong TopologyBuildSequence,
+ int TransformCount,
+ PendingTransformSet? Pending,
+ ulong ConsumedDenseRevision);
+
+ private sealed class PendingTransformSet
+ {
+ private int[] _slots;
+ private bool[] _marked;
+
+ internal PendingTransformSet(int capacity)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(capacity);
+ _slots = new int[capacity];
+ _marked = new bool[capacity];
+ }
+
+ internal int Count { get; private set; }
+
+ internal long RetainedBytes => checked(
+ (long)_slots.Length * sizeof(int) + _marked.Length);
+
+ internal void EnsureCapacity(int capacity)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(capacity);
+ if (_slots.Length >= capacity)
+ return;
+ Array.Resize(ref _slots, capacity);
+ Array.Resize(ref _marked, capacity);
+ }
+
+ internal void Mark(ReadOnlySpan slots)
+ {
+ for (int index = 0; index < slots.Length; index++)
+ {
+ int slot = slots[index];
+ if (_marked[slot])
+ continue;
+ _marked[slot] = true;
+ _slots[Count++] = slot;
+ }
+ }
+
+ internal ReadOnlySpan GetSorted()
+ {
+ Array.Sort(_slots, 0, Count);
+ return _slots.AsSpan(0, Count);
+ }
+
+ internal void Clear()
+ {
+ for (int index = 0; index < Count; index++)
+ _marked[_slots[index]] = false;
+ Count = 0;
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/DirectionalShadowUniforms.cs b/src/AcDream.App/Rendering/DirectionalShadowUniforms.cs
new file mode 100644
index 00000000..0148ce06
--- /dev/null
+++ b/src/AcDream.App/Rendering/DirectionalShadowUniforms.cs
@@ -0,0 +1,111 @@
+using System.Numerics;
+using System.Runtime.InteropServices;
+using AcDream.App.Rendering.Gpu;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Shader ABI SSOT companion for opt-in set 3 binding 6. The matching GLSL block is
+/// directional_shadow_common.glsl; both are pinned at 336 std140 bytes.
+///
+[StructLayout(LayoutKind.Sequential, Pack = 4)]
+internal readonly struct DirectionalShadowUniforms
+{
+ internal const int SizeInBytes = 336;
+
+ public readonly Matrix4x4 WorldToClip0;
+ public readonly Matrix4x4 WorldToClip1;
+ public readonly Matrix4x4 WorldToClip2;
+ public readonly Matrix4x4 WorldToClip3;
+ public readonly Vector4 SplitFarMeters;
+ public readonly Vector4 Control;
+ public readonly Vector4 BiasMeters;
+ public readonly UInt4 TextureAndFlags;
+ public readonly Vector4 LightDirectionAndSource;
+
+ internal DirectionalShadowUniforms(
+ Matrix4x4 worldToClip0,
+ Matrix4x4 worldToClip1,
+ Matrix4x4 worldToClip2,
+ Matrix4x4 worldToClip3,
+ Vector4 splitFarMeters,
+ Vector4 control,
+ Vector4 biasMeters,
+ UInt4 textureAndFlags,
+ Vector4 lightDirectionAndSource)
+ {
+ WorldToClip0 = worldToClip0;
+ WorldToClip1 = worldToClip1;
+ WorldToClip2 = worldToClip2;
+ WorldToClip3 = worldToClip3;
+ SplitFarMeters = splitFarMeters;
+ Control = control;
+ BiasMeters = biasMeters;
+ TextureAndFlags = textureAndFlags;
+ LightDirectionAndSource = lightDirectionAndSource;
+ }
+
+ internal static DirectionalShadowUniforms Create(
+ ReadOnlySpan cascades,
+ in DirectionalShadowEnvironmentState environment,
+ in DirectionalShadowQuality quality,
+ GpuTextureSlot textureSlot)
+ {
+ if (cascades.Length != quality.CascadeCount)
+ throw new ArgumentException("The cascade span must match the selected quality.", nameof(cascades));
+ if (!textureSlot.IsAssigned)
+ throw new ArgumentException("The directional depth array requires an assigned texture slot.", nameof(textureSlot));
+
+ Matrix4x4 matrix0 = cascades[0].WorldToShadowClip;
+ Matrix4x4 matrix1 = cascades.Length > 1 ? cascades[1].WorldToShadowClip : Matrix4x4.Identity;
+ Matrix4x4 matrix2 = cascades.Length > 2 ? cascades[2].WorldToShadowClip : Matrix4x4.Identity;
+ Matrix4x4 matrix3 = cascades.Length > 3 ? cascades[3].WorldToShadowClip : Matrix4x4.Identity;
+ float split0 = cascades[0].SplitFarMeters;
+ float split1 = cascades.Length > 1 ? cascades[1].SplitFarMeters : quality.MaximumReachMeters;
+ float split2 = cascades.Length > 2 ? cascades[2].SplitFarMeters : quality.MaximumReachMeters;
+ float split3 = cascades.Length > 3 ? cascades[3].SplitFarMeters : quality.MaximumReachMeters;
+
+ // The pinned v1 receiver block carries one world-space bias triple.
+ // Publish the conservative outer-cascade values; the receiver derives
+ // each inner cascade's relative texel footprint from its projection
+ // matrix before applying this triple. That preserves the bias portion
+ // of the v1 ABI while the selected-light vec4 is appended at byte 320
+ // without applying the outer map's visibly excessive offset nearby.
+ DirectionalShadowWorldBias bias = cascades[^1].Bias;
+ float effectiveReachMeters = cascades[^1].SplitFarMeters;
+ return new DirectionalShadowUniforms(
+ matrix0,
+ matrix1,
+ matrix2,
+ matrix3,
+ new Vector4(split0, split1, split2, split3),
+ new Vector4(
+ environment.Strength,
+ environment.SoftnessMultiplier,
+ effectiveReachMeters,
+ MathF.Max(1f, effectiveReachMeters * 0.02f)),
+ new Vector4(
+ bias.ConstantDepthMeters,
+ bias.SlopeDepthMeters,
+ bias.NormalOffsetMeters,
+ cascades[0].CasterDepthPaddingMeters),
+ new UInt4(
+ textureSlot.Index,
+ checked((uint)quality.CascadeCount),
+ checked((uint)quality.MapResolution),
+ 1u | (checked((uint)quality.PcfRadiusTexels) << 8)),
+ new Vector4(
+ environment.SurfaceToLightDirection,
+ checked((uint)environment.SourceKind)));
+ }
+}
+
+/// Four uints with the exact 16-byte std140 uvec4 representation.
+[StructLayout(LayoutKind.Sequential, Pack = 4)]
+internal readonly struct UInt4(uint x, uint y, uint z, uint w)
+{
+ public readonly uint X = x;
+ public readonly uint Y = y;
+ public readonly uint Z = z;
+ public readonly uint W = w;
+}
diff --git a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
new file mode 100644
index 00000000..56eaccef
--- /dev/null
+++ b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
@@ -0,0 +1,951 @@
+using System.Diagnostics;
+using System.Numerics;
+using System.Runtime.InteropServices;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Packs;
+using AcDream.App.Rendering.Scene;
+using AcDream.App.Rendering.Wb;
+using DatReaderWriter.Enums;
+
+namespace AcDream.App.Rendering;
+
+internal readonly record struct DirectionalSunShadowRenderInput(
+ DirectionalShadowEnvironmentInput Environment,
+ Matrix4x4 CameraView,
+ Matrix4x4 CameraProjection,
+ DirectionalShadowCasterFrame Casters,
+ float CameraNearMeters = 0.1f,
+ float CasterDepthPaddingMeters = 48f,
+ float ResidentMaximumReachMeters = float.PositiveInfinity,
+ bool MeasureGpuTimers = true,
+ bool MeasureCpuStages = false);
+
+internal readonly record struct DirectionalSunShadowCpuStageTicks(
+ long EnvironmentGateTicks,
+ long PreparedDrawsAndTransformsTicks,
+ long FitAndUniformTicks,
+ long LayeredPassRecordingTicks,
+ long BookkeepingTicks);
+
+internal readonly record struct DirectionalShadowTransformChurnDiagnostics(
+ int CopiedSceneChanges,
+ int UpdateTransformChanges,
+ int UpdateAppearanceChanges,
+ int DynamicSynchronizationChanges,
+ int ActiveAnimatedStaticChanges,
+ int LiveDynamicRootChanges,
+ int EquippedChildChanges,
+ int DedupedCasterSlots,
+ bool SceneJournalFullRefresh,
+ bool DensityBulkRefresh,
+ int BatchedProjectionCopyCalls,
+ int ChangedMatrixSlots,
+ int FlightCurrentChangedMatrices,
+ int FlightPendingReplayMatrices,
+ int FlightUploadedMatrices,
+ int FlightUploadRanges,
+ long FlightBytesWritten,
+ bool FlightFullDynamicFallback,
+ bool DenseDirectUpload,
+ bool DenseFlightReplay,
+ DirectionalShadowCasterClassDiagnostics CasterClasses = default);
+
+internal readonly record struct DirectionalSunShadowDiagnostics(
+ DirectionalShadowGateReason GateReason,
+ float Strength,
+ int CascadeCount,
+ int DrawCalls,
+ int WorldOpaqueCommands,
+ int WorldAlphaCutoutCommands,
+ int TerrainCommands,
+ ulong WorldPreparationSequence,
+ ulong TerrainPreparationSequence,
+ double CpuMilliseconds,
+ double LastResolvedGpuMilliseconds,
+ bool HasResolvedGpuMeasurement,
+ long ResidentDepthBytes,
+ DirectionalSunShadowCpuStageTicks CpuStages = default,
+ DirectionalShadowTransformChurnDiagnostics TransformChurn = default,
+ AuthoredCelestialShadowSourceKind SourceKind =
+ AuthoredCelestialShadowSourceKind.None,
+ int SourceObjectIndex = -1,
+ uint SourceGfxObjId = 0u,
+ Vector3 SurfaceToLightDirection = default,
+ float LightElevationSin = 0f);
+
+internal static class DirectionalShadowBatchFlags
+{
+ internal const uint AlphaCutout = 1u << 0;
+ internal static uint Encode(DirectionalShadowCasterMaterial material) =>
+ material is DirectionalShadowCasterMaterial.AlphaCutout
+ ? AlphaCutout
+ : 0u;
+}
+
+///
+/// Tier-2 producer only: fits selected celestial-light cascades and records
+/// their depth maps.
+/// It does not alter the retail world pass or sample shadows in receivers.
+///
+internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverSource, IDisposable
+{
+ internal const string TimerPrefix = "directional-shadow-cascade-";
+ internal const string MultiviewTimerName = "directional-shadow-multiview";
+ internal const uint LowMultiviewMask = 0b11;
+ private const int DrawCommandStride = 20;
+
+ private readonly IGpuDevice _device;
+ private readonly DirectionalShadowQuality _quality;
+ private readonly DirectionalShadowAtmospherePolicy _atmospherePolicy;
+ private readonly DirectionalShadowPipelineShaders _pipelineShaders;
+ private readonly bool _multiviewCascades;
+ private readonly IGpuDirectionalDepthTarget _target;
+ private readonly IGpuSampler _sampler;
+ private readonly GpuTextureSlot _textureSlot;
+ private readonly IGpuPipeline _terrainPipeline;
+ private readonly IGpuPipeline _worldOpaquePipeline;
+ private readonly IGpuPipeline _worldCutoutPipeline;
+ private readonly IGpuPipeline? _terrainMultiviewPipeline;
+ private readonly IGpuPipeline? _worldOpaqueMultiviewPipeline;
+ private readonly IGpuPipeline? _worldCutoutMultiviewPipeline;
+ private readonly DirectionalShadowTransformBufferSet _transformBuffers;
+ private readonly DirectionalShadowCascade[] _cascades = new DirectionalShadowCascade[4];
+ private DirectionalShadowBatchGpuData[] _batchScratch = [];
+ private IGpuBuffer? _worldBatchBuffer;
+ private IGpuBuffer? _worldCommandBuffer;
+ private IGpuBuffer? _terrainCommandBuffer;
+ private ulong _worldGpuBuildSequence;
+ private ulong _terrainGpuBuildSequence;
+ private DirectionalShadowFrameBinding _currentFrameBinding;
+ private bool _disposed;
+
+ internal DirectionalSunShadowRenderer(
+ IGpuDevice device,
+ DirectionalShadowPreset preset,
+ DirectionalShadowAtmospherePolicy? atmospherePolicy = null,
+ DirectionalShadowPipelineShaders? pipelineShaders = null,
+ bool multiviewCascades = false)
+ : this(
+ device,
+ DirectionalShadowQuality.For(preset),
+ atmospherePolicy,
+ pipelineShaders,
+ multiviewCascades)
+ {
+ }
+
+ internal DirectionalSunShadowRenderer(
+ IGpuDevice device,
+ DirectionalShadowQuality quality,
+ DirectionalShadowAtmospherePolicy? atmospherePolicy = null,
+ DirectionalShadowPipelineShaders? pipelineShaders = null,
+ bool multiviewCascades = false)
+ {
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ if (quality.CascadeCount is < 1 or > 4
+ || quality.MapResolution <= 0
+ || !float.IsFinite(quality.MaximumReachMeters)
+ || quality.MaximumReachMeters <= 0f
+ || quality.PcfRadiusTexels is < 0 or > 2)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(quality),
+ "Directional-shadow quality must declare 1..4 cascades, a positive "
+ + "resolution/reach, and a 0..2 PCF radius.");
+ }
+ _quality = quality;
+ _atmospherePolicy = atmospherePolicy ?? DirectionalShadowAtmospherePolicy.BuiltIn;
+ _pipelineShaders = pipelineShaders ?? DirectionalShadowPipelineShaders.Local;
+ _multiviewCascades = multiviewCascades;
+ if (multiviewCascades && quality.CascadeCount != 2)
+ throw new NotSupportedException("The multiview shadow hint requires exactly two Low cascades.");
+ if (multiviewCascades && !device.Capabilities.SupportsMultiview)
+ throw new NotSupportedException("The selected device does not support multiview shadow cascades.");
+ if (multiviewCascades && _pipelineShaders.MultiviewCasters is null)
+ throw new NotSupportedException("The pack did not declare all multiview shadow caster variants.");
+
+ IGpuDirectionalDepthTarget? target = null;
+ IGpuSampler? sampler = null;
+ GpuTextureSlot textureSlot = GpuTextureSlot.Unassigned;
+ IGpuPipeline? terrain = null;
+ IGpuPipeline? opaque = null;
+ IGpuPipeline? cutout = null;
+ IGpuPipeline? terrainMultiview = null;
+ IGpuPipeline? opaqueMultiview = null;
+ IGpuPipeline? cutoutMultiview = null;
+ DirectionalShadowTransformBufferSet? transformBuffers = null;
+ try
+ {
+ target = device.CreateDirectionalDepthTarget(
+ new GpuDirectionalDepthTargetDescription(
+ $"directional-shadow-{quality.Preset.ToString().ToLowerInvariant()}",
+ _quality.MapResolution,
+ _quality.CascadeCount));
+ sampler = device.CreateSampler(GpuSamplerDescription.ShadowNearestClamp);
+ textureSlot = device.RegisterTexture(target.DepthTexture, sampler);
+ terrain = CreatePipeline(
+ device,
+ "directional-shadow-terrain",
+ _pipelineShaders.TerrainCaster,
+ TerrainModernRenderer.TerrainVertexLayout,
+ GpuFrontFace.CounterClockwise);
+ opaque = CreatePipeline(
+ device,
+ "directional-shadow-world-opaque",
+ _pipelineShaders.WorldOpaqueCaster,
+ GpuVertexLayout.WorldMesh,
+ GpuFrontFace.Clockwise);
+ cutout = CreatePipeline(
+ device,
+ "directional-shadow-world-cutout",
+ _pipelineShaders.WorldAlphaCutoutCaster,
+ GpuVertexLayout.WorldMesh,
+ GpuFrontFace.Clockwise);
+ if (multiviewCascades)
+ {
+ DirectionalShadowMultiviewPipelineShaders shaders =
+ _pipelineShaders.MultiviewCasters!.Value;
+ terrainMultiview = CreatePipeline(device, "directional-shadow-terrain-multiview",
+ shaders.TerrainCaster, TerrainModernRenderer.TerrainVertexLayout,
+ GpuFrontFace.CounterClockwise, LowMultiviewMask);
+ opaqueMultiview = CreatePipeline(device, "directional-shadow-world-opaque-multiview",
+ shaders.WorldOpaqueCaster, GpuVertexLayout.WorldMesh,
+ GpuFrontFace.Clockwise, LowMultiviewMask);
+ cutoutMultiview = CreatePipeline(device, "directional-shadow-world-cutout-multiview",
+ shaders.WorldAlphaCutoutCaster, GpuVertexLayout.WorldMesh,
+ GpuFrontFace.Clockwise, LowMultiviewMask);
+ }
+ transformBuffers = new DirectionalShadowTransformBufferSet(device);
+ }
+ catch
+ {
+ transformBuffers?.Dispose();
+ cutoutMultiview?.Dispose();
+ opaqueMultiview?.Dispose();
+ terrainMultiview?.Dispose();
+ cutout?.Dispose();
+ opaque?.Dispose();
+ terrain?.Dispose();
+ if (textureSlot.IsAssigned)
+ device.ReleaseTextureSlot(textureSlot);
+ sampler?.Dispose();
+ target?.Dispose();
+ throw;
+ }
+
+ _target = target;
+ _sampler = sampler;
+ _textureSlot = textureSlot;
+ _terrainPipeline = terrain;
+ _worldOpaquePipeline = opaque;
+ _worldCutoutPipeline = cutout;
+ _terrainMultiviewPipeline = terrainMultiview;
+ _worldOpaqueMultiviewPipeline = opaqueMultiview;
+ _worldCutoutMultiviewPipeline = cutoutMultiview;
+ _transformBuffers = transformBuffers;
+ }
+
+ internal DirectionalShadowQuality Quality => _quality;
+
+ internal bool MultiviewCascadesEnabled => _multiviewCascades;
+
+ internal static string TimerName(int cascadeIndex) => cascadeIndex switch
+ {
+ 0 => "directional-shadow-cascade-0",
+ 1 => "directional-shadow-cascade-1",
+ 2 => "directional-shadow-cascade-2",
+ 3 => "directional-shadow-cascade-3",
+ _ => throw new ArgumentOutOfRangeException(nameof(cascadeIndex)),
+ };
+
+ internal IGpuTexture DepthTexture => _target.DepthTexture;
+
+ internal GpuTextureSlot TextureSlot => _textureSlot;
+
+ public DirectionalShadowPipelineShaders PipelineShaders => _pipelineShaders;
+
+ 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.
+ ///
+ internal long RetainedCommandBufferBytes => checked(
+ (_worldBatchBuffer?.SizeBytes ?? 0L)
+ + (_worldCommandBuffer?.SizeBytes ?? 0L)
+ + (_terrainCommandBuffer?.SizeBytes ?? 0L));
+
+ internal int RetainedCommandBufferCount =>
+ (_worldBatchBuffer is null ? 0 : 1)
+ + (_worldCommandBuffer is null ? 0 : 1)
+ + (_terrainCommandBuffer is null ? 0 : 1);
+
+ internal long RetainedGpuBufferBytes => checked(
+ RetainedCommandBufferBytes + _transformBuffers.RetainedGpuBytes);
+
+ internal int RetainedGpuBufferCount => checked(
+ RetainedCommandBufferCount + _transformBuffers.BufferCount);
+
+ public bool TryGetCurrentFrameBinding(
+ IGpuFrame frame,
+ out DirectionalShadowFrameBinding binding)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ binding = _currentFrameBinding;
+ return !_disposed && binding.IsValidFor(frame);
+ }
+
+ internal DirectionalSunShadowDiagnostics Render(
+ IGpuFrame frame,
+ in DirectionalSunShadowRenderInput input,
+ WbDrawDispatcher world,
+ TerrainModernRenderer terrain)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(frame);
+ _currentFrameBinding = DirectionalShadowFrameBinding.Disabled;
+ ArgumentNullException.ThrowIfNull(world);
+ ArgumentNullException.ThrowIfNull(terrain);
+ long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+ DirectionalShadowEnvironmentState environment =
+ DirectionalShadowEnvironmentGate.Evaluate(
+ input.Environment,
+ _atmospherePolicy);
+ long environmentGateTicks = input.MeasureCpuStages
+ ? Stopwatch.GetTimestamp() - cpuStageStarted
+ : 0L;
+ if (!environment.ShouldRender)
+ return Disabled(
+ in environment,
+ new DirectionalSunShadowCpuStageTicks(
+ environmentGateTicks, 0L, 0L, 0L, 0L));
+ if (input.ResidentMaximumReachMeters <= input.CameraNearMeters)
+ {
+ environment = environment with
+ {
+ Reason = DirectionalShadowGateReason.ResidentWindowUnavailable,
+ };
+ return Disabled(
+ in environment,
+ new DirectionalSunShadowCpuStageTicks(
+ environmentGateTicks, 0L, 0L, 0L, 0L));
+ }
+
+ cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+ DirectionalShadowPreparedDraws worldDraws =
+ world.PrepareDirectionalShadowDraws(input.Casters);
+ DirectionalShadowTerrainPreparedDraws terrainDraws =
+ terrain.PrepareDirectionalShadowDraws();
+ DirectionalShadowMeshGeometry? worldGeometry =
+ worldDraws.Commands.IsEmpty ? null : world.GetDirectionalShadowGeometry();
+ DirectionalShadowTerrainGeometry? terrainGeometry =
+ terrainDraws.Commands.IsEmpty ? null : terrain.GetDirectionalShadowGeometry();
+ uint transformBindingSizeBytes =
+ world.ResolveDirectionalShadowTransformBindingSize(
+ worldDraws.Transforms.Length,
+ // Stats counts every current WB source render batch before
+ // transparent/cutout shadow rejection. Ordinary WB submission
+ // publishes at most one matrix per source batch, making this a
+ // complete-frame upper bound available before shadow commands
+ // bind the one authoritative pose buffer.
+ worldDraws.Stats.SourceBatches);
+ WorldTransformFrameSlice retainedTransforms = _transformBuffers.Publish(
+ frame,
+ worldDraws.BuildSequence,
+ worldDraws.Transforms,
+ worldDraws.DynamicTransformSlots,
+ worldDraws.AllDynamicTransformSlots,
+ worldDraws.LastDynamicTransformRefreshWasDense,
+ transformBindingSizeBytes);
+ WorldTransformFrameSlice transforms =
+ world.BeginDirectionalShadowTransformFrame(
+ frame,
+ in retainedTransforms);
+ DirectionalShadowCasterBuildStats casterStats = input.Casters.Stats;
+ DirectionalShadowCasterClassDiagnostics casterClasses =
+ CompleteCasterClassDiagnostics(
+ in casterStats,
+ terrainDraws.Commands.Length);
+ DirectionalShadowTransformPublishStats publishStats =
+ _transformBuffers.LastStats;
+ var transformChurn = new DirectionalShadowTransformChurnDiagnostics(
+ casterStats.CopiedTransformChanges,
+ casterStats.UpdateTransformChanges,
+ casterStats.UpdateAppearanceChanges,
+ casterStats.DynamicSynchronizationChanges,
+ casterStats.ActiveAnimatedStaticChanges,
+ casterStats.LiveDynamicRootChanges,
+ casterStats.EquippedChildChanges,
+ casterStats.DedupedChangedCasterSlots,
+ casterStats.TransformJournalFullRefresh,
+ casterStats.DensityBulkRefresh,
+ casterStats.BatchedProjectionCopyCalls,
+ worldDraws.LastDynamicTransformRefreshCount,
+ publishStats.CurrentChangedMatrices,
+ publishStats.PendingReplayMatrices,
+ publishStats.DynamicMatricesUpdated,
+ publishStats.DynamicRangesUpdated,
+ publishStats.BytesWritten,
+ publishStats.UsedFullDynamicFallback,
+ publishStats.DenseDirectUpload,
+ publishStats.DenseFlightReplay,
+ casterClasses);
+ long preparedDrawsAndTransformsTicks = input.MeasureCpuStages
+ ? Stopwatch.GetTimestamp() - cpuStageStarted
+ : 0L;
+ try
+ {
+ return RenderPrepared(
+ frame,
+ environment,
+ input.CameraView,
+ input.CameraProjection,
+ input.CameraNearMeters,
+ input.CasterDepthPaddingMeters,
+ worldDraws,
+ terrainDraws,
+ worldGeometry,
+ terrainGeometry,
+ transforms,
+ input.ResidentMaximumReachMeters,
+ input.MeasureGpuTimers,
+ input.MeasureCpuStages,
+ new DirectionalSunShadowCpuStageTicks(
+ environmentGateTicks,
+ preparedDrawsAndTransformsTicks,
+ 0L,
+ 0L,
+ 0L),
+ transformChurn);
+ }
+ catch
+ {
+ world.CancelDirectionalShadowTransformFrame(frame);
+ throw;
+ }
+ }
+
+ internal static DirectionalShadowCasterClassDiagnostics
+ CompleteCasterClassDiagnostics(
+ in DirectionalShadowCasterBuildStats casterStats,
+ int terrainCommandCount)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(terrainCommandCount);
+ return casterStats.CasterClasses with
+ {
+ TerrainCommands = terrainCommandCount,
+ };
+ }
+
+ internal DirectionalSunShadowDiagnostics RenderPrepared(
+ IGpuFrame frame,
+ in DirectionalShadowEnvironmentState environment,
+ Matrix4x4 cameraView,
+ Matrix4x4 cameraProjection,
+ float cameraNearMeters,
+ float casterDepthPaddingMeters,
+ DirectionalShadowPreparedDraws worldDraws,
+ DirectionalShadowTerrainPreparedDraws terrainDraws,
+ DirectionalShadowMeshGeometry? worldGeometry,
+ DirectionalShadowTerrainGeometry? terrainGeometry,
+ WorldTransformFrameSlice worldTransforms,
+ float residentMaximumReachMeters = float.PositiveInfinity,
+ bool measureGpuTimers = true,
+ bool measureCpuStages = false,
+ DirectionalSunShadowCpuStageTicks cpuStages = default,
+ DirectionalShadowTransformChurnDiagnostics transformChurn = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(frame);
+ _currentFrameBinding = DirectionalShadowFrameBinding.Disabled;
+ ArgumentNullException.ThrowIfNull(worldDraws);
+ ArgumentNullException.ThrowIfNull(terrainDraws);
+ if (!environment.ShouldRender)
+ return Disabled(in environment, cpuStages);
+ if (!worldDraws.Commands.IsEmpty && worldGeometry is null)
+ throw new ArgumentNullException(nameof(worldGeometry));
+ if (!terrainDraws.Commands.IsEmpty && terrainGeometry is null)
+ throw new ArgumentNullException(nameof(terrainGeometry));
+ if (!worldTransforms.IsValidFor(frame))
+ throw new ArgumentException(
+ "Shadow transforms must use this frame's shared N.5 allocation.",
+ nameof(worldTransforms));
+
+ long started = Stopwatch.GetTimestamp();
+ var fit = new DirectionalShadowCascadeFitInput(
+ cameraView,
+ cameraProjection,
+ environment.SurfaceToLightDirection,
+ _quality,
+ cameraNearMeters,
+ PracticalSplitLambda: 0.65f,
+ casterDepthPaddingMeters,
+ residentMaximumReachMeters);
+ int cascadeCount = DirectionalShadowCascadeFitter.Fit(
+ fit,
+ _cascades);
+ if (cascadeCount == 0)
+ {
+ DirectionalShadowEnvironmentState unavailable = environment with
+ {
+ Reason = DirectionalShadowGateReason.ResidentWindowUnavailable,
+ };
+ return Disabled(in unavailable, cpuStages);
+ }
+
+ ReadOnlySpan cascades =
+ _cascades.AsSpan(0, cascadeCount);
+ DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create(
+ cascades,
+ environment,
+ _quality,
+ _textureSlot);
+ GpuRingAllocation uniformAllocation = frame.AllocateRing(
+ DirectionalShadowUniforms.SizeInBytes,
+ GpuRingUsage.Uniform);
+ MemoryMarshal.Write(uniformAllocation.Data, in uniforms);
+
+ long fitAndUniformFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+ PreparedGpuUploads uploads = PrepareGpuData(
+ worldTransforms,
+ worldDraws,
+ terrainDraws);
+ if (MultiviewCascadesEnabled)
+ {
+ using IGpuPassEncoder encoder = frame.BeginPass(
+ GpuPassDescription.DirectionalDepthMultiview(
+ "directional-shadow-multiview",
+ _target,
+ LowMultiviewMask));
+ using IDisposable? timer = measureGpuTimers
+ ? encoder.BeginTimerScope(MultiviewTimerName)
+ : null;
+ encoder.BindUniformBuffer(
+ GpuBindingModel.UniformDirectionalShadow,
+ uniformAllocation.Buffer,
+ uniformAllocation.OffsetBytes,
+ DirectionalShadowUniforms.SizeInBytes);
+ DrawTerrain(encoder, uploads, terrainDraws, terrainGeometry, 0,
+ _terrainMultiviewPipeline);
+ DrawWorld(encoder, uploads, worldDraws, worldGeometry, 0,
+ _worldOpaqueMultiviewPipeline, _worldCutoutMultiviewPipeline);
+ }
+ else for (int cascadeIndex = 0; cascadeIndex < cascadeCount; cascadeIndex++)
+ {
+ using IGpuPassEncoder encoder = frame.BeginPass(
+ GpuPassDescription.DirectionalDepth(
+ $"directional-shadow-{cascadeIndex}",
+ _target,
+ cascadeIndex));
+ using IDisposable? timer = measureGpuTimers
+ ? encoder.BeginTimerScope(TimerName(cascadeIndex))
+ : null;
+ encoder.BindUniformBuffer(
+ GpuBindingModel.UniformDirectionalShadow,
+ uniformAllocation.Buffer,
+ uniformAllocation.OffsetBytes,
+ DirectionalShadowUniforms.SizeInBytes);
+
+ DrawTerrain(encoder, uploads, terrainDraws, terrainGeometry, cascadeIndex);
+ DrawWorld(encoder, uploads, worldDraws, worldGeometry, cascadeIndex);
+ }
+
+ long passRecordingFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+
+ _currentFrameBinding = new DirectionalShadowFrameBinding(
+ frame.Serial,
+ Enabled: true,
+ uniformAllocation.Buffer,
+ uniformAllocation.OffsetBytes,
+ DirectionalShadowUniforms.SizeInBytes,
+ _textureSlot,
+ cascadeCount);
+
+ (bool hasGpu, double gpuMilliseconds) = ResolveGpu(cascadeCount);
+ int drawsPerCascade = terrainDraws.Commands.IsEmpty ? 0 : 1;
+ drawsPerCascade = checked(
+ drawsPerCascade
+ + (worldDraws.Commands.IsEmpty
+ ? 0
+ : worldDraws.OpaqueRuns.Length + worldDraws.AlphaCutoutRuns.Length));
+ long finished = Stopwatch.GetTimestamp();
+ cpuStages = cpuStages with
+ {
+ FitAndUniformTicks = measureCpuStages
+ ? fitAndUniformFinished - started
+ : 0L,
+ LayeredPassRecordingTicks = measureCpuStages
+ ? passRecordingFinished - fitAndUniformFinished
+ : 0L,
+ BookkeepingTicks = measureCpuStages
+ ? finished - passRecordingFinished
+ : 0L,
+ };
+ return new DirectionalSunShadowDiagnostics(
+ DirectionalShadowGateReason.Enabled,
+ environment.Strength,
+ cascadeCount,
+ checked((MultiviewCascadesEnabled ? 1 : cascadeCount) * drawsPerCascade),
+ worldDraws.OpaqueCommandCount,
+ worldDraws.AlphaCutoutCommandCount,
+ terrainDraws.Commands.Length,
+ worldDraws.BuildSequence,
+ terrainDraws.BuildSequence,
+ (finished - started) * 1000d / Stopwatch.Frequency,
+ gpuMilliseconds,
+ hasGpu,
+ _quality.ApproximateDepthMapBytes,
+ cpuStages,
+ transformChurn,
+ environment.SourceKind,
+ environment.SourceObjectIndex,
+ environment.SourceGfxObjId,
+ environment.SurfaceToLightDirection,
+ environment.LightElevationSin);
+ }
+
+ private PreparedGpuUploads PrepareGpuData(
+ in WorldTransformFrameSlice transforms,
+ DirectionalShadowPreparedDraws world,
+ DirectionalShadowTerrainPreparedDraws terrain)
+ {
+ if (_worldGpuBuildSequence != world.BuildSequence)
+ RebuildWorldGpuData(world);
+ if (_terrainGpuBuildSequence != terrain.BuildSequence)
+ RebuildTerrainGpuData(terrain);
+
+ return new PreparedGpuUploads(
+ transforms,
+ Slice(_worldBatchBuffer),
+ Slice(_worldCommandBuffer),
+ Slice(_terrainCommandBuffer));
+ }
+
+ private void RebuildWorldGpuData(DirectionalShadowPreparedDraws world)
+ {
+ IGpuBuffer? batches = null;
+ IGpuBuffer? commands = null;
+ try
+ {
+ if (!world.Commands.IsEmpty)
+ {
+ EnsureBatchCapacity(world.Batches.Length);
+ for (int i = 0; i < world.Batches.Length; i++)
+ {
+ DirectionalShadowPreparedBatch batch = world.Batches[i];
+ _batchScratch[i] = new DirectionalShadowBatchGpuData(
+ batch.TextureSlot.Index,
+ 0u,
+ batch.TextureLayer,
+ DirectionalShadowBatchFlags.Encode(batch.Material));
+ }
+
+ ReadOnlySpan batchBytes = MemoryMarshal.AsBytes(
+ _batchScratch.AsSpan(0, world.Batches.Length));
+ ReadOnlySpan commandBytes = MemoryMarshal.AsBytes(
+ world.Commands);
+ batches = CreateRetainedBuffer(
+ $"directional-shadow-world-batches-{world.BuildSequence}",
+ batchBytes,
+ GpuBufferUsage.Storage);
+ commands = CreateRetainedBuffer(
+ $"directional-shadow-world-commands-{world.BuildSequence}",
+ commandBytes,
+ GpuBufferUsage.Indirect);
+ }
+ }
+ catch
+ {
+ commands?.Dispose();
+ batches?.Dispose();
+ throw;
+ }
+
+ IGpuBuffer? previousBatches = _worldBatchBuffer;
+ IGpuBuffer? previousCommands = _worldCommandBuffer;
+ _worldBatchBuffer = batches;
+ _worldCommandBuffer = commands;
+ _worldGpuBuildSequence = world.BuildSequence;
+ previousCommands?.Dispose();
+ previousBatches?.Dispose();
+ }
+
+ private void RebuildTerrainGpuData(DirectionalShadowTerrainPreparedDraws terrain)
+ {
+ IGpuBuffer? commands = null;
+ if (!terrain.Commands.IsEmpty)
+ {
+ commands = CreateRetainedBuffer(
+ $"directional-shadow-terrain-commands-{terrain.BuildSequence}",
+ MemoryMarshal.AsBytes(terrain.Commands),
+ GpuBufferUsage.Indirect);
+ }
+
+ IGpuBuffer? previous = _terrainCommandBuffer;
+ _terrainCommandBuffer = commands;
+ _terrainGpuBuildSequence = terrain.BuildSequence;
+ previous?.Dispose();
+ }
+
+ private IGpuBuffer CreateRetainedBuffer(
+ string name,
+ ReadOnlySpan contents,
+ GpuBufferUsage usage)
+ {
+ if (contents.IsEmpty)
+ throw new ArgumentException("Retained shadow buffers cannot be empty.", nameof(contents));
+ IGpuBuffer buffer = _device.CreateBuffer(new GpuBufferDescription(
+ name,
+ contents.Length,
+ usage | GpuBufferUsage.TransferDestination,
+ GpuMemoryResidency.DeviceLocal));
+ try
+ {
+ buffer.Upload(0, contents);
+ return buffer;
+ }
+ catch
+ {
+ buffer.Dispose();
+ throw;
+ }
+ }
+
+ private static RetainedGpuBufferSlice Slice(IGpuBuffer? buffer) =>
+ new(buffer, 0u, checked((uint)(buffer?.SizeBytes ?? 0L)));
+
+ private void DrawTerrain(
+ IGpuPassEncoder encoder,
+ in PreparedGpuUploads uploads,
+ DirectionalShadowTerrainPreparedDraws draws,
+ DirectionalShadowTerrainGeometry? geometry,
+ int cascadeIndex,
+ IGpuPipeline? pipeline = null)
+ {
+ if (draws.Commands.IsEmpty)
+ return;
+ DirectionalShadowTerrainGeometry actual = geometry!.Value;
+ encoder.BindPipeline(pipeline ?? _terrainPipeline);
+ encoder.BindVertexBuffer(0, actual.VertexBuffer, 0);
+ encoder.BindIndexBuffer(actual.IndexBuffer, 0, GpuIndexType.UInt32);
+ GpuPushConstants push = PushForCascade(cascadeIndex, 0);
+ encoder.SetPushConstants(in push);
+ encoder.MultiDrawIndexedIndirect(
+ uploads.TerrainCommands.RequireBuffer(),
+ uploads.TerrainCommands.OffsetBytes,
+ checked((uint)draws.Commands.Length),
+ DrawCommandStride);
+ }
+
+ private void DrawWorld(
+ IGpuPassEncoder encoder,
+ in PreparedGpuUploads uploads,
+ DirectionalShadowPreparedDraws draws,
+ DirectionalShadowMeshGeometry? geometry,
+ int cascadeIndex,
+ IGpuPipeline? opaquePipeline = null,
+ IGpuPipeline? cutoutPipeline = null)
+ {
+ if (draws.Commands.IsEmpty)
+ return;
+ DirectionalShadowMeshGeometry actual = geometry!.Value;
+ encoder.BindStorageBuffer(
+ GpuBindingModel.StorageInstances,
+ uploads.Transforms.Buffer,
+ uploads.Transforms.BaseOffsetBytes,
+ uploads.Transforms.BindingSizeBytes);
+ encoder.BindStorageBuffer(
+ GpuBindingModel.StorageBatches,
+ uploads.Batches.RequireBuffer(),
+ uploads.Batches.OffsetBytes,
+ uploads.Batches.SizeBytes);
+ DrawWorldRange(
+ encoder,
+ uploads.WorldCommands,
+ draws.OpaqueRuns,
+ cascadeIndex,
+ opaquePipeline ?? _worldOpaquePipeline,
+ actual);
+ DrawWorldRange(
+ encoder,
+ uploads.WorldCommands,
+ draws.AlphaCutoutRuns,
+ cascadeIndex,
+ cutoutPipeline ?? _worldCutoutPipeline,
+ actual);
+ }
+
+ private static void DrawWorldRange(
+ IGpuPassEncoder encoder,
+ in RetainedGpuBufferSlice commands,
+ ReadOnlySpan runs,
+ int cascadeIndex,
+ IGpuPipeline pipeline,
+ in DirectionalShadowMeshGeometry geometry)
+ {
+ if (runs.IsEmpty)
+ return;
+ encoder.BindPipeline(pipeline);
+ encoder.BindVertexBuffer(0, geometry.VertexBuffer, 0);
+ encoder.BindIndexBuffer(geometry.IndexBuffer, 0, GpuIndexType.UInt16);
+
+ for (int runIndex = 0; runIndex < runs.Length; runIndex++)
+ {
+ DirectionalShadowPreparedRun run = runs[runIndex];
+ ApplyCull(encoder, run.CullMode);
+ GpuPushConstants push = PushForCascade(cascadeIndex, run.StartCommand);
+ encoder.SetPushConstants(in push);
+ encoder.MultiDrawIndexedIndirect(
+ commands.RequireBuffer(),
+ commands.OffsetBytes + checked((uint)(run.StartCommand * DrawCommandStride)),
+ checked((uint)run.CommandCount),
+ DrawCommandStride);
+ }
+ }
+
+ private static GpuPushConstants PushForCascade(int cascadeIndex, int drawIdOffset)
+ {
+ GpuPushConstants push = GpuPushConstants.Default;
+ push.RenderPass = cascadeIndex;
+ push.DrawIdOffset = drawIdOffset;
+ return push;
+ }
+
+ private static void ApplyCull(IGpuPassEncoder encoder, CullMode mode)
+ {
+ encoder.SetFrontFace(GpuFrontFace.Clockwise);
+ encoder.SetCullMode(mode switch
+ {
+ CullMode.None => GpuCullMode.None,
+ CullMode.Clockwise => GpuCullMode.Front,
+ _ => GpuCullMode.Back,
+ });
+ }
+
+ private static IGpuPipeline CreatePipeline(
+ IGpuDevice device,
+ string name,
+ GpuShaderSet shaders,
+ GpuVertexLayout layout,
+ GpuFrontFace frontFace,
+ uint viewMask = 0) =>
+ device.CreatePipeline(new GpuPipelineDescription
+ {
+ Name = name,
+ Shaders = shaders,
+ VertexLayout = layout,
+ Topology = GpuPrimitiveTopology.TriangleList,
+ Blend = GpuBlendMode.None,
+ Depth = new GpuDepthState(true, true, GpuCompareOp.Less),
+ Cull = GpuCullMode.Back,
+ FrontFace = frontFace,
+ AlphaToCoverage = false,
+ ColorWrite = false,
+ HasColorAttachment = false,
+ AllowColorFormatVariants = false,
+ SampleCount = 1,
+ UsesRenderPackShaderAbi = true,
+ ViewMask = viewMask,
+ });
+
+ private (bool HasMeasurement, double Milliseconds) ResolveGpu(int cascadeCount)
+ {
+ if (MultiviewCascadesEnabled)
+ return _device.Timers.TryResolve(MultiviewTimerName, out double measured)
+ ? (true, measured)
+ : (false, 0d);
+ double total = 0d;
+ for (int i = 0; i < cascadeCount; i++)
+ {
+ if (!_device.Timers.TryResolve(TimerName(i), out double milliseconds))
+ return (false, 0d);
+ total += milliseconds;
+ }
+ return (true, total);
+ }
+
+ private DirectionalSunShadowDiagnostics Disabled(
+ in DirectionalShadowEnvironmentState environment,
+ DirectionalSunShadowCpuStageTicks cpuStages = default) =>
+ new(
+ environment.Reason,
+ 0f,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0d,
+ 0d,
+ false,
+ _quality.ApproximateDepthMapBytes,
+ cpuStages,
+ SourceKind: environment.SourceKind,
+ SourceObjectIndex: environment.SourceObjectIndex,
+ SourceGfxObjId: environment.SourceGfxObjId,
+ SurfaceToLightDirection: environment.SurfaceToLightDirection,
+ LightElevationSin: environment.LightElevationSin);
+
+ private void EnsureBatchCapacity(int required)
+ {
+ if (_batchScratch.Length >= required)
+ return;
+ int capacity = _batchScratch.Length == 0 ? 16 : _batchScratch.Length;
+ while (capacity < required)
+ capacity = checked(capacity * 2);
+ Array.Resize(ref _batchScratch, capacity);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ _currentFrameBinding = DirectionalShadowFrameBinding.Disabled;
+ _worldCutoutPipeline.Dispose();
+ _worldCutoutMultiviewPipeline?.Dispose();
+ _worldOpaqueMultiviewPipeline?.Dispose();
+ _terrainMultiviewPipeline?.Dispose();
+ _worldOpaquePipeline.Dispose();
+ _terrainPipeline.Dispose();
+ _terrainCommandBuffer?.Dispose();
+ _worldCommandBuffer?.Dispose();
+ _worldBatchBuffer?.Dispose();
+ _transformBuffers.Dispose();
+ _device.ReleaseTextureSlot(_textureSlot);
+ _sampler.Dispose();
+ _target.Dispose();
+ }
+
+ [StructLayout(LayoutKind.Sequential, Pack = 4)]
+ private readonly record struct DirectionalShadowBatchGpuData(
+ uint TextureIndex,
+ uint Reserved,
+ uint TextureLayer,
+ uint Flags);
+
+ private readonly record struct RetainedGpuBufferSlice(
+ IGpuBuffer? Buffer,
+ uint OffsetBytes,
+ uint SizeBytes)
+ {
+ internal IGpuBuffer RequireBuffer() => Buffer
+ ?? throw new InvalidOperationException(
+ "A non-empty directional-shadow draw has no retained GPU buffer.");
+ }
+
+ private readonly record struct PreparedGpuUploads(
+ WorldTransformFrameSlice transforms,
+ RetainedGpuBufferSlice batches,
+ RetainedGpuBufferSlice worldCommands,
+ RetainedGpuBufferSlice terrainCommands)
+ {
+ internal WorldTransformFrameSlice Transforms { get; } = transforms;
+ internal RetainedGpuBufferSlice Batches { get; } = batches;
+ internal RetainedGpuBufferSlice WorldCommands { get; } = worldCommands;
+ internal RetainedGpuBufferSlice TerrainCommands { get; } = terrainCommands;
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 6eba7668..29d16fdd 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -35,6 +35,43 @@ public sealed class GameWindow :
System.Diagnostics.Stopwatch.GetTimestamp()
/ (double)System.Diagnostics.Stopwatch.Frequency;
+ internal static WindowOptions CreateStartupWindowOptions(
+ bool exactAutomationFramebuffer,
+ string persistedResolution,
+ bool useVSync)
+ {
+ WindowOptions defaults = WindowOptions.DefaultVulkan;
+ Vector2D size = new(1280, 720);
+ WindowBorder border = defaults.WindowBorder;
+ if (exactAutomationFramebuffer)
+ {
+ if (!SilkRuntimeDisplayWindowTarget.TryParseResolution(
+ persistedResolution,
+ out int width,
+ out int height))
+ {
+ throw new InvalidOperationException(
+ "Exact automation framebuffer requires a valid persisted resolution.");
+ }
+ size = new Vector2D(width, height);
+ border = WindowBorder.Hidden;
+ }
+
+ return defaults with
+ {
+ Size = size,
+ Title = "acdream — Vulkan",
+ VSync = useVSync,
+ WindowBorder = border,
+ // A desktop-sized borderless automation window must stay hidden,
+ // not iconified. Windows throttles/occludes an iconified GLFW
+ // surface, which prevents the performance gate from collecting a
+ // complete rolling sample window. Ordinary launches retain the
+ // Silk default visibility.
+ IsVisible = !exactAutomationFramebuffer,
+ };
+ }
+
private readonly AcDream.App.RuntimeOptions _options;
// Campaign LA slice LA1: no-op instance when --session-config didn't
// configure a statusFile (or the env-var launch path was used at all).
@@ -57,6 +94,13 @@ public sealed class GameWindow :
// loop!" and would otherwise bury whatever exception actually wounded
// the loop). See docs/ISSUES.md #343.
private bool _renderLoopArmed;
+ // Silk may invoke Closing synchronously from IWindow.Close during Update,
+ // then still invoke Render once before its loop exits. Teardown cannot run
+ // from that Closing callback: it would dispose the scene while the cached
+ // render delegate is still eligible to execute. Latch the edge, skip that
+ // terminal render, and close the ownership graph after Run returns.
+ private bool _nativeCloseRequested;
+ private bool _nativeRunReturned;
private SilkWindowCallbackBinding? _windowCallbacks;
private GameWindowGraphics? _graphics;
// Campaign V slice V6h: borrowed, not owned — _graphics owns the context and
@@ -414,6 +458,8 @@ public sealed class GameWindow :
private readonly AcDream.App.UI.RetailUiRuntimeLease _retailUiLease = new();
private InteractionUiLateBindings? _interactionUiLateBindings;
private readonly DeferredRenderFrameDiagnosticsSource _uiFrameDiagnostics = new();
+ private readonly AcDream.App.Rendering.Packs.DeferredRenderPackDiagnosticsSource
+ _renderPackDiagnostics = new();
private readonly AcDream.App.Combat.CombatAttackOperationsSlot
_combatAttackOperations = new();
private readonly AcDream.App.Combat.RuntimeCombatTargetOperationsSlot
@@ -449,6 +495,7 @@ public sealed class GameWindow :
private AcDream.App.Rendering.ChargenPreviewController? _summaryPreviewController;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
+ private readonly AcDream.App.Plugins.BufferedRenderPackRegistry? _renderPackRegistry;
private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
// Campaign V slice V11 deleted the ImGui developer-tools frontend along
// with the OpenGL backend it required, so no host ever composes a
@@ -636,7 +683,8 @@ public sealed class GameWindow :
WorldEvents worldEvents,
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry,
GraphicalHostPlatformServices platformServices,
- AcDream.App.Plugins.AppAutomationSurface? automation = null)
+ AcDream.App.Plugins.AppAutomationSurface? automation = null,
+ AcDream.App.Plugins.BufferedRenderPackRegistry? renderPackRegistry = null)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_automation = automation;
@@ -724,6 +772,7 @@ public sealed class GameWindow :
characterOptionValue: _runtime.CharacterOwner.Options.GetOptionBit);
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry;
+ _renderPackRegistry = renderPackRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView(
_liveEntityRuntimeSlot);
// #184 Slice 2a: the extracted per-remote DR tick. Its stateful
@@ -799,12 +848,10 @@ public sealed class GameWindow :
// attribute there — both are attachment properties the RHI device
// configures instead. The raw-GL window options this used to fork to
// were deleted at Campaign V slice V11.
- var options = WindowOptions.DefaultVulkan with
- {
- Size = new Vector2D(1280, 720),
- Title = "acdream — Vulkan",
- VSync = startupPacing.UseVSync,
- };
+ WindowOptions options = CreateStartupWindowOptions(
+ _options.ExactAutomationFramebuffer,
+ startup.Display.Resolution,
+ startupPacing.UseVSync);
_startupPacing = startupPacing;
_startupQuality = startup.Quality;
@@ -833,6 +880,8 @@ public sealed class GameWindow :
try
{
_window.Run();
+ _nativeRunReturned = true;
+ CompleteShutdown(releaseNativeWindow: false);
}
catch (Exception failure)
{
@@ -1359,7 +1408,10 @@ public sealed class GameWindow :
_localPlayerMode,
_chaseCameraInput,
_pointerPosition,
- _renderDiagnosticLog),
+ _renderDiagnosticLog,
+ _options.InitialOrbitDistanceMeters,
+ _options.InitialOrbitYawDegrees,
+ _options.InitialOrbitPitchDegrees),
this).Compose(platformResult),
(platformResult, hostInputCamera) =>
new ContentEffectsAudioCompositionPhase(
@@ -1387,7 +1439,11 @@ public sealed class GameWindow :
new SilkRuntimeDisplayWindowTarget(_window!),
_displayFramePacing,
hostInputCamera.CameraController,
- contentEffectsAudio.Audio?.Engine)))
+ contentEffectsAudio.Audio?.Engine))
+ {
+ RenderPacks = _renderPackRegistry,
+ GpuDevice = hostInputCamera.GpuDevice,
+ })
.Compose(platformResult, hostInputCamera, contentEffectsAudio),
(platformResult, contentEffectsAudio, settingsDevTools) =>
{
@@ -1461,7 +1517,9 @@ public sealed class GameWindow :
Console.WriteLine,
hostInputCamera.GpuDevice,
hostInputCamera.GpuFrameLifetime,
- () => WorldTime.CurrentCalendar),
+ () => WorldTime.CurrentCalendar,
+ settingsDevTools.RenderPacks,
+ _renderPackDiagnostics.CaptureDiagnostics),
_retailUiLease,
this).Compose(
platformResult,
@@ -1519,7 +1577,8 @@ public sealed class GameWindow :
DevFrameDiagnostics: null,
_uiFrameDiagnostics,
Console.WriteLine,
- compositionToast),
+ compositionToast,
+ _renderPackDiagnostics),
this).Compose(
platformResult,
hostInputCamera,
@@ -1631,7 +1690,8 @@ public sealed class GameWindow :
_animatedEntities,
_updateFrameClock,
_frameGraphs,
- Console.WriteLine),
+ Console.WriteLine,
+ _renderPackDiagnostics),
this).Compose(
platformResult,
hostInputCamera,
@@ -1672,6 +1732,11 @@ public sealed class GameWindow :
// #343: see OnUpdate above — armed on entry, cleared on every normal
// exit path below, left stuck true if anything here throws.
_renderLoopArmed = true;
+ if (_nativeCloseRequested)
+ {
+ _renderLoopArmed = false;
+ return;
+ }
Vector2D size = _window!.Size;
// Campaign V slice V6h: swapchain currency is the one piece of
// presentation the RHI contract deliberately leaves to the host (plan
@@ -1735,12 +1800,23 @@ public sealed class GameWindow :
private void CompleteShutdown(bool releaseNativeWindow)
{
+ // IWindow.Close can raise Closing synchronously from Update and Silk
+ // can still issue one cached Render callback before Run returns. Keep
+ // Closing as the one narrow shutdown edge, but do not release frame
+ // owners until the native loop has actually returned. OnRender sees
+ // this latch and makes that terminal callback inert.
+ if (!releaseNativeWindow && !_nativeRunReturned)
+ {
+ _nativeCloseRequested = true;
+ return;
+ }
+
if (!_lifetime.HasShutdownRoots)
{
// Campaign LA slice LA1: capture BEFORE the shutdown roots run —
// by the time teardown completes, IsInWorld is always false
// regardless of whether a real session was ever connected.
- // OnClosing() and Dispose() both funnel through this method;
+ // post-Run shutdown and Dispose() both funnel through this method;
// HasShutdownRoots's own guard means this fires exactly once,
// from whichever of the two reaches it first.
if (_runtime.Session.IsInWorld)
@@ -1754,7 +1830,7 @@ public sealed class GameWindow :
if (report.Status == GameWindowLifetimeStatus.Complete)
{
// "exited" = terminal — only the true Dispose() call (not the
- // OnClosing() native-window-close-request pass) represents the
+ // post-Run native-window-close-request pass) represents the
// process actually being done.
if (releaseNativeWindow)
ReportExited(report);
diff --git a/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs b/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs
index 5cdb3026..51ee71b7 100644
--- a/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs
+++ b/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs
@@ -1,3 +1,5 @@
+using AcDream.Plugin.Abstractions.Rendering;
+
namespace AcDream.App.Rendering.Gpu;
///
@@ -60,16 +62,20 @@ internal static class GpuBindingModel
/// Retail SmartBox selection lighting: one vec2 (luminosity, diffuse) per instance.
public const uint StorageInstanceSelectionLighting = 8;
- // Campaign V slice V11 deleted StorageTextureTable (binding 9): the GL-only
- // emulation of the Vulkan texture table via a storage buffer of uvec2
- // bindless handles indexed by GpuTextureSlot.Index. The Vulkan backend
- // always bound TextureTableSet instead and never used this binding — every
- // Vulkan descriptor set layout declared it anyway (seeded with a dummy
- // buffer, like every other unused-by-a-given-shader binding), purely
- // because it counted toward StorageBindingCount.
+ ///
+ /// #226 per-instance retail detail category. One uint parallel to
+ /// : 1 = building shell, 0 = every other
+ /// object. EnvCell detail uses its renderer-wide category and does not
+ /// inspect this field.
+ ///
+ public const uint StorageInstanceDetailCategory = 9;
+
+ // Campaign V slice V11 deleted the old GL-only StorageTextureTable from
+ // binding 9. #226 deliberately reclaims that vacant number for the detail
+ // category above; the Vulkan texture table remains set 2.
/// One past the highest storage binding — the count the backend must support.
- public const uint StorageBindingCount = 9;
+ public const uint StorageBindingCount = 10;
// ---- set 1: uniform buffers ----
@@ -106,9 +112,43 @@ internal static class GpuBindingModel
///
public const uint UniformSkyParams = 4;
- /// Set index carrying every uniform buffer.
+ ///
+ /// Immutable authored-atmosphere inputs for one enhanced world frame in
+ /// opt-in render-pack descriptor set 3.
+ /// The std140 ABI is four vec4 values: sunScreen, sunColor, viewport, and
+ /// weather. See AtmosphericFrameUniforms and atmospheric_common.glsl.
+ ///
+ public const uint UniformAtmosphericFrame = 5;
+
+ ///
+ /// Directional-shadow cascade matrices and sampling parameters. Reserved by
+ /// the shared pack ABI even when a Tier-1 graph leaves the dummy binding in
+ /// place, so Tier 2 never changes the common pipeline layout.
+ ///
+ public const uint UniformDirectionalShadow = 6;
+
+ ///
+ /// Per-fullscreen-pass values for enhancement graphs. The v1 std140 ABI is
+ /// four vec4 values named params0..params3; individual passes assign their
+ /// meanings without changing the descriptor layout.
+ ///
+ public const uint UniformPackPass = 7;
+
+ ///
+ /// Pack-declared settings in declaration order: sixteen std140 vec4 values
+ /// (64 scalar slots). Preset overrides are resolved before activation.
+ ///
+ public const uint UniformPackSettings = RenderPackShaderAbi.PackSettingsBinding;
+
+ /// Set index carrying retail uniform buffers.
public const uint UniformSet = 1;
+ ///
+ /// Opt-in render-pack uniform set. It is absent from retail layouts and is
+ /// created only while a render-pack pipeline is alive.
+ ///
+ public const uint RenderPackUniformSet = RenderPackShaderAbi.UniformDescriptorSet;
+
// ---- set 2: the global texture table ----
/// Set index of the sampled-texture descriptor array.
diff --git a/src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs b/src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs
index 3a64564e..2eb1c7e2 100644
--- a/src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs
+++ b/src/AcDream.App/Rendering/Gpu/GpuCapabilityRecord.cs
@@ -34,6 +34,13 @@ internal sealed record GpuCapabilityRecord
/// Required alignment for a storage-buffer binding offset.
public required uint MinStorageBufferOffsetAlignment { get; init; }
+ ///
+ /// Largest byte range one storage-buffer descriptor may expose. Vulkan
+ /// guarantees at least 128 MiB; optional render packs use the exact probed
+ /// value to size scene-dependent buffers instead of imposing a host ceiling.
+ ///
+ public uint MaxStorageBufferRangeBytes { get; init; } = 128u * 1024u * 1024u;
+
/// Required alignment for a uniform-buffer binding offset.
public required uint MinUniformBufferOffsetAlignment { get; init; }
@@ -43,6 +50,20 @@ internal sealed record GpuCapabilityRecord
/// Highest supported multisample count for the backbuffer.
public required uint MaxSampleCount { get; init; }
+ /// Largest supported two-dimensional image edge from the selected adapter.
+ public required uint MaxImageDimension2D { get; init; }
+
+ /// Largest supported image-array layer count from the selected adapter.
+ public required uint MaxImageArrayLayers { get; init; }
+
+ ///
+ /// Total bytes in device-local heaps on the selected adapter. Render-pack
+ /// policy derives a deliberately bounded share from this value before any
+ /// optional image is allocated; zero means that no optional pack memory may
+ /// be assumed.
+ ///
+ public required ulong DeviceLocalMemoryBytes { get; init; }
+
/// Multi-draw-indirect. Mandatory — it is the entire draw architecture.
public required bool SupportsMultiDrawIndirect { get; init; }
@@ -62,6 +83,27 @@ internal sealed record GpuCapabilityRecord
///
public required bool SupportsPersistentlyMappedRings { get; init; }
+ ///
+ /// Whether RGBA16F images can be colour attachments, sampled, and linearly
+ /// filtered. Optional: absence disables HDR packs, never the retail client.
+ ///
+ public required bool SupportsRgba16FloatRenderTargets { get; init; }
+
+ ///
+ /// Highest usable sample count for an RGBA16F colour attachment that is
+ /// also a sampled resolve target. Zero means the format is unavailable.
+ ///
+ public required uint MaxRgba16FloatSampleCount { get; init; }
+
+ ///
+ /// Whether the selected combined depth/stencil format can also expose its
+ /// depth aspect as a sampled image. Optional: needed by screen-space packs.
+ ///
+ public required bool SupportsSampledDepth { get; init; }
+
+ /// Vulkan core multiview; optional and used only by packs that declare it.
+ public required bool SupportsMultiview { get; init; }
+
///
/// Every mandatory capability this device fails to provide, phrased as
/// operator-facing sentences. Empty means the device can run acdream.
diff --git a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs
index 607d9a11..d4396d9d 100644
--- a/src/AcDream.App/Rendering/Gpu/GpuEnums.cs
+++ b/src/AcDream.App/Rendering/Gpu/GpuEnums.cs
@@ -57,9 +57,9 @@ internal enum GpuRingUsage
}
///
-/// Texture formats acdream actually produces from DAT surfaces. BC1/2/3 are the
-/// DXT1/3/5 compressed surfaces uploaded verbatim; RGBA8 covers decoded and
-/// composited art; R8 is the stb-baked font atlas.
+/// Texture formats acdream uploads or renders. BC1/2/3 are the DXT1/3/5 DAT
+/// surfaces uploaded verbatim; RGBA8 covers decoded and composited art; R8 is
+/// the stb-baked font atlas; RGBA16F is reserved for opt-in HDR intermediates.
///
internal enum GpuTextureFormat
{
@@ -72,6 +72,12 @@ internal enum GpuTextureFormat
/// Colour attachment format for offscreen targets (paperdoll, appraisal).
Rgba8UnormRenderTarget,
+ ///
+ /// Half-float HDR colour attachment used only by opt-in enhancement graphs.
+ /// The retail/default graph remains on .
+ ///
+ Rgba16FloatRenderTarget,
+
/// Combined depth+stencil attachment. #117's portal punch needs the stencil aspect.
Depth24Stencil8,
}
@@ -129,6 +135,14 @@ internal enum GpuBlendMode
/// `ParticleRenderer` needs it too (slice V4e).
///
InverseAlpha,
+
+ ///
+ /// Retail building/EnvCell detail overlay:
+ /// DstColor, OneMinusSrcAlpha. This intentionally preserves the
+ /// retail client's measured brightening; it is not a conventional
+ /// modulate/roughening blend.
+ ///
+ RetailDetail,
}
internal enum GpuCompareOp
diff --git a/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs b/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs
index 96b2740a..ab66c82f 100644
--- a/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs
+++ b/src/AcDream.App/Rendering/Gpu/GpuPassDescription.cs
@@ -21,20 +21,24 @@ internal readonly record struct GpuColorAttachment(
Vector4 ClearColor);
///
-/// The depth/stencil attachment for a pass. Depth is transient in every acdream
-/// pass — nothing reads it after the frame — so is normally
-/// , which lets Vulkan skip writing it back to
-/// memory entirely.
+/// The depth/stencil attachment for a pass. Ordinary world/private-viewport
+/// depth is transient, so is normally
+/// . Directional shadow layers instead name a
+/// and use Store so receivers may sample them.
///
/// What happens to existing contents on entry.
/// What happens to contents on exit.
/// Depth clear value. acdream renders with NDC z in [0,1], so far = 1.
/// Stencil clear value; #117's portal punch uses the stencil aspect.
+/// Dedicated layered depth target, or null for the pass colour target/backbuffer depth.
+/// The cascade layer when is present.
internal readonly record struct GpuDepthAttachment(
GpuLoadOp Load,
GpuStoreOp Store,
float ClearDepth,
- uint ClearStencil);
+ uint ClearStencil,
+ IGpuDirectionalDepthTarget? DirectionalTarget = null,
+ int Layer = 0);
///
/// One rendering pass: a set of attachments, their load/store behaviour, and the
@@ -57,15 +61,21 @@ internal sealed record GpuPassDescription
/// Stable identifier, surfaced as a debug label in captures.
public required string Name { get; init; }
- /// The colour attachment. Required — acdream has no colour-less passes.
+ /// The colour attachment. Ignored when is false.
public required GpuColorAttachment Color { get; init; }
+ /// False only for dedicated depth-only producers such as directional shadow maps.
+ public bool HasColorAttachment { get; init; } = true;
+
/// Depth/stencil attachment, or null for 2-D passes that need no depth.
public GpuDepthAttachment? Depth { get; init; }
/// Samples per pixel. Must equal of every pipeline bound inside.
public int SampleCount { get; init; } = 1;
+ /// Non-zero Vulkan multiview mask. Ordinary passes always leave this zero.
+ public uint ViewMask { get; init; }
+
/// Clears colour and depth to the standard frame-start values against the backbuffer.
public static GpuPassDescription BackbufferClear(string name, Vector4 clearColor, int sampleCount) => new()
{
@@ -82,4 +92,43 @@ internal sealed record GpuPassDescription
ClearStencil: 0),
SampleCount = sampleCount,
};
+
+ /// Clears and stores one cascade layer of a directional-depth array.
+ public static GpuPassDescription DirectionalDepth(
+ string name,
+ IGpuDirectionalDepthTarget target,
+ int layer) => new()
+ {
+ Name = name,
+ Color = default,
+ HasColorAttachment = false,
+ Depth = new GpuDepthAttachment(
+ Load: GpuLoadOp.Clear,
+ Store: GpuStoreOp.Store,
+ ClearDepth: 1f,
+ ClearStencil: 0,
+ DirectionalTarget: target,
+ Layer: layer),
+ SampleCount = 1,
+ };
+
+ /// Clears and stores all contiguous cascade layers in one multiview pass.
+ public static GpuPassDescription DirectionalDepthMultiview(
+ string name,
+ IGpuDirectionalDepthTarget target,
+ uint viewMask) => new()
+ {
+ Name = name,
+ Color = default,
+ HasColorAttachment = false,
+ Depth = new GpuDepthAttachment(
+ GpuLoadOp.Clear,
+ GpuStoreOp.Store,
+ 1f,
+ 0,
+ target,
+ Layer: 0),
+ SampleCount = 1,
+ ViewMask = viewMask,
+ };
}
diff --git a/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs b/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs
index e04ee578..ef4b9cb8 100644
--- a/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs
+++ b/src/AcDream.App/Rendering/Gpu/GpuPipelineDescription.cs
@@ -168,13 +168,39 @@ internal sealed record GpuVertexLayout(
}
///
-/// Names one GLSL shader pair. The backend resolves it: the GL backend loads
-/// Rendering/Shaders/{Name}.vert and .frag and compiles at startup;
-/// the Vulkan backend loads the committed Rendering/Shaders/spv/{Name}.vert.spv
-/// and .frag.spv produced by tools/compile-shaders.ps1. One source
-/// of truth (the GLSL), two consumption paths.
+/// Names one SPIR-V shader pair. Renderer-owned shaders resolve from the
+/// committed shader directory. A selected render pack instead supplies an
+/// immutable candidate-owned byte pair, so validation never turns into a
+/// second host-path lookup or a private built-in shortcut.
///
-internal readonly record struct GpuShaderSet(string Name);
+internal readonly record struct GpuShaderSet
+{
+ internal GpuShaderSet(string name)
+ : this(name, ReadOnlyMemory.Empty, ReadOnlyMemory.Empty)
+ {
+ }
+
+ internal GpuShaderSet(
+ string name,
+ ReadOnlyMemory vertexSpirv,
+ ReadOnlyMemory fragmentSpirv)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ if (vertexSpirv.IsEmpty != fragmentSpirv.IsEmpty)
+ throw new ArgumentException("Both SPIR-V stages must be supplied together.");
+ Name = name;
+ VertexSpirv = vertexSpirv;
+ FragmentSpirv = fragmentSpirv;
+ }
+
+ internal string Name { get; }
+
+ internal ReadOnlyMemory VertexSpirv { get; }
+
+ internal ReadOnlyMemory FragmentSpirv { get; }
+
+ internal bool HasEmbeddedSpirv => !VertexSpirv.IsEmpty;
+}
/// Depth-buffer behaviour baked into a pipeline.
/// Whether depth testing is enabled at all.
@@ -241,6 +267,8 @@ internal readonly record struct GpuStencilState(
///
internal sealed record GpuPipelineDescription
{
+ /// Non-zero only for a pipeline compiled for a matching multiview pass.
+ public uint ViewMask { get; init; }
/// Stable identifier, e.g. "mesh-opaque". Surfaced to RenderDoc and validation layers.
public required string Name { get; init; }
@@ -275,6 +303,12 @@ internal sealed record GpuPipelineDescription
/// Whether the pipeline writes colour at all. False for depth/stencil-only prepasses.
public bool ColorWrite { get; init; } = true;
+ ///
+ /// Whether the compatible dynamic-rendering pass carries a colour
+ /// attachment. False creates a true depth-only graphics pipeline.
+ ///
+ public bool HasColorAttachment { get; init; } = true;
+
///
/// Whether this pipeline uses the stencil aspect at all.
///
@@ -324,6 +358,21 @@ internal sealed record GpuPipelineDescription
///
public GpuTextureFormat ColorFormat { get; init; } = GpuTextureFormat.Rgba8UnormRenderTarget;
+ ///
+ /// Whether an opt-in graph may prebuild this pipeline against an additional
+ /// colour-attachment format. World pipelines leave this enabled; dedicated
+ /// fullscreen pipelines already name their only format and disable it.
+ ///
+ public bool AllowColorFormatVariants { get; init; } = true;
+
+ ///
+ /// Opts this pipeline into render-pack shader ABI v1. Vulkan then uses the
+ /// lazy four-set pipeline layout whose set 3 contains bindings 5..8; retail
+ /// pipelines keep the authoritative three-set layout and create no pack
+ /// descriptors or layouts.
+ ///
+ public bool UsesRenderPackShaderAbi { get; init; }
+
/// Sample count of the passes this pipeline is used in. Must match the pass.
public int SampleCount { get; init; } = 1;
}
diff --git a/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs b/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs
index 5e50d0fe..59f31b7a 100644
--- a/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs
+++ b/src/AcDream.App/Rendering/Gpu/GpuPushConstants.cs
@@ -55,7 +55,9 @@ internal struct GpuPushConstants
///
public uint TextureIndexA;
- /// GLSL uTextureIndexB. Secondary per-pass slot — currently the terrain alpha-mask array.
+ /// GLSL uTextureIndexB. Secondary per-pass slot; terrain
+ /// uses it for the alpha-mask array, while shared-pose world/detail passes
+ /// carry the absolute transform-prefix instance count.
public uint TextureIndexB;
///
diff --git a/src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs b/src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs
index f16605fb..6118123c 100644
--- a/src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs
+++ b/src/AcDream.App/Rendering/Gpu/GpuResourceDescriptions.cs
@@ -76,22 +76,55 @@ internal readonly record struct GpuSamplerDescription(
GpuAddressMode.ClampToEdge,
GpuAddressMode.ClampToEdge,
MaxAnisotropy: 1f);
+
+ ///
+ /// Discrete nearest-clamp depth reads for manual PCF. Mip-nearest is
+ /// deliberate even though the shadow image has one level: it keeps this
+ /// pack-owned sampler distinct from the device's long-lived UI sampler.
+ ///
+ public static GpuSamplerDescription ShadowNearestClamp { get; } = new(
+ GpuFilter.Nearest,
+ GpuFilter.Nearest,
+ GpuMipFilter.Nearest,
+ GpuAddressMode.ClampToEdge,
+ GpuAddressMode.ClampToEdge,
+ MaxAnisotropy: 1f);
}
-/// An offscreen colour(+depth) bundle: paperdoll, creature appraisal, portal masking.
+/// An offscreen colour(+depth) bundle: paperdoll, creature appraisal, portal masking, or an enhancement intermediate.
/// Stable identifier for debug tooling.
/// Colour attachment width in pixels.
/// Colour attachment height in pixels.
/// Colour attachment format.
/// Depth/stencil format, or null for a colour-only target.
-/// 1 for single-sampled. Offscreen targets stay single-sampled.
+///
+/// Attachment sample count. Values above one use transient multisample
+/// attachments and resolve into the single-sampled textures exposed by
+/// .
+///
+///
+/// Whether the depth result must be exposed for later shader sampling. This is
+/// opt-in so ordinary private viewports retain transient attachment-only depth.
+///
internal readonly record struct GpuRenderTargetDescription(
string Name,
int Width,
int Height,
GpuTextureFormat ColorFormat,
GpuTextureFormat? DepthFormat,
- int SampleCount);
+ int SampleCount,
+ bool SampleableDepth = false);
+
+///
+/// A single-sampled, sampleable depth-array used by directional shadow maps.
+/// Each cascade is rendered through its own 2-D layer attachment while the
+/// complete array is registered once in the global texture table.
+///
+internal readonly record struct GpuDirectionalDepthTargetDescription(
+ string Name,
+ int Resolution,
+ int LayerCount,
+ GpuTextureFormat DepthFormat = GpuTextureFormat.Depth24Stencil8);
///
/// A slot in the device's global texture table — the backend-neutral replacement
diff --git a/src/AcDream.App/Rendering/Gpu/GpuResources.cs b/src/AcDream.App/Rendering/Gpu/GpuResources.cs
index 17709943..262c2596 100644
--- a/src/AcDream.App/Rendering/Gpu/GpuResources.cs
+++ b/src/AcDream.App/Rendering/Gpu/GpuResources.cs
@@ -13,6 +13,13 @@ internal interface IGpuBuffer : IDisposable
GpuBufferUsage Usage { get; }
GpuMemoryResidency Residency { get; }
+ ///
+ /// True when CPU writes through a mapped HostWritable allocation are made
+ /// available without an explicit non-coherent atom flush. Retained mapped
+ /// resources may require this and fail safe when a device cannot provide it.
+ ///
+ bool HostWritesAreCoherent { get; }
+
///
/// Writes at . On a
/// buffer this stages through a
@@ -89,8 +96,32 @@ internal interface IGpuRenderTarget : IDisposable
{
GpuRenderTargetDescription Description { get; }
- /// The colour attachment, for registering into the texture table or blitting into UI.
+ ///
+ /// The single-sampled colour result, for registering into the texture table
+ /// or blitting into UI. A multisampled target resolves into this texture;
+ /// callers never sample its transient multisample attachment directly.
+ ///
IGpuTexture ColorTexture { get; }
+
+ ///
+ /// The single-sampled depth result when
+ /// was requested;
+ /// otherwise null. Combined depth/stencil targets expose the depth aspect
+ /// only through the sampled view while retaining stencil for rendering.
+ ///
+ IGpuTexture? DepthTexture { get; }
+}
+
+///
+/// A sampleable directional-depth array. Layers are attachment-addressable by
+/// ; callers sample the full array through
+/// after the producing passes end.
+///
+internal interface IGpuDirectionalDepthTarget : IDisposable
+{
+ GpuDirectionalDepthTargetDescription Description { get; }
+
+ IGpuTexture DepthTexture { get; }
}
///
@@ -106,4 +137,12 @@ internal interface IGpuTimerPool
/// Milliseconds measured for in the most recent retired frame.
bool TryResolve(string scopeName, out double milliseconds);
+
+ ///
+ /// Consumes the newest retired measurement for .
+ /// Distribution builders use this form so a GPU result is sampled exactly
+ /// once even when the render thread runs several frames before another
+ /// flight slot retires.
+ ///
+ bool TryTakeResolved(string scopeName, out double milliseconds);
}
diff --git a/src/AcDream.App/Rendering/Gpu/IGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/IGpuDevice.cs
index 710accbb..fe9fe2bf 100644
--- a/src/AcDream.App/Rendering/Gpu/IGpuDevice.cs
+++ b/src/AcDream.App/Rendering/Gpu/IGpuDevice.cs
@@ -4,6 +4,10 @@ namespace AcDream.App.Rendering.Gpu;
/// The RHI root: creates every GPU resource, owns the global texture table, and
/// drives the frame loop. One instance per graphics context, constructed during
/// composition and threaded into renderers in place of the raw GL handle.
+/// Resource creation/registration and retirement are safe for one asynchronous
+/// off-side render-pack preparation worker while the render thread records the
+/// active generation. Frame/pass recording and queued-device-action draining
+/// remain render-thread-only.
///
/// Campaign V (see docs/plans/2026-07-27-vulkan-campaign.md) implements
/// this interface twice: first on OpenGL — behaviour-preserving, so each renderer
@@ -49,6 +53,10 @@ internal interface IGpuDevice : IDisposable
IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description);
+ /// Creates the dedicated 2-4 cascade sampleable depth array.
+ IGpuDirectionalDepthTarget CreateDirectionalDepthTarget(
+ in GpuDirectionalDepthTargetDescription description);
+
///
/// Publishes a (texture, sampler) pair into the global table and returns the
/// slot shaders index it by. The same texture registered with two samplers
diff --git a/src/AcDream.App/Rendering/Gpu/IGpuFrame.cs b/src/AcDream.App/Rendering/Gpu/IGpuFrame.cs
index 1aff1d76..7c18ff04 100644
--- a/src/AcDream.App/Rendering/Gpu/IGpuFrame.cs
+++ b/src/AcDream.App/Rendering/Gpu/IGpuFrame.cs
@@ -65,6 +65,14 @@ internal interface IGpuFrame : IDisposable
///
GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage);
+ ///
+ /// Publishes CPU writes made through a retained host-writable storage
+ /// buffer before a later pass reads them in a shader. Frame-ring writes use
+ /// the frame submission's existing visibility contract; this explicit seam
+ /// exists for pack-owned mapped buffers that persist across submissions.
+ ///
+ void PublishHostStorageWrites(IGpuBuffer buffer);
+
///
/// Opens a rendering pass. The returned encoder must be disposed before the
/// next pass begins; nesting is not supported and no acdream pass needs it.
diff --git a/src/AcDream.App/Rendering/Gpu/IGpuPipelineFormatVariantHost.cs b/src/AcDream.App/Rendering/Gpu/IGpuPipelineFormatVariantHost.cs
new file mode 100644
index 00000000..e039ecbe
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/IGpuPipelineFormatVariantHost.cs
@@ -0,0 +1,13 @@
+namespace AcDream.App.Rendering.Gpu;
+
+///
+/// Device-owned lifetime for attachment-format variants of already-created
+/// graphics pipelines. Vulkan dynamic rendering bakes the colour format into a
+/// pipeline; an enhancement graph acquires its HDR format before recording any
+/// enhanced pass and releases it when the pack retires. The clean retail path
+/// never acquires a lease and therefore creates no HDR world variants.
+///
+internal interface IGpuPipelineFormatVariantHost
+{
+ IDisposable AcquirePipelineColorFormat(GpuTextureFormat format);
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs
index ad689e33..5e9d5d42 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs
@@ -39,6 +39,9 @@ internal sealed record VulkanDeviceFeatureSupport
/// gl_DrawID. Resets per indirect dispatch exactly as GL's does.
public required bool ShaderDrawParameters { get; init; }
+ /// Optional Vulkan 1.1 core multiview support for layered shadow cascades.
+ public required bool Multiview { get; init; }
+
// ---- 1.2 ----
/// One monotonic serial replaces the GL fence array; the retirement ledger keeps its keys.
@@ -93,6 +96,7 @@ internal sealed record VulkanDeviceFeatureSupport
TextureCompressionBc = true,
SamplerAnisotropy = true,
ShaderDrawParameters = true,
+ Multiview = true,
TimelineSemaphore = true,
HostQueryReset = true,
RuntimeDescriptorArray = true,
@@ -123,6 +127,7 @@ internal sealed record VulkanDeviceFeatureSupport
var n when Is(n, nameof(TextureCompressionBc)) => this with { TextureCompressionBc = false },
var n when Is(n, nameof(SamplerAnisotropy)) => this with { SamplerAnisotropy = false },
var n when Is(n, nameof(ShaderDrawParameters)) => this with { ShaderDrawParameters = false },
+ var n when Is(n, nameof(Multiview)) => this with { Multiview = false },
var n when Is(n, nameof(TimelineSemaphore)) => this with { TimelineSemaphore = false },
var n when Is(n, nameof(HostQueryReset)) => this with { HostQueryReset = false },
var n when Is(n, nameof(RuntimeDescriptorArray)) => this with { RuntimeDescriptorArray = false },
@@ -170,6 +175,15 @@ internal sealed record VulkanDeviceLimitSupport
///
public required uint MaxDescriptorSetStorageBuffersDynamic { get; init; }
+ /// Must reach every storage binding declared by descriptor set 0.
+ public required uint MaxDescriptorSetStorageBuffers { get; init; }
+
+ ///
+ /// Must reach every set-0 storage binding because the shared layout exposes
+ /// all of them to both the vertex and fragment stages.
+ ///
+ public required uint MaxPerStageDescriptorStorageBuffers { get; init; }
+
/// Must reach the number of dynamic uniform bindings set 1 declares.
public required uint MaxDescriptorSetUniformBuffersDynamic { get; init; }
@@ -185,12 +199,25 @@ internal sealed record VulkanDeviceLimitSupport
/// Ring allocations must satisfy this; getting it wrong is a driver error on Vulkan.
public required uint MinStorageBufferOffsetAlignment { get; init; }
+ ///
+ /// Largest legal range in one storage-buffer descriptor. Vulkan 1.3
+ /// guarantees at least 128 MiB; enhanced scene buffers are bounded by the
+ /// actual adapter value rather than a renderer-authored constant.
+ ///
+ public required uint MaxStorageBufferRange { get; init; }
+
/// As above, for the SceneLighting uniform block.
public required uint MinUniformBufferOffsetAlignment { get; init; }
/// Largest 2D image edge; the terrain atlas and composite arrays are sized against it.
public required uint MaxImageDimension2D { get; init; }
+ /// Largest image-array layer count reported by the selected physical device.
+ public required uint MaxImageArrayLayers { get; init; }
+
+ /// Sum of device-local heap bytes reported by the selected physical device.
+ public required ulong DeviceLocalHeapBytes { get; init; }
+
/// Highest colour sample count the framebuffer supports, as a plain count (1/2/4/8...).
public required uint MaxColorSampleCount { get; init; }
@@ -205,16 +232,22 @@ internal sealed record VulkanDeviceLimitSupport
MaxPushConstantsSize = GpuBindingModel.MaxPushConstantBytes,
MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
MaxBoundDescriptorSets = 4,
- // Vulkan's guaranteed minimums. That the layout fits inside them is the
- // point of slice V6g's split — see VulkanPipelineLayouts.
+ // The dynamic counts use Vulkan's guaranteed minimums. Total/per-stage
+ // counts use acdream's shared-layout requirement, which the startup
+ // capability gate verifies on the real device.
MaxDescriptorSetStorageBuffersDynamic = 4,
+ MaxDescriptorSetStorageBuffers = GpuBindingModel.StorageBindingCount,
+ MaxPerStageDescriptorStorageBuffers = GpuBindingModel.StorageBindingCount,
MaxDescriptorSetUniformBuffersDynamic = 8,
MaxDescriptorSetUpdateAfterBindSampledImages = GpuBindingModel.TextureTableCapacity,
MaxPerStageDescriptorUpdateAfterBindSampledImages = GpuBindingModel.TextureTableCapacity,
TimestampComputeAndGraphics = true,
MinStorageBufferOffsetAlignment = 256,
+ MaxStorageBufferRange = 128u * 1024u * 1024u,
MinUniformBufferOffsetAlignment = 256,
MaxImageDimension2D = 16384,
+ MaxImageArrayLayers = 2048,
+ DeviceLocalHeapBytes = 8UL * 1024 * 1024 * 1024,
MaxColorSampleCount = 8,
};
}
@@ -236,6 +269,24 @@ internal sealed record VulkanFormatSupport
/// The chosen depth+stencil format, or when none is usable.
public required Format DepthStencilFormat { get; init; }
+ /// Whether the chosen combined depth/stencil format is sampleable through its depth aspect.
+ public required bool DepthStencilSampled { get; init; }
+
+ /// RGBA16F supports optimal-tiling colour-attachment writes.
+ public required bool Rgba16FloatColorAttachment { get; init; }
+
+ /// RGBA16F supports optimal-tiling sampled-image reads.
+ public required bool Rgba16FloatSampled { get; init; }
+
+ /// RGBA16F supports linear filtering, required by scaled bloom/ray passes.
+ public required bool Rgba16FloatLinearFilter { get; init; }
+
+ ///
+ /// Highest supported RGBA16F sample count for a colour-attachment image.
+ /// Zero means the format/usage combination is unavailable.
+ ///
+ public required uint MaxRgba16FloatSampleCount { get; init; }
+
/// BC1 (DXT1) sampled-image support with optimal tiling.
public required bool Bc1Sampled { get; init; }
@@ -249,6 +300,11 @@ internal sealed record VulkanFormatSupport
{
SwapchainUnormFormat = true,
DepthStencilFormat = Format.D32SfloatS8Uint,
+ DepthStencilSampled = true,
+ Rgba16FloatColorAttachment = true,
+ Rgba16FloatSampled = true,
+ Rgba16FloatLinearFilter = true,
+ MaxRgba16FloatSampleCount = 8,
Bc1Sampled = true,
Bc2Sampled = true,
Bc3Sampled = true,
@@ -368,23 +424,36 @@ internal sealed record VulkanCapabilityRecord(
Math.Min(
Limits.MaxDescriptorSetUpdateAfterBindSampledImages,
Limits.MaxPerStageDescriptorUpdateAfterBindSampledImages),
- // Sets 0..2 give each binding its own namespace, so the storage
- // bindings the model declares (nine, since Campaign V slice V11
- // deleted the GL-only StorageTextureTable binding) are always all
- // available once the set count requirement passes. There is no
- // per-set binding-count limit in Vulkan below
- // maxPerStageDescriptorStorageBuffers, which is far higher.
- MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
+ MaxStorageBufferBindings = Math.Min(
+ Limits.MaxDescriptorSetStorageBuffers,
+ Limits.MaxPerStageDescriptorStorageBuffers),
MaxPushConstantBytes = Limits.MaxPushConstantsSize,
MinStorageBufferOffsetAlignment = Limits.MinStorageBufferOffsetAlignment,
+ MaxStorageBufferRangeBytes = Limits.MaxStorageBufferRange,
MinUniformBufferOffsetAlignment = Limits.MinUniformBufferOffsetAlignment,
MaxClipDistances = Limits.MaxClipDistances,
MaxSampleCount = Limits.MaxColorSampleCount,
+ MaxImageDimension2D = Limits.MaxImageDimension2D,
+ MaxImageArrayLayers = Limits.MaxImageArrayLayers,
+ DeviceLocalMemoryBytes = Limits.DeviceLocalHeapBytes,
SupportsMultiDrawIndirect = Features.MultiDrawIndirect,
SupportsDrawParameters = Features.ShaderDrawParameters,
SupportsTextureCompressionBc = Features.TextureCompressionBc,
SupportsTimestampQueries = Limits.TimestampComputeAndGraphics,
+ SupportsMultiview = Features.Multiview,
SupportsPersistentlyMappedRings = true,
+ SupportsRgba16FloatRenderTargets =
+ Formats.Rgba16FloatColorAttachment
+ && Formats.Rgba16FloatSampled
+ && Formats.Rgba16FloatLinearFilter
+ && Formats.MaxRgba16FloatSampleCount > 0,
+ MaxRgba16FloatSampleCount =
+ Formats.Rgba16FloatColorAttachment
+ && Formats.Rgba16FloatSampled
+ && Formats.Rgba16FloatLinearFilter
+ ? Math.Min(Formats.MaxRgba16FloatSampleCount, Limits.MaxColorSampleCount)
+ : 0u,
+ SupportsSampledDepth = Formats.DepthStencilSampled,
};
}
@@ -478,6 +547,18 @@ internal static class VulkanCapabilityRequirements
$"set 0 declares {VulkanPipelineLayouts.DynamicStorageBindingCount} dynamic storage bindings " +
$"(Vulkan guarantees 4); this device provides {limits.MaxDescriptorSetStorageBuffersDynamic}.");
}
+ if (limits.MaxDescriptorSetStorageBuffers < GpuBindingModel.StorageBindingCount)
+ {
+ failures.Add(
+ $"set 0 declares {GpuBindingModel.StorageBindingCount} total storage bindings; " +
+ $"this device provides {limits.MaxDescriptorSetStorageBuffers} per set.");
+ }
+ if (limits.MaxPerStageDescriptorStorageBuffers < GpuBindingModel.StorageBindingCount)
+ {
+ failures.Add(
+ $"set 0 exposes {GpuBindingModel.StorageBindingCount} storage bindings to each shader stage; " +
+ $"this device provides {limits.MaxPerStageDescriptorStorageBuffers} per stage.");
+ }
if (limits.MaxDescriptorSetUniformBuffersDynamic < VulkanFrameBindings.DynamicUniformBindingCount)
{
failures.Add(
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs
index 8aad6b1a..55adab4b 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs
@@ -1,6 +1,10 @@
+using System.Diagnostics;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
+using AcDream.App.Rendering.Packs;
+using AcDream.App.Rendering.Scene;
+using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.World;
@@ -68,8 +72,13 @@ internal sealed class VulkanRenderFrameClearPhase : IRenderFrameClearPhase
Math.Clamp(atmosphere.FogColor.Z, 0f, 1f),
1f);
+ var foundation = new RenderFrameFoundation(
+ portalViewportVisible,
+ sky,
+ atmosphere);
_clear.ClearColor = clear;
- return new RenderFrameFoundation(portalViewportVisible, sky, atmosphere);
+ _clear.Foundation = foundation;
+ return foundation;
}
}
@@ -108,19 +117,39 @@ internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase
private readonly Func _sampleCount;
private readonly VulkanWorldPassScope _scope;
private readonly IWorldSceneFramePhase _world;
+ private readonly RenderPackController? _renderPacks;
+ private readonly AtmosphericFrameInputState? _atmosphere;
+ private readonly Func?
+ _applyRenderPackBoundary;
+ private readonly RenderSceneShadowRuntime? _renderScene;
+ private readonly WbDrawDispatcher? _worldMeshes;
+ private readonly TerrainModernRenderer? _terrain;
public VulkanWorldScenePhase(
ICurrentGpuFrameSource frames,
VulkanBackbufferClearState clear,
Func sampleCount,
VulkanWorldPassScope scope,
- IWorldSceneFramePhase world)
+ IWorldSceneFramePhase world,
+ RenderPackController? renderPacks = null,
+ AtmosphericFrameInputState? atmosphere = null,
+ Func?
+ applyRenderPackBoundary = null,
+ RenderSceneShadowRuntime? renderScene = null,
+ WbDrawDispatcher? worldMeshes = null,
+ TerrainModernRenderer? terrain = null)
{
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
_clear = clear ?? throw new ArgumentNullException(nameof(clear));
_sampleCount = sampleCount ?? throw new ArgumentNullException(nameof(sampleCount));
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
_world = world ?? throw new ArgumentNullException(nameof(world));
+ _renderPacks = renderPacks;
+ _atmosphere = atmosphere;
+ _applyRenderPackBoundary = applyRenderPackBoundary;
+ _renderScene = renderScene;
+ _worldMeshes = worldMeshes;
+ _terrain = terrain;
}
public WorldRenderFrameOutcome Render(RenderFrameInput input)
@@ -129,6 +158,264 @@ internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase
?? throw new InvalidOperationException(
"The Vulkan world phase requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
+ int samples = _sampleCount();
+ if (_renderPacks is not null)
+ {
+ var extent = new RenderPackActivationExtent(
+ input.ViewportWidth,
+ input.ViewportHeight,
+ samples);
+ _ = _applyRenderPackBoundary is not null
+ ? _applyRenderPackBoundary(extent)
+ : _renderPacks.ApplyAtFrameBoundary(extent);
+ }
+ if (_renderPacks?.ActiveRuntime is { } active)
+ {
+ if (active is IDefaultWorldPathRenderPackRuntime)
+ return RenderRetail(frame, input);
+ if (active is not IAtmosphericWorldGraphRuntime graph
+ || _atmosphere is null)
+ {
+ _renderPacks.OnRuntimeFailure(
+ "The selected pack has no compatible production world graph.");
+ return RenderRetail(frame, input);
+ }
+
+ IAtmosphericCpuStageProfileRuntime? cpuStageProfile =
+ graph as IAtmosphericCpuStageProfileRuntime;
+ bool profileCpuStages = cpuStageProfile?.ShouldProfileCpuFrame(frame.Serial) == true;
+ long packCpuTicks = 0;
+ long targetPreparationTicks = 0;
+ IGpuRenderTarget target;
+ long packStarted = Stopwatch.GetTimestamp();
+ try
+ {
+ target = graph.PrepareWorldTarget(
+ input.ViewportWidth,
+ input.ViewportHeight,
+ samples);
+ }
+ catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ _renderPacks.OnRuntimeFailure(
+ "Atmospheric target creation failed: "
+ + error.GetBaseException().Message);
+ return RenderRetail(frame, input);
+ }
+ finally
+ {
+ long elapsed = Stopwatch.GetTimestamp() - packStarted;
+ packCpuTicks += elapsed;
+ if (profileCpuStages)
+ targetPreparationTicks = elapsed;
+ }
+
+ _atmosphere.BeginFrame(in input, _clear.Foundation);
+ PreparedWorldSceneFrame? prepared = null;
+ if (graph is IDirectionalShadowWorldGraphRuntime directional)
+ {
+ if (_world is not IPreparedWorldSceneFramePhase preparedWorld
+ || _renderScene is null
+ || _worldMeshes is null
+ || _terrain is null)
+ {
+ _renderPacks.OnRuntimeFailure(
+ "The selected directional-shadow pack has no compatible world preparation seam.");
+ return RenderRetail(frame, input);
+ }
+
+ PreparedWorldSceneFrame value;
+ try
+ {
+ value = preparedWorld.PrepareEnhanced(input);
+ }
+ catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ _renderPacks.OnRuntimeFailure(
+ "Atmospheric world preparation failed: "
+ + error.GetBaseException().Message);
+ return RenderRetail(frame, input);
+ }
+ prepared = value;
+ if (value.ShouldRender)
+ {
+ packStarted = Stopwatch.GetTimestamp();
+ try
+ {
+ RenderSceneQuery scene = _renderScene.Query;
+ RenderFrameFoundation preparedFoundation = value.Foundation;
+ WorldRenderFrame preparedWorldFrame = value.World;
+ directional.RenderDirectionalShadows(
+ frame,
+ in preparedFoundation,
+ in preparedWorldFrame,
+ value.ActiveDayGroup,
+ in scene,
+ _worldMeshes,
+ _terrain);
+ }
+ catch (Exception error) when (VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ preparedWorld.CancelPreparedEnhanced(in value);
+ throw;
+ }
+ catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ preparedWorld.CancelPreparedEnhanced(in value);
+ // DirectionalShadowRenderer may have completed its depth
+ // pass and published the pack-owned retained transform
+ // prefix before a later graph check fails (notably the
+ // scene-dependent retained-VRAM ceiling). Cancel the
+ // dispatcher's borrowed same-frame slice before
+ // OnRuntimeFailure disposes the pack and its buffers;
+ // RenderRetail below must allocate its ordinary N.5
+ // transforms from the frame ring, never append to that
+ // retired prefix.
+ _worldMeshes.CancelDirectionalShadowTransformFrame(frame);
+ _renderPacks.OnRuntimeFailure(
+ "Directional shadow rendering failed: "
+ + error.GetBaseException().Message);
+ return RenderRetail(frame, input);
+ }
+ finally
+ {
+ packCpuTicks += Stopwatch.GetTimestamp() - packStarted;
+ }
+ }
+ }
+ WorldRenderFrameOutcome outcome;
+ long receiverCpuTicks = 0;
+ try
+ {
+ using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
+ {
+ Name = "atmospheric-world-hdr",
+ Color = new GpuColorAttachment(
+ target,
+ GpuLoadOp.Clear,
+ samples > 1 ? GpuStoreOp.Resolve : GpuStoreOp.Store,
+ _clear.ClearColor),
+ Depth = new GpuDepthAttachment(
+ GpuLoadOp.Clear,
+ GpuStoreOp.Store,
+ 1f,
+ 0),
+ SampleCount = samples,
+ });
+ using IDisposable publication = prepared is { ShouldRender: true }
+ ? _scope.PublishPrepared(encoder)
+ : _scope.Publish(encoder);
+ if (prepared is { } value)
+ {
+ using IDisposable receiverTimer = encoder.BeginTimerScope(
+ RenderPackPerformanceScopeNames.EnhancedWorldReceiver);
+ long receiverStarted = Stopwatch.GetTimestamp();
+ try
+ {
+ outcome = ((IPreparedWorldSceneFramePhase)_world)
+ .RenderPreparedEnhanced(input, in value);
+ }
+ finally
+ {
+ receiverCpuTicks += Stopwatch.GetTimestamp() - receiverStarted;
+ }
+ }
+ else
+ {
+ outcome = _world.Render(input);
+ }
+ }
+ catch (Exception error) when (VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ if (prepared is { } value
+ && _world is IPreparedWorldSceneFramePhase preparedWorld)
+ {
+ preparedWorld.CancelPreparedEnhanced(in value);
+ }
+ _worldMeshes?.CancelDirectionalShadowTransformFrame(frame);
+ throw;
+ }
+ catch (Exception error)
+ {
+ if (prepared is { } value
+ && _world is IPreparedWorldSceneFramePhase preparedWorld)
+ {
+ preparedWorld.CancelPreparedEnhanced(in value);
+ }
+ _worldMeshes?.CancelDirectionalShadowTransformFrame(frame);
+ // The HDR pass may already contain receiver commands, so it
+ // cannot be replayed through retail in this frame. Quarantine
+ // the pack, return an empty outcome for this one aborted frame,
+ // and let the next frame use the unchanged default renderer.
+ _renderPacks?.OnRuntimeFailure(
+ "Atmospheric world rendering failed: "
+ + error.GetBaseException().Message);
+ return default;
+ }
+
+ try
+ {
+ AtmosphericFrameInputs atmospheric = _atmosphere.Snapshot();
+ packStarted = Stopwatch.GetTimestamp();
+ graph.RenderPostProcess(frame, in atmospheric);
+ packCpuTicks += Stopwatch.GetTimestamp() - packStarted;
+ var observation = new RenderPackFramePerformanceObservation(
+ PackAddedCpuMilliseconds: packCpuTicks * 1000d / Stopwatch.Frequency,
+ StableFrameBoundary: outcome.NormalWorldDrawn,
+ input.ViewportWidth,
+ input.ViewportHeight,
+ samples,
+ AbsoluteEnhancedWorldReceiverCpuMilliseconds:
+ receiverCpuTicks * 1000d / Stopwatch.Frequency);
+ bool observationSucceeded = false;
+ long observeStarted = profileCpuStages ? Stopwatch.GetTimestamp() : 0L;
+ try
+ {
+ _renderPacks.ObserveActiveFrame(in observation);
+ observationSucceeded = true;
+ }
+ catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ _renderPacks.OnRuntimeFailure(
+ "Atmospheric performance observation failed: "
+ + error.GetBaseException().Message);
+ }
+ long observeBookkeepingTicks = profileCpuStages
+ ? Stopwatch.GetTimestamp() - observeStarted
+ : 0L;
+ if (observationSucceeded && profileCpuStages)
+ {
+ cpuStageProfile!.CompleteCpuProfile(
+ frame.Serial,
+ targetPreparationTicks,
+ packCpuTicks,
+ observeBookkeepingTicks,
+ outcome.NormalWorldDrawn);
+ }
+ return outcome;
+ }
+ catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ // The canonical world transaction has already completed and
+ // cannot legally be replayed. Keep its outcome, quarantine the
+ // pack, and let the next frame use the unchanged default path.
+ _renderPacks.OnRuntimeFailure(
+ "Atmospheric post-processing failed: "
+ + error.GetBaseException().Message);
+ return outcome;
+ }
+ }
+
+ return RenderRetail(frame, input);
+ }
+
+ private WorldRenderFrameOutcome RenderRetail(
+ IGpuFrame frame,
+ RenderFrameInput input)
+ {
+ // This is the exact pre-pack pass/resource/pipeline path. Keep the branch
+ // whole so Retail selection does not create, touch, or query any pack
+ // object after ApplyAtFrameBoundary reports no active runtime.
int samples = _sampleCount();
using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{
@@ -167,6 +454,8 @@ internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase
internal sealed class VulkanBackbufferClearState
{
internal System.Numerics.Vector4 ClearColor { get; set; } = new(0f, 0f, 0f, 1f);
+
+ internal RenderFrameFoundation Foundation { get; set; }
}
///
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanDeviceMemoryAllocator.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDeviceMemoryAllocator.cs
index 207eb51b..17e084a4 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanDeviceMemoryAllocator.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDeviceMemoryAllocator.cs
@@ -12,6 +12,7 @@ internal readonly unsafe struct VulkanAllocation(
ulong offsetBytes,
ulong sizeBytes,
uint memoryTypeIndex,
+ MemoryPropertyFlags memoryProperties,
VulkanMemoryRange range,
void* mapped)
{
@@ -19,6 +20,7 @@ internal readonly unsafe struct VulkanAllocation(
internal ulong OffsetBytes { get; } = offsetBytes;
internal ulong SizeBytes { get; } = sizeBytes;
internal uint MemoryTypeIndex { get; } = memoryTypeIndex;
+ internal MemoryPropertyFlags MemoryProperties { get; } = memoryProperties;
internal VulkanMemoryRange Range { get; } = range;
/// First mapped byte of this allocation, or null on device-local memory.
@@ -64,6 +66,7 @@ internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable
private readonly MemoryPropertyFlags[] _memoryTypeProperties;
private readonly ulong _blockSizeBytes;
private readonly ulong _dedicatedThresholdBytes;
+ private readonly object _sync = new();
private readonly Dictionary _pools = [];
private readonly Dictionary<(uint TypeIndex, int BlockIndex), BlockMemory> _blockMemory = [];
@@ -110,9 +113,11 @@ internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable
GpuMemoryResidency residency,
string ownerName)
{
- ObjectDisposedException.ThrowIf(_disposed, this);
+ lock (_sync)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
- uint typeIndex = VulkanMemoryTypeSelection.Choose(
+ uint typeIndex = VulkanMemoryTypeSelection.Choose(
_memoryTypeProperties,
requirements.MemoryTypeBits,
residency)
@@ -121,64 +126,69 @@ internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable
$"Allowed type bits 0x{requirements.MemoryTypeBits:X8}; the device exposes " +
$"{_memoryTypeProperties.Length} memory types.");
- if (!_pools.TryGetValue(typeIndex, out VulkanMemoryTypePool? pool))
- {
- pool = new VulkanMemoryTypePool(typeIndex, _blockSizeBytes, _dedicatedThresholdBytes);
- _pools.Add(typeIndex, pool);
+ if (!_pools.TryGetValue(typeIndex, out VulkanMemoryTypePool? pool))
+ {
+ pool = new VulkanMemoryTypePool(typeIndex, _blockSizeBytes, _dedicatedThresholdBytes);
+ _pools.Add(typeIndex, pool);
+ }
+
+ ulong size = requirements.Size;
+ ulong alignment = Math.Max(requirements.Alignment, 1);
+ if (!pool.TryAllocate(size, alignment, out VulkanMemoryRange range))
+ {
+ bool dedicated = pool.IsDedicatedSize(size);
+ ulong capacity = Math.Max(pool.BlockCapacityFor(size), size);
+ int blockIndex = pool.AddBlock(capacity, dedicated);
+ CreateBlockMemory(typeIndex, blockIndex, capacity, ownerName);
+
+ range = dedicated
+ ? pool.AllocateWholeBlock(blockIndex, size)
+ : pool.TryAllocate(size, alignment, out VulkanMemoryRange placed)
+ ? placed
+ : throw new InvalidOperationException(
+ $"A freshly created {capacity}-byte block could not satisfy a {size}-byte " +
+ $"allocation at alignment {alignment} for '{ownerName}'.");
+ }
+
+ BlockMemory block = _blockMemory[(typeIndex, range.BlockIndex)];
+ AllocatedBytes += range.SizeBytes;
+ void* mapped = block.Mapped == 0
+ ? null
+ : (void*)(block.Mapped + (nint)range.OffsetBytes);
+ return new VulkanAllocation(
+ block.Memory,
+ range.OffsetBytes,
+ range.SizeBytes,
+ typeIndex,
+ _memoryTypeProperties[(int)typeIndex],
+ range,
+ mapped);
}
-
- ulong size = requirements.Size;
- ulong alignment = Math.Max(requirements.Alignment, 1);
- if (!pool.TryAllocate(size, alignment, out VulkanMemoryRange range))
- {
- bool dedicated = pool.IsDedicatedSize(size);
- ulong capacity = Math.Max(pool.BlockCapacityFor(size), size);
- int blockIndex = pool.AddBlock(capacity, dedicated);
- CreateBlockMemory(typeIndex, blockIndex, capacity, ownerName);
-
- range = dedicated
- ? pool.AllocateWholeBlock(blockIndex, size)
- : pool.TryAllocate(size, alignment, out VulkanMemoryRange placed)
- ? placed
- : throw new InvalidOperationException(
- $"A freshly created {capacity}-byte block could not satisfy a {size}-byte " +
- $"allocation at alignment {alignment} for '{ownerName}'.");
- }
-
- BlockMemory block = _blockMemory[(typeIndex, range.BlockIndex)];
- AllocatedBytes += range.SizeBytes;
- void* mapped = block.Mapped == 0
- ? null
- : (void*)(block.Mapped + (nint)range.OffsetBytes);
- return new VulkanAllocation(
- block.Memory,
- range.OffsetBytes,
- range.SizeBytes,
- typeIndex,
- range,
- mapped);
}
/// Returns an allocation's bytes to its pool, freeing the block when a dedicated one empties.
internal void Free(in VulkanAllocation allocation)
{
- if (_disposed || allocation.SizeBytes == 0)
- return;
- if (!_pools.TryGetValue(allocation.MemoryTypeIndex, out VulkanMemoryTypePool? pool))
- return;
+ lock (_sync)
+ {
+ if (_disposed || allocation.SizeBytes == 0)
+ return;
+ if (!_pools.TryGetValue(allocation.MemoryTypeIndex, out VulkanMemoryTypePool? pool))
+ return;
- AllocatedBytes -= Math.Min(AllocatedBytes, allocation.Range.SizeBytes);
- if (!pool.Free(allocation.Range))
- return;
+ AllocatedBytes -= Math.Min(AllocatedBytes, allocation.Range.SizeBytes);
+ if (!pool.Free(allocation.Range))
+ return;
- var key = (allocation.MemoryTypeIndex, allocation.Range.BlockIndex);
- if (!_blockMemory.Remove(key, out BlockMemory block))
- return;
+ var key = (allocation.MemoryTypeIndex, allocation.Range.BlockIndex);
+ if (!_blockMemory.Remove(key, out BlockMemory block))
+ return;
- if (block.Mapped != 0)
- _vk.UnmapMemory(_device, block.Memory);
- _vk.FreeMemory(_device, block.Memory, null);
- CommittedBytes -= Math.Min(CommittedBytes, block.CapacityBytes);
+ if (block.Mapped != 0)
+ _vk.UnmapMemory(_device, block.Memory);
+ _vk.FreeMemory(_device, block.Memory, null);
+ CommittedBytes -= Math.Min(CommittedBytes, block.CapacityBytes);
+ }
}
private void CreateBlockMemory(uint typeIndex, int blockIndex, ulong capacityBytes, string ownerName)
@@ -216,27 +226,35 @@ internal sealed unsafe class VulkanDeviceMemoryAllocator : IDisposable
}
/// Human-readable accounting for the diagnostics report and for teardown assertions.
- internal string Describe() =>
- $"{DeviceMemoryObjectCount} device-memory object(s), " +
- $"{CommittedBytes / (1024 * 1024)} MiB committed, " +
- $"{AllocatedBytes / (1024 * 1024)} MiB allocated";
+ internal string Describe()
+ {
+ lock (_sync)
+ {
+ return $"{DeviceMemoryObjectCount} device-memory object(s), "
+ + $"{CommittedBytes / (1024 * 1024)} MiB committed, "
+ + $"{AllocatedBytes / (1024 * 1024)} MiB allocated";
+ }
+ }
public void Dispose()
{
- if (_disposed)
- return;
- _disposed = true;
-
- foreach (BlockMemory block in _blockMemory.Values)
+ lock (_sync)
{
- if (block.Mapped != 0)
- _vk.UnmapMemory(_device, block.Memory);
- _vk.FreeMemory(_device, block.Memory, null);
- }
+ if (_disposed)
+ return;
+ _disposed = true;
- _blockMemory.Clear();
- _pools.Clear();
- AllocatedBytes = 0;
- CommittedBytes = 0;
+ foreach (BlockMemory block in _blockMemory.Values)
+ {
+ if (block.Mapped != 0)
+ _vk.UnmapMemory(_device, block.Memory);
+ _vk.FreeMemory(_device, block.Memory, null);
+ }
+
+ _blockMemory.Clear();
+ _pools.Clear();
+ AllocatedBytes = 0;
+ CommittedBytes = 0;
+ }
}
}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanDirectionalDepthTarget.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDirectionalDepthTarget.cs
new file mode 100644
index 00000000..b72cfffc
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDirectionalDepthTarget.cs
@@ -0,0 +1,157 @@
+using Silk.NET.Vulkan;
+
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+internal readonly record struct VulkanDirectionalMultiviewRange(uint BaseLayer, uint LayerCount);
+
+internal static class VulkanDirectionalMultiviewContract
+{
+ internal static VulkanDirectionalMultiviewRange Resolve(uint viewMask, int targetLayerCount)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(targetLayerCount);
+ if (targetLayerCount > 31)
+ throw new ArgumentOutOfRangeException(nameof(targetLayerCount));
+ uint expected = (1u << targetLayerCount) - 1u;
+ if (viewMask != expected)
+ throw new NotSupportedException("Directional multiview must cover every contiguous target layer.");
+ return new VulkanDirectionalMultiviewRange(0u, (uint)targetLayerCount);
+ }
+}
+
+///
+/// One sampleable depth array plus a 2-D attachment view for every cascade.
+/// Layout is tracked per layer because cascades are produced in distinct
+/// dynamic-rendering passes and become shader-readable independently.
+///
+internal sealed unsafe class VulkanDirectionalDepthTarget : IGpuDirectionalDepthTarget
+{
+ private readonly Silk.NET.Vulkan.Vk _vk;
+ private readonly Device _device;
+ private readonly IGpuResourceRetirementQueue _retirement;
+ private readonly ImageView[] _layerViews;
+ private readonly ImageLayout[] _layerLayouts;
+ private bool _disposed;
+
+ internal VulkanDirectionalDepthTarget(
+ Silk.NET.Vulkan.Vk vk,
+ Device device,
+ VulkanDeviceMemoryAllocator allocator,
+ VulkanUploadQueue uploads,
+ IGpuResourceRetirementQueue retirement,
+ VulkanDebugNames debugNames,
+ in GpuDirectionalDepthTargetDescription description,
+ Format depthStencilFormat)
+ {
+ _vk = vk ?? throw new ArgumentNullException(nameof(vk));
+ _device = device;
+ _retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
+ Description = description;
+
+ var textureDescription = new GpuTextureDescription(
+ description.Name,
+ GpuTextureKind.Texture2DArray,
+ description.DepthFormat,
+ description.Resolution,
+ description.Resolution,
+ description.LayerCount,
+ MipLevelCount: 1);
+ Texture = new VulkanGpuTexture(
+ vk,
+ device,
+ allocator,
+ uploads,
+ retirement,
+ debugNames,
+ textureDescription,
+ sampleCount: 1,
+ renderTarget: true,
+ sampleable: true,
+ formatOverride: depthStencilFormat);
+
+ _layerViews = new ImageView[description.LayerCount];
+ _layerLayouts = new ImageLayout[description.LayerCount];
+ try
+ {
+ for (int layer = 0; layer < _layerViews.Length; layer++)
+ {
+ var create = new ImageViewCreateInfo
+ {
+ SType = StructureType.ImageViewCreateInfo,
+ Image = Texture.Image,
+ ViewType = ImageViewType.Type2D,
+ Format = Texture.VkFormat,
+ SubresourceRange = new ImageSubresourceRange
+ {
+ AspectMask = ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit,
+ BaseMipLevel = 0,
+ LevelCount = 1,
+ BaseArrayLayer = (uint)layer,
+ LayerCount = 1,
+ },
+ };
+ VulkanInterop.Check(
+ vk.CreateImageView(device, &create, null, out ImageView view),
+ $"vkCreateImageView ('{description.Name}', layer {layer})");
+ _layerViews[layer] = view;
+ debugNames.NameImageView(view, $"{description.Name}-layer-{layer}");
+ }
+ }
+ catch
+ {
+ foreach (ImageView view in _layerViews)
+ {
+ if (view.Handle != 0)
+ vk.DestroyImageView(device, view, null);
+ }
+ Texture.Dispose();
+ throw;
+ }
+ }
+
+ public GpuDirectionalDepthTargetDescription Description { get; }
+
+ public IGpuTexture DepthTexture => Texture;
+
+ internal VulkanGpuTexture Texture { get; }
+
+ internal ImageView ViewAt(int layer)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentOutOfRangeException.ThrowIfNegative(layer);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(layer, _layerViews.Length);
+ return _layerViews[layer];
+ }
+
+ internal ImageView MultiviewView(uint viewMask)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ _ = VulkanDirectionalMultiviewContract.Resolve(viewMask, Description.LayerCount);
+ return Texture.View;
+ }
+
+ internal int LayerCountForViewMask(uint viewMask)
+ {
+ return checked((int)VulkanDirectionalMultiviewContract.Resolve(
+ viewMask,
+ Description.LayerCount).LayerCount);
+ }
+
+ internal ImageLayout LayoutAt(int layer) => _layerLayouts[layer];
+
+ internal void MarkLayout(int layer, ImageLayout layout) => _layerLayouts[layer] = layout;
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+
+ ImageView[] views = [.. _layerViews];
+ _retirement.Retire(() =>
+ {
+ foreach (ImageView view in views)
+ _vk.DestroyImageView(_device, view, null);
+ });
+ Texture.Dispose();
+ }
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanDrawBindingState.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDrawBindingState.cs
new file mode 100644
index 00000000..7e6c6331
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanDrawBindingState.cs
@@ -0,0 +1,34 @@
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+///
+/// Per-pass descriptor-bind state. Vulkan descriptor bindings survive pipeline
+/// changes and remain valid until their layout or dynamic offsets change, so a
+/// draw can omit an identical second vkCmdBindDescriptorSets command.
+/// A new pass receives a fresh state and therefore always binds before its
+/// first draw.
+///
+internal struct VulkanDrawBindingState
+{
+ private ulong _pipelineLayout;
+ private int _packGeneration;
+ private bool _hasBinding;
+ private bool _dirty;
+
+ internal readonly bool RequiresBind(
+ ulong pipelineLayout,
+ int packGeneration) =>
+ !_hasBinding
+ || _dirty
+ || _pipelineLayout != pipelineLayout
+ || _packGeneration != packGeneration;
+
+ internal void MarkDirty() => _dirty = true;
+
+ internal void MarkBound(ulong pipelineLayout, int packGeneration)
+ {
+ _pipelineLayout = pipelineLayout;
+ _packGeneration = packGeneration;
+ _hasBinding = true;
+ _dirty = false;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameBindings.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameBindings.cs
index f1f6fbea..d60480db 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameBindings.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameBindings.cs
@@ -26,10 +26,9 @@ namespace AcDream.App.Rendering.Gpu.Vk;
///
/// Every binding is always bound, whether a renderer uses it or
/// not. Bindings a shader does not declare still need a live descriptor, so
-/// unused ones point at a shared dummy range. That is what lets there be ONE
-/// descriptor set layout and one pipeline layout rather than a permutation per
-/// renderer — plan §4.4's requirement, and the thing that makes switching
-/// pipelines mid-pass free.
+/// unused ones point at a shared dummy range. Retail keeps its one common
+/// layout; opt-in render packs add exactly one compatible set rather than
+/// changing these sets or creating renderer permutations.
///
/// Slice V6i: one set pair per renderer scope. There is no longer a
/// single (set 0, set 1) pair per flight slot; there is an arena of them, and
@@ -45,8 +44,16 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
private readonly Device _device;
private readonly VulkanPipelineLayouts.Created _layouts;
private readonly VulkanBindingScopeArena _arena;
+ private readonly uint _maxStorageBufferRangeBytes;
private readonly List _pools = [];
private readonly List<(DescriptorSet Storage, DescriptorSet Uniform)> _sets = [];
+ private readonly ulong[] _packBuffers = new ulong[VulkanPipelineLayouts.PackUniformBindingCount];
+ private readonly uint[] _packOffsets = new uint[VulkanPipelineLayouts.PackUniformBindingCount];
+ private readonly uint[] _packRanges = new uint[VulkanPipelineLayouts.PackUniformBindingCount];
+ private readonly Dictionary _packSlotsByState = [];
+ private readonly List _packSets = [];
+ private int _packLiveCount;
+ private int _packGeneration = -1;
private bool _disposed;
@@ -76,8 +83,7 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
}
///
- /// Bindings 0..4 of set 1. Slice V6i-2 raised this from 4 when the layout
- /// gained binding 4 (sky params); binding 0 remains unused and is counted
+ /// Bindings 0..4 of retail set 1. Binding 0 remains unused and is counted
/// only so the bookkeeping arrays stay index-aligned with the binding number.
/// Which of them the layout DECLARES is
/// .
@@ -87,45 +93,46 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
///
/// How many of set 1's bindings the layout actually declares, all dynamic.
/// Asserted against maxDescriptorSetUniformBuffersDynamic by the
- /// capability gate; Vulkan guarantees 8, so this is comfortable.
+ /// capability gate; Vulkan guarantees exactly the four bindings declared.
///
internal static uint DynamicUniformBindingCount { get; } =
(uint)VulkanPipelineLayouts.DeclaredUniformBindings.Length;
- ///
- /// Widest range any single binding may address. Dynamic descriptors take a
- /// static range at write time and slide it with an offset, so this bounds
- /// how much of the ring one binding can see at once.
- ///
- internal const uint MaxBindingRangeBytes = 4 * 1024 * 1024;
-
internal VulkanFrameBindings(
Silk.NET.Vulkan.Vk vk,
Device device,
VulkanPipelineLayouts.Created layouts,
VulkanGpuBuffer ring,
- VulkanGpuBuffer dummy)
+ VulkanGpuBuffer dummy,
+ uint maxStorageBufferRangeBytes)
{
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
_device = device;
_layouts = layouts ?? throw new ArgumentNullException(nameof(layouts));
ArgumentNullException.ThrowIfNull(ring);
ArgumentNullException.ThrowIfNull(dummy);
+ ArgumentOutOfRangeException.ThrowIfLessThan(maxStorageBufferRangeBytes, 16u);
Ring = ring;
Dummy = dummy;
+ _maxStorageBufferRangeBytes = maxStorageBufferRangeBytes;
_arena = new VulkanBindingScopeArena(
(int)GpuBindingModel.StorageBindingCount,
UniformBindingCount,
VulkanPipelineLayouts.IsDynamicStorageBinding);
- uint dummyStorageRange = (uint)Math.Min(dummy.SizeBytes, MaxBindingRangeBytes);
+ uint dummyStorageRange = (uint)Math.Min(dummy.SizeBytes, _maxStorageBufferRangeBytes);
for (uint binding = 0; binding < GpuBindingModel.StorageBindingCount; binding++)
_arena.SeedStorage(binding, dummy.Handle.Handle, offsetBytes: 0, dummyStorageRange);
uint dummyUniformRange = (uint)Math.Min(dummy.SizeBytes, 65536);
for (uint binding = 0; binding < UniformBindingCount; binding++)
_arena.SeedUniform(binding, dummy.Handle.Handle, dummyUniformRange);
+ for (int binding = 0; binding < _packBuffers.Length; binding++)
+ {
+ _packBuffers[binding] = dummy.Handle.Handle;
+ _packRanges[binding] = dummyUniformRange;
+ }
}
internal VulkanGpuBuffer Ring { get; }
@@ -144,7 +151,12 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
/// previous submission has retired before BeginFrame returns, which is
/// the same guarantee that lets the ring rewind.
///
- internal void BeginFrame() => _arena.BeginFrame();
+ internal void BeginFrame()
+ {
+ _arena.BeginFrame();
+ _packSlotsByState.Clear();
+ _packLiveCount = 0;
+ }
internal void SetStorage(uint binding, VulkanGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
@@ -159,16 +171,33 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
internal void SetUniform(uint binding, VulkanGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
- ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(binding, (uint)UniformBindingCount);
- _arena.SetUniform(
- binding,
- buffer.Handle.Handle,
- offsetBytes,
- Math.Min(ClampRange(buffer, sizeBytes, offsetBytes: 0), 65536));
+ if (binding < UniformBindingCount)
+ {
+ _arena.SetUniform(
+ binding,
+ buffer.Handle.Handle,
+ offsetBytes,
+ Math.Min(ClampRange(buffer, sizeBytes, offsetBytes: 0), 65536));
+ return;
+ }
+
+ ArgumentOutOfRangeException.ThrowIfLessThan(binding, GpuBindingModel.UniformAtmosphericFrame);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(binding, GpuBindingModel.UniformPackSettings);
+ int packBinding = (int)(binding - GpuBindingModel.UniformAtmosphericFrame);
+ _packBuffers[packBinding] = buffer.Handle.Handle;
+ _packOffsets[packBinding] = offsetBytes;
+ _packRanges[packBinding] = Math.Min(ClampRange(buffer, sizeBytes, offsetBytes: 0), 65536);
}
- /// Binds all three sets with the current dynamic offsets.
- internal void Bind(CommandBuffer commands, VulkanGpuDevice device)
+ ///
+ /// Binds retail sets 0..2. A flagged pipeline additionally supplies its
+ /// live pack state, which lazily materialises and binds set 3.
+ ///
+ internal void Bind(
+ CommandBuffer commands,
+ VulkanGpuDevice device,
+ PipelineLayout pipelineLayout,
+ VulkanPipelineLayouts.Created.PackState? packState = null)
{
(int index, int slot, bool needsWrite) = _arena.Resolve();
if (slot < 0)
@@ -199,12 +228,71 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
_vk.CmdBindDescriptorSets(
commands,
PipelineBindPoint.Graphics,
- device.Layouts.PipelineLayout,
+ pipelineLayout,
0,
3,
sets,
(uint)dynamicCount,
offsets);
+
+ if (packState is not null)
+ BindPackSet(commands, pipelineLayout, packState);
+ }
+
+ private void BindPackSet(
+ CommandBuffer commands,
+ PipelineLayout pipelineLayout,
+ VulkanPipelineLayouts.Created.PackState state)
+ {
+ if (_packGeneration != state.Generation)
+ {
+ _packGeneration = state.Generation;
+ _packSets.Clear();
+ _packSlotsByState.Clear();
+ _packLiveCount = 0;
+ }
+
+ PackBindingKey key = CurrentPackKey();
+ if (!_packSlotsByState.TryGetValue(key, out int slot))
+ {
+ slot = _packLiveCount++;
+ _packSlotsByState.Add(key, slot);
+ if (slot == _packSets.Count)
+ _packSets.Add(state.AllocateDescriptorSet());
+ WritePackSet(_packSets[slot]);
+ }
+
+ DescriptorSet set = _packSets[slot];
+ uint* offsets = stackalloc uint[(int)VulkanPipelineLayouts.PackUniformBindingCount];
+ for (int i = 0; i < _packOffsets.Length; i++)
+ offsets[i] = _packOffsets[i];
+ _vk.CmdBindDescriptorSets(
+ commands,
+ PipelineBindPoint.Graphics,
+ pipelineLayout,
+ GpuBindingModel.RenderPackUniformSet,
+ 1,
+ &set,
+ VulkanPipelineLayouts.PackUniformBindingCount,
+ offsets);
+ }
+
+ private PackBindingKey CurrentPackKey() => new(
+ _packBuffers[0], _packRanges[0],
+ _packBuffers[1], _packRanges[1],
+ _packBuffers[2], _packRanges[2],
+ _packBuffers[3], _packRanges[3]);
+
+ private void WritePackSet(DescriptorSet set)
+ {
+ for (int i = 0; i < _packBuffers.Length; i++)
+ {
+ WriteUniform(
+ set,
+ GpuBindingModel.UniformAtmosphericFrame + (uint)i,
+ new Silk.NET.Vulkan.Buffer(_packBuffers[i]),
+ _packRanges[i]);
+ }
}
private void WritePair((DescriptorSet Storage, DescriptorSet Uniform) pair)
@@ -273,7 +361,7 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
return pool;
}
- private static uint ClampRange(VulkanGpuBuffer buffer, uint requested, uint offsetBytes)
+ private uint ClampRange(VulkanGpuBuffer buffer, uint requested, uint offsetBytes)
{
long remaining = buffer.SizeBytes - offsetBytes;
if (remaining <= 0)
@@ -285,7 +373,7 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
"A descriptor range of zero is not representable in Vulkan.");
}
- uint available = (uint)Math.Min(remaining, MaxBindingRangeBytes);
+ uint available = (uint)Math.Min(remaining, _maxStorageBufferRangeBytes);
return requested == 0 ? available : Math.Min(Math.Max(requested, 16), available);
}
@@ -370,5 +458,17 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
_pools.Clear();
_sets.Clear();
+ _packSlotsByState.Clear();
+ _packSets.Clear();
}
+
+ private readonly record struct PackBindingKey(
+ ulong Buffer0,
+ uint Range0,
+ ulong Buffer1,
+ uint Range1,
+ ulong Buffer2,
+ uint Range2,
+ ulong Buffer3,
+ uint Range3);
}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameFlightController.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameFlightController.cs
index 5ef2a7b6..cd6c234a 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameFlightController.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanFrameFlightController.cs
@@ -81,6 +81,7 @@ internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue,
private readonly IVulkanTimelineApi _timeline;
private readonly SortedDictionary> _retirements = [];
+ private readonly object _sync = new();
private long _openSerial;
private long _submittedSerial;
@@ -99,15 +100,43 @@ internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue,
internal int SlotCount { get; }
/// Serial of the frame currently being recorded, or 0 when none is open.
- internal long OpenSerial => _openSerial;
+ internal long OpenSerial
+ {
+ get
+ {
+ lock (_sync)
+ return _openSerial;
+ }
+ }
/// Highest serial handed to .
- internal long SubmittedSerial => _submittedSerial;
+ internal long SubmittedSerial
+ {
+ get
+ {
+ lock (_sync)
+ return _submittedSerial;
+ }
+ }
/// Flight slot index of the currently open frame.
- internal int CurrentSlot => SlotIndexOf(_openSerial);
+ internal int CurrentSlot
+ {
+ get
+ {
+ lock (_sync)
+ return SlotIndexOf(_openSerial);
+ }
+ }
- internal int PendingRetirementCount => _retirements.Sum(entry => entry.Value.Count);
+ internal int PendingRetirementCount
+ {
+ get
+ {
+ lock (_sync)
+ return _retirements.Sum(entry => entry.Value.Count);
+ }
+ }
/// Maps a frame serial onto its flight slot. Serials are 1-based.
internal int SlotIndexOf(long serial) =>
@@ -120,30 +149,36 @@ internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue,
///
internal long BeginFrame()
{
- ObjectDisposedException.ThrowIf(_disposed, this);
- if (_openSerial != 0)
+ lock (_sync)
{
- throw new InvalidOperationException(
- $"Frame {_openSerial} is still open; call EndFrame before beginning another.");
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_openSerial != 0)
+ {
+ throw new InvalidOperationException(
+ $"Frame {_openSerial} is still open; call EndFrame before beginning another.");
+ }
+
+ long serial = _submittedSerial + 1;
+ long mustComplete = serial - SlotCount;
+ if (mustComplete > 0)
+ _timeline.Wait((ulong)mustComplete);
+
+ _openSerial = serial;
+ RunRetirements();
+ return serial;
}
-
- long serial = _submittedSerial + 1;
- long mustComplete = serial - SlotCount;
- if (mustComplete > 0)
- _timeline.Wait((ulong)mustComplete);
-
- _openSerial = serial;
- RunRetirements();
- return serial;
}
/// Records that the open frame has been submitted with its serial as the timeline signal value.
internal void EndFrame()
{
- if (_openSerial == 0)
- return;
- _submittedSerial = _openSerial;
- _openSerial = 0;
+ lock (_sync)
+ {
+ if (_openSerial == 0)
+ return;
+ _submittedSerial = _openSerial;
+ _openSerial = 0;
+ }
}
///
@@ -154,68 +189,83 @@ internal sealed class VulkanFrameFlightController : IGpuResourceRetirementQueue,
public void Retire(Action release)
{
ArgumentNullException.ThrowIfNull(release);
- if (_disposed)
+ lock (_sync)
{
- // Teardown already drained the ledger; running immediately is the
- // only way this release ever happens, and by then the device is idle.
- release();
- return;
- }
+ if (_disposed)
+ {
+ // Teardown already drained the ledger; running immediately is the
+ // only way this release ever happens, and by then the device is idle.
+ release();
+ return;
+ }
- long key = _openSerial != 0 ? _openSerial : _submittedSerial + 1;
- if (!_retirements.TryGetValue(key, out List? actions))
- {
- actions = [];
- _retirements.Add(key, actions);
- }
+ long key = _openSerial != 0 ? _openSerial : _submittedSerial + 1;
+ if (!_retirements.TryGetValue(key, out List? actions))
+ {
+ actions = [];
+ _retirements.Add(key, actions);
+ }
- actions.Add(release);
+ actions.Add(release);
+ }
}
/// Runs every retirement whose frame the GPU has completed.
internal void RunRetirements()
{
- if (_retirements.Count == 0)
- return;
-
- var completed = (long)_timeline.CurrentValue;
- while (_retirements.Count > 0)
+ lock (_sync)
{
- KeyValuePair> first = _retirements.First();
- if (first.Key > completed)
- break;
+ if (_retirements.Count == 0)
+ return;
- _retirements.Remove(first.Key);
- foreach (Action release in first.Value)
- release();
+ var completed = (long)_timeline.CurrentValue;
+ while (_retirements.Count > 0)
+ {
+ KeyValuePair> first = _retirements.First();
+ if (first.Key > completed)
+ break;
+
+ _retirements.Remove(first.Key);
+ foreach (Action release in first.Value)
+ release();
+ }
}
}
/// Blocks until every submitted frame has completed, then drains the whole ledger.
internal void WaitForSubmittedWork()
{
- if (_submittedSerial > 0)
- _timeline.Wait((ulong)_submittedSerial);
- DrainAll();
+ lock (_sync)
+ {
+ if (_submittedSerial > 0)
+ _timeline.Wait((ulong)_submittedSerial);
+ DrainAll();
+ }
}
/// Runs every pending retirement regardless of serial. Only legal when the device is idle.
internal void DrainAll()
{
- while (_retirements.Count > 0)
+ lock (_sync)
{
- KeyValuePair> first = _retirements.First();
- _retirements.Remove(first.Key);
- foreach (Action release in first.Value)
- release();
+ while (_retirements.Count > 0)
+ {
+ KeyValuePair> first = _retirements.First();
+ _retirements.Remove(first.Key);
+ foreach (Action release in first.Value)
+ release();
+ }
}
}
public void Dispose()
{
- if (_disposed)
- return;
- DrainAll();
- _disposed = true;
+ lock (_sync)
+ {
+ if (_disposed)
+ return;
+ DrainAll();
+ _disposed = true;
+ }
}
}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuBuffer.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuBuffer.cs
index c2e9cad2..9bc334db 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuBuffer.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuBuffer.cs
@@ -91,6 +91,8 @@ internal sealed unsafe class VulkanGpuBuffer : IGpuBuffer
public long SizeBytes { get; }
public GpuBufferUsage Usage { get; }
public GpuMemoryResidency Residency { get; }
+ public bool HostWritesAreCoherent =>
+ _allocation.MemoryProperties.HasFlag(MemoryPropertyFlags.HostCoherentBit);
internal Buffer Handle { get; }
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
index 71af91f4..3804644f 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
@@ -26,6 +26,9 @@ internal sealed unsafe partial class VulkanGpuDevice
private readonly Dictionary _samplers = [];
private readonly Dictionary _shaderModules = [];
+ private readonly HashSet _pipelines = [];
+ private readonly Dictionary _pipelineFormatLeaseCounts = [];
+ private readonly object _resourceCreationSync = new();
private string _shaderSpirvDirectory = string.Empty;
private float _maxSamplerAnisotropy = 1f;
@@ -127,7 +130,8 @@ internal sealed unsafe partial class VulkanGpuDevice
_device,
_layouts,
_ringBuffers[slot],
- _bindingDummy);
+ _bindingDummy,
+ Capabilities.MaxStorageBufferRangeBytes);
}
}
@@ -228,6 +232,8 @@ internal sealed unsafe partial class VulkanGpuDevice
}
_shaderModules.Clear();
+ _pipelines.Clear();
+ _pipelineFormatLeaseCounts.Clear();
foreach (VulkanGpuSampler sampler in _samplers.Values)
sampler.Dispose();
@@ -290,6 +296,18 @@ internal sealed unsafe partial class VulkanGpuDevice
public IGpuTexture CreateTexture(in GpuTextureDescription description)
{
ThrowIfDisposed();
+ if (description.Format == GpuTextureFormat.Rgba16FloatRenderTarget
+ && !Capabilities.SupportsRgba16FloatRenderTargets)
+ {
+ throw new NotSupportedException(
+ "RGBA16F colour-attachment, sampling, and linear filtering are unavailable.");
+ }
+ if (description.Format == GpuTextureFormat.Depth24Stencil8
+ && !Capabilities.SupportsSampledDepth)
+ {
+ throw new NotSupportedException(
+ "The selected combined depth/stencil format cannot expose a sampled depth aspect.");
+ }
return new VulkanGpuTexture(
_vk,
_device,
@@ -303,9 +321,16 @@ internal sealed unsafe partial class VulkanGpuDevice
}
public IGpuSampler CreateSampler(in GpuSamplerDescription description)
+ {
+ lock (_resourceCreationSync)
+ return CreateSamplerLocked(in description);
+ }
+
+ private IGpuSampler CreateSamplerLocked(in GpuSamplerDescription description)
{
ThrowIfDisposed();
- if (_samplers.TryGetValue(description, out VulkanGpuSampler? existing))
+ if (_samplers.TryGetValue(description, out VulkanGpuSampler? existing)
+ && !existing.IsDisposed)
return existing;
var created = new VulkanGpuSampler(
@@ -315,13 +340,39 @@ internal sealed unsafe partial class VulkanGpuDevice
_debugNames,
description,
_maxSamplerAnisotropy);
- _samplers.Add(description, created);
+ _samplers[description] = created;
return created;
}
public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description)
{
ThrowIfDisposed();
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.SampleCount);
+ if ((uint)description.SampleCount > Capabilities.MaxSampleCount)
+ {
+ throw new NotSupportedException(
+ $"The device supports at most {Capabilities.MaxSampleCount} colour/depth samples; "
+ + $"'{description.Name}' requested {description.SampleCount}.");
+ }
+ if (description.ColorFormat == GpuTextureFormat.Rgba16FloatRenderTarget)
+ {
+ if (!Capabilities.SupportsRgba16FloatRenderTargets)
+ {
+ throw new NotSupportedException(
+ "RGBA16F colour-attachment, sampling, and linear filtering are required by this render target.");
+ }
+ if ((uint)description.SampleCount > Capabilities.MaxRgba16FloatSampleCount)
+ {
+ throw new NotSupportedException(
+ $"RGBA16F supports at most {Capabilities.MaxRgba16FloatSampleCount} samples on this device; "
+ + $"'{description.Name}' requested {description.SampleCount}.");
+ }
+ }
+ if (description.SampleableDepth && !Capabilities.SupportsSampledDepth)
+ {
+ throw new NotSupportedException(
+ "The selected combined depth/stencil format cannot expose a sampled depth aspect.");
+ }
return new VulkanGpuRenderTarget(
_vk,
_device,
@@ -333,6 +384,39 @@ internal sealed unsafe partial class VulkanGpuDevice
DepthStencilFormat);
}
+ public IGpuDirectionalDepthTarget CreateDirectionalDepthTarget(
+ in GpuDirectionalDepthTargetDescription description)
+ {
+ ThrowIfDisposed();
+ ArgumentException.ThrowIfNullOrWhiteSpace(description.Name);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Resolution);
+ if (description.LayerCount is < 2 or > 4)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(description),
+ description.LayerCount,
+ "Directional depth targets require 2-4 cascade layers.");
+ }
+ if (description.DepthFormat != GpuTextureFormat.Depth24Stencil8)
+ {
+ throw new ArgumentException(
+ "Directional depth targets currently require Depth24Stencil8.",
+ nameof(description));
+ }
+ if (!Capabilities.SupportsSampledDepth)
+ throw new NotSupportedException("Sampled depth is unavailable on this device.");
+
+ return new VulkanDirectionalDepthTarget(
+ _vk,
+ _device,
+ _allocator,
+ _uploads,
+ _flights,
+ _debugNames,
+ description,
+ DepthStencilFormat);
+ }
+
public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler)
{
ThrowIfDisposed();
@@ -342,6 +426,12 @@ internal sealed unsafe partial class VulkanGpuDevice
throw new ArgumentException("The Vulkan backend can only register a Vulkan texture.", nameof(texture));
if (sampler is not VulkanGpuSampler vulkanSampler)
throw new ArgumentException("The Vulkan backend can only register a Vulkan sampler.", nameof(sampler));
+ if (!vulkanTexture.IsSampleable || vulkanTexture.SampledView.Handle == 0)
+ {
+ throw new ArgumentException(
+ $"Texture '{vulkanTexture.Name}' is an attachment-only image and has no sampled view.",
+ nameof(texture));
+ }
// Campaign V slice V6k made this a loud refusal, and V6l is the slice
// that serves it. A render-target image is viewed as
@@ -352,7 +442,10 @@ internal sealed unsafe partial class VulkanGpuDevice
// layered view over the same image for exactly this, and every texture
// that is not an attachment has always had one; SampledView is that view
// in both cases, so the question disappears rather than being answered.
- return TextureTable.Register(vulkanTexture.SampledView, vulkanSampler.Handle);
+ return TextureTable.Register(
+ vulkanTexture.SampledView,
+ vulkanSampler.Handle,
+ vulkanTexture.SampledLayout);
}
public void ReleaseTextureSlot(GpuTextureSlot slot)
@@ -375,11 +468,20 @@ internal sealed unsafe partial class VulkanGpuDevice
/// frame ever pays a shader compile or a driver state revalidation.
///
public IGpuPipeline CreatePipeline(GpuPipelineDescription description)
+ {
+ lock (_resourceCreationSync)
+ return CreatePipelineLocked(description);
+ }
+
+ private IGpuPipeline CreatePipelineLocked(GpuPipelineDescription description)
{
ThrowIfDisposed();
ArgumentNullException.ThrowIfNull(description);
+ if (description.ViewMask != 0 && !Capabilities.SupportsMultiview)
+ throw new NotSupportedException("The selected Vulkan device does not support multiview pipelines.");
- (ShaderModule vertex, ShaderModule fragment) = LoadShaderModules(description.Shaders.Name);
+ (ShaderModule vertex, ShaderModule fragment, bool ownsModules) =
+ LoadShaderModules(description.Shaders);
// Slice V6d: the pipeline names the format it renders into, rather than
// every pipeline being hard-coded to one. Rgba8UnormRenderTarget — the
// default — still maps to the swapchain's format; see
@@ -387,29 +489,164 @@ internal sealed unsafe partial class VulkanGpuDevice
// offscreen targets adopt the swapchain's format rather than the other
// way round.
Format colorFormat = VulkanTextureFormatMapping.FormatOf(description.ColorFormat);
- return new VulkanGpuPipeline(
- _vk,
- _device,
- _flights,
- _debugNames,
- Layouts.PipelineLayout,
- _pipelineCache?.Handle ?? default,
- vertex,
- fragment,
- description,
- colorFormat,
- DepthStencilFormat);
+ VulkanGpuPipeline pipeline;
+ VulkanPipelineLayouts.Created.PackLayoutLease? packLease = null;
+ try
+ {
+ packLease = description.UsesRenderPackShaderAbi
+ ? Layouts.AcquirePackLayout()
+ : null;
+ pipeline = new VulkanGpuPipeline(
+ _vk,
+ _device,
+ _flights,
+ _debugNames,
+ Layouts,
+ packLease,
+ packLease?.PipelineLayout ?? Layouts.PipelineLayout,
+ _pipelineCache?.Handle ?? default,
+ vertex,
+ fragment,
+ ownsModules,
+ description,
+ colorFormat,
+ DepthStencilFormat);
+ }
+ catch
+ {
+ packLease?.Dispose();
+ if (ownsModules)
+ {
+ _vk.DestroyShaderModule(_device, fragment, null);
+ _vk.DestroyShaderModule(_device, vertex, null);
+ }
+ throw;
+ }
+ try
+ {
+ foreach (GpuTextureFormat format in _pipelineFormatLeaseCounts.Keys)
+ pipeline.AddColorFormatVariant(format);
+ _pipelines.Add(pipeline);
+ return pipeline;
+ }
+ catch
+ {
+ pipeline.Dispose();
+ throw;
+ }
}
- private (ShaderModule Vertex, ShaderModule Fragment) LoadShaderModules(string name)
+ public IDisposable AcquirePipelineColorFormat(GpuTextureFormat format)
{
+ lock (_resourceCreationSync)
+ return AcquirePipelineColorFormatLocked(format);
+ }
+
+ private IDisposable AcquirePipelineColorFormatLocked(GpuTextureFormat format)
+ {
+ ThrowIfDisposed();
+ if (!VulkanTextureFormatMapping.IsRenderTarget(format)
+ || VulkanTextureFormatMapping.IsDepthStencil(format))
+ {
+ throw new ArgumentException(
+ $"{format} is not a colour render-target format.",
+ nameof(format));
+ }
+ if (format == GpuTextureFormat.Rgba16FloatRenderTarget
+ && !Capabilities.SupportsRgba16FloatRenderTargets)
+ {
+ throw new NotSupportedException(
+ "RGBA16F colour-attachment, sampling, and linear filtering are unavailable.");
+ }
+
+ _pipelines.RemoveWhere(static pipeline => pipeline.IsDisposed);
+ if (!_pipelineFormatLeaseCounts.TryGetValue(format, out int count))
+ {
+ var added = new List(_pipelines.Count);
+ try
+ {
+ foreach (VulkanGpuPipeline pipeline in _pipelines)
+ {
+ if (pipeline.AddColorFormatVariant(format))
+ added.Add(pipeline);
+ }
+ }
+ catch
+ {
+ foreach (VulkanGpuPipeline pipeline in added)
+ pipeline.RemoveColorFormatVariant(format);
+ throw;
+ }
+ _pipelineFormatLeaseCounts.Add(format, 1);
+ }
+ else
+ {
+ _pipelineFormatLeaseCounts[format] = checked(count + 1);
+ }
+
+ return new PipelineColorFormatLease(this, format);
+ }
+
+ private void ReleasePipelineColorFormat(GpuTextureFormat format)
+ {
+ lock (_resourceCreationSync)
+ {
+ if (_disposed || !_pipelineFormatLeaseCounts.TryGetValue(format, out int count))
+ return;
+ if (count > 1)
+ {
+ _pipelineFormatLeaseCounts[format] = count - 1;
+ return;
+ }
+
+ _pipelineFormatLeaseCounts.Remove(format);
+ _pipelines.RemoveWhere(static pipeline => pipeline.IsDisposed);
+ foreach (VulkanGpuPipeline pipeline in _pipelines)
+ pipeline.RemoveColorFormatVariant(format);
+ }
+ }
+
+ private sealed class PipelineColorFormatLease(
+ VulkanGpuDevice device,
+ GpuTextureFormat format) : IDisposable
+ {
+ private VulkanGpuDevice? _device = device;
+
+ public void Dispose() =>
+ Interlocked.Exchange(ref _device, null)?.ReleasePipelineColorFormat(format);
+ }
+
+ private (ShaderModule Vertex, ShaderModule Fragment, bool OwnsModules) LoadShaderModules(
+ in GpuShaderSet shaders)
+ {
+ if (shaders.HasEmbeddedSpirv)
+ {
+ ShaderModule embeddedVertex = CreateShaderModule(
+ shaders.Name,
+ "vert",
+ shaders.VertexSpirv.Span);
+ try
+ {
+ return (
+ embeddedVertex,
+ CreateShaderModule(shaders.Name, "frag", shaders.FragmentSpirv.Span),
+ true);
+ }
+ catch
+ {
+ _vk.DestroyShaderModule(_device, embeddedVertex, null);
+ throw;
+ }
+ }
+
+ string name = shaders.Name;
if (_shaderModules.TryGetValue(name, out (ShaderModule Vertex, ShaderModule Fragment) existing))
- return existing;
+ return (existing.Vertex, existing.Fragment, false);
ShaderModule vertex = CreateShaderModule(name, "vert");
ShaderModule fragment = CreateShaderModule(name, "frag");
_shaderModules[name] = (vertex, fragment);
- return (vertex, fragment);
+ return (vertex, fragment, false);
}
private ShaderModule CreateShaderModule(string name, string stage)
@@ -424,9 +661,19 @@ internal sealed unsafe partial class VulkanGpuDevice
path);
}
- byte[] code = File.ReadAllBytes(path);
- if (code.Length % 4 != 0)
- throw new InvalidDataException($"'{path}' is {code.Length} bytes, which is not a whole number of SPIR-V words.");
+ return CreateShaderModule(name, stage, File.ReadAllBytes(path));
+ }
+
+ private ShaderModule CreateShaderModule(
+ string name,
+ string stage,
+ ReadOnlySpan code)
+ {
+ if (code.Length < 4 || code.Length % 4 != 0)
+ throw new InvalidDataException(
+ $"'{name}.{stage}' is {code.Length} bytes, which is not valid word-aligned SPIR-V.");
+ if (System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(code) != 0x07230203u)
+ throw new InvalidDataException($"'{name}.{stage}' has no SPIR-V header.");
fixed (byte* first = code)
{
@@ -505,12 +752,50 @@ internal sealed unsafe partial class VulkanGpuDevice
uint width;
uint height;
- ImageView colorView;
+ ImageView colorView = default;
ImageView resolveView = default;
ImageView depthView = default;
- bool backbuffer = description.Color.Target is null;
+ ImageView depthResolveView = default;
+ bool hasColorAttachment = description.HasColorAttachment;
+ bool backbuffer = hasColorAttachment && description.Color.Target is null;
+ uint viewMask = description.ViewMask;
+ GpuTextureFormat passColorFormat = GpuTextureFormat.Rgba8UnormRenderTarget;
- if (backbuffer)
+ if (!hasColorAttachment)
+ {
+ if (description.SampleCount != 1)
+ throw new InvalidOperationException("Directional depth passes are single-sampled.");
+ if (description.Depth is not { DirectionalTarget: VulkanDirectionalDepthTarget target } depth)
+ {
+ throw new ArgumentException(
+ "A colour-less pass requires a Vulkan directional-depth target.",
+ nameof(description));
+ }
+ if (depth.Store != GpuStoreOp.Store)
+ throw new InvalidOperationException("Directional depth must be stored for later sampling.");
+ if (depth.Layer < 0 || depth.Layer >= target.Description.LayerCount)
+ throw new ArgumentOutOfRangeException(nameof(description), "Directional depth layer is outside the target.");
+
+ width = (uint)target.Description.Resolution;
+ height = (uint)target.Description.Resolution;
+ if (viewMask != 0)
+ {
+ if (!Capabilities.SupportsMultiview)
+ throw new NotSupportedException("The selected Vulkan device does not support multiview.");
+ depthView = target.MultiviewView(viewMask);
+ TransitionDirectionalDepthForRendering(
+ commands,
+ target,
+ baseLayer: 0,
+ layerCount: target.LayerCountForViewMask(viewMask));
+ }
+ else
+ {
+ depthView = target.ViewAt(depth.Layer);
+ TransitionDirectionalDepthForRendering(commands, target, depth.Layer, 1);
+ }
+ }
+ else if (backbuffer)
{
if (_backbuffer is null || _acquiredImageIndex is not { } imageIndex)
{
@@ -545,52 +830,106 @@ internal sealed unsafe partial class VulkanGpuDevice
{
if (description.Color.Target is not VulkanGpuRenderTarget target)
throw new ArgumentException("The Vulkan backend can only render into a Vulkan render target.");
+ if (target.Description.SampleCount != description.SampleCount)
+ {
+ throw new InvalidOperationException(
+ $"Pass '{description.Name}' declares {description.SampleCount} samples but target "
+ + $"'{target.Description.Name}' was created for {target.Description.SampleCount}.");
+ }
width = (uint)target.Description.Width;
height = (uint)target.Description.Height;
- colorView = target.Color.View;
+ passColorFormat = target.Description.ColorFormat;
+ colorView = target.ColorAttachment.View;
+ if (target.ColorResolve is { } colorResolve)
+ {
+ if (description.Color.Load == GpuLoadOp.Load)
+ {
+ throw new InvalidOperationException(
+ $"Multisampled target '{target.Description.Name}' cannot Load a prior resolved image; "
+ + "its transient multisample attachment has no preserved contents.");
+ }
+ if (description.Color.Store != GpuStoreOp.Resolve)
+ {
+ throw new InvalidOperationException(
+ $"Multisampled target '{target.Description.Name}' must use Store=Resolve so its "
+ + "single-sampled ColorTexture receives this pass.");
+ }
+ resolveView = colorResolve.View;
+ }
+ else if (description.Color.Store == GpuStoreOp.Resolve)
+ {
+ throw new InvalidOperationException(
+ $"Single-sampled target '{target.Description.Name}' cannot use Store=Resolve.");
+ }
TransitionRenderTargetForRendering(commands, target);
- if (description.Depth is not null && target.Depth is { } depth)
+ if (description.Depth is not null && target.DepthAttachment is { } depth)
+ {
depthView = depth.View;
+ if (target.Description.SampleCount > 1
+ && description.Depth.Value.Load == GpuLoadOp.Load)
+ {
+ throw new InvalidOperationException(
+ $"Multisampled depth target '{target.Description.Name}' cannot Load transient depth.");
+ }
+ if (target.Description.SampleableDepth
+ && description.Depth.Value.Store != GpuStoreOp.Store)
+ {
+ throw new InvalidOperationException(
+ $"Sampleable depth on '{target.Description.Name}' requires Store=Store.");
+ }
+ if (target.DepthResolve is { } depthResolve)
+ {
+ depthResolveView = depthResolve.View;
+ }
+ }
}
- Vector4 clear = description.Color.ClearColor;
- var colorAttachment = new RenderingAttachmentInfo
+ RenderingAttachmentInfo colorAttachment = default;
+ if (hasColorAttachment)
{
- SType = StructureType.RenderingAttachmentInfo,
- ImageView = colorView,
- ImageLayout = ImageLayout.ColorAttachmentOptimal,
- LoadOp = VulkanViewportMapping.ToVulkan(description.Color.Load),
- StoreOp = description.Color.Store == GpuStoreOp.Resolve
- ? AttachmentStoreOp.DontCare
- : VulkanViewportMapping.ToVulkan(description.Color.Store),
- ClearValue = new ClearValue
+ Vector4 clear = description.Color.ClearColor;
+ colorAttachment = new RenderingAttachmentInfo
{
- Color = new ClearColorValue
+ SType = StructureType.RenderingAttachmentInfo,
+ ImageView = colorView,
+ ImageLayout = ImageLayout.ColorAttachmentOptimal,
+ LoadOp = VulkanViewportMapping.ToVulkan(description.Color.Load),
+ StoreOp = description.Color.Store == GpuStoreOp.Resolve
+ ? AttachmentStoreOp.DontCare
+ : VulkanViewportMapping.ToVulkan(description.Color.Store),
+ ClearValue = new ClearValue
{
- Float32_0 = clear.X,
- Float32_1 = clear.Y,
- Float32_2 = clear.Z,
- Float32_3 = clear.W,
+ Color = new ClearColorValue
+ {
+ Float32_0 = clear.X,
+ Float32_1 = clear.Y,
+ Float32_2 = clear.Z,
+ Float32_3 = clear.W,
+ },
},
- },
- };
- if (resolveView.Handle != 0)
- {
- colorAttachment.ResolveMode = ResolveModeFlags.AverageBit;
- colorAttachment.ResolveImageView = resolveView;
- colorAttachment.ResolveImageLayout = ImageLayout.ColorAttachmentOptimal;
+ };
+ if (resolveView.Handle != 0)
+ {
+ colorAttachment.ResolveMode = ResolveModeFlags.AverageBit;
+ colorAttachment.ResolveImageView = resolveView;
+ colorAttachment.ResolveImageLayout = ImageLayout.ColorAttachmentOptimal;
+ }
}
RenderingAttachmentInfo depthAttachment = default;
+ RenderingAttachmentInfo stencilAttachment = default;
if (description.Depth is { } depthDescription && depthView.Handle != 0)
{
+ bool resolveDepth = depthResolveView.Handle != 0;
depthAttachment = new RenderingAttachmentInfo
{
SType = StructureType.RenderingAttachmentInfo,
ImageView = depthView,
ImageLayout = ImageLayout.DepthStencilAttachmentOptimal,
LoadOp = VulkanViewportMapping.ToVulkan(depthDescription.Load),
- StoreOp = VulkanViewportMapping.ToVulkan(depthDescription.Store),
+ StoreOp = resolveDepth
+ ? AttachmentStoreOp.DontCare
+ : VulkanViewportMapping.ToVulkan(depthDescription.Store),
ClearValue = new ClearValue
{
DepthStencil = new ClearDepthStencilValue(
@@ -598,6 +937,19 @@ internal sealed unsafe partial class VulkanGpuDevice
depthDescription.ClearStencil),
},
};
+ stencilAttachment = depthAttachment;
+ if (resolveDepth)
+ {
+ // SAMPLE_ZERO is guaranteed for both depth and stencil by the
+ // Vulkan 1.3 depth/stencil-resolve contract. Resolving both
+ // aspects avoids depending on independentResolveNone.
+ depthAttachment.ResolveMode = ResolveModeFlags.SampleZeroBit;
+ depthAttachment.ResolveImageView = depthResolveView;
+ depthAttachment.ResolveImageLayout = ImageLayout.DepthStencilAttachmentOptimal;
+ stencilAttachment.ResolveMode = ResolveModeFlags.SampleZeroBit;
+ stencilAttachment.ResolveImageView = depthResolveView;
+ stencilAttachment.ResolveImageLayout = ImageLayout.DepthStencilAttachmentOptimal;
+ }
}
var rendering = new RenderingInfo
@@ -605,13 +957,14 @@ internal sealed unsafe partial class VulkanGpuDevice
SType = StructureType.RenderingInfo,
RenderArea = new Rect2D(new Offset2D(0, 0), new Extent2D(width, height)),
LayerCount = 1,
- ColorAttachmentCount = 1,
- PColorAttachments = &colorAttachment,
+ ViewMask = viewMask,
+ ColorAttachmentCount = hasColorAttachment ? 1u : 0u,
+ PColorAttachments = hasColorAttachment ? &colorAttachment : null,
PDepthAttachment = depthAttachment.SType == StructureType.RenderingAttachmentInfo
? &depthAttachment
: null,
- PStencilAttachment = depthAttachment.SType == StructureType.RenderingAttachmentInfo
- ? &depthAttachment
+ PStencilAttachment = stencilAttachment.SType == StructureType.RenderingAttachmentInfo
+ ? &stencilAttachment
: null,
};
_vk.CmdBeginRendering(commands, &rendering);
@@ -625,7 +978,9 @@ internal sealed unsafe partial class VulkanGpuDevice
description,
width,
height,
- hasDepthAttachment: depthView.Handle != 0);
+ hasDepthAttachment: depthView.Handle != 0,
+ hasColorAttachment,
+ colorFormat: passColorFormat);
_openPass = encoder;
return encoder;
}
@@ -640,7 +995,29 @@ internal sealed unsafe partial class VulkanGpuDevice
_debugNames.EndLabel(commands);
if (!_openPassIsBackbuffer && encoder.Pass.Color.Target is VulkanGpuRenderTarget target)
- TransitionRenderTargetForSampling(commands, target);
+ {
+ TransitionRenderTargetForSampling(
+ commands,
+ target,
+ colorStored: encoder.Pass.Color.Store != GpuStoreOp.DontCare,
+ depthStored: encoder.Pass.Depth?.Store == GpuStoreOp.Store);
+ }
+ else if (encoder.Pass.Depth is
+ { DirectionalTarget: VulkanDirectionalDepthTarget directionalTarget } depth)
+ {
+ if (encoder.Pass.ViewMask != 0)
+ {
+ TransitionDirectionalDepthForSampling(
+ commands,
+ directionalTarget,
+ 0,
+ directionalTarget.LayerCountForViewMask(encoder.Pass.ViewMask));
+ }
+ else
+ {
+ TransitionDirectionalDepthForSampling(commands, directionalTarget, depth.Layer, 1);
+ }
+ }
_openPass = null;
}
@@ -766,19 +1143,35 @@ internal sealed unsafe partial class VulkanGpuDevice
private void TransitionRenderTargetForRendering(CommandBuffer commands, VulkanGpuRenderTarget target)
{
+ VulkanGpuTexture colorAttachment = target.ColorAttachment;
TransitionImage(
commands,
- target.Color.Image,
+ colorAttachment.Image,
ImageAspectFlags.ColorBit,
- target.Color.CurrentLayout,
+ colorAttachment.CurrentLayout,
ImageLayout.ColorAttachmentOptimal,
PipelineStageFlags2.AllCommandsBit,
AccessFlags2.None,
PipelineStageFlags2.ColorAttachmentOutputBit,
AccessFlags2.ColorAttachmentWriteBit);
- target.Color.MarkLayout(ImageLayout.ColorAttachmentOptimal);
+ colorAttachment.MarkLayout(ImageLayout.ColorAttachmentOptimal);
- if (target.Depth is { } depth)
+ if (target.ColorResolve is { } colorResolve)
+ {
+ TransitionImage(
+ commands,
+ colorResolve.Image,
+ ImageAspectFlags.ColorBit,
+ colorResolve.CurrentLayout,
+ ImageLayout.ColorAttachmentOptimal,
+ PipelineStageFlags2.AllCommandsBit,
+ AccessFlags2.None,
+ PipelineStageFlags2.ColorAttachmentOutputBit,
+ AccessFlags2.ColorAttachmentWriteBit);
+ colorResolve.MarkLayout(ImageLayout.ColorAttachmentOptimal);
+ }
+
+ if (target.DepthAttachment is { } depth)
{
TransitionImage(
commands,
@@ -788,25 +1181,157 @@ internal sealed unsafe partial class VulkanGpuDevice
ImageLayout.DepthStencilAttachmentOptimal,
PipelineStageFlags2.AllCommandsBit,
AccessFlags2.None,
- PipelineStageFlags2.EarlyFragmentTestsBit,
+ PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit,
AccessFlags2.DepthStencilAttachmentWriteBit);
depth.MarkLayout(ImageLayout.DepthStencilAttachmentOptimal);
}
+
+ if (target.DepthResolve is { } depthResolve)
+ {
+ TransitionImage(
+ commands,
+ depthResolve.Image,
+ ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit,
+ depthResolve.CurrentLayout,
+ ImageLayout.DepthStencilAttachmentOptimal,
+ PipelineStageFlags2.AllCommandsBit,
+ AccessFlags2.None,
+ PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit,
+ AccessFlags2.DepthStencilAttachmentWriteBit);
+ depthResolve.MarkLayout(ImageLayout.DepthStencilAttachmentOptimal);
+ }
}
- private void TransitionRenderTargetForSampling(CommandBuffer commands, VulkanGpuRenderTarget target)
+ ///
+ /// Makes retained mapped-storage writes visible to vertex-shader SSBO
+ /// reads. The buffer belongs to the current flight slot, whose prior use has
+ /// retired before the host write; this barrier supplies the in-submission
+ /// HOST_WRITE to SHADER_READ dependency before the shadow pass consumes it.
+ ///
+ internal void PublishHostStorageWrites(
+ VulkanGpuFrame frame,
+ IGpuBuffer buffer)
+ {
+ ThrowIfDisposed();
+ ArgumentNullException.ThrowIfNull(frame);
+ ArgumentNullException.ThrowIfNull(buffer);
+ if (!ReferenceEquals(_openFrame, frame))
+ throw new InvalidOperationException("Host writes require the open Vulkan frame.");
+ if (_openPass is not null)
+ {
+ throw new InvalidOperationException(
+ "Retained host writes must be published before opening a rendering pass.");
+ }
+ if (buffer is not VulkanGpuBuffer vkBuffer
+ || buffer.Residency != GpuMemoryResidency.HostWritable
+ || !buffer.Usage.HasFlag(GpuBufferUsage.Storage))
+ {
+ throw new ArgumentException(
+ "Published host writes require a Vulkan host-writable storage buffer.",
+ nameof(buffer));
+ }
+
+ CommandBuffer commands = _commandBuffers[frame.SlotIndex];
+ BufferMemoryBarrier2 barrier = VulkanHostStorageVisibility.Create(
+ vkBuffer.Handle,
+ checked((ulong)vkBuffer.SizeBytes));
+ var dependency = new DependencyInfo
+ {
+ SType = StructureType.DependencyInfo,
+ BufferMemoryBarrierCount = 1,
+ PBufferMemoryBarriers = &barrier,
+ };
+ _vk.CmdPipelineBarrier2(commands, &dependency);
+ }
+
+ private void TransitionRenderTargetForSampling(
+ CommandBuffer commands,
+ VulkanGpuRenderTarget target,
+ bool colorStored,
+ bool depthStored)
+ {
+ if (colorStored)
+ {
+ VulkanGpuTexture color = target.ColorResult;
+ TransitionImage(
+ commands,
+ color.Image,
+ ImageAspectFlags.ColorBit,
+ color.CurrentLayout,
+ ImageLayout.ShaderReadOnlyOptimal,
+ PipelineStageFlags2.ColorAttachmentOutputBit,
+ AccessFlags2.ColorAttachmentWriteBit,
+ PipelineStageFlags2.FragmentShaderBit,
+ AccessFlags2.ShaderReadBit);
+ color.MarkLayout(ImageLayout.ShaderReadOnlyOptimal);
+ }
+
+ if (depthStored && target.Description.SampleableDepth && target.DepthResult is { } depth)
+ {
+ TransitionImage(
+ commands,
+ depth.Image,
+ ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit,
+ depth.CurrentLayout,
+ ImageLayout.DepthStencilReadOnlyOptimal,
+ PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit,
+ AccessFlags2.DepthStencilAttachmentWriteBit,
+ PipelineStageFlags2.FragmentShaderBit,
+ AccessFlags2.ShaderReadBit);
+ depth.MarkLayout(ImageLayout.DepthStencilReadOnlyOptimal);
+ }
+ }
+
+ private void TransitionDirectionalDepthForRendering(
+ CommandBuffer commands,
+ VulkanDirectionalDepthTarget target,
+ int baseLayer,
+ int layerCount)
+ {
+ const PipelineStageFlags2 DepthStages =
+ PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit;
+ ImageLayout oldLayout = target.LayoutAt(baseLayer);
+ for (int i = 1; i < layerCount; i++)
+ {
+ if (target.LayoutAt(baseLayer + i) != oldLayout)
+ throw new InvalidOperationException("Multiview directional layers must share one layout.");
+ }
+ TransitionImage(
+ commands,
+ target.Texture.Image,
+ ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit,
+ oldLayout,
+ ImageLayout.DepthStencilAttachmentOptimal,
+ oldLayout == ImageLayout.Undefined ? PipelineStageFlags2.TopOfPipeBit : PipelineStageFlags2.FragmentShaderBit,
+ oldLayout == ImageLayout.Undefined ? AccessFlags2.None : AccessFlags2.ShaderReadBit,
+ DepthStages,
+ AccessFlags2.DepthStencilAttachmentWriteBit,
+ baseArrayLayer: (uint)baseLayer,
+ layerCount: (uint)layerCount);
+ for (int i = 0; i < layerCount; i++)
+ target.MarkLayout(baseLayer + i, ImageLayout.DepthStencilAttachmentOptimal);
+ }
+
+ private void TransitionDirectionalDepthForSampling(
+ CommandBuffer commands,
+ VulkanDirectionalDepthTarget target,
+ int baseLayer,
+ int layerCount)
{
TransitionImage(
commands,
- target.Color.Image,
- ImageAspectFlags.ColorBit,
- ImageLayout.ColorAttachmentOptimal,
- ImageLayout.ShaderReadOnlyOptimal,
- PipelineStageFlags2.ColorAttachmentOutputBit,
- AccessFlags2.ColorAttachmentWriteBit,
+ target.Texture.Image,
+ ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit,
+ target.LayoutAt(baseLayer),
+ ImageLayout.DepthStencilReadOnlyOptimal,
+ PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit,
+ AccessFlags2.DepthStencilAttachmentWriteBit,
PipelineStageFlags2.FragmentShaderBit,
- AccessFlags2.ShaderReadBit);
- target.Color.MarkLayout(ImageLayout.ShaderReadOnlyOptimal);
+ AccessFlags2.ShaderReadBit,
+ baseArrayLayer: (uint)baseLayer,
+ layerCount: (uint)layerCount);
+ for (int i = 0; i < layerCount; i++)
+ target.MarkLayout(baseLayer + i, ImageLayout.DepthStencilReadOnlyOptimal);
}
private void TransitionImage(
@@ -818,7 +1343,9 @@ internal sealed unsafe partial class VulkanGpuDevice
PipelineStageFlags2 sourceStage,
AccessFlags2 sourceAccess,
PipelineStageFlags2 destinationStage,
- AccessFlags2 destinationAccess)
+ AccessFlags2 destinationAccess,
+ uint baseArrayLayer = 0,
+ uint layerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers)
{
var barrier = new ImageMemoryBarrier2
{
@@ -837,8 +1364,8 @@ internal sealed unsafe partial class VulkanGpuDevice
AspectMask = aspect,
BaseMipLevel = 0,
LevelCount = Silk.NET.Vulkan.Vk.RemainingMipLevels,
- BaseArrayLayer = 0,
- LayerCount = Silk.NET.Vulkan.Vk.RemainingArrayLayers,
+ BaseArrayLayer = baseArrayLayer,
+ LayerCount = layerCount,
},
};
var dependency = new DependencyInfo
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs
index 31b4daec..b2556e49 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs
@@ -67,7 +67,7 @@ internal interface IVulkanBackbuffer
/// signalling both the per-image render-complete semaphore and the timeline at
/// this frame's serial, present.
///
-internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice
+internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice, IGpuPipelineFormatVariantHost
{
/// Per-flight-slot ring capacity, matching the GL backend's 16 MiB.
internal const int DefaultRingCapacityBytesPerSlot = 16 * 1024 * 1024;
@@ -150,17 +150,34 @@ internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice
MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
MaxPushConstantBytes = limits.MaxPushConstantsSize,
MinStorageBufferOffsetAlignment = Math.Max(limits.MinStorageBufferOffsetAlignment, 1),
+ MaxStorageBufferRangeBytes = limits.MaxStorageBufferRange,
MinUniformBufferOffsetAlignment = Math.Max(limits.MinUniformBufferOffsetAlignment, 1),
MaxClipDistances = limits.MaxClipDistances,
MaxSampleCount = limits.MaxColorSampleCount,
+ MaxImageDimension2D = limits.MaxImageDimension2D,
+ MaxImageArrayLayers = limits.MaxImageArrayLayers,
+ DeviceLocalMemoryBytes = limits.DeviceLocalHeapBytes,
SupportsMultiDrawIndirect = features.MultiDrawIndirect,
SupportsDrawParameters = features.ShaderDrawParameters,
SupportsTextureCompressionBc =
features.TextureCompressionBc && formats.Bc1Sampled && formats.Bc2Sampled && formats.Bc3Sampled,
SupportsTimestampQueries = limits.TimestampComputeAndGraphics,
+ SupportsMultiview = features.Multiview,
// The one capability that is true here and false on GL, and the
// mechanism behind the campaign's CPU-cost target.
SupportsPersistentlyMappedRings = true,
+ SupportsRgba16FloatRenderTargets =
+ formats.Rgba16FloatColorAttachment
+ && formats.Rgba16FloatSampled
+ && formats.Rgba16FloatLinearFilter
+ && formats.MaxRgba16FloatSampleCount > 0,
+ MaxRgba16FloatSampleCount =
+ formats.Rgba16FloatColorAttachment
+ && formats.Rgba16FloatSampled
+ && formats.Rgba16FloatLinearFilter
+ ? Math.Min(formats.MaxRgba16FloatSampleCount, limits.MaxColorSampleCount)
+ : 0u,
+ SupportsSampledDepth = formats.DepthStencilSampled,
};
var timelineType = new SemaphoreTypeCreateInfo
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuFrame.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuFrame.cs
index 3ec2eec2..82f64378 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuFrame.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuFrame.cs
@@ -34,6 +34,9 @@ internal sealed class VulkanGpuFrame : IGpuFrame
public GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage) =>
_device.AllocateRing(SlotIndex, byteCount, usage);
+ public void PublishHostStorageWrites(IGpuBuffer buffer) =>
+ _device.PublishHostStorageWrites(this, buffer);
+
public IGpuPassEncoder BeginPass(GpuPassDescription description)
{
ArgumentNullException.ThrowIfNull(description);
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs
index 14778fc8..ea3b1a20 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs
@@ -17,10 +17,9 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// Storage and uniform bindings go through a dynamic descriptor
/// set. The contract lets a renderer bind an arbitrary buffer range per
/// draw, and ring allocations mean that range moves every frame. Rather than
-/// writing descriptors mid-frame, set 0 and set 1 are allocated per flight slot
+/// writing descriptors mid-frame, retail sets 0 and 1 are allocated per flight slot
/// with DYNAMIC descriptor types and the per-draw offset is supplied at bind
-/// time — which is what keeps the campaign's "zero descriptor writes per frame"
-/// property true for buffers as well as for textures.
+/// time. Opt-in set 3 uses the same rule from a separately owned lazy pool.
///
internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
{
@@ -31,6 +30,8 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
private readonly uint _attachmentWidth;
private readonly uint _attachmentHeight;
private readonly bool _hasDepthAttachment;
+ private readonly bool _hasColorAttachment;
+ private readonly GpuTextureFormat _colorFormat;
///
/// Extent of the attachments vkCmdBeginRendering was handed. Campaign V
@@ -47,6 +48,7 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
internal bool HasDepthAttachment => _hasDepthAttachment;
private VulkanGpuPipeline? _pipeline;
+ private VulkanDrawBindingState _drawBindingState;
private bool _closed;
internal VulkanGpuPassEncoder(
@@ -57,7 +59,9 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
GpuPassDescription pass,
uint attachmentWidth,
uint attachmentHeight,
- bool hasDepthAttachment)
+ bool hasDepthAttachment,
+ bool hasColorAttachment,
+ GpuTextureFormat colorFormat)
{
_device = device;
_frame = frame;
@@ -70,6 +74,8 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
// the attachments exist gets none, and the pipeline variant has to agree
// with the command buffer rather than with the intent.
_hasDepthAttachment = hasDepthAttachment;
+ _hasColorAttachment = hasColorAttachment;
+ _colorFormat = colorFormat;
Pass = pass;
// A pass always starts with the whole attachment drawable. GL's
@@ -80,23 +86,15 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
SetViewport(0, 0, (int)attachmentWidth, (int)attachmentHeight);
SetScissor(0, 0, (int)attachmentWidth, (int)attachmentHeight);
- // Campaign V slice V6h: and for the same reason, the descriptor sets.
+ // Campaign V slice V6h requires every pass to be self-contained rather
+ // than inheriting descriptor state from an earlier renderer. The first
+ // draw now establishes that state through FlushBindings. Deferring it
+ // until a draw exists avoids recording an unused initial binding and
+ // lets later draws reuse an identical binding safely.
//
- // Before this, sets 0/1/2 were bound only as a side effect of
- // BindStorageBuffer/BindUniformBuffer, so a pass whose pipeline reads the
- // texture table but binds no buffer — every retained-UI and debug-line
- // pass, because their per-draw data travels in push constants and a
- // vertex buffer — issued vkCmdDraw with set 2 unbound. That is
- // VUID-vkCmdDraw-None-08600 and, on the RX 9070 XT, an immediate
- // ErrorDeviceLost at submit.
- //
- // It went unseen through V6c–V6g because the bring-up host always drew
- // VulkanRhiScene first: its storage binds left all three sets bound in
- // the same command buffer, so the UI pass that followed inherited them.
- // The composition host has no 3-D scene, so its UI pass is the first
- // thing in the buffer and inherits nothing. Binding here makes a pass
- // self-contained rather than dependent on what preceded it in the frame.
- _bindings.Bind(_commands, _device);
+ // FlushBindings is called by every draw verb, including passes such as
+ // retained UI and debug lines that bind no buffers themselves. Thus set
+ // 2 is still guaranteed before vkCmdDraw and VUID 08600 stays closed.
}
public GpuPassDescription Pass { get; }
@@ -107,17 +105,27 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
ThrowIfClosed();
if (pipeline is not VulkanGpuPipeline vulkanPipeline)
throw new ArgumentException("The Vulkan backend can only bind a Vulkan pipeline.", nameof(pipeline));
+ if (vulkanPipeline.Description.HasColorAttachment != _hasColorAttachment)
+ {
+ throw new InvalidOperationException(
+ $"Pipeline '{vulkanPipeline.Description.Name}' colour-attachment intent does not match pass '{Pass.Name}'.");
+ }
+ if (vulkanPipeline.Description.ViewMask != Pass.ViewMask)
+ {
+ throw new InvalidOperationException(
+ $"Pipeline '{vulkanPipeline.Description.Name}' view mask does not match pass '{Pass.Name}'.");
+ }
_pipeline = vulkanPipeline;
_device.Api.CmdBindPipeline(
_commands,
PipelineBindPoint.Graphics,
- vulkanPipeline.HandleFor(_hasDepthAttachment));
+ vulkanPipeline.HandleFor(_hasDepthAttachment, _colorFormat));
- // Every pipeline shares one layout, so the descriptor sets and push
- // constants bound earlier in the pass survive this call. That is the
- // whole reason for the shared layout, and it is why a bucketed world
- // pass can change pipeline per bucket for free.
+ // Retail and pack pipelines share sets 0..2 and the same 96-byte push
+ // range, but a pack pipeline has one additional set. Bind against the
+ // exact layout used to create the active pipeline so set 3 can never
+ // leak onto the authoritative retail path.
_device.CmdBindPipelineDefaults(_commands, vulkanPipeline.Description);
}
@@ -125,12 +133,14 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
{
ThrowIfClosed();
_bindings.SetStorage(binding, RequireBuffer(buffer), offsetBytes, sizeBytes);
+ _drawBindingState.MarkDirty();
}
public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
ThrowIfClosed();
_bindings.SetUniform(binding, RequireBuffer(buffer), offsetBytes, sizeBytes);
+ _drawBindingState.MarkDirty();
}
///
@@ -149,10 +159,23 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
/// what the arena was designed to produce.
///
/// Legal because descriptor-set binding is independent of pipeline
- /// binding when the layouts are compatible, and acdream has ONE pipeline
- /// layout by design (§4.4).
+ /// binding when the layouts are compatible. Retail and pack layouts share
+ /// identical sets 0..2; the active pipeline supplies the optional set 3.
///
- private void FlushBindings() => _bindings.Bind(_commands, _device);
+ private void FlushBindings()
+ {
+ VulkanGpuPipeline pipeline = RequirePipeline();
+ ulong pipelineLayout = pipeline.PipelineLayout.Handle;
+ int packGeneration = pipeline.PackState?.Generation ?? 0;
+ if (!_drawBindingState.RequiresBind(pipelineLayout, packGeneration))
+ return;
+ _bindings.Bind(
+ _commands,
+ _device,
+ pipeline.PipelineLayout,
+ pipeline.PackState);
+ _drawBindingState.MarkBound(pipelineLayout, packGeneration);
+ }
///
/// A scoped clear inside the live render-pass instance — retail's interior
@@ -194,7 +217,7 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
{
_device.Api.CmdPushConstants(
_commands,
- _device.Layouts.PipelineLayout,
+ _pipeline?.PipelineLayout ?? _device.Layouts.PipelineLayout,
ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
0,
(uint)GpuBindingModel.PushConstantBytes,
@@ -313,10 +336,11 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
return vulkanBuffer;
}
- private void RequirePipeline()
+ private VulkanGpuPipeline RequirePipeline()
{
if (_pipeline is null)
throw new InvalidOperationException("BindPipeline must be called before drawing.");
+ return _pipeline;
}
private void ThrowIfClosed() => ObjectDisposedException.ThrowIf(_closed, this);
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs
index becc067e..2012ab08 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPipeline.cs
@@ -43,8 +43,18 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
private readonly Silk.NET.Vulkan.Vk _vk;
private readonly Device _device;
private readonly IGpuResourceRetirementQueue _retirement;
+ private readonly VulkanDebugNames _debugNames;
+ private readonly VulkanPipelineLayouts.Created _layouts;
+ private readonly VulkanPipelineLayouts.Created.PackLayoutLease? _packLayoutLease;
+ private readonly PipelineLayout _layout;
+ private readonly PipelineCache _cache;
+ private readonly ShaderModule _vertexModule;
+ private readonly ShaderModule _fragmentModule;
+ private readonly bool _ownsShaderModules;
+ private readonly Format _depthStencilFormat;
private readonly Pipeline _withDepthAttachment;
private readonly Pipeline _withoutDepthAttachment;
+ private readonly Dictionary _colorVariants = [];
private bool _disposed;
internal VulkanGpuPipeline(
@@ -52,10 +62,13 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
Device device,
IGpuResourceRetirementQueue retirement,
VulkanDebugNames debugNames,
+ VulkanPipelineLayouts.Created layouts,
+ VulkanPipelineLayouts.Created.PackLayoutLease? packLayoutLease,
PipelineLayout layout,
PipelineCache cache,
ShaderModule vertexModule,
ShaderModule fragmentModule,
+ bool ownsShaderModules,
GpuPipelineDescription description,
Format colorFormat,
Format depthStencilFormat)
@@ -63,6 +76,15 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
_device = device;
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
+ _debugNames = debugNames ?? throw new ArgumentNullException(nameof(debugNames));
+ _layouts = layouts ?? throw new ArgumentNullException(nameof(layouts));
+ _packLayoutLease = packLayoutLease;
+ _layout = layout;
+ _cache = cache;
+ _vertexModule = vertexModule;
+ _fragmentModule = fragmentModule;
+ _ownsShaderModules = ownsShaderModules;
+ _depthStencilFormat = depthStencilFormat;
Description = description ?? throw new ArgumentNullException(nameof(description));
nint entryPoint = SilkMarshal.StringToPtr("main");
@@ -211,8 +233,8 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
{
SType = StructureType.PipelineColorBlendStateCreateInfo,
LogicOpEnable = false,
- AttachmentCount = 1,
- PAttachments = &attachment,
+ AttachmentCount = description.HasColorAttachment ? 1u : 0u,
+ PAttachments = description.HasColorAttachment ? &attachment : null,
};
DynamicState* dynamicStates = stackalloc DynamicState[9];
@@ -246,8 +268,9 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
var rendering = new PipelineRenderingCreateInfo
{
SType = StructureType.PipelineRenderingCreateInfo,
- ColorAttachmentCount = 1,
- PColorAttachmentFormats = &color,
+ ViewMask = description.ViewMask,
+ ColorAttachmentCount = description.HasColorAttachment ? 1u : 0u,
+ PColorAttachmentFormats = description.HasColorAttachment ? &color : null,
DepthAttachmentFormat = depthStencilFormat,
StencilAttachmentFormat = depthStencilFormat,
};
@@ -312,17 +335,107 @@ internal sealed unsafe class VulkanGpuPipeline : IGpuPipeline
internal Pipeline HandleFor(bool passHasDepthAttachment) =>
passHasDepthAttachment ? _withDepthAttachment : _withoutDepthAttachment;
+ ///
+ /// Selects the prebuilt attachment-format variant required by the live pass.
+ /// Missing variants fail before a draw can record undefined Vulkan usage.
+ ///
+ internal Pipeline HandleFor(
+ bool passHasDepthAttachment,
+ GpuTextureFormat colorFormat)
+ {
+ if (colorFormat == Description.ColorFormat)
+ return HandleFor(passHasDepthAttachment);
+ if (_colorVariants.TryGetValue(colorFormat, out VulkanGpuPipeline? variant))
+ return variant.HandleFor(passHasDepthAttachment);
+ throw new InvalidOperationException(
+ $"Pipeline '{Description.Name}' has no prebuilt {colorFormat} attachment variant.");
+ }
+
+ internal bool IsDisposed => _disposed;
+
+ /// The exact layout this pipeline was created against.
+ internal PipelineLayout PipelineLayout => _layout;
+
+ /// Non-null only for a pipeline flagged for render-pack ABI v1.
+ internal VulkanPipelineLayouts.Created.PackState? PackState => _packLayoutLease?.State;
+
+ internal bool AddColorFormatVariant(GpuTextureFormat format)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (!Description.HasColorAttachment
+ || !Description.AllowColorFormatVariants
+ || format == Description.ColorFormat
+ || _colorVariants.ContainsKey(format))
+ return false;
+
+ var variantDescription = Description with
+ {
+ Name = $"{Description.Name}-{format.ToString().ToLowerInvariant()}",
+ ColorFormat = format,
+ AllowColorFormatVariants = false,
+ };
+ VulkanPipelineLayouts.Created.PackLayoutLease? packLease =
+ Description.UsesRenderPackShaderAbi ? _layouts.AcquirePackLayout() : null;
+ VulkanGpuPipeline variant;
+ try
+ {
+ variant = new VulkanGpuPipeline(
+ _vk,
+ _device,
+ _retirement,
+ _debugNames,
+ _layouts,
+ packLease,
+ packLease?.PipelineLayout ?? _layouts.PipelineLayout,
+ _cache,
+ _vertexModule,
+ _fragmentModule,
+ ownsShaderModules: false,
+ variantDescription,
+ VulkanTextureFormatMapping.FormatOf(format),
+ _depthStencilFormat);
+ }
+ catch
+ {
+ packLease?.Dispose();
+ throw;
+ }
+ _colorVariants.Add(format, variant);
+ return true;
+ }
+
+ internal void RemoveColorFormatVariant(GpuTextureFormat format)
+ {
+ if (_colorVariants.Remove(format, out VulkanGpuPipeline? variant))
+ variant.Dispose();
+ }
+
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
+ foreach (VulkanGpuPipeline variant in _colorVariants.Values)
+ variant.Dispose();
+ _colorVariants.Clear();
Pipeline withDepth = _withDepthAttachment;
Pipeline withoutDepth = _withoutDepthAttachment;
+ ShaderModule vertex = _vertexModule;
+ ShaderModule fragment = _fragmentModule;
+ bool destroyModules = _ownsShaderModules;
+ VulkanPipelineLayouts.Created.PackLayoutLease? packLease = _packLayoutLease;
_retirement.Retire(() =>
{
_vk.DestroyPipeline(_device, withDepth, null);
_vk.DestroyPipeline(_device, withoutDepth, null);
+ if (destroyModules)
+ {
+ _vk.DestroyShaderModule(_device, fragment, null);
+ _vk.DestroyShaderModule(_device, vertex, null);
+ }
+ // The optional set-3 and four-set layout cannot be destroyed until
+ // every pipeline that names them has actually retired.
+ packLease?.Dispose();
});
}
}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs
index 4f679a94..f1e540ba 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuRenderTarget.cs
@@ -7,11 +7,10 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// offscreen colour(+depth) bundle behind the paperdoll, the creature-appraisal
/// viewport and the portal mask.
///
-/// Offscreen targets stay single-sampled, matching the contract. Their
-/// colour image carries SAMPLED as well as COLOR_ATTACHMENT usage
-/// so it can be registered into the texture table and drawn by the retained UI
-/// the moment its pass ends — which is the whole reason these exist rather than
-/// rendering those views onto the backbuffer.
+/// The textures exposed through are always
+/// single-sampled. When the requested attachment sample count is greater than
+/// one, separate transient multisample attachments resolve into those textures;
+/// the global table never receives an illegal multisampled view.
///
/// Slice V6l made both halves of that sentence true. The colour image now
/// carries a second, LAYERED view for the table to sample (see
@@ -22,7 +21,9 @@ namespace AcDream.App.Rendering.Gpu.Vk;
internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
{
private readonly VulkanGpuTexture _color;
+ private readonly VulkanGpuTexture? _multisampleColor;
private readonly VulkanGpuTexture? _depth;
+ private readonly VulkanGpuTexture? _multisampleDepth;
private bool _disposed;
internal VulkanGpuRenderTarget(
@@ -38,8 +39,18 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
ArgumentException.ThrowIfNullOrWhiteSpace(description.Name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Width);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Height);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.SampleCount);
+ if (description.SampleableDepth && description.DepthFormat is null)
+ {
+ throw new ArgumentException(
+ "SampleableDepth requires a depth format.",
+ nameof(description));
+ }
Description = description;
+ // The public colour texture is the single-sampled result even when the
+ // pass itself is multisampled. Post-process and retained-UI consumers
+ // always register this image, never the transient attachment below.
_color = new VulkanGpuTexture(
vk,
device,
@@ -55,11 +66,36 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
description.Height,
LayerCount: 1,
MipLevelCount: 1),
- Math.Max(1, description.SampleCount),
- renderTarget: true);
+ sampleCount: 1,
+ renderTarget: true,
+ sampleable: true);
+
+ if (description.SampleCount > 1)
+ {
+ _multisampleColor = new VulkanGpuTexture(
+ vk,
+ device,
+ allocator,
+ uploads,
+ retirement,
+ debugNames,
+ new GpuTextureDescription(
+ $"{description.Name}-color-msaa",
+ GpuTextureKind.Texture2D,
+ description.ColorFormat,
+ description.Width,
+ description.Height,
+ LayerCount: 1,
+ MipLevelCount: 1),
+ description.SampleCount,
+ renderTarget: true,
+ sampleable: false);
+ }
if (description.DepthFormat is { } depthFormat)
{
+ int retainedDepthSamples =
+ description.SampleableDepth ? 1 : description.SampleCount;
_depth = new VulkanGpuTexture(
vk,
device,
@@ -75,14 +111,38 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
description.Height,
LayerCount: 1,
MipLevelCount: 1),
- Math.Max(1, description.SampleCount),
+ retainedDepthSamples,
renderTarget: true,
+ sampleable: description.SampleableDepth,
// Slice V6l: the DEVICE's combined depth/stencil format, not the
// contract enum's literal one. Every pipeline bakes one
// depth/stencil format under dynamic rendering and the same
// pipelines draw in both the backbuffer pass and this one, so a
// second format here would make one of the two undefined.
formatOverride: deviceDepthStencilFormat);
+
+ if (description.SampleableDepth && description.SampleCount > 1)
+ {
+ _multisampleDepth = new VulkanGpuTexture(
+ vk,
+ device,
+ allocator,
+ uploads,
+ retirement,
+ debugNames,
+ new GpuTextureDescription(
+ $"{description.Name}-depth-msaa",
+ GpuTextureKind.Texture2D,
+ depthFormat,
+ description.Width,
+ description.Height,
+ LayerCount: 1,
+ MipLevelCount: 1),
+ description.SampleCount,
+ renderTarget: true,
+ sampleable: false,
+ formatOverride: deviceDepthStencilFormat);
+ }
}
}
@@ -90,16 +150,33 @@ internal sealed class VulkanGpuRenderTarget : IGpuRenderTarget
public IGpuTexture ColorTexture => _color;
- internal VulkanGpuTexture Color => _color;
+ public IGpuTexture? DepthTexture => Description.SampleableDepth ? _depth : null;
- internal VulkanGpuTexture? Depth => _depth;
+ /// The image written as the pass's colour attachment.
+ internal VulkanGpuTexture ColorAttachment => _multisampleColor ?? _color;
+
+ /// The single-sampled resolve destination, or null at one sample.
+ internal VulkanGpuTexture? ColorResolve => _multisampleColor is null ? null : _color;
+
+ /// The image written as the pass's depth/stencil attachment.
+ internal VulkanGpuTexture? DepthAttachment => _multisampleDepth ?? _depth;
+
+ /// The sampleable depth resolve destination, or null when no resolve is required.
+ internal VulkanGpuTexture? DepthResolve =>
+ Description.SampleableDepth && _multisampleDepth is not null ? _depth : null;
+
+ internal VulkanGpuTexture ColorResult => _color;
+
+ internal VulkanGpuTexture? DepthResult => _depth;
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
+ _multisampleDepth?.Dispose();
_depth?.Dispose();
+ _multisampleColor?.Dispose();
_color.Dispose();
}
}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs
index 31264aae..296e7207 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTexture.cs
@@ -41,6 +41,7 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
in GpuTextureDescription description,
int sampleCount = 1,
bool renderTarget = false,
+ bool sampleable = true,
// Fully qualified: in a parameter-default expression the simple name
// `Format` binds to this type's own GpuTextureFormat property first.
Format formatOverride = Silk.NET.Vulkan.Format.Undefined)
@@ -55,6 +56,13 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Height);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.LayerCount);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.MipLevelCount);
+ if (sampleCount > 1 && sampleable)
+ {
+ throw new ArgumentException(
+ "A multisampled image cannot be registered in acdream's single-sampled texture table; "
+ + "create a separate single-sampled resolve image.",
+ nameof(sampleable));
+ }
Name = description.Name;
Kind = description.Kind;
@@ -64,6 +72,7 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
LayerCount = description.LayerCount;
MipLevelCount = description.MipLevelCount;
SampleCount = sampleCount;
+ IsSampleable = sampleable;
// Slice V6l: an offscreen target's DEPTH attachment takes the format the
// device already chose for the backbuffer, because a pipeline bakes one
// depth/stencil format and draws in both kinds of pass. The contract's
@@ -77,7 +86,9 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
bool depthStencil = VulkanTextureFormatMapping.IsDepthStencil(description.Format);
ImageUsageFlags usage = depthStencil
? ImageUsageFlags.DepthStencilAttachmentBit
- : ImageUsageFlags.SampledBit | ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit;
+ : ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit;
+ if (sampleable)
+ usage |= ImageUsageFlags.SampledBit;
if (renderTarget && !depthStencil)
usage |= ImageUsageFlags.ColorAttachmentBit;
if (sampleCount > 1)
@@ -142,9 +153,9 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
_vk.CreateImageView(_device, &viewCreate, null, out ImageView view),
$"vkCreateImageView ('{description.Name}')");
View = view;
- SampledView = view;
+ SampledView = renderTarget && !sampleable ? default : view;
- // Campaign V slice V6l: a colour render target needs TWO views.
+ // Campaign V slice V6l: a sampleable render target needs TWO views.
//
// An ATTACHMENT view must be VK_IMAGE_VIEW_TYPE_2D, and the global
// texture table's descriptor array is declared sampler2DArray, so the
@@ -155,9 +166,14 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
// fix: one image, one allocation, two ways of looking at it. Legal
// without any creation flag — a 2D_ARRAY view over an imageType-2D
// image with arrayLayers >= 1 is exactly what the spec permits.
- if (renderTarget && !depthStencil)
+ if (renderTarget && sampleable)
{
viewCreate.ViewType = VulkanTextureFormatMapping.SampledViewTypeOf(description.Kind);
+ // Combined depth/stencil remains one attachment for #117, but
+ // sampling exposes only depth. A sampled view containing the
+ // stencil aspect is invalid for sampler2DArray.
+ if (depthStencil)
+ viewCreate.SubresourceRange.AspectMask = ImageAspectFlags.DepthBit;
VulkanInterop.Check(
_vk.CreateImageView(_device, &viewCreate, null, out ImageView sampled),
$"vkCreateImageView ('{description.Name}', sampled)");
@@ -184,6 +200,7 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
public int MipLevelCount { get; }
internal int SampleCount { get; }
+ internal bool IsSampleable { get; }
internal Image Image { get; }
/// The view a pass names as an attachment, and the only view a non-attachment has.
@@ -196,6 +213,10 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
/// is sampler2DArray (slice V6l, plan §5.5.7).
///
internal ImageView SampledView { get; }
+ internal ImageLayout SampledLayout =>
+ VulkanTextureFormatMapping.IsDepthStencil(Format)
+ ? ImageLayout.DepthStencilReadOnlyOptimal
+ : ImageLayout.ShaderReadOnlyOptimal;
internal Format VkFormat { get; }
internal ImageAspectFlags Aspect { get; }
@@ -264,7 +285,7 @@ internal sealed unsafe class VulkanGpuTexture : IGpuTexture
VulkanAllocation allocation = _allocation;
_retirement.Retire(() =>
{
- if (sampledView.Handle != view.Handle)
+ if (sampledView.Handle != 0 && sampledView.Handle != view.Handle)
_vk.DestroyImageView(_device, sampledView, null);
_vk.DestroyImageView(_device, view, null);
_vk.DestroyImage(_device, image, null);
@@ -337,6 +358,8 @@ internal sealed unsafe class VulkanGpuSampler : IGpuSampler
internal Sampler Handle { get; }
+ internal bool IsDisposed => _disposed;
+
public void Dispose()
{
if (_disposed)
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs
index 6f08a3ec..799bd77c 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuTimerPool.cs
@@ -176,7 +176,7 @@ internal sealed unsafe class VulkanGpuTimerPool : IGpuTimerPool, IDisposable
/// deliberately reports the last known value
/// forever — right for a diagnostic readout, wrong for a percentile.
///
- internal bool TryTakeResolved(string scopeName, out double milliseconds)
+ public bool TryTakeResolved(string scopeName, out double milliseconds)
{
if (!_resolved.TryGetValue(scopeName, out milliseconds))
return false;
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGraphicsContext.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGraphicsContext.cs
index cedef45b..4db64348 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGraphicsContext.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGraphicsContext.cs
@@ -214,6 +214,13 @@ internal sealed unsafe class VulkanGraphicsContext : IDisposable
_physicalDevice = handles[choice.Device.Index];
+ // The logical-device feature chain consumes this exact probe result.
+ // Keep the probe owned by the selected physical device and publish it
+ // before VulkanLogicalDeviceFactory.Create: probing later leaves the
+ // production Acquire path with no safe value from which to decide
+ // whether the optional Vulkan 1.1 multiview feature may be enabled.
+ _features = VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice);
+
IReadOnlyList queueFamilies =
VulkanPhysicalDeviceInspector.ReadQueueFamilies(
vk,
@@ -233,7 +240,8 @@ internal sealed unsafe class VulkanGraphicsContext : IDisposable
vk,
_physicalDevice,
families,
- requireSwapchain: true);
+ requireSwapchain: true,
+ availableFeatures: _features);
_device = created.Device;
_graphicsQueue = created.GraphicsQueue;
_presentQueue = created.PresentQueue;
@@ -287,7 +295,6 @@ internal sealed unsafe class VulkanGraphicsContext : IDisposable
_graphicsQueue,
families.GraphicsFamily);
- _features = VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice);
_limits = VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice);
_formats = VulkanPhysicalDeviceInspector.ReadFormats(
vk,
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanHostStorageVisibility.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanHostStorageVisibility.cs
new file mode 100644
index 00000000..db95808d
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanHostStorageVisibility.cs
@@ -0,0 +1,30 @@
+using Silk.NET.Vulkan;
+using Buffer = Silk.NET.Vulkan.Buffer;
+
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+///
+/// Exact sync2 dependency for CPU writes into a retained mapped SSBO before
+/// shadow vertex shaders read it. Kept pure so driverless contract tests can
+/// assert the stage/access/ownership/range tuple Vulkan receives.
+///
+internal static class VulkanHostStorageVisibility
+{
+ internal static BufferMemoryBarrier2 Create(Buffer buffer, ulong sizeBytes)
+ {
+ ArgumentOutOfRangeException.ThrowIfZero(sizeBytes);
+ return new BufferMemoryBarrier2
+ {
+ SType = StructureType.BufferMemoryBarrier2,
+ SrcStageMask = PipelineStageFlags2.HostBit,
+ SrcAccessMask = AccessFlags2.HostWriteBit,
+ DstStageMask = PipelineStageFlags2.VertexShaderBit,
+ DstAccessMask = AccessFlags2.ShaderReadBit,
+ SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
+ DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
+ Buffer = buffer,
+ Offset = 0,
+ Size = sizeBytes,
+ };
+ }
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs
index 1f74bf7b..3dae155b 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs
@@ -314,6 +314,7 @@ internal static unsafe class VulkanPhysicalDeviceInspector
TextureCompressionBc = core.TextureCompressionBC,
SamplerAnisotropy = core.SamplerAnisotropy,
ShaderDrawParameters = vulkan11.ShaderDrawParameters,
+ Multiview = vulkan11.Multiview,
TimelineSemaphore = vulkan12.TimelineSemaphore,
HostQueryReset = vulkan12.HostQueryReset,
RuntimeDescriptorArray = vulkan12.RuntimeDescriptorArray,
@@ -363,6 +364,8 @@ internal static unsafe class VulkanPhysicalDeviceInspector
MaxClipDistances = limits.MaxClipDistances,
MaxBoundDescriptorSets = limits.MaxBoundDescriptorSets,
MaxDescriptorSetStorageBuffersDynamic = limits.MaxDescriptorSetStorageBuffersDynamic,
+ MaxDescriptorSetStorageBuffers = limits.MaxDescriptorSetStorageBuffers,
+ MaxPerStageDescriptorStorageBuffers = limits.MaxPerStageDescriptorStorageBuffers,
MaxDescriptorSetUniformBuffersDynamic = limits.MaxDescriptorSetUniformBuffersDynamic,
MaxDescriptorSetUpdateAfterBindSampledImages =
indexing.MaxDescriptorSetUpdateAfterBindSampledImages,
@@ -370,8 +373,11 @@ internal static unsafe class VulkanPhysicalDeviceInspector
indexing.MaxPerStageDescriptorUpdateAfterBindSampledImages,
TimestampComputeAndGraphics = limits.TimestampComputeAndGraphics,
MinStorageBufferOffsetAlignment = (uint)limits.MinStorageBufferOffsetAlignment,
+ MaxStorageBufferRange = limits.MaxStorageBufferRange,
MinUniformBufferOffsetAlignment = (uint)limits.MinUniformBufferOffsetAlignment,
MaxImageDimension2D = limits.MaxImageDimension2D,
+ MaxImageArrayLayers = limits.MaxImageArrayLayers,
+ DeviceLocalHeapBytes = LargestDeviceLocalHeap(vk, device),
MaxColorSampleCount = HighestSampleCount(
limits.FramebufferColorSampleCounts & limits.FramebufferDepthSampleCounts),
};
@@ -398,10 +404,28 @@ internal static unsafe class VulkanPhysicalDeviceInspector
{
ArgumentNullException.ThrowIfNull(vk);
+ Format depthStencil = ChooseDepthStencilFormat(vk, device);
+ FormatProperties rgba16;
+ vk.GetPhysicalDeviceFormatProperties(device, Format.R16G16B16A16Sfloat, &rgba16);
+ FormatFeatureFlags rgba16Features = rgba16.OptimalTilingFeatures;
+
return new VulkanFormatSupport
{
SwapchainUnormFormat = surfaceOffersUnorm,
- DepthStencilFormat = ChooseDepthStencilFormat(vk, device),
+ DepthStencilFormat = depthStencil,
+ DepthStencilSampled =
+ depthStencil != Format.Undefined
+ && SupportsOptimalSampling(vk, device, depthStencil),
+ Rgba16FloatColorAttachment =
+ rgba16Features.HasFlag(FormatFeatureFlags.ColorAttachmentBit),
+ Rgba16FloatSampled =
+ rgba16Features.HasFlag(FormatFeatureFlags.SampledImageBit),
+ Rgba16FloatLinearFilter =
+ rgba16Features.HasFlag(FormatFeatureFlags.SampledImageFilterLinearBit),
+ MaxRgba16FloatSampleCount = ReadOptimalColorSampleCount(
+ vk,
+ device,
+ Format.R16G16B16A16Sfloat),
Bc1Sampled = SupportsOptimalSampling(vk, device, Format.BC1RgbaUnormBlock),
Bc2Sampled = SupportsOptimalSampling(vk, device, Format.BC2UnormBlock),
Bc3Sampled = SupportsOptimalSampling(vk, device, Format.BC3UnormBlock),
@@ -436,6 +460,32 @@ internal static unsafe class VulkanPhysicalDeviceInspector
return properties.OptimalTilingFeatures.HasFlag(FormatFeatureFlags.SampledImageBit);
}
+ ///
+ /// Query the sample-count mask for an optimal-tiling colour attachment.
+ /// Physical-device framebuffer limits are only an upper bound; the concrete
+ /// RGBA16F format may support fewer samples. Sampling is checked separately
+ /// because the multisample image is transient and only its one-sample
+ /// resolve target carries SAMPLED usage.
+ ///
+ internal static uint ReadOptimalColorSampleCount(
+ Silk.NET.Vulkan.Vk vk,
+ PhysicalDevice device,
+ Format format)
+ {
+ ImageFormatProperties properties;
+ Result result = vk.GetPhysicalDeviceImageFormatProperties(
+ device,
+ format,
+ ImageType.Type2D,
+ ImageTiling.Optimal,
+ ImageUsageFlags.ColorAttachmentBit,
+ ImageCreateFlags.None,
+ &properties);
+ return result == Result.Success
+ ? HighestSampleCount(properties.SampleCounts)
+ : 0u;
+ }
+
///
/// Enumerate queue families, reporting present support only when a surface
/// is supplied. The headless probe passes null and takes the
@@ -517,10 +567,12 @@ internal sealed unsafe class VulkanLogicalDeviceFactory
Silk.NET.Vulkan.Vk vk,
PhysicalDevice physicalDevice,
VulkanQueueFamilyChoice families,
- bool requireSwapchain)
+ bool requireSwapchain,
+ VulkanDeviceFeatureSupport availableFeatures)
{
ArgumentNullException.ThrowIfNull(vk);
ArgumentNullException.ThrowIfNull(families);
+ ArgumentNullException.ThrowIfNull(availableFeatures);
IReadOnlyList available =
VulkanInterop.EnumerateDeviceExtensions(vk, physicalDevice);
@@ -577,6 +629,9 @@ internal sealed unsafe class VulkanLogicalDeviceFactory
SType = StructureType.PhysicalDeviceVulkan11Features,
PNext = &vulkan12,
ShaderDrawParameters = true,
+ // Core 1.1 optional feature: enable it iff the physical-device
+ // Features2 chain reported it. No extension path is attempted.
+ Multiview = availableFeatures.Multiview,
};
var core = new PhysicalDeviceFeatures
{
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs
index b9503b13..3da35bd7 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanPipelineLayouts.cs
@@ -3,8 +3,8 @@ using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
///
-/// Campaign V, plan §3.4 and §4.4: the three descriptor set layouts and the ONE
-/// pipeline layout every acdream pipeline shares.
+/// Campaign V, plan §3.4 and §4.4: retail's three descriptor set layouts and
+/// shared pipeline layout, plus the strictly opt-in render-pack extension.
///
/// Extracted from slice V5's active capability probe at V6b so the probe
/// and the live backend build the same objects from the same code. The probe's
@@ -12,33 +12,110 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// device; a second, similar-looking definition would quietly destroy that
/// property the first time one of them changed.
///
-/// One pipeline layout is a decision, not an economy. Because every
-/// pipeline shares it, switching pipelines mid-pass does not invalidate bound
-/// descriptor sets or push constants — which is what lets the world dispatcher
-/// bind the texture table once per frame and then change pipeline per bucket.
-/// The single 96-byte push-constant block exists for the same reason.
+/// Retail remains authoritative. Its layout is exactly sets 0, 1,
+/// and 2 with the original 96-byte push block. A flagged render-pack pipeline
+/// lazily acquires a compatible four-set layout whose additional set 3 owns
+/// bindings 5..8. The pack objects are reference-counted through pipeline
+/// retirement, so selecting retail creates no Vulkan pack object at all.
///
internal static unsafe class VulkanPipelineLayouts
{
- /// The three sets plus the shared layout, owned together and destroyed together.
- internal sealed class Created(
- DescriptorSetLayout storage,
- DescriptorSetLayout uniform,
- DescriptorSetLayout textureTable,
- PipelineLayout pipelineLayout) : IDisposable
+ /// The retail layouts and the lazy lifetime of the optional pack layout.
+ internal sealed class Created : IDisposable
{
+ private readonly Silk.NET.Vulkan.Vk _vk;
+ private readonly Device _device;
+ private readonly object _packLock = new();
+ private PackState? _pack;
+ private int _packReferences;
+ private int _nextPackGeneration;
private bool _disposed;
- internal DescriptorSetLayout Storage { get; } = storage;
- internal DescriptorSetLayout Uniform { get; } = uniform;
- internal DescriptorSetLayout TextureTable { get; } = textureTable;
- internal PipelineLayout PipelineLayout { get; } = pipelineLayout;
+ internal Created(
+ Silk.NET.Vulkan.Vk vk,
+ Device device,
+ DescriptorSetLayout storage,
+ DescriptorSetLayout uniform,
+ DescriptorSetLayout textureTable,
+ PipelineLayout pipelineLayout)
+ {
+ _vk = vk;
+ _device = device;
+ Storage = storage;
+ Uniform = uniform;
+ TextureTable = textureTable;
+ PipelineLayout = pipelineLayout;
+ }
+
+ internal DescriptorSetLayout Storage { get; }
+ internal DescriptorSetLayout Uniform { get; }
+ internal DescriptorSetLayout TextureTable { get; }
+ internal PipelineLayout PipelineLayout { get; }
+
+ ///
+ /// Acquires the opt-in layout. The first acquisition creates set 3 and
+ /// its compatible four-set pipeline layout; the last retired pipeline
+ /// destroys them together with every descriptor pool allocated from it.
+ ///
+ internal PackLayoutLease AcquirePackLayout()
+ {
+ lock (_packLock)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ _pack ??= CreatePackState(++_nextPackGeneration);
+ _packReferences++;
+ return new PackLayoutLease(this, _pack);
+ }
+ }
+
+ private PackState CreatePackState(int generation)
+ {
+ DescriptorSetLayout packUniform = default;
+ try
+ {
+ packUniform = CreatePackUniformSetLayout(_vk, _device);
+ PipelineLayout packPipeline = CreatePackPipelineLayout(
+ _vk,
+ _device,
+ Storage,
+ Uniform,
+ TextureTable,
+ packUniform);
+ return new PackState(_vk, _device, generation, packUniform, packPipeline);
+ }
+ catch
+ {
+ if (packUniform.Handle != 0)
+ _vk.DestroyDescriptorSetLayout(_device, packUniform, null);
+ throw;
+ }
+ }
+
+ private void ReleasePackLayout(PackState state)
+ {
+ lock (_packLock)
+ {
+ if (_pack != state || _packReferences <= 0)
+ return;
+ _packReferences--;
+ if (_packReferences != 0)
+ return;
+ _pack = null;
+ state.Destroy();
+ }
+ }
internal void Destroy(Silk.NET.Vulkan.Vk vk, Device device)
{
if (_disposed)
return;
_disposed = true;
+ lock (_packLock)
+ {
+ _pack?.Destroy();
+ _pack = null;
+ _packReferences = 0;
+ }
if (PipelineLayout.Handle != 0)
vk.DestroyPipelineLayout(device, PipelineLayout, null);
if (TextureTable.Handle != 0)
@@ -51,6 +128,114 @@ internal static unsafe class VulkanPipelineLayouts
/// Destruction needs the device, so is the real disposer.
public void Dispose() => _disposed = true;
+
+ internal sealed class PackLayoutLease : IDisposable
+ {
+ private Created? _owner;
+
+ internal PackLayoutLease(Created owner, PackState state)
+ {
+ _owner = owner;
+ State = state;
+ }
+
+ internal PackState State { get; }
+ internal PipelineLayout PipelineLayout => State.PipelineLayout;
+
+ public void Dispose()
+ {
+ Created? owner = Interlocked.Exchange(ref _owner, null);
+ owner?.ReleasePackLayout(State);
+ }
+ }
+
+ ///
+ /// One generation of the pack layout and all descriptor pools allocated
+ /// against it. Keeping the pools here prevents a stale per-flight set
+ /// from outliving the descriptor-set layout it was allocated from.
+ ///
+ internal sealed unsafe class PackState
+ {
+ private const int SetsPerPool = 32;
+ private readonly Silk.NET.Vulkan.Vk _vk;
+ private readonly Device _device;
+ private readonly List _pools = [];
+ private int _setCount;
+ private bool _destroyed;
+
+ internal PackState(
+ Silk.NET.Vulkan.Vk vk,
+ Device device,
+ int generation,
+ DescriptorSetLayout descriptorSetLayout,
+ PipelineLayout pipelineLayout)
+ {
+ _vk = vk;
+ _device = device;
+ Generation = generation;
+ DescriptorSetLayout = descriptorSetLayout;
+ PipelineLayout = pipelineLayout;
+ }
+
+ internal int Generation { get; }
+ internal DescriptorSetLayout DescriptorSetLayout { get; }
+ internal PipelineLayout PipelineLayout { get; }
+
+ internal DescriptorSet AllocateDescriptorSet()
+ {
+ ObjectDisposedException.ThrowIf(_destroyed, this);
+ if (_setCount % SetsPerPool == 0)
+ _pools.Add(CreatePool());
+
+ DescriptorSetLayout layout = DescriptorSetLayout;
+ var allocate = new DescriptorSetAllocateInfo
+ {
+ SType = StructureType.DescriptorSetAllocateInfo,
+ DescriptorPool = _pools[^1],
+ DescriptorSetCount = 1,
+ PSetLayouts = &layout,
+ };
+ VulkanInterop.Check(
+ _vk.AllocateDescriptorSets(_device, &allocate, out DescriptorSet set),
+ "vkAllocateDescriptorSets (render-pack set 3)");
+ _setCount++;
+ return set;
+ }
+
+ private DescriptorPool CreatePool()
+ {
+ var size = new DescriptorPoolSize
+ {
+ Type = DescriptorType.UniformBufferDynamic,
+ DescriptorCount = PackUniformBindingCount * SetsPerPool,
+ };
+ var create = new DescriptorPoolCreateInfo
+ {
+ SType = StructureType.DescriptorPoolCreateInfo,
+ MaxSets = SetsPerPool,
+ PoolSizeCount = 1,
+ PPoolSizes = &size,
+ };
+ VulkanInterop.Check(
+ _vk.CreateDescriptorPool(_device, &create, null, out DescriptorPool pool),
+ "vkCreateDescriptorPool (render-pack set 3)");
+ return pool;
+ }
+
+ internal void Destroy()
+ {
+ if (_destroyed)
+ return;
+ _destroyed = true;
+ foreach (DescriptorPool pool in _pools)
+ _vk.DestroyDescriptorPool(_device, pool, null);
+ _pools.Clear();
+ if (PipelineLayout.Handle != 0)
+ _vk.DestroyPipelineLayout(_device, PipelineLayout, null);
+ if (DescriptorSetLayout.Handle != 0)
+ _vk.DestroyDescriptorSetLayout(_device, DescriptorSetLayout, null);
+ }
+ }
}
/// Creates all four objects, cleaning up whatever succeeded if a later one fails.
@@ -67,7 +252,7 @@ internal static unsafe class VulkanPipelineLayouts
uniform = CreateUniformSetLayout(vk, device);
table = CreateTextureTableSetLayout(vk, device);
PipelineLayout layout = CreatePipelineLayout(vk, device, storage, uniform, table);
- return new Created(storage, uniform, table, layout);
+ return new Created(vk, device, storage, uniform, table, layout);
}
catch
{
@@ -105,11 +290,10 @@ internal static unsafe class VulkanPipelineLayouts
/// at all can fail this layout. That matters for slice V9's lavapipe row and
/// for whatever Linux driver the deferred physical row eventually uses.
///
- /// Binding 9 is the clearest case. The texture table is the
- /// GL-only uvec2 handle-buffer emulation; the Vulkan backend binds set
- /// 2 instead and never touches binding 9 at all, so a dynamic descriptor for
- /// it would be a device resource spent on a binding that is provably never
- /// bound.
+ /// Binding 9 is a plain descriptor. #226 uploads one
+ /// per-instance detail-category array at a stable ring range for each
+ /// submission. It does not need to be re-pointed between draw calls, so a
+ /// scarce dynamic descriptor buys it nothing.
///
/// What to do if V4c disagrees. Bindings 6, 7 and 8 are
/// per-instance arrays grouped here with the frame-global tables because
@@ -153,8 +337,8 @@ internal static unsafe class VulkanPipelineLayouts
///
/// Set 0 — the storage
- /// bindings pins (nine, since Campaign V
- /// slice V11 deleted the GL-only StorageTextureTable binding), split
+ /// bindings pins (ten after #226 reclaimed
+ /// binding 9 from the deleted GL-only texture table), split
/// between dynamic and plain by .
///
internal static DescriptorSetLayout CreateStorageSetLayout(Silk.NET.Vulkan.Vk vk, Device device)
@@ -199,6 +383,13 @@ internal static unsafe class VulkanPipelineLayouts
///
internal const uint UniformTerrainClip = 2;
+ /// Bindings 5..8 in opt-in set 3.
+ internal const uint PackUniformBindingCount = 4;
+
+ internal static bool IsDeclaredPackUniformBinding(uint binding) =>
+ binding >= GpuBindingModel.UniformAtmosphericFrame
+ && binding <= GpuBindingModel.UniformPackSettings;
+
///
/// Which of set 1's bindings the layout declares — the uniform-side twin of
/// , and for the same reason: the
@@ -247,12 +438,9 @@ internal static unsafe class VulkanPipelineLayouts
}
///
- /// Set 1 — the SceneLighting, terrain-clip, terrain-tiling and sky-params
- /// uniform blocks. All four are dynamic: each is fed from the per-frame ring,
- /// so its offset moves every frame and a dynamic descriptor is exactly what
- /// spares the write. Four is half Vulkan's guaranteed
- /// maxDescriptorSetUniformBuffersDynamic of 8, and the capability gate
- /// asserts it.
+ /// Set 1 — only retail frame blocks. All four are dynamic: each is fed from
+ /// the per-frame ring, so its offset moves every frame and a dynamic
+ /// descriptor is exactly what spares the write.
///
internal static DescriptorSetLayout CreateUniformSetLayout(Silk.NET.Vulkan.Vk vk, Device device)
{
@@ -281,6 +469,38 @@ internal static unsafe class VulkanPipelineLayouts
return layout;
}
+ ///
+ /// Opt-in set 3 — sparse dynamic uniform bindings 5..8 from render-pack ABI
+ /// v1. It is deliberately not created by .
+ ///
+ internal static DescriptorSetLayout CreatePackUniformSetLayout(
+ Silk.NET.Vulkan.Vk vk,
+ Device device)
+ {
+ DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[(int)PackUniformBindingCount];
+ for (uint i = 0; i < PackUniformBindingCount; i++)
+ {
+ bindings[i] = new DescriptorSetLayoutBinding
+ {
+ Binding = GpuBindingModel.UniformAtmosphericFrame + i,
+ DescriptorType = DescriptorType.UniformBufferDynamic,
+ DescriptorCount = 1,
+ StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
+ };
+ }
+
+ var create = new DescriptorSetLayoutCreateInfo
+ {
+ SType = StructureType.DescriptorSetLayoutCreateInfo,
+ BindingCount = PackUniformBindingCount,
+ PBindings = bindings,
+ };
+ VulkanInterop.Check(
+ vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout),
+ "vkCreateDescriptorSetLayout (set 3, render-pack uniforms)");
+ return layout;
+ }
+
///
/// Set 2 — the production texture table exactly as §4.4 specifies it: one
/// combined-image-sampler binding of
@@ -323,7 +543,7 @@ internal static unsafe class VulkanPipelineLayouts
}
///
- /// One shared pipeline layout: three sets plus the single 96-byte
+ /// Retail's shared pipeline layout: three sets plus the single 96-byte
/// push-constant block. Creating it proves maxBoundDescriptorSets and
/// maxPushConstantsSize for real rather than by reading a limit.
///
@@ -358,4 +578,42 @@ internal static unsafe class VulkanPipelineLayouts
"vkCreatePipelineLayout");
return layout;
}
+
+ ///
+ /// Render-pack layout. Sets 0..2 are byte-for-byte the retail layouts; set 3
+ /// adds only the pack uniform ABI, and the push range remains 96 bytes.
+ ///
+ internal static PipelineLayout CreatePackPipelineLayout(
+ Silk.NET.Vulkan.Vk vk,
+ Device device,
+ DescriptorSetLayout storage,
+ DescriptorSetLayout uniform,
+ DescriptorSetLayout table,
+ DescriptorSetLayout packUniform)
+ {
+ DescriptorSetLayout* sets = stackalloc DescriptorSetLayout[4];
+ sets[0] = storage;
+ sets[1] = uniform;
+ sets[2] = table;
+ sets[3] = packUniform;
+
+ var pushConstants = new PushConstantRange
+ {
+ StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
+ Offset = 0,
+ Size = GpuBindingModel.PushConstantBytes,
+ };
+ var create = new PipelineLayoutCreateInfo
+ {
+ SType = StructureType.PipelineLayoutCreateInfo,
+ SetLayoutCount = 4,
+ PSetLayouts = sets,
+ PushConstantRangeCount = 1,
+ PPushConstantRanges = &pushConstants,
+ };
+ VulkanInterop.Check(
+ vk.CreatePipelineLayout(device, &create, null, out PipelineLayout layout),
+ "vkCreatePipelineLayout (render-pack ABI)");
+ return layout;
+ }
}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanRenderFailurePolicy.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanRenderFailurePolicy.cs
new file mode 100644
index 00000000..cca4e5ae
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanRenderFailurePolicy.cs
@@ -0,0 +1,43 @@
+using Silk.NET.Vulkan;
+
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+///
+/// Separates process/device-terminal Vulkan failures from faults contributed by
+/// an optional render pack. Only the latter may be quarantined to the default
+/// renderer; a lost device or exhausted host/device memory cannot be made safe
+/// by changing render graphs.
+///
+internal static class VulkanRenderFailurePolicy
+{
+ internal static bool IsFatal(Exception error)
+ {
+ ArgumentNullException.ThrowIfNull(error);
+
+ if (error is AggregateException aggregate)
+ {
+ foreach (Exception inner in aggregate.Flatten().InnerExceptions)
+ {
+ if (IsFatal(inner))
+ return true;
+ }
+ }
+
+ for (Exception? current = error; current is not null; current = current.InnerException)
+ {
+ if (current is OutOfMemoryException)
+ return true;
+ if (current is VulkanCallException vulkan && IsFatal(vulkan.Result))
+ return true;
+ }
+ return false;
+ }
+
+ private static bool IsFatal(Result result) => result is
+ Result.ErrorDeviceLost
+ or Result.ErrorOutOfHostMemory
+ or Result.ErrorOutOfDeviceMemory
+ // The swapchain policy already treats a lost surface as terminal. It
+ // cannot be repaired by falling back from a pack to the default graph.
+ or Result.ErrorSurfaceLostKhr;
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs
index 3d84dda6..d787bad5 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureFormatMapping.cs
@@ -6,14 +6,11 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// Campaign V slice V6b, plan §4.3: to
/// , and the byte arithmetic each format implies.
///
-/// Every format here is UNORM, and that is a finding rather than a
-/// default. The V3 audit (plan §4.10) established that acdream has no sRGB
-/// anywhere: not on upload, not in a shader, not at the framebuffer. The plan
-/// previously specified an sRGB swapchain "matching the GL FramebufferSrgb
-/// contract" — a contract that does not exist. Shipping an sRGB format would
-/// have applied an unwanted encode to already-display-space values, brightening
-/// every frame, and it would have passed silently until the V7 differential.
-///
+/// The retail path's formats are UNORM, and that is a finding rather than
+/// a default. The V3 audit (plan §4.10) established that acdream has no sRGB
+/// anywhere: not on upload, not in a shader, not at the framebuffer. The one
+/// float format in this mapping is an opt-in HDR intermediate; it does not alter
+/// the retail swapchain or texture decode convention.
///
internal static class VulkanTextureFormatMapping
{
@@ -54,6 +51,7 @@ internal static class VulkanTextureFormatMapping
// Deliberately the same 32-bit UNORM order as the swapchain rather than
// literal RGBA — see CanonicalColorAttachmentFormat.
GpuTextureFormat.Rgba8UnormRenderTarget => CanonicalColorAttachmentFormat,
+ GpuTextureFormat.Rgba16FloatRenderTarget => Format.R16G16B16A16Sfloat,
GpuTextureFormat.Depth24Stencil8 => Format.D24UnormS8Uint,
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown texture format."),
};
@@ -62,12 +60,15 @@ internal static class VulkanTextureFormatMapping
format == GpuTextureFormat.Depth24Stencil8;
internal static bool IsRenderTarget(GpuTextureFormat format) =>
- format is GpuTextureFormat.Rgba8UnormRenderTarget or GpuTextureFormat.Depth24Stencil8;
+ format is GpuTextureFormat.Rgba8UnormRenderTarget
+ or GpuTextureFormat.Rgba16FloatRenderTarget
+ or GpuTextureFormat.Depth24Stencil8;
/// Bytes one texel occupies. Only meaningful for uncompressed formats.
internal static int BytesPerTexel(GpuTextureFormat format) => format switch
{
GpuTextureFormat.Rgba8Unorm or GpuTextureFormat.Rgba8UnormRenderTarget => 4,
+ GpuTextureFormat.Rgba16FloatRenderTarget => 8,
GpuTextureFormat.R8Unorm => 1,
GpuTextureFormat.Depth24Stencil8 => 4,
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "A block-compressed format has no texel size."),
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureTable.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureTable.cs
index c41d12e8..0bc6bcc4 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureTable.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanTextureTable.cs
@@ -110,9 +110,11 @@ internal sealed unsafe class VulkanTextureTable : IDisposable
private readonly VulkanTextureSlotAllocator _slots;
private readonly DescriptorPool _pool;
private readonly DescriptorSet _set;
+ private readonly object _sync = new();
private ImageView _defaultView;
private Sampler _defaultSampler;
+ private ImageLayout _defaultLayout = ImageLayout.ShaderReadOnlyOptimal;
private bool _disposed;
internal VulkanTextureTable(
@@ -171,27 +173,51 @@ internal sealed unsafe class VulkanTextureTable : IDisposable
internal DescriptorSet Set => _set;
- internal int LiveSlotCount => _slots.LiveCount;
+ internal int LiveSlotCount
+ {
+ get
+ {
+ lock (_sync)
+ return _slots.LiveCount;
+ }
+ }
- internal uint HighWater => _slots.HighWater;
+ internal uint HighWater
+ {
+ get
+ {
+ lock (_sync)
+ return _slots.HighWater;
+ }
+ }
///
/// Records the (view, sampler) pair written into a slot when it is scrubbed.
/// Supplied after the default texture exists, which is necessarily after the
/// table itself.
///
- internal void SetScrubTarget(ImageView view, Sampler sampler)
+ internal void SetScrubTarget(
+ ImageView view,
+ Sampler sampler,
+ ImageLayout layout = ImageLayout.ShaderReadOnlyOptimal)
{
_defaultView = view;
_defaultSampler = sampler;
+ _defaultLayout = layout;
}
- internal GpuTextureSlot Register(ImageView view, Sampler sampler)
+ internal GpuTextureSlot Register(
+ ImageView view,
+ Sampler sampler,
+ ImageLayout layout = ImageLayout.ShaderReadOnlyOptimal)
{
- ObjectDisposedException.ThrowIf(_disposed, this);
- uint slot = _slots.Allocate();
- Write(slot, view, sampler);
- return new GpuTextureSlot(slot);
+ lock (_sync)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ uint slot = _slots.Allocate();
+ Write(slot, view, sampler, layout);
+ return new GpuTextureSlot(slot);
+ }
}
///
@@ -201,22 +227,33 @@ internal sealed unsafe class VulkanTextureTable : IDisposable
///
internal void ReleaseNow(GpuTextureSlot slot)
{
- if (_disposed || !slot.IsAssigned)
- return;
- if (_defaultView.Handle != 0 && _defaultSampler.Handle != 0)
- Write(slot.Index, _defaultView, _defaultSampler);
- _slots.Release(slot.Index);
+ lock (_sync)
+ {
+ if (_disposed || !slot.IsAssigned)
+ return;
+ if (_defaultView.Handle != 0 && _defaultSampler.Handle != 0)
+ Write(slot.Index, _defaultView, _defaultSampler, _defaultLayout);
+ _slots.Release(slot.Index);
+ }
}
- internal bool IsLive(GpuTextureSlot slot) => slot.IsAssigned && _slots.IsLive(slot.Index);
+ internal bool IsLive(GpuTextureSlot slot)
+ {
+ lock (_sync)
+ return slot.IsAssigned && _slots.IsLive(slot.Index);
+ }
- private void Write(uint slot, ImageView view, Sampler sampler)
+ private void Write(
+ uint slot,
+ ImageView view,
+ Sampler sampler,
+ ImageLayout layout)
{
var info = new DescriptorImageInfo
{
ImageView = view,
Sampler = sampler,
- ImageLayout = ImageLayout.ShaderReadOnlyOptimal,
+ ImageLayout = layout,
};
var write = new WriteDescriptorSet
{
@@ -233,10 +270,13 @@ internal sealed unsafe class VulkanTextureTable : IDisposable
public void Dispose()
{
- if (_disposed)
- return;
- _disposed = true;
- if (_pool.Handle != 0)
- _vk.DestroyDescriptorPool(_device, _pool, null);
+ lock (_sync)
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ if (_pool.Handle != 0)
+ _vk.DestroyDescriptorPool(_device, _pool, null);
+ }
}
}
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs
index 11bf73ab..d736a9ef 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanViewportMapping.cs
@@ -175,6 +175,9 @@ internal static class VulkanViewportMapping
GpuBlendMode.Additive => (BlendFactor.SrcAlpha, BlendFactor.One),
// Retail's third mode, found at slice V4c in WbDrawDispatcher.ApplyRetailBlend.
GpuBlendMode.InverseAlpha => (BlendFactor.OneMinusSrcAlpha, BlendFactor.SrcAlpha),
+ // ACRender::SetDetailSurfaceInternal with DrawBuilding/DrawEnvCell's
+ // category state: D3DBLEND_DESTCOLOR + D3DBLEND_INVSRCALPHA.
+ GpuBlendMode.RetailDetail => (BlendFactor.DstColor, BlendFactor.OneMinusSrcAlpha),
GpuBlendMode.None => (BlendFactor.One, BlendFactor.Zero),
_ => throw new ArgumentOutOfRangeException(nameof(blend), blend, "Unknown blend mode."),
};
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs
index 7d20e4da..23da2962 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanWorldPassScope.cs
@@ -52,7 +52,24 @@ internal sealed unsafe class VulkanWorldPassScope : IWorldPassScope
/// per frame, so a nested publication could only mean two phases believe they
/// own the frame.
///
- public IDisposable Publish(IGpuPassEncoder encoder)
+ public IDisposable Publish(IGpuPassEncoder encoder) =>
+ PublishCore(encoder, preservePreparedSections: false);
+
+ ///
+ /// Publishes a world pass after
+ /// has already built the frame. That preparation writes the authoritative
+ /// scene-lighting ring section before the shadow passes can run; clearing it
+ /// here would make every HDR receiver bind the zero fallback. The preceding
+ /// publication's disposal (or construction for the first frame) already
+ /// established an empty section set, so this preserves only values prepared
+ /// for the current GPU frame.
+ ///
+ internal IDisposable PublishPrepared(IGpuPassEncoder encoder) =>
+ PublishCore(encoder, preservePreparedSections: true);
+
+ private IDisposable PublishCore(
+ IGpuPassEncoder encoder,
+ bool preservePreparedSections)
{
ArgumentNullException.ThrowIfNull(encoder);
if (_encoder is not null)
@@ -62,7 +79,8 @@ internal sealed unsafe class VulkanWorldPassScope : IWorldPassScope
}
_encoder = encoder;
- Sections.Reset();
+ if (!preservePreparedSections)
+ Sections.Reset();
return _publication;
}
diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericAutoQualityController.cs b/src/AcDream.App/Rendering/Packs/AtmosphericAutoQualityController.cs
new file mode 100644
index 00000000..b94613e2
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/AtmosphericAutoQualityController.cs
@@ -0,0 +1,228 @@
+namespace AcDream.App.Rendering.Packs;
+
+using AcDream.Plugin.Abstractions.Rendering;
+
+internal enum AtmosphericQualityLevel : byte
+{
+ Low,
+ Medium,
+ High,
+}
+
+internal readonly record struct AtmosphericQualityMeasurement(
+ double InclusivePackGpuMillisecondsP99,
+ double IncrementalCpuMillisecondsP99,
+ long ResidentGpuBytes,
+ bool StableFrameBoundary);
+
+internal readonly record struct AtmosphericAutoQualitySnapshot(
+ AtmosphericQualityLevel Current,
+ int ConsecutiveOverBudgetFrames,
+ int ConsecutiveHeadroomFrames,
+ int CooldownFramesRemaining,
+ long ChangeGeneration,
+ bool SafeFallbackToRetailRequested);
+
+internal readonly record struct AtmosphericQualityBudget(
+ double GpuMillisecondsP99,
+ double CpuMillisecondsP99,
+ long ResidentGpuBytes)
+{
+ internal static AtmosphericQualityBudget FromPreset(RenderQualityPreset preset) => new(
+ preset.MaxIncrementalGpuMillisecondsP99,
+ preset.MaxIncrementalCpuMillisecondsP99,
+ preset.MaxResidentGpuBytes);
+}
+
+///
+/// Long-hysteresis automatic quality policy. It changes only resolution,
+/// cascade count/reach, and post-process sampling through one stable preset
+/// swap. If even Low remains over its declared budget, it requests an atomic
+/// whole-pack fallback to retail instead of silently dropping caster classes.
+///
+internal sealed class AtmosphericAutoQualityController
+{
+ internal const int DowngradeHysteresisFrames = 180;
+ internal const int UpgradeHysteresisFrames = 900;
+ internal const int ChangeCooldownFrames = 300;
+
+ private AtmosphericQualityLevel _current;
+ private readonly AtmosphericQualityLevel _minimum;
+ private readonly AtmosphericQualityLevel _maximum;
+ private readonly AtmosphericQualityBudget[] _budgets;
+ private int _overBudget;
+ private int _headroom;
+ private int _cooldown;
+ private long _generation;
+ private bool _safeFallbackToRetailRequested;
+
+ internal AtmosphericAutoQualityController(
+ AtmosphericQualityLevel initial = AtmosphericQualityLevel.Medium,
+ AtmosphericQualityLevel minimum = AtmosphericQualityLevel.Low,
+ AtmosphericQualityLevel maximum = AtmosphericQualityLevel.High)
+ : this(DefaultBudgets(), initial, minimum, maximum)
+ {
+ }
+
+ internal AtmosphericAutoQualityController(
+ IReadOnlyList budgets,
+ AtmosphericQualityLevel initial = AtmosphericQualityLevel.Medium,
+ AtmosphericQualityLevel minimum = AtmosphericQualityLevel.Low,
+ AtmosphericQualityLevel maximum = AtmosphericQualityLevel.High)
+ {
+ ArgumentNullException.ThrowIfNull(budgets);
+ if (budgets.Count != 3)
+ throw new ArgumentException("Auto quality requires Low, Medium, and High budgets.", nameof(budgets));
+ if (minimum > initial || initial > maximum)
+ throw new ArgumentOutOfRangeException(nameof(initial));
+ _budgets = budgets.ToArray();
+ foreach (AtmosphericQualityBudget budget in _budgets)
+ {
+ if (!double.IsFinite(budget.GpuMillisecondsP99)
+ || budget.GpuMillisecondsP99 < 0d
+ || !double.IsFinite(budget.CpuMillisecondsP99)
+ || budget.CpuMillisecondsP99 < 0d
+ || budget.ResidentGpuBytes < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(budgets),
+ "Automatic-quality budgets must be finite and non-negative.");
+ }
+ }
+ _minimum = minimum;
+ _maximum = maximum;
+ _current = initial;
+ }
+
+ internal AtmosphericAutoQualitySnapshot Snapshot => new(
+ _current,
+ _overBudget,
+ _headroom,
+ _cooldown,
+ _generation,
+ _safeFallbackToRetailRequested);
+
+ internal AtmosphericQualityBudget CurrentBudget => _budgets[(int)_current];
+
+ internal AtmosphericAutoQualitySnapshot Observe(
+ in AtmosphericQualityMeasurement measurement)
+ {
+ Validate(in measurement);
+ if (!measurement.StableFrameBoundary)
+ return Snapshot;
+ if (_safeFallbackToRetailRequested)
+ return Snapshot;
+ if (_cooldown > 0)
+ {
+ _cooldown--;
+ _overBudget = 0;
+ _headroom = 0;
+ return Snapshot;
+ }
+
+ AtmosphericQualityBudget budget = _budgets[(int)_current];
+ bool over = measurement.InclusivePackGpuMillisecondsP99
+ > budget.GpuMillisecondsP99
+ || measurement.IncrementalCpuMillisecondsP99
+ > budget.CpuMillisecondsP99
+ || measurement.ResidentGpuBytes > budget.ResidentGpuBytes;
+ if (over)
+ {
+ _overBudget++;
+ _headroom = 0;
+ if (_overBudget >= DowngradeHysteresisFrames)
+ {
+ if (_current != _minimum)
+ Change((AtmosphericQualityLevel)((int)_current - 1));
+ else
+ RequestSafeFallback();
+ }
+ return Snapshot;
+ }
+
+ _overBudget = 0;
+ if (_current == _maximum)
+ {
+ _headroom = 0;
+ return Snapshot;
+ }
+
+ AtmosphericQualityLevel next =
+ (AtmosphericQualityLevel)((int)_current + 1);
+ AtmosphericQualityBudget nextBudget = _budgets[(int)next];
+ bool hasHeadroom = measurement.InclusivePackGpuMillisecondsP99
+ <= nextBudget.GpuMillisecondsP99 * 0.70
+ && measurement.IncrementalCpuMillisecondsP99
+ <= nextBudget.CpuMillisecondsP99 * 0.70
+ && measurement.ResidentGpuBytes
+ <= (long)(nextBudget.ResidentGpuBytes * 0.70);
+ if (!hasHeadroom)
+ {
+ _headroom = 0;
+ return Snapshot;
+ }
+
+ _headroom++;
+ if (_headroom >= UpgradeHysteresisFrames)
+ Change(next);
+ return Snapshot;
+ }
+
+ internal void Reset(AtmosphericQualityLevel level)
+ {
+ _current = level;
+ _overBudget = 0;
+ _headroom = 0;
+ _cooldown = 0;
+ _safeFallbackToRetailRequested = false;
+ _generation = checked(_generation + 1);
+ }
+
+ private void Change(AtmosphericQualityLevel value)
+ {
+ _current = value;
+ _overBudget = 0;
+ _headroom = 0;
+ _cooldown = ChangeCooldownFrames;
+ _generation = checked(_generation + 1);
+ }
+
+ private void RequestSafeFallback()
+ {
+ _overBudget = DowngradeHysteresisFrames;
+ _headroom = 0;
+ _cooldown = 0;
+ _safeFallbackToRetailRequested = true;
+ _generation = checked(_generation + 1);
+ }
+
+ private static AtmosphericQualityBudget[] DefaultBudgets() =>
+ [
+ From(DirectionalShadowPreset.Low),
+ From(DirectionalShadowPreset.Medium),
+ From(DirectionalShadowPreset.High),
+ ];
+
+ private static AtmosphericQualityBudget From(DirectionalShadowPreset preset)
+ {
+ DirectionalShadowQuality quality = DirectionalShadowQuality.For(preset);
+ return new AtmosphericQualityBudget(
+ quality.IncrementalGpuP99BudgetMilliseconds,
+ quality.IncrementalCpuP99BudgetMilliseconds,
+ quality.PackResidentGpuByteBudget);
+ }
+
+ private static void Validate(in AtmosphericQualityMeasurement value)
+ {
+ if (!double.IsFinite(value.InclusivePackGpuMillisecondsP99)
+ || value.InclusivePackGpuMillisecondsP99 < 0
+ || !double.IsFinite(value.IncrementalCpuMillisecondsP99)
+ || value.IncrementalCpuMillisecondsP99 < 0
+ || value.ResidentGpuBytes < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(value),
+ "Atmospheric quality measurements must be finite and non-negative.");
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericCpuStageProfiler.cs b/src/AcDream.App/Rendering/Packs/AtmosphericCpuStageProfiler.cs
new file mode 100644
index 00000000..d6912243
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/AtmosphericCpuStageProfiler.cs
@@ -0,0 +1,153 @@
+using System.Diagnostics;
+using AcDream.App.Diagnostics;
+
+namespace AcDream.App.Rendering.Packs;
+
+internal readonly record struct AtmosphericCpuStageFrame(
+ long FrameSerial,
+ long ShadowCasterBuildTicks,
+ long ShadowEnvironmentTicks,
+ long ShadowPreparedDrawsAndTransformsTicks,
+ long ShadowFitAndUniformTicks,
+ long ShadowLayeredPassRecordingTicks,
+ long ShadowBookkeepingTicks,
+ long PostSetupAndOtherTicks,
+ long PostSunRaysTicks,
+ long PostFilmicTicks);
+
+internal readonly record struct RenderPackCpuStageDiagnostics(
+ string Stage,
+ int SampleCount,
+ double CpuMillisecondsP50,
+ double CpuMillisecondsP95,
+ double CpuMillisecondsP99);
+
+///
+/// Temporary Low-only structural profiler for the incremental CPU budget. It
+/// samples the same one-in-four frames as Low GPU timestamps, keeping fewer
+/// than half of the ordinary performance window instrumented while retaining
+/// enough observations for a short physical run. Every hot-path buffer is
+/// fixed at construction and observation is allocation-free.
+///
+internal sealed class AtmosphericCpuStageProfiler
+{
+ internal const int SampleIntervalFrames = AtmosphericGpuTimerSampling.LowIntervalFrames;
+
+ private static readonly string[] StageNames =
+ [
+ "target-preparation",
+ "shadow-caster-build",
+ "shadow-environment",
+ "shadow-prepared-draws-and-transforms",
+ "shadow-fit-and-uniform",
+ "shadow-layered-pass-recording",
+ "shadow-bookkeeping",
+ "post-setup-and-other",
+ "post-sun-rays",
+ "post-filmic",
+ "performance-observe-bookkeeping",
+ "measured-pack-total",
+ "measured-pack-unattributed",
+ ];
+
+ private readonly FrameStatsBuffer[] _microseconds;
+
+ internal AtmosphericCpuStageProfiler(int capacity = RenderPackPerformanceWindow.DefaultCapacity)
+ {
+ _microseconds = new FrameStatsBuffer[StageNames.Length];
+ for (int i = 0; i < _microseconds.Length; i++)
+ _microseconds[i] = new FrameStatsBuffer(capacity);
+ }
+
+ internal static bool ShouldMeasure(long frameSerial)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(frameSerial);
+ return frameSerial % SampleIntervalFrames == 0;
+ }
+
+ internal void Observe(
+ in AtmosphericCpuStageFrame frame,
+ long targetPreparationTicks,
+ long measuredPackTotalTicks,
+ long observeBookkeepingTicks)
+ {
+ if (frame.FrameSerial <= 0)
+ throw new ArgumentOutOfRangeException(nameof(frame));
+ ArgumentOutOfRangeException.ThrowIfNegative(targetPreparationTicks);
+ ArgumentOutOfRangeException.ThrowIfNegative(measuredPackTotalTicks);
+ ArgumentOutOfRangeException.ThrowIfNegative(observeBookkeepingTicks);
+
+ long attributedTicks = checked(
+ targetPreparationTicks
+ + frame.ShadowCasterBuildTicks
+ + frame.ShadowEnvironmentTicks
+ + frame.ShadowPreparedDrawsAndTransformsTicks
+ + frame.ShadowFitAndUniformTicks
+ + frame.ShadowLayeredPassRecordingTicks
+ + frame.ShadowBookkeepingTicks
+ + frame.PostSetupAndOtherTicks
+ + frame.PostSunRaysTicks
+ + frame.PostFilmicTicks);
+ long unattributedTicks = Math.Max(0L, measuredPackTotalTicks - attributedTicks);
+
+ Push(0, targetPreparationTicks);
+ Push(1, frame.ShadowCasterBuildTicks);
+ Push(2, frame.ShadowEnvironmentTicks);
+ Push(3, frame.ShadowPreparedDrawsAndTransformsTicks);
+ Push(4, frame.ShadowFitAndUniformTicks);
+ Push(5, frame.ShadowLayeredPassRecordingTicks);
+ Push(6, frame.ShadowBookkeepingTicks);
+ Push(7, frame.PostSetupAndOtherTicks);
+ Push(8, frame.PostSunRaysTicks);
+ Push(9, frame.PostFilmicTicks);
+ Push(10, observeBookkeepingTicks);
+ Push(11, measuredPackTotalTicks);
+ Push(12, unattributedTicks);
+ }
+
+ internal IReadOnlyList Snapshot()
+ {
+ var result = new RenderPackCpuStageDiagnostics[StageNames.Length];
+ for (int i = 0; i < result.Length; i++)
+ {
+ FrameStatsBuffer samples = _microseconds[i];
+ result[i] = new RenderPackCpuStageDiagnostics(
+ StageNames[i],
+ samples.Count,
+ samples.Percentile(0.50) / 1000d,
+ samples.Percentile(0.95) / 1000d,
+ samples.Percentile(0.99) / 1000d);
+ }
+ return result;
+ }
+
+ internal void Reset()
+ {
+ for (int i = 0; i < _microseconds.Length; i++)
+ _microseconds[i].Reset();
+ }
+
+ private void Push(int stage, long ticks)
+ {
+ long microseconds = checked((long)Math.Round(
+ ticks * 1_000_000d / Stopwatch.Frequency,
+ MidpointRounding.AwayFromZero));
+ _microseconds[stage].Push(microseconds);
+ }
+}
+
+///
+/// Optional production-frame seam. Only Low's built-in graph implements it;
+/// retail, Medium, High, and declared graphs never enter the profiling path.
+///
+internal interface IAtmosphericCpuStageProfileRuntime
+{
+ bool ShouldProfileCpuFrame(long frameSerial);
+
+ void CompleteCpuProfile(
+ long frameSerial,
+ long targetPreparationTicks,
+ long measuredPackTotalTicks,
+ long observeBookkeepingTicks,
+ bool stableFrameBoundary);
+}
diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs b/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs
new file mode 100644
index 00000000..e61ef0f7
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs
@@ -0,0 +1,229 @@
+using System.Numerics;
+using System.Runtime.InteropServices;
+using System.Runtime.CompilerServices;
+using AcDream.Core.World;
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.App.Rendering.Packs;
+
+///
+/// Immutable authored atmosphere and exact camera projection captured from the
+/// normal world frame. This value owns no gameplay or renderer objects and is
+/// valid after the wrapped world renderer returns.
+///
+internal readonly record struct AtmosphericFrameInputs(
+ Vector2 SunScreenUv,
+ bool SunIsOnScreen,
+ float SunElevationDegrees,
+ Vector3 SunColor,
+ Vector3 SunDirection,
+ float SunDirectionalBrightness,
+ Matrix4x4 InverseViewProjection,
+ int ActiveDayGroup,
+ WeatherKind Weather,
+ float WeatherIntensity,
+ double DeltaSeconds,
+ int ViewportWidth,
+ int ViewportHeight,
+ bool IsOutdoor);
+
+internal interface IAtmosphericWorldFrameSink
+{
+ void Publish(
+ in RenderFrameFoundation foundation,
+ in WorldRenderFrame world,
+ int activeDayGroup);
+}
+
+///
+/// One-frame handoff between , which owns the
+/// canonical camera build, and the post graph. Reset happens before the world
+/// pass so an intentionally skipped world can never reuse a prior camera.
+///
+internal sealed class AtmosphericFrameInputState : IAtmosphericWorldFrameSink
+{
+ private RenderFrameInput _host;
+ private RenderFrameFoundation _foundation;
+ private AtmosphericFrameInputs _current;
+ private bool _published;
+
+ internal void BeginFrame(
+ in RenderFrameInput host,
+ in RenderFrameFoundation foundation)
+ {
+ _host = host;
+ _foundation = foundation;
+ _current = default;
+ _published = false;
+ }
+
+ public void Publish(
+ in RenderFrameFoundation foundation,
+ in WorldRenderFrame world,
+ int activeDayGroup)
+ {
+ Vector3 direction = SkyStateProvider.SunDirectionFromKeyframe(foundation.Sky);
+ Vector3 sunPoint = world.Camera.Position + (direction * 10_000f);
+ Vector4 clip = Vector4.Transform(
+ new Vector4(sunPoint, 1f),
+ world.Camera.ViewProjection);
+ bool finite = float.IsFinite(clip.X)
+ && float.IsFinite(clip.Y)
+ && float.IsFinite(clip.W)
+ && clip.W > 1e-5f;
+ Vector2 uv = finite
+ ? new Vector2(
+ (clip.X / clip.W * 0.5f) + 0.5f,
+ 0.5f - (clip.Y / clip.W * 0.5f))
+ : new Vector2(-1f, -1f);
+ bool onScreen = finite
+ && uv.X >= 0f && uv.X <= 1f
+ && uv.Y >= 0f && uv.Y <= 1f;
+ Matrix4x4 inverseViewProjection = Matrix4x4.Invert(
+ world.Camera.ViewProjection,
+ out Matrix4x4 inverse)
+ ? inverse
+ : Matrix4x4.Identity;
+
+ _current = new AtmosphericFrameInputs(
+ uv,
+ onScreen,
+ foundation.Sky.SunPitchDeg,
+ foundation.Sky.SunColor,
+ direction,
+ foundation.Sky.DirBright,
+ inverseViewProjection,
+ activeDayGroup,
+ foundation.Atmosphere.Kind,
+ Math.Clamp(foundation.Atmosphere.Intensity, 0f, 1f),
+ _host.DeltaSeconds,
+ _host.ViewportWidth,
+ _host.ViewportHeight,
+ IsOutdoor: world.Roots.RenderSky && !world.Roots.CameraInsideCell);
+ _published = true;
+ }
+
+ internal AtmosphericFrameInputs Snapshot()
+ {
+ if (_published)
+ return _current;
+
+ // A portal/login frame deliberately skipped the normal world. Preserve
+ // its authored colour inputs but suppress every directional effect.
+ return new AtmosphericFrameInputs(
+ new Vector2(-1f, -1f),
+ SunIsOnScreen: false,
+ _foundation.Sky.SunPitchDeg,
+ _foundation.Sky.SunColor,
+ SkyStateProvider.SunDirectionFromKeyframe(_foundation.Sky),
+ _foundation.Sky.DirBright,
+ Matrix4x4.Identity,
+ -1,
+ _foundation.Atmosphere.Kind,
+ Math.Clamp(_foundation.Atmosphere.Intensity, 0f, 1f),
+ _host.DeltaSeconds,
+ _host.ViewportWidth,
+ _host.ViewportHeight,
+ IsOutdoor: false);
+ }
+}
+
+///
+/// Shader ABI SSOT for opt-in set 3 binding 5. Six std140 vec4 values followed by one
+/// mat4, 160 bytes.
+///
+[StructLayout(LayoutKind.Sequential, Pack = 4)]
+internal readonly struct AtmosphericFrameUniforms
+{
+ internal const int SizeInBytes = 160;
+
+ internal AtmosphericFrameUniforms(
+ Vector4 sunScreen,
+ Vector4 sunColor,
+ Vector4 viewport,
+ Vector4 weather,
+ Vector4 sunDirection,
+ Vector4 policy,
+ Matrix4x4 inverseViewProjection)
+ {
+ SunScreen = sunScreen;
+ SunColor = sunColor;
+ Viewport = viewport;
+ Weather = weather;
+ SunDirection = sunDirection;
+ Policy = policy;
+ InverseViewProjection = inverseViewProjection;
+ }
+
+ internal readonly Vector4 SunScreen;
+ internal readonly Vector4 SunColor;
+ internal readonly Vector4 Viewport;
+ internal readonly Vector4 Weather;
+ internal readonly Vector4 SunDirection;
+ internal readonly Vector4 Policy;
+ internal readonly Matrix4x4 InverseViewProjection;
+}
+
+///
+/// Shader ABI SSOT for opt-in set 3 binding 7. Passes assign meanings to four std140
+/// vec4 values without changing the shared descriptor layout.
+///
+[StructLayout(LayoutKind.Sequential, Pack = 4)]
+internal readonly struct AtmosphericPackPassUniforms
+{
+ internal const int SizeInBytes = 64;
+
+ internal AtmosphericPackPassUniforms(
+ Vector4 params0,
+ Vector4 params1,
+ Vector4 params2,
+ Vector4 params3)
+ {
+ Params0 = params0;
+ Params1 = params1;
+ Params2 = params2;
+ Params3 = params3;
+ }
+
+ internal readonly Vector4 Params0;
+ internal readonly Vector4 Params1;
+ internal readonly Vector4 Params2;
+ internal readonly Vector4 Params3;
+
+ internal static AtmosphericPackPassUniforms From(Vector4 params0) =>
+ new(params0, Vector4.Zero, Vector4.Zero, Vector4.Zero);
+}
+
+///
+/// Shader ABI SSOT for opt-in set 3 binding 8. API v1 exposes 64 scalar values in
+/// descriptor declaration order, physically grouped as sixteen std140 vec4s.
+///
+[InlineArray(RenderPackShaderAbi.PackSettingScalarCapacity)]
+internal struct PackSettingsUniforms
+{
+ internal const int SizeInBytes = RenderPackShaderAbi.PackSettingsSizeBytes;
+ private float _element0;
+
+ internal static PackSettingsUniforms Create(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary? userSettingOverrides = null)
+ {
+ var result = new PackSettingsUniforms();
+ int count = Math.Min(
+ descriptor.Settings.Count,
+ RenderPackShaderAbi.PackSettingScalarCapacity);
+ for (int i = 0; i < count; i++)
+ {
+ RenderSettingDeclaration setting = descriptor.Settings[i];
+ string value = RenderPackSettingResolution.Resolve(
+ setting,
+ preset,
+ userSettingOverrides);
+ result[i] = RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded)
+ ? encoded
+ : 0f;
+ }
+ return result;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericGpuTimerSampling.cs b/src/AcDream.App/Rendering/Packs/AtmosphericGpuTimerSampling.cs
new file mode 100644
index 00000000..aea19c4b
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/AtmosphericGpuTimerSampling.cs
@@ -0,0 +1,24 @@
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.App.Rendering.Packs;
+
+///
+/// Detailed per-pass GPU timestamps are diagnostic commands, not visual work.
+/// Low samples one complete frame in four so its tight median CPU budget is not
+/// dominated by instrumentation; sampled frames still include every receiver,
+/// shadow, and post-process scope and therefore preserve the inclusive GPU
+/// measurement contract. Medium and High retain continuous measurement.
+///
+internal static class AtmosphericGpuTimerSampling
+{
+ internal const int LowIntervalFrames = 4;
+
+ internal static bool ShouldMeasure(
+ RenderQualitySemantic quality,
+ long frameSerial)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(frameSerial);
+ return quality is not RenderQualitySemantic.Low
+ || frameSerial % LowIntervalFrames == 0;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs
new file mode 100644
index 00000000..1009ff77
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs
@@ -0,0 +1,1731 @@
+using System.Diagnostics;
+using System.Numerics;
+using System.Runtime.InteropServices;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Scene;
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.World;
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.App.Rendering.Packs;
+
+internal readonly record struct AtmosphericPostProcessSettings(
+ float BloomStrength,
+ float FilmicStrength,
+ float Exposure,
+ float Saturation,
+ float Contrast,
+ float VignetteStrength,
+ float SunRayStrength)
+{
+ internal static AtmosphericPostProcessSettings Neutral { get; } = new(
+ BloomStrength: 0f,
+ FilmicStrength: 0f,
+ Exposure: 1f,
+ Saturation: 1f,
+ Contrast: 1f,
+ VignetteStrength: 0f,
+ SunRayStrength: 0f);
+
+ internal static AtmosphericPostProcessSettings FromDescriptor(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary? userSettingOverrides = null)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(preset);
+ return new AtmosphericPostProcessSettings(
+ Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.BloomStrength, 0.65f),
+ Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.FilmicStrength, 1f),
+ Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.Exposure, 1f),
+ Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.GradeSaturation, 1f),
+ Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.GradeContrast, 1f),
+ Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.VignetteStrength, 0.12f),
+ Read(descriptor, preset, userSettingOverrides, RenderSettingSemantic.SunRayStrength, 0.55f));
+ }
+
+ private static float Read(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary? userSettingOverrides,
+ RenderSettingSemantic semantic,
+ float fallback)
+ {
+ RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(value =>
+ value.Semantic == semantic);
+ if (setting is null)
+ return fallback;
+ string value = RenderPackSettingResolution.Resolve(
+ setting,
+ preset,
+ userSettingOverrides);
+ return RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded)
+ ? encoded
+ : fallback;
+ }
+}
+
+internal interface IAtmosphericWorldGraphRuntime : IRenderPackRuntime
+{
+ IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount);
+
+ void RenderPostProcess(
+ IGpuFrame frame,
+ in AtmosphericFrameInputs inputs);
+}
+
+internal interface IDirectionalShadowWorldGraphRuntime :
+ IAtmosphericWorldGraphRuntime
+{
+ IDirectionalShadowReceiverSource DirectionalShadowReceivers { get; }
+
+ DirectionalSunShadowDiagnostics RenderDirectionalShadows(
+ IGpuFrame frame,
+ in RenderFrameFoundation foundation,
+ in WorldRenderFrame world,
+ int activeDayGroup,
+ in RenderSceneQuery scene,
+ WbDrawDispatcher worldMeshes,
+ TerrainModernRenderer terrain);
+}
+
+///
+/// Built-in Tier-1 graph. It owns every enhancement image, texture-table slot,
+/// fullscreen pipeline, and the temporary HDR variants of existing world
+/// pipelines. Retail selection constructs none of these objects.
+///
+internal sealed class AtmosphericPostProcessGraph :
+ IDirectionalShadowWorldGraphRuntime,
+ IRenderPackRuntimeDiagnosticsSource,
+ IRenderPackRuntimePerformanceSource,
+ IAtmosphericCpuStageProfileRuntime
+{
+ private readonly IGpuDevice _device;
+ private readonly IDisposable _hdrPipelineLease;
+ private readonly IGpuSampler _linearSampler;
+ private readonly IGpuSampler _nearestSampler;
+ private readonly IGpuPipeline _sunOcclusion;
+ private readonly IGpuPipeline _sunRays;
+ private readonly IGpuPipeline _bloomDownsample;
+ private readonly IGpuPipeline _bloomBlur;
+ private readonly IGpuPipeline _filmic;
+ private readonly AtmosphericPostProcessSettings _settings;
+ private readonly float _shadowStrength;
+ private readonly PackSettingsUniforms _packSettings;
+ private readonly DirectionalSunShadowRenderer _directionalShadows;
+ private readonly VolumetricShaftRenderer? _volumetric;
+ private readonly bool _fuseLowPostProcess;
+ private readonly AtmosphericCpuStageProfiler? _cpuStageProfiler;
+ private readonly DirectionalShadowCasterFrame _shadowCasters = new();
+ private TargetSet? _targets;
+ private AtmosphericFrameInputs _lastInputs;
+ private DirectionalSunShadowDiagnostics _lastShadowDiagnostics;
+ private int _lastShadowCasterCount;
+ private int _lastShadowClassificationCalls;
+ private long _lastShadowFrameSerial = -1;
+ private WbDrawDispatcher? _lastShadowWorldMeshes;
+ private AtmosphericCpuStageFrame _cpuStageFrame;
+ private long _residentGpuBudgetBytes;
+ private bool _renderedFrame;
+ private bool _disposed;
+
+ internal AtmosphericPostProcessGraph(
+ IGpuDevice device,
+ RenderPackDescriptor descriptor,
+ IRenderPackAssets assets,
+ RenderQualityPreset preset,
+ AtmosphericPostProcessSettings? settings = null,
+ IReadOnlyDictionary? userSettingOverrides = null)
+ : this(
+ device,
+ descriptor,
+ RenderPackShaderAssets.Validate(descriptor, assets),
+ preset,
+ settings,
+ userSettingOverrides)
+ {
+ }
+
+ internal AtmosphericPostProcessGraph(
+ IGpuDevice device,
+ RenderPackDescriptor descriptor,
+ ValidatedRenderPackShaderAssets assets,
+ RenderQualityPreset preset,
+ AtmosphericPostProcessSettings? settings = null,
+ IReadOnlyDictionary? userSettingOverrides = null)
+ {
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor));
+ ArgumentNullException.ThrowIfNull(assets);
+ Preset = preset ?? throw new ArgumentNullException(nameof(preset));
+ if (device is not IGpuPipelineFormatVariantHost variants)
+ {
+ throw new NotSupportedException(
+ "The active RHI cannot prebuild HDR variants of the normal world pipelines.");
+ }
+
+ IDisposable? lease = null;
+ DirectionalSunShadowRenderer? directionalShadows = null;
+ VolumetricShaftRenderer? volumetric = null;
+ var created = new List(capacity: 5);
+ try
+ {
+ lease = variants.AcquirePipelineColorFormat(
+ GpuTextureFormat.Rgba16FloatRenderTarget);
+ _linearSampler = device.CreateSampler(GpuSamplerDescription.WorldClamp);
+ _nearestSampler = device.CreateSampler(GpuSamplerDescription.UiNearest);
+ _sunOcclusion = CreatePipeline(
+ device,
+ "atmospheric-sun-occlusion",
+ ShaderSet(descriptor, assets, RenderPassSemantic.SunOcclusion),
+ GpuTextureFormat.Rgba8UnormRenderTarget);
+ created.Add(_sunOcclusion);
+ _sunRays = CreatePipeline(
+ device,
+ "atmospheric-sun-rays",
+ ShaderSet(descriptor, assets, RenderPassSemantic.SunRays),
+ GpuTextureFormat.Rgba16FloatRenderTarget);
+ created.Add(_sunRays);
+ _bloomDownsample = CreatePipeline(
+ device,
+ "atmospheric-bloom-downsample",
+ ShaderSet(descriptor, assets, RenderPassSemantic.BloomDownsample),
+ GpuTextureFormat.Rgba16FloatRenderTarget);
+ created.Add(_bloomDownsample);
+ _bloomBlur = CreatePipeline(
+ device,
+ "atmospheric-bloom-blur",
+ ShaderSet(descriptor, assets, RenderPassSemantic.BloomBlurHorizontal),
+ GpuTextureFormat.Rgba16FloatRenderTarget);
+ created.Add(_bloomBlur);
+ _filmic = CreatePipeline(
+ device,
+ "atmospheric-filmic",
+ ShaderSet(descriptor, assets, RenderPassSemantic.FilmicComposite),
+ GpuTextureFormat.Rgba8UnormRenderTarget);
+ created.Add(_filmic);
+ _settings = settings
+ ?? AtmosphericPostProcessSettings.FromDescriptor(
+ descriptor,
+ preset,
+ userSettingOverrides);
+ _packSettings = PackSettingsUniforms.Create(
+ descriptor,
+ preset,
+ userSettingOverrides);
+ _fuseLowPostProcess = (preset.ExecutionHints
+ & RenderQualityExecutionHints.FusedAtmosphericPostProcess) != 0;
+ _cpuStageProfiler = preset.Semantic is RenderQualitySemantic.Low
+ ? new AtmosphericCpuStageProfiler()
+ : null;
+ _shadowStrength = ReadSemanticSetting(
+ descriptor,
+ preset,
+ userSettingOverrides,
+ RenderSettingSemantic.DirectionalShadowStrength,
+ 0.72f);
+ directionalShadows = new DirectionalSunShadowRenderer(
+ device,
+ ResolveShadowQuality(
+ descriptor,
+ preset,
+ userSettingOverrides),
+ atmospherePolicy:
+ RenderPackAtmospherePolicyEvaluation.NeutralDirectionalShadowElevation,
+ pipelineShaders: LoadDirectionalShadowShaders(descriptor, assets),
+ multiviewCascades: (preset.ExecutionHints
+ & RenderQualityExecutionHints
+ .MultiviewDirectionalShadowCascades) != 0);
+ if (HasPass(descriptor, RenderPassSemantic.VolumetricShafts))
+ {
+ volumetric = new VolumetricShaftRenderer(
+ device,
+ descriptor,
+ assets,
+ preset,
+ userSettingOverrides);
+ }
+ _directionalShadows = directionalShadows;
+ directionalShadows = null;
+ _volumetric = volumetric;
+ volumetric = null;
+ _hdrPipelineLease = lease;
+ lease = null;
+ }
+ catch
+ {
+ volumetric?.Dispose();
+ directionalShadows?.Dispose();
+ for (int i = created.Count - 1; i >= 0; i--)
+ created[i].Dispose();
+ lease?.Dispose();
+ throw;
+ }
+ }
+
+ public RenderPackDescriptor Descriptor { get; }
+
+ public RenderQualityPreset Preset { get; }
+
+ internal int ResourceGeneration { get; private set; }
+
+ internal AtmosphericPostProcessSettings Settings => _settings;
+
+ internal VolumetricShaftQuality? VolumetricQuality => _volumetric?.Quality;
+
+ public IDirectionalShadowReceiverSource DirectionalShadowReceivers =>
+ _directionalShadows;
+
+ public DirectionalSunShadowDiagnostics RenderDirectionalShadows(
+ IGpuFrame frame,
+ in RenderFrameFoundation foundation,
+ in WorldRenderFrame world,
+ int activeDayGroup,
+ in RenderSceneQuery scene,
+ WbDrawDispatcher worldMeshes,
+ TerrainModernRenderer terrain)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ bool measureCpuStages = _cpuStageProfiler is not null
+ && AtmosphericCpuStageProfiler.ShouldMeasure(frame.Serial);
+ long stageStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+ _shadowCasters.Build(in scene);
+ long casterBuildFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+ AuthoredCelestialShadowSource source = world.CelestialShadowSource;
+ var environment = new DirectionalShadowEnvironmentInput(
+ PackEnabled: true,
+ PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
+ PlayerInsideCell: world.Roots.PlayerInsideCell
+ || world.Roots.CameraInsideCell,
+ source,
+ foundation.Atmosphere,
+ ActiveDayGroupMultiplier: Math.Clamp(
+ EvaluateDayGroupPolicy(activeDayGroup)
+ * RenderPackAtmospherePolicyEvaluation.DirectionalShadowFromSin(
+ Descriptor.AtmospherePolicy!
+ .DirectionalShadowLightElevationResponse,
+ source.ElevationSin)
+ * _shadowStrength,
+ 0f,
+ 1f));
+ var input = new DirectionalSunShadowRenderInput(
+ environment,
+ world.Camera.Camera.View,
+ world.Camera.Projection,
+ _shadowCasters,
+ ResidentMaximumReachMeters:
+ world.ResidentStreamingWindow.MaximumReachMeters,
+ MeasureGpuTimers: AtmosphericGpuTimerSampling.ShouldMeasure(
+ Preset.Semantic,
+ frame.Serial),
+ MeasureCpuStages: measureCpuStages);
+ long environmentFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+ _lastShadowCasterCount = _shadowCasters.Stats.Accepted;
+ _lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0;
+ _lastShadowDiagnostics = _directionalShadows.Render(
+ frame,
+ in input,
+ worldMeshes,
+ terrain);
+ _lastShadowWorldMeshes = worldMeshes;
+ if (measureCpuStages)
+ {
+ DirectionalSunShadowCpuStageTicks shadow = _lastShadowDiagnostics.CpuStages;
+ _cpuStageFrame = new AtmosphericCpuStageFrame(
+ frame.Serial,
+ casterBuildFinished - stageStarted,
+ checked(environmentFinished - casterBuildFinished
+ + shadow.EnvironmentGateTicks),
+ shadow.PreparedDrawsAndTransformsTicks,
+ shadow.FitAndUniformTicks,
+ shadow.LayeredPassRecordingTicks,
+ shadow.BookkeepingTicks,
+ 0L,
+ 0L,
+ 0L);
+ }
+ else
+ {
+ _cpuStageFrame = default;
+ }
+ RequireRetainedGpuBudget();
+ _lastShadowFrameSerial = frame.Serial;
+ return _lastShadowDiagnostics;
+ }
+
+ public IGpuRenderTarget PrepareWorldTarget(
+ int width,
+ int height,
+ int sampleCount)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleCount);
+ if (_targets is { } current
+ && current.Width == width
+ && current.Height == height
+ && current.SampleCount == sampleCount)
+ return current.World;
+
+ RenderPackHostCapabilities capabilities =
+ RenderPackCapabilityResolver.Resolve(_device.Capabilities);
+ RenderPackResourceBudgetPlanner.RequireWithinHost(
+ Descriptor,
+ Preset,
+ width,
+ height,
+ sampleCount,
+ capabilities);
+ TargetSet candidate = TargetSet.Create(
+ _device,
+ width,
+ height,
+ sampleCount,
+ PostScale(Descriptor, Preset),
+ RayScale(Descriptor, Preset),
+ allocateBloomIntermediates: !_fuseLowPostProcess,
+ _linearSampler,
+ _nearestSampler);
+ try
+ {
+ _volumetric?.PrepareTarget(width, height);
+ }
+ catch
+ {
+ candidate.Dispose();
+ throw;
+ }
+ TargetSet? previous = _targets;
+ _targets = candidate;
+ _residentGpuBudgetBytes = Math.Min(
+ Preset.MaxResidentGpuBytes,
+ capabilities.MaxPackResidentBytes);
+ ResourceGeneration = checked(ResourceGeneration + 1);
+ _cpuStageProfiler?.Reset();
+ previous?.Dispose();
+ return candidate.World;
+ }
+
+ public void RenderPostProcess(
+ IGpuFrame frame,
+ in AtmosphericFrameInputs inputs)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(frame);
+ TargetSet targets = _targets
+ ?? throw new InvalidOperationException(
+ "PrepareWorldTarget must succeed before post-processing begins.");
+ if (inputs.ViewportWidth != targets.Width
+ || inputs.ViewportHeight != targets.Height)
+ {
+ throw new InvalidOperationException(
+ "Atmospheric inputs and target extent belong to different frames.");
+ }
+ if (_lastShadowFrameSerial != frame.Serial)
+ {
+ _lastShadowDiagnostics = default;
+ _lastShadowCasterCount = 0;
+ _lastShadowClassificationCalls = 0;
+ }
+
+ bool measureCpuStages = _cpuStageProfiler is not null
+ && _cpuStageFrame.FrameSerial == frame.Serial;
+ long postStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+
+ float sunPolicy = EvaluateSunPolicy(inputs);
+ float elevationPolicy = EvaluateSunElevationPolicy(inputs.SunElevationDegrees);
+ float dayGroupPolicy = EvaluateDayGroupPolicy(inputs.ActiveDayGroup);
+ float shadowElevationPolicy = RenderPackAtmospherePolicyEvaluation
+ .DirectionalShadow(
+ Descriptor.AtmospherePolicy?.DirectionalShadowLightElevationResponse,
+ inputs.SunElevationDegrees,
+ elevationPolicy);
+ float volumetricElevationPolicy = RenderPackAtmospherePolicyEvaluation
+ .VolumetricShaft(
+ Descriptor.AtmospherePolicy?.VolumetricShaftSunElevationResponse,
+ inputs.SunElevationDegrees);
+ float rayStrength = inputs.SunIsOnScreen && inputs.IsOutdoor
+ ? Math.Clamp(_settings.SunRayStrength * sunPolicy, 0f, 4f)
+ : 0f;
+ var frameUniforms = new AtmosphericFrameUniforms(
+ new Vector4(
+ inputs.SunScreenUv,
+ rayStrength,
+ inputs.SunElevationDegrees),
+ new Vector4(inputs.SunColor, sunPolicy),
+ new Vector4(
+ targets.Width,
+ targets.Height,
+ 1f / targets.Width,
+ 1f / targets.Height),
+ new Vector4(
+ (float)inputs.Weather,
+ inputs.WeatherIntensity,
+ (float)Math.Clamp(inputs.DeltaSeconds, 0d, 1d),
+ inputs.IsOutdoor ? 1f : 0f),
+ new Vector4(inputs.SunDirection, inputs.SunDirectionalBrightness),
+ new Vector4(
+ inputs.ActiveDayGroup,
+ dayGroupPolicy,
+ shadowElevationPolicy,
+ volumetricElevationPolicy),
+ inputs.InverseViewProjection);
+ GpuRingAllocation frameBlock;
+ GpuRingAllocation settingsBlock;
+ GpuRingAllocation fusedSunPassBlock = default;
+ GpuRingAllocation fusedFilmicPassBlock = default;
+ if (_fuseLowPostProcess)
+ {
+ int alignment = checked((int)Math.Max(
+ 1u,
+ _device.Capabilities.MinUniformBufferOffsetAlignment));
+ int settingsOffset = AlignUp(
+ AtmosphericFrameUniforms.SizeInBytes,
+ alignment);
+ int sunPassOffset = AlignUp(
+ checked(settingsOffset + PackSettingsUniforms.SizeInBytes),
+ alignment);
+ int filmicPassOffset = AlignUp(
+ checked(sunPassOffset + AtmosphericPackPassUniforms.SizeInBytes),
+ alignment);
+ GpuRingAllocation uniforms = frame.AllocateRing(
+ checked(filmicPassOffset + AtmosphericPackPassUniforms.SizeInBytes),
+ GpuRingUsage.Uniform);
+ frameBlock = Slice(
+ uniforms,
+ offsetBytes: 0,
+ AtmosphericFrameUniforms.SizeInBytes);
+ settingsBlock = Slice(
+ uniforms,
+ settingsOffset,
+ PackSettingsUniforms.SizeInBytes);
+ fusedSunPassBlock = Slice(
+ uniforms,
+ sunPassOffset,
+ AtmosphericPackPassUniforms.SizeInBytes);
+ fusedFilmicPassBlock = Slice(
+ uniforms,
+ filmicPassOffset,
+ AtmosphericPackPassUniforms.SizeInBytes);
+ }
+ else
+ {
+ frameBlock = frame.AllocateRing(
+ AtmosphericFrameUniforms.SizeInBytes,
+ GpuRingUsage.Uniform);
+ settingsBlock = frame.AllocateRing(
+ PackSettingsUniforms.SizeInBytes,
+ GpuRingUsage.Uniform);
+ }
+ MemoryMarshal.Write(frameBlock.Data, in frameUniforms);
+ PackSettingsUniforms packSettings = _packSettings;
+ MemoryMarshal.Write(settingsBlock.Data, in packSettings);
+ bool measureGpuTimers = AtmosphericGpuTimerSampling.ShouldMeasure(
+ Preset.Semantic,
+ frame.Serial);
+
+ long sunRaysStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+ if (!_fuseLowPostProcess)
+ {
+ DrawFullscreen(
+ frame,
+ "atmospheric-sun-occlusion",
+ targets.SunMask,
+ _sunOcclusion,
+ targets.WorldDepthSlot,
+ GpuTextureSlot.Unassigned,
+ AtmosphericPackPassUniforms.From(Vector4.Zero),
+ frameBlock,
+ settingsBlock,
+ GpuTextureSlot.Unassigned,
+ GpuTextureSlot.Unassigned,
+ measureGpuTimers);
+ }
+ var sunPassUniforms = new AtmosphericPackPassUniforms(
+ new Vector4(0.965f, 0.24f, 0.82f, 48f),
+ _fuseLowPostProcess
+ ? new Vector4(
+ 1f,
+ targets.SunRays.Description.Width,
+ targets.SunRays.Description.Height,
+ 0f)
+ : Vector4.Zero,
+ Vector4.Zero,
+ Vector4.Zero);
+ if (_fuseLowPostProcess)
+ {
+ DrawFullscreenPrepared(
+ frame,
+ "atmospheric-sun-rays",
+ targets.SunRays,
+ _sunRays,
+ targets.WorldDepthSlot,
+ GpuTextureSlot.Unassigned,
+ in sunPassUniforms,
+ frameBlock,
+ settingsBlock,
+ fusedSunPassBlock,
+ GpuTextureSlot.Unassigned,
+ GpuTextureSlot.Unassigned,
+ measureGpuTimers);
+ }
+ else
+ {
+ DrawFullscreen(
+ frame,
+ "atmospheric-sun-rays",
+ targets.SunRays,
+ _sunRays,
+ targets.SunMaskSlot,
+ GpuTextureSlot.Unassigned,
+ in sunPassUniforms,
+ frameBlock,
+ settingsBlock,
+ GpuTextureSlot.Unassigned,
+ GpuTextureSlot.Unassigned,
+ measureGpuTimers);
+ }
+ long sunRaysFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+ DirectionalShadowFrameBinding shadowBinding =
+ _directionalShadows.TryGetCurrentFrameBinding(frame, out var currentShadow)
+ ? currentShadow
+ : DirectionalShadowFrameBinding.Disabled;
+ VolumetricShaftOutput volumetric = _volumetric is null
+ ? default
+ : _volumetric.Render(
+ frame,
+ in inputs,
+ in shadowBinding,
+ targets.WorldDepthSlot);
+ if (!_fuseLowPostProcess)
+ {
+ DrawFullscreen(
+ frame,
+ "atmospheric-bloom-downsample",
+ targets.BloomA,
+ _bloomDownsample,
+ targets.WorldColorSlot,
+ targets.SunRaysSlot,
+ AtmosphericPackPassUniforms.From(new Vector4(
+ _settings.BloomStrength,
+ 1f,
+ 0.45f,
+ volumetric.HasTexture ? 1f : 0f)),
+ frameBlock,
+ settingsBlock,
+ volumetric.TextureSlot,
+ GpuTextureSlot.Unassigned,
+ measureGpuTimers);
+ DrawFullscreen(
+ frame,
+ "atmospheric-bloom-blur-horizontal",
+ targets.BloomB,
+ _bloomBlur,
+ targets.BloomASlot,
+ GpuTextureSlot.Unassigned,
+ AtmosphericPackPassUniforms.From(new Vector4(
+ 1f / targets.BloomA.Description.Width,
+ 0f,
+ 0f,
+ 0f)),
+ frameBlock,
+ settingsBlock,
+ GpuTextureSlot.Unassigned,
+ GpuTextureSlot.Unassigned,
+ measureGpuTimers);
+ DrawFullscreen(
+ frame,
+ "atmospheric-bloom-blur-vertical",
+ targets.BloomA,
+ _bloomBlur,
+ targets.BloomBSlot,
+ GpuTextureSlot.Unassigned,
+ AtmosphericPackPassUniforms.From(new Vector4(
+ 0f,
+ 1f / targets.BloomA.Description.Height,
+ 0f,
+ 0f)),
+ frameBlock,
+ settingsBlock,
+ GpuTextureSlot.Unassigned,
+ GpuTextureSlot.Unassigned,
+ measureGpuTimers);
+ }
+ var filmicPassUniforms = new AtmosphericPackPassUniforms(
+ new Vector4(
+ _settings.Exposure,
+ _settings.Saturation,
+ _settings.Contrast,
+ _settings.VignetteStrength),
+ new Vector4(
+ _settings.FilmicStrength,
+ volumetric.HasTexture ? 1f : 0f,
+ _fuseLowPostProcess ? 1f : 0f,
+ 0f),
+ _fuseLowPostProcess
+ ? new Vector4(
+ _settings.BloomStrength,
+ 1f,
+ 0.45f,
+ volumetric.HasTexture ? 1f : 0f)
+ : Vector4.Zero,
+ _fuseLowPostProcess
+ ? new Vector4(
+ 1f / targets.PostWidth,
+ 1f / targets.PostHeight,
+ 0f,
+ 0f)
+ : Vector4.Zero);
+ long filmicStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+ if (_fuseLowPostProcess)
+ {
+ DrawFullscreenPrepared(
+ frame,
+ "atmospheric-filmic",
+ target: null,
+ _filmic,
+ targets.WorldColorSlot,
+ targets.SunRaysSlot,
+ in filmicPassUniforms,
+ frameBlock,
+ settingsBlock,
+ fusedFilmicPassBlock,
+ volumetric.TextureSlot,
+ GpuTextureSlot.Unassigned,
+ measureGpuTimers);
+ }
+ else
+ {
+ DrawFullscreen(
+ frame,
+ "atmospheric-filmic",
+ target: null,
+ _filmic,
+ targets.WorldColorSlot,
+ targets.BloomASlot,
+ in filmicPassUniforms,
+ frameBlock,
+ settingsBlock,
+ targets.SunRaysSlot,
+ volumetric.TextureSlot,
+ measureGpuTimers);
+ }
+ long filmicFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+ if (measureCpuStages)
+ {
+ _cpuStageFrame = _cpuStageFrame with
+ {
+ PostSetupAndOtherTicks = checked(
+ sunRaysStarted - postStarted
+ + filmicStarted - sunRaysFinished),
+ PostSunRaysTicks = sunRaysFinished - sunRaysStarted,
+ PostFilmicTicks = filmicFinished - filmicStarted,
+ };
+ }
+ _lastInputs = inputs;
+ _renderedFrame = true;
+ }
+
+ bool IAtmosphericCpuStageProfileRuntime.ShouldProfileCpuFrame(long frameSerial) =>
+ _cpuStageProfiler is not null
+ && AtmosphericCpuStageProfiler.ShouldMeasure(frameSerial);
+
+ void IAtmosphericCpuStageProfileRuntime.CompleteCpuProfile(
+ long frameSerial,
+ long targetPreparationTicks,
+ long measuredPackTotalTicks,
+ long observeBookkeepingTicks,
+ bool stableFrameBoundary)
+ {
+ if (!stableFrameBoundary
+ || _cpuStageProfiler is null
+ || _cpuStageFrame.FrameSerial != frameSerial)
+ {
+ return;
+ }
+
+ _cpuStageProfiler.Observe(
+ in _cpuStageFrame,
+ targetPreparationTicks,
+ measuredPackTotalTicks,
+ observeBookkeepingTicks);
+ }
+
+ public RenderPackRuntimeDiagnostics CaptureDiagnostics()
+ {
+ TargetSet? targets = _targets;
+ if (!_renderedFrame || targets is null)
+ return RenderPackRuntimeDiagnostics.Empty(Preset.Id);
+ VolumetricShaftDiagnostics volumetric = _volumetric?.LastDiagnostics ?? default;
+ (string Name, int DrawCalls)[] postPasses =
+ (_fuseLowPostProcess, _volumetric is null) switch
+ {
+ (true, true) =>
+ [
+ ("atmospheric-sun-rays", 1),
+ ("atmospheric-filmic", 1),
+ ],
+ (true, false) =>
+ [
+ ("atmospheric-sun-rays", 1),
+ (VolumetricShaftRenderer.TimerName, volumetric.DrawCalls),
+ ("atmospheric-filmic", 1),
+ ],
+ (false, true) =>
+ [
+ ("atmospheric-sun-occlusion", 1),
+ ("atmospheric-sun-rays", 1),
+ ("atmospheric-bloom-downsample", 1),
+ ("atmospheric-bloom-blur-horizontal", 1),
+ ("atmospheric-bloom-blur-vertical", 1),
+ ("atmospheric-filmic", 1),
+ ],
+ _ =>
+ [
+ ("atmospheric-sun-occlusion", 1),
+ ("atmospheric-sun-rays", 1),
+ (VolumetricShaftRenderer.TimerName, volumetric.DrawCalls),
+ ("atmospheric-bloom-downsample", 1),
+ ("atmospheric-bloom-blur-horizontal", 1),
+ ("atmospheric-bloom-blur-vertical", 1),
+ ("atmospheric-filmic", 1),
+ ],
+ };
+ int shadowPassCount = _directionalShadows.MultiviewCascadesEnabled
+ && _lastShadowDiagnostics.CascadeCount > 0
+ ? 1
+ : _lastShadowDiagnostics.CascadeCount;
+ const int receiverPassCount = 1;
+ var passes = new RenderPackPassDiagnostics[
+ receiverPassCount + postPasses.Length + shadowPassCount];
+ _device.Timers.TryResolve(
+ RenderPackPerformanceScopeNames.EnhancedWorldReceiver,
+ out double receiverMilliseconds);
+ passes[0] = new RenderPackPassDiagnostics(
+ RenderPackPerformanceScopeNames.EnhancedWorldReceiver,
+ receiverMilliseconds,
+ DrawCalls: 0,
+ DispatchCalls: 0);
+ for (int i = 0; i < shadowPassCount; i++)
+ {
+ string name = _directionalShadows.MultiviewCascadesEnabled
+ ? DirectionalSunShadowRenderer.MultiviewTimerName
+ : DirectionalSunShadowRenderer.TimerName(i);
+ _device.Timers.TryResolve(name, out double milliseconds);
+ passes[receiverPassCount + i] = new RenderPackPassDiagnostics(
+ name,
+ milliseconds,
+ DrawCalls: shadowPassCount == 0
+ ? 0
+ : _lastShadowDiagnostics.DrawCalls / shadowPassCount,
+ DispatchCalls: 0);
+ }
+ for (int i = 0; i < postPasses.Length; i++)
+ {
+ (string name, int drawCalls) = postPasses[i];
+ _device.Timers.TryResolve(name, out double milliseconds);
+ passes[receiverPassCount + shadowPassCount + i] = new RenderPackPassDiagnostics(
+ name,
+ milliseconds,
+ drawCalls,
+ DispatchCalls: 0);
+ }
+ return new RenderPackRuntimeDiagnostics(
+ Preset.Id,
+ checked(
+ targets.RetainedBytes
+ + _directionalShadows.Quality.ApproximateDepthMapBytes
+ + volumetric.RetainedGpuBytes
+ + _directionalShadows.RetainedGpuBufferBytes),
+ targets.TransientBytes,
+ targets.ImageCount + 1 + (volumetric.RetainedGpuBytes > 0 ? 1 : 0),
+ BufferCount: _directionalShadows.RetainedGpuBufferCount,
+ DrawCalls: postPasses.Sum(pass => pass.DrawCalls)
+ + _lastShadowDiagnostics.DrawCalls,
+ DispatchCalls: 0,
+ ShadowCasterCount: _lastShadowCasterCount,
+ CascadeDrawCount: _lastShadowDiagnostics.CascadeCount,
+ CpuClassificationCalls: _lastShadowClassificationCalls,
+ _lastInputs.SunElevationDegrees,
+ ActiveDayGroup: _lastInputs.ActiveDayGroup,
+ _lastInputs.Weather.ToString(),
+ _lastInputs.WeatherIntensity,
+ _lastInputs.IsOutdoor,
+ DirectionalShadowStrength: _lastShadowDiagnostics.Strength,
+ passes)
+ {
+ CpuStages = _cpuStageProfiler?.Snapshot() ?? [],
+ DirectionalShadowSourceKind = _lastShadowDiagnostics.SourceKind,
+ DirectionalShadowSourceObjectIndex =
+ _lastShadowDiagnostics.SourceObjectIndex,
+ DirectionalShadowSourceGfxObjId =
+ _lastShadowDiagnostics.SourceGfxObjId,
+ DirectionalShadowSurfaceToLightDirection =
+ _lastShadowDiagnostics.SurfaceToLightDirection,
+ DirectionalShadowLightElevationSin =
+ _lastShadowDiagnostics.LightElevationSin,
+ ShadowTransformChurn = _lastShadowDiagnostics.TransformChurn,
+ SharedWorldTransformUsedInstances =
+ _lastShadowWorldMeshes is not null
+ && _lastShadowWorldMeshes.HasDirectionalShadowTransformFrame(
+ _lastShadowFrameSerial)
+ ? _lastShadowWorldMeshes
+ .DirectionalShadowTransformFrameUsedInstances
+ : 0u,
+ };
+ }
+
+ public RenderPackRuntimePerformanceMetrics CapturePerformanceMetrics()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ TargetSet? targets = _targets;
+ if (targets is null)
+ {
+ return new RenderPackRuntimePerformanceMetrics(
+ ResourceGeneration,
+ HasResolvedGpuMeasurement: false,
+ InclusiveResolvedGpuMilliseconds: 0d,
+ RetainedGpuBytes: _directionalShadows.Quality.ApproximateDepthMapBytes,
+ TransientGpuBytes: 0L);
+ }
+
+ double gpuMilliseconds = 0d;
+ bool resolved = true;
+ resolved &= TryAddResolvedTimer(
+ RenderPackPerformanceScopeNames.EnhancedWorldReceiver,
+ ref gpuMilliseconds);
+ if (!_fuseLowPostProcess)
+ {
+ resolved &= TryAddResolvedTimer(
+ "atmospheric-sun-occlusion",
+ ref gpuMilliseconds);
+ }
+ resolved &= TryAddResolvedTimer("atmospheric-sun-rays", ref gpuMilliseconds);
+ if (!_fuseLowPostProcess)
+ {
+ resolved &= TryAddResolvedTimer(
+ "atmospheric-bloom-downsample",
+ ref gpuMilliseconds);
+ resolved &= TryAddResolvedTimer(
+ "atmospheric-bloom-blur-horizontal",
+ ref gpuMilliseconds);
+ resolved &= TryAddResolvedTimer(
+ "atmospheric-bloom-blur-vertical",
+ ref gpuMilliseconds);
+ }
+ resolved &= TryAddResolvedTimer("atmospheric-filmic", ref gpuMilliseconds);
+ int shadowTimerCount = _directionalShadows.MultiviewCascadesEnabled
+ && _lastShadowDiagnostics.CascadeCount > 0
+ ? 1
+ : _lastShadowDiagnostics.CascadeCount;
+ for (int i = 0; i < shadowTimerCount; i++)
+ {
+ resolved &= TryAddResolvedTimer(
+ _directionalShadows.MultiviewCascadesEnabled
+ ? DirectionalSunShadowRenderer.MultiviewTimerName
+ : DirectionalSunShadowRenderer.TimerName(i),
+ ref gpuMilliseconds);
+ }
+
+ VolumetricShaftDiagnostics volumetric = _volumetric?.LastDiagnostics ?? default;
+ if (volumetric.DrawCalls > 0)
+ {
+ resolved &= TryAddResolvedTimer(
+ VolumetricShaftRenderer.TimerName,
+ ref gpuMilliseconds);
+ }
+
+ return new RenderPackRuntimePerformanceMetrics(
+ ResourceGeneration,
+ resolved,
+ resolved ? gpuMilliseconds : 0d,
+ checked(
+ targets.RetainedBytes
+ + _directionalShadows.Quality.ApproximateDepthMapBytes
+ + volumetric.RetainedGpuBytes
+ + _directionalShadows.RetainedGpuBufferBytes),
+ targets.TransientBytes);
+ }
+
+ private void RequireRetainedGpuBudget()
+ {
+ TargetSet? targets = _targets;
+ if (targets is null)
+ return;
+ long total = checked(
+ targets.RetainedBytes
+ + _directionalShadows.Quality.ApproximateDepthMapBytes
+ + (_volumetric?.LastDiagnostics.RetainedGpuBytes ?? 0L)
+ + _directionalShadows.RetainedGpuBufferBytes);
+ if (total <= _residentGpuBudgetBytes)
+ return;
+ throw new NotSupportedException(
+ $"Render pack preset '{Preset.Id}' needs {total} resident GPU bytes "
+ + "after materializing its scene-dependent shadow command buffers; "
+ + $"the active pack budget is {_residentGpuBudgetBytes} bytes.");
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ _targets?.Dispose();
+ _targets = null;
+ _volumetric?.Dispose();
+ _directionalShadows.Dispose();
+ _filmic.Dispose();
+ _bloomBlur.Dispose();
+ _bloomDownsample.Dispose();
+ _sunRays.Dispose();
+ _sunOcclusion.Dispose();
+ _hdrPipelineLease.Dispose();
+ }
+
+ internal float EvaluateSunPolicy(in AtmosphericFrameInputs inputs)
+ {
+ if (!inputs.IsOutdoor || !inputs.SunIsOnScreen)
+ return 0f;
+ return Math.Clamp(
+ EvaluateSunElevationPolicy(inputs.SunElevationDegrees)
+ * EvaluateDayGroupPolicy(inputs.ActiveDayGroup)
+ * EvaluateWeatherPolicy(inputs.Weather, inputs.WeatherIntensity),
+ 0f,
+ 4f);
+ }
+
+ internal float EvaluateDirectionalShadowStrength(
+ float sunElevationDegrees,
+ int activeDayGroup) => Math.Clamp(
+ RenderPackAtmospherePolicyEvaluation.DirectionalShadow(
+ Descriptor.AtmospherePolicy?.DirectionalShadowLightElevationResponse,
+ sunElevationDegrees)
+ * EvaluateDayGroupPolicy(activeDayGroup)
+ * _shadowStrength,
+ 0f,
+ 1f);
+
+ private static float EvaluateWeatherPolicy(
+ AcDream.Core.World.WeatherKind weather,
+ float intensity)
+ {
+ float weatherTarget = weather switch
+ {
+ AcDream.Core.World.WeatherKind.Clear => 1f,
+ AcDream.Core.World.WeatherKind.Overcast => 0.18f,
+ AcDream.Core.World.WeatherKind.Rain => 0.10f,
+ AcDream.Core.World.WeatherKind.Snow => 0.16f,
+ AcDream.Core.World.WeatherKind.Storm => 0.06f,
+ _ => 0f,
+ };
+ return 1f + ((weatherTarget - 1f) * Math.Clamp(intensity, 0f, 1f));
+ }
+
+ private bool TryAddResolvedTimer(string name, ref double total)
+ {
+ if (!_device.Timers.TryTakeResolved(name, out double milliseconds))
+ return false;
+ total += milliseconds;
+ return true;
+ }
+
+ private float EvaluateSunElevationPolicy(float elevation)
+ {
+ IReadOnlyList? points =
+ Descriptor.AtmospherePolicy?.SunElevationResponse;
+ return RenderPackAtmospherePolicyEvaluation.Ray(points, elevation);
+ }
+
+ private float EvaluateDayGroupPolicy(int activeDayGroup)
+ {
+ ActiveDayGroupMultiplier? value = Descriptor.AtmospherePolicy?
+ .ActiveDayGroupMultipliers
+ .FirstOrDefault(entry => entry.ActiveDayGroup == activeDayGroup);
+ return value is null ? 1f : (float)value.Multiplier;
+ }
+
+ private static IGpuPipeline CreatePipeline(
+ IGpuDevice device,
+ string name,
+ GpuShaderSet shaders,
+ GpuTextureFormat colorFormat) =>
+ device.CreatePipeline(new GpuPipelineDescription
+ {
+ Name = name,
+ Shaders = shaders,
+ VertexLayout = GpuVertexLayout.None,
+ Blend = GpuBlendMode.None,
+ Depth = GpuDepthState.Disabled,
+ Cull = GpuCullMode.None,
+ ColorFormat = colorFormat,
+ AllowColorFormatVariants = false,
+ SampleCount = 1,
+ UsesRenderPackShaderAbi = true,
+ });
+
+ private static bool HasPass(
+ RenderPackDescriptor descriptor,
+ RenderPassSemantic semantic) =>
+ descriptor.Passes.Any(pass => pass.Semantic == semantic);
+
+ private static GpuShaderSet ShaderSet(
+ RenderPackDescriptor descriptor,
+ ValidatedRenderPackShaderAssets assets,
+ RenderPassSemantic semantic)
+ {
+ RenderPassDeclaration pass = descriptor.Passes.FirstOrDefault(value =>
+ value.Semantic == semantic)
+ ?? throw new InvalidOperationException(
+ $"Atmospheric graph requires declared pass semantic '{semantic}'.");
+ return RenderPackShaderAssets.LoadPass(descriptor, assets, pass);
+ }
+
+ private static DirectionalShadowPipelineShaders LoadDirectionalShadowShaders(
+ RenderPackDescriptor descriptor,
+ ValidatedRenderPackShaderAssets assets)
+ {
+ DirectionalShadowPipelineShaders shaders = new(
+ Variant(RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster),
+ Variant(RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster),
+ Variant(RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster),
+ Variant(RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver),
+ Variant(RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver));
+ if (descriptor.PipelineVariants.Any(value =>
+ value.Semantic == RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster))
+ {
+ shaders = shaders with
+ {
+ MultiviewCasters = new DirectionalShadowMultiviewPipelineShaders(
+ Variant(RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster),
+ Variant(RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster),
+ Variant(RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster)),
+ };
+ }
+ return shaders;
+
+ GpuShaderSet Variant(RenderPipelineVariantSemantic semantic)
+ {
+ PipelineVariantDeclaration variant = descriptor.PipelineVariants
+ .FirstOrDefault(value => value.Semantic == semantic)
+ ?? throw new InvalidOperationException(
+ $"Atmospheric graph requires declared pipeline-variant semantic '{semantic}'.");
+ return RenderPackShaderAssets.LoadVariant(descriptor, assets, variant);
+ }
+ }
+
+ private static DirectionalShadowPreset ShadowPreset(
+ RenderQualityPreset preset) => preset.Semantic switch
+ {
+ RenderQualitySemantic.Low => DirectionalShadowPreset.Low,
+ RenderQualitySemantic.High => DirectionalShadowPreset.High,
+ _ => DirectionalShadowPreset.Medium,
+ };
+
+ private static DirectionalShadowQuality ResolveShadowQuality(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary? userSettingOverrides)
+ {
+ DirectionalShadowQuality quality = DirectionalShadowQuality.For(
+ ShadowPreset(preset));
+ RenderResourceDeclaration resource = descriptor.Resources.Single(value =>
+ value.Semantic == RenderResourceSemantic.DirectionalShadowDepth);
+ RenderQualityResourceOverride? resourceOverride = preset.ResourceOverrides
+ .FirstOrDefault(value => string.Equals(
+ value.ResourceId,
+ resource.Id,
+ StringComparison.OrdinalIgnoreCase));
+ RenderExtentDeclaration extent = resourceOverride?.Extent
+ ?? resource.Extent
+ ?? throw new NotSupportedException(
+ "The DirectionalShadowDepth semantic resource has no image extent.");
+ if (extent.Mode != RenderExtentMode.AbsolutePixels
+ || extent.Width != extent.Height
+ || extent.Width != Math.Truncate(extent.Width)
+ || extent.Width is < 1 or > 16_384
+ || extent.Layers is < 1 or > 4)
+ {
+ throw new NotSupportedException(
+ "The DirectionalShadowDepth semantic resource must be a square "
+ + "absolute 1..16384 image with 1..4 array layers.");
+ }
+
+ float reach = ReadSemanticSetting(
+ descriptor,
+ preset,
+ userSettingOverrides,
+ RenderSettingSemantic.DirectionalShadowReachMetres,
+ quality.MaximumReachMeters);
+ int taps = ReadShadowPcfTaps(
+ descriptor,
+ preset,
+ userSettingOverrides,
+ quality.PcfRadiusTexels switch
+ {
+ 0 => 1,
+ 1 => 9,
+ _ => 25,
+ });
+ int radius = taps switch
+ {
+ 1 => 0,
+ 9 => 1,
+ 25 => 2,
+ _ => throw new NotSupportedException(
+ "DirectionalShadowPcfTaps must resolve to exactly 1, 9, or 25 samples."),
+ };
+ int resolution = checked((int)extent.Width);
+ int cascades = extent.Layers;
+ return quality with
+ {
+ CascadeCount = cascades,
+ MapResolution = resolution,
+ MaximumReachMeters = Math.Clamp(reach, 1f, 10_000f),
+ PcfRadiusTexels = radius,
+ ApproximateDepthMapBytes = checked(
+ (long)cascades * resolution * resolution * sizeof(float)),
+ IncrementalGpuP50BudgetMilliseconds = preset.MaxIncrementalGpuMillisecondsP50,
+ IncrementalGpuP99BudgetMilliseconds = preset.MaxIncrementalGpuMillisecondsP99,
+ IncrementalCpuP50BudgetMilliseconds = preset.MaxIncrementalCpuMillisecondsP50,
+ IncrementalCpuP99BudgetMilliseconds = preset.MaxIncrementalCpuMillisecondsP99,
+ PackResidentGpuByteBudget = preset.MaxResidentGpuBytes,
+ };
+ }
+
+ private static int ReadShadowPcfTaps(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary? userSettingOverrides,
+ int fallback)
+ {
+ RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(value =>
+ value.Semantic == RenderSettingSemantic.DirectionalShadowPcfTaps);
+ if (setting is null)
+ return fallback;
+
+ string value = RenderPackSettingResolution.Resolve(
+ setting,
+ preset,
+ userSettingOverrides);
+ return int.TryParse(
+ value,
+ System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out int taps)
+ ? taps
+ : throw new NotSupportedException(
+ "DirectionalShadowPcfTaps must resolve to an integer sample count.");
+ }
+
+ private static float ReadSemanticSetting(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary? userSettingOverrides,
+ RenderSettingSemantic semantic,
+ float fallback)
+ {
+ RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(value =>
+ value.Semantic == semantic);
+ if (setting is null)
+ return fallback;
+ string value = RenderPackSettingResolution.Resolve(
+ setting,
+ preset,
+ userSettingOverrides);
+ return RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded)
+ && float.IsFinite(encoded)
+ ? encoded
+ : fallback;
+ }
+
+ private static void DrawFullscreen(
+ IGpuFrame frame,
+ string name,
+ IGpuRenderTarget? target,
+ IGpuPipeline pipeline,
+ GpuTextureSlot textureA,
+ GpuTextureSlot textureB,
+ in AtmosphericPackPassUniforms passUniforms,
+ GpuRingAllocation frameBlock,
+ GpuRingAllocation settingsBlock,
+ GpuTextureSlot textureC,
+ GpuTextureSlot textureD,
+ bool measureGpuTimers)
+ {
+ GpuRingAllocation passBlock = frame.AllocateRing(
+ AtmosphericPackPassUniforms.SizeInBytes,
+ GpuRingUsage.Uniform);
+ DrawFullscreenPrepared(
+ frame,
+ name,
+ target,
+ pipeline,
+ textureA,
+ textureB,
+ in passUniforms,
+ frameBlock,
+ settingsBlock,
+ passBlock,
+ textureC,
+ textureD,
+ measureGpuTimers);
+ }
+
+ private static void DrawFullscreenPrepared(
+ IGpuFrame frame,
+ string name,
+ IGpuRenderTarget? target,
+ IGpuPipeline pipeline,
+ GpuTextureSlot textureA,
+ GpuTextureSlot textureB,
+ in AtmosphericPackPassUniforms passUniforms,
+ GpuRingAllocation frameBlock,
+ GpuRingAllocation settingsBlock,
+ GpuRingAllocation passBlock,
+ GpuTextureSlot textureC,
+ GpuTextureSlot textureD,
+ bool measureGpuTimers)
+ {
+ using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
+ {
+ Name = name,
+ Color = new GpuColorAttachment(
+ target,
+ GpuLoadOp.Clear,
+ GpuStoreOp.Store,
+ Vector4.Zero),
+ Depth = null,
+ SampleCount = 1,
+ });
+ using IDisposable? timer = measureGpuTimers
+ ? encoder.BeginTimerScope(name)
+ : null;
+ encoder.BindPipeline(pipeline);
+ encoder.BindUniformBuffer(
+ GpuBindingModel.UniformAtmosphericFrame,
+ frameBlock.Buffer,
+ frameBlock.OffsetBytes,
+ AtmosphericFrameUniforms.SizeInBytes);
+ MemoryMarshal.Write(passBlock.Data, in passUniforms);
+ encoder.BindUniformBuffer(
+ GpuBindingModel.UniformPackPass,
+ passBlock.Buffer,
+ passBlock.OffsetBytes,
+ AtmosphericPackPassUniforms.SizeInBytes);
+ encoder.BindUniformBuffer(
+ GpuBindingModel.UniformPackSettings,
+ settingsBlock.Buffer,
+ settingsBlock.OffsetBytes,
+ PackSettingsUniforms.SizeInBytes);
+ GpuPushConstants constants = GpuPushConstants.Default;
+ constants.TextureIndexA = textureA.IsAssigned
+ ? textureA.Index
+ : GpuTextureSlot.Unassigned.Index;
+ constants.TextureIndexB = textureB.IsAssigned
+ ? textureB.Index
+ : GpuTextureSlot.Unassigned.Index;
+ constants.ParamA = BitConverter.UInt32BitsToSingle(
+ textureC.IsAssigned ? textureC.Index : GpuTextureSlot.Unassigned.Index);
+ constants.ParamB = BitConverter.UInt32BitsToSingle(
+ textureD.IsAssigned ? textureD.Index : GpuTextureSlot.Unassigned.Index);
+ encoder.SetPushConstants(in constants);
+ encoder.Draw(3, 1, 0, 0);
+ }
+
+ private static GpuRingAllocation Slice(
+ GpuRingAllocation allocation,
+ int offsetBytes,
+ int sizeBytes) => new(
+ allocation.Buffer,
+ checked(allocation.OffsetBytes + (uint)offsetBytes),
+ allocation.Data.Slice(offsetBytes, sizeBytes));
+
+ private static int AlignUp(int value, int alignment)
+ {
+ int remainder = value % alignment;
+ return remainder == 0
+ ? value
+ : checked(value + alignment - remainder);
+ }
+
+ private static float PostScale(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset)
+ {
+ RenderQualityResourceOverride? value = preset.ResourceOverrides
+ .FirstOrDefault(overrideValue =>
+ ResourceSemantic(
+ descriptor,
+ overrideValue,
+ RenderResourceSemantic.BloomPing));
+ if (value?.Extent is { } extent
+ && extent.Mode == RenderExtentMode.RelativeToMainWorld)
+ return (float)Math.Clamp(extent.Width, 0.125, 1.0);
+ return preset.Semantic == RenderQualitySemantic.Low
+ ? 0.25f
+ : 0.5f;
+ }
+
+ private static float RayScale(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset)
+ {
+ RenderQualityResourceOverride? value = preset.ResourceOverrides
+ .FirstOrDefault(overrideValue =>
+ ResourceSemantic(
+ descriptor,
+ overrideValue,
+ RenderResourceSemantic.SunRays));
+ if (value?.Extent is { } extent
+ && extent.Mode == RenderExtentMode.RelativeToMainWorld)
+ return (float)Math.Clamp(extent.Width, 0.125, 1.0);
+ return preset.Semantic == RenderQualitySemantic.Low
+ ? 0.25f
+ : 0.5f;
+ }
+
+ private static bool ResourceSemantic(
+ RenderPackDescriptor descriptor,
+ RenderQualityResourceOverride value,
+ RenderResourceSemantic semantic) =>
+ descriptor.Resources.FirstOrDefault(resource => string.Equals(
+ resource.Id,
+ value.ResourceId,
+ StringComparison.OrdinalIgnoreCase))?.Semantic == semantic;
+
+ private sealed class TargetSet : IDisposable
+ {
+ private readonly IGpuDevice _device;
+ private readonly GpuTextureSlot[] _slots;
+ private bool _disposed;
+
+ private TargetSet(
+ IGpuDevice device,
+ int width,
+ int height,
+ int sampleCount,
+ IGpuRenderTarget world,
+ IGpuRenderTarget? bloomA,
+ IGpuRenderTarget? bloomB,
+ IGpuRenderTarget sunMask,
+ IGpuRenderTarget sunRays,
+ int postWidth,
+ int postHeight,
+ GpuTextureSlot worldColorSlot,
+ GpuTextureSlot worldDepthSlot,
+ GpuTextureSlot bloomASlot,
+ GpuTextureSlot bloomBSlot,
+ GpuTextureSlot sunMaskSlot,
+ GpuTextureSlot sunRaysSlot)
+ {
+ _device = device;
+ Width = width;
+ Height = height;
+ SampleCount = sampleCount;
+ World = world;
+ BloomAOrNull = bloomA;
+ BloomBOrNull = bloomB;
+ SunMask = sunMask;
+ SunRays = sunRays;
+ PostWidth = postWidth;
+ PostHeight = postHeight;
+ WorldColorSlot = worldColorSlot;
+ WorldDepthSlot = worldDepthSlot;
+ BloomASlot = bloomASlot;
+ BloomBSlot = bloomBSlot;
+ SunMaskSlot = sunMaskSlot;
+ SunRaysSlot = sunRaysSlot;
+ _slots = bloomA is null
+ ? [worldColorSlot, worldDepthSlot, sunMaskSlot, sunRaysSlot]
+ : [worldColorSlot, worldDepthSlot, bloomASlot, bloomBSlot,
+ sunMaskSlot, sunRaysSlot];
+ }
+
+ internal int Width { get; }
+ internal int Height { get; }
+ internal int SampleCount { get; }
+ internal IGpuRenderTarget World { get; }
+ private IGpuRenderTarget? BloomAOrNull { get; }
+ private IGpuRenderTarget? BloomBOrNull { get; }
+ internal IGpuRenderTarget BloomA => BloomAOrNull
+ ?? throw new InvalidOperationException(
+ "The fused Low graph has no bloom ping intermediate.");
+ internal IGpuRenderTarget BloomB => BloomBOrNull
+ ?? throw new InvalidOperationException(
+ "The fused Low graph has no bloom pong intermediate.");
+ internal IGpuRenderTarget SunMask { get; }
+ internal IGpuRenderTarget SunRays { get; }
+ internal int PostWidth { get; }
+ internal int PostHeight { get; }
+ internal GpuTextureSlot WorldColorSlot { get; }
+ internal GpuTextureSlot WorldDepthSlot { get; }
+ internal GpuTextureSlot BloomASlot { get; }
+ internal GpuTextureSlot BloomBSlot { get; }
+ internal GpuTextureSlot SunMaskSlot { get; }
+ internal GpuTextureSlot SunRaysSlot { get; }
+ internal long RetainedBytes =>
+ checked(
+ (long)Width * Height * 12L
+ + (BloomAOrNull is null
+ ? 0L
+ : (long)PostWidth * PostHeight * 16L)
+ + ((long)SunMask.Description.Width * SunMask.Description.Height * 4L)
+ + ((long)SunRays.Description.Width * SunRays.Description.Height * 8L));
+ internal long TransientBytes => SampleCount > 1
+ ? checked((long)Width * Height * 12L * SampleCount)
+ : 0L;
+ internal int ImageCount => (BloomAOrNull is null ? 4 : 6)
+ + (SampleCount > 1 ? 2 : 0);
+
+ internal static TargetSet Create(
+ IGpuDevice device,
+ int width,
+ int height,
+ int sampleCount,
+ float postScale,
+ float rayScale,
+ bool allocateBloomIntermediates,
+ IGpuSampler linear,
+ IGpuSampler nearest)
+ {
+ var targets = new List(capacity: 5);
+ var slots = new List(capacity: 6);
+ try
+ {
+ IGpuRenderTarget world = CreateTarget(
+ device,
+ "atmospheric-world-hdr",
+ width,
+ height,
+ GpuTextureFormat.Rgba16FloatRenderTarget,
+ GpuTextureFormat.Depth24Stencil8,
+ sampleCount,
+ sampleableDepth: true);
+ targets.Add(world);
+ int postWidth = Math.Max(1, (int)MathF.Ceiling(width * postScale));
+ int postHeight = Math.Max(1, (int)MathF.Ceiling(height * postScale));
+ int rayWidth = Math.Max(1, (int)MathF.Ceiling(width * rayScale));
+ int rayHeight = Math.Max(1, (int)MathF.Ceiling(height * rayScale));
+ IGpuRenderTarget? bloomA = null;
+ IGpuRenderTarget? bloomB = null;
+ if (allocateBloomIntermediates)
+ {
+ bloomA = CreateTarget(
+ device, "atmospheric-bloom-a", postWidth, postHeight,
+ GpuTextureFormat.Rgba16FloatRenderTarget, null, 1, false);
+ targets.Add(bloomA);
+ bloomB = CreateTarget(
+ device, "atmospheric-bloom-b", postWidth, postHeight,
+ GpuTextureFormat.Rgba16FloatRenderTarget, null, 1, false);
+ targets.Add(bloomB);
+ }
+ IGpuRenderTarget sunMask = CreateTarget(
+ device, "atmospheric-sun-mask", rayWidth, rayHeight,
+ GpuTextureFormat.Rgba8UnormRenderTarget, null, 1, false);
+ targets.Add(sunMask);
+ IGpuRenderTarget sunRays = CreateTarget(
+ device, "atmospheric-sun-rays", rayWidth, rayHeight,
+ GpuTextureFormat.Rgba16FloatRenderTarget, null, 1, false);
+ targets.Add(sunRays);
+
+ GpuTextureSlot worldColor = Register(device, world.ColorTexture, linear, slots);
+ GpuTextureSlot worldDepth = Register(
+ device,
+ world.DepthTexture
+ ?? throw new InvalidOperationException("The HDR world target exposed no sampled depth."),
+ nearest,
+ slots);
+ GpuTextureSlot bloomASlot = bloomA is null
+ ? GpuTextureSlot.Unassigned
+ : Register(device, bloomA.ColorTexture, linear, slots);
+ GpuTextureSlot bloomBSlot = bloomB is null
+ ? GpuTextureSlot.Unassigned
+ : Register(device, bloomB.ColorTexture, linear, slots);
+ GpuTextureSlot sunMaskSlot = Register(device, sunMask.ColorTexture, linear, slots);
+ GpuTextureSlot sunRaysSlot = Register(device, sunRays.ColorTexture, linear, slots);
+ return new TargetSet(
+ device,
+ width,
+ height,
+ sampleCount,
+ world,
+ bloomA,
+ bloomB,
+ sunMask,
+ sunRays,
+ postWidth,
+ postHeight,
+ worldColor,
+ worldDepth,
+ bloomASlot,
+ bloomBSlot,
+ sunMaskSlot,
+ sunRaysSlot);
+ }
+ catch
+ {
+ for (int i = slots.Count - 1; i >= 0; i--)
+ device.ReleaseTextureSlot(slots[i]);
+ for (int i = targets.Count - 1; i >= 0; i--)
+ targets[i].Dispose();
+ throw;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ for (int i = _slots.Length - 1; i >= 0; i--)
+ _device.ReleaseTextureSlot(_slots[i]);
+ SunRays.Dispose();
+ SunMask.Dispose();
+ BloomBOrNull?.Dispose();
+ BloomAOrNull?.Dispose();
+ World.Dispose();
+ }
+
+ private static IGpuRenderTarget CreateTarget(
+ IGpuDevice device,
+ string name,
+ int width,
+ int height,
+ GpuTextureFormat color,
+ GpuTextureFormat? depth,
+ int samples,
+ bool sampleableDepth) =>
+ device.CreateRenderTarget(new GpuRenderTargetDescription(
+ name,
+ width,
+ height,
+ color,
+ depth,
+ samples,
+ sampleableDepth));
+
+ private static GpuTextureSlot Register(
+ IGpuDevice device,
+ IGpuTexture texture,
+ IGpuSampler sampler,
+ List slots)
+ {
+ GpuTextureSlot slot = device.RegisterTexture(texture, sampler);
+ slots.Add(slot);
+ return slot;
+ }
+ }
+}
+
+internal sealed class AtmosphericRenderPackRuntimeFactory(IGpuDevice device) :
+ IRenderPackRuntimeFactory
+{
+ private readonly IGpuDevice _device = device
+ ?? throw new ArgumentNullException(nameof(device));
+
+ public IRenderPackRuntime Build(
+ RenderPackDescriptor descriptor,
+ IRenderPackAssets assets,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary userSettingOverrides) =>
+ Build(
+ descriptor,
+ RenderPackShaderAssets.Validate(descriptor, assets),
+ preset,
+ userSettingOverrides);
+
+ public IRenderPackRuntime Build(
+ RenderPackDescriptor descriptor,
+ ValidatedRenderPackShaderAssets assets,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary userSettingOverrides)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(assets);
+ ArgumentNullException.ThrowIfNull(preset);
+ ArgumentNullException.ThrowIfNull(userSettingOverrides);
+ if (descriptor.Passes.Count == 0)
+ {
+ if (descriptor.SceneReplays.Count != 0 || descriptor.PipelineVariants.Count != 0)
+ {
+ throw new NotSupportedException(
+ $"Pack '{descriptor.Id}' declares scene replay or pipeline variants without an executable pass.");
+ }
+ return new NoOpRenderPackRuntime(descriptor, preset);
+ }
+
+ RenderPassSemantic[] atmosphericSemanticPasses =
+ [
+ RenderPassSemantic.DirectionalShadowDepth,
+ RenderPassSemantic.SunOcclusion,
+ RenderPassSemantic.SunRays,
+ RenderPassSemantic.VolumetricShafts,
+ RenderPassSemantic.BloomDownsample,
+ RenderPassSemantic.BloomBlurHorizontal,
+ RenderPassSemantic.BloomBlurVertical,
+ RenderPassSemantic.FilmicComposite,
+ ];
+ bool standardAtmosphericGraph = atmosphericSemanticPasses.All(required =>
+ descriptor.Passes.Count(pass => pass.Semantic == required) == 1);
+ if (standardAtmosphericGraph)
+ {
+ return new AtmosphericPostProcessGraph(
+ _device,
+ descriptor,
+ assets,
+ preset,
+ userSettingOverrides: userSettingOverrides);
+ }
+
+ bool declaredDirectionalShadowGraph = descriptor.Passes.Count(pass =>
+ pass.Semantic == RenderPassSemantic.DirectionalShadowDepth) == 1
+ && descriptor.Passes.All(pass =>
+ pass.Semantic is RenderPassSemantic.CustomFullscreen
+ or RenderPassSemantic.DirectionalShadowDepth);
+ if (declaredDirectionalShadowGraph)
+ {
+ return new DeclaredDirectionalShadowRenderPackGraph(
+ _device,
+ descriptor,
+ assets,
+ preset,
+ userSettingOverrides);
+ }
+
+ if (descriptor.SceneReplays.Count != 0 || descriptor.PipelineVariants.Count != 0)
+ {
+ throw new NotSupportedException(
+ $"Pack '{descriptor.Id}' uses scene replay or renderer-pipeline variants "
+ + "without a host semantic executor.");
+ }
+ if (descriptor.Passes.Any(pass => pass.Hook is
+ RenderPassHook.ShadowDepthBeforeWorld or
+ RenderPassHook.AfterToneMapBeforePrivateViewports))
+ {
+ throw new NotSupportedException(
+ $"Pack '{descriptor.Id}' uses a pass hook outside the API-v1 Tier-1 fullscreen executor.");
+ }
+ return new DeclaredFullscreenRenderPackGraph(
+ _device,
+ descriptor,
+ assets,
+ preset,
+ userSettingOverrides);
+ }
+}
+
+internal sealed class NoOpRenderPackRuntime(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset) : IDefaultWorldPathRenderPackRuntime
+{
+ public RenderPackDescriptor Descriptor { get; } = descriptor
+ ?? throw new ArgumentNullException(nameof(descriptor));
+
+ public RenderQualityPreset Preset { get; } = preset
+ ?? throw new ArgumentNullException(nameof(preset));
+
+ public void Dispose()
+ {
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs b/src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs
new file mode 100644
index 00000000..819e4dbd
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs
@@ -0,0 +1,183 @@
+using System.Numerics;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering.Packs;
+
+///
+/// Pack-only identity for the one celestial direction selected to cast the
+/// current directional shadow map. Retail exposes one authored directional
+/// colour/energy channel; moon meshes contribute direction only.
+///
+internal enum AuthoredCelestialShadowSourceKind : uint
+{
+ None = 0,
+ Sun = 1,
+ DominantMoon = 2,
+ SecondaryMoon = 3,
+}
+
+internal readonly record struct AuthoredCelestialShadowSource(
+ AuthoredCelestialShadowSourceKind Kind,
+ int ObjectIndex,
+ uint GfxObjId,
+ Vector3 SurfaceToLightDirection,
+ float ElevationSin,
+ float AuthoredEnergy)
+{
+ internal static AuthoredCelestialShadowSource None(float authoredEnergy = 0f) =>
+ new(
+ AuthoredCelestialShadowSourceKind.None,
+ -1,
+ 0u,
+ Vector3.UnitZ,
+ 0f,
+ Math.Clamp(authoredEnergy, 0f, 1f));
+
+ internal bool IsAvailable =>
+ Kind is not AuthoredCelestialShadowSourceKind.None;
+}
+
+///
+/// Resolves the visible Dereth sun/moons from retail DAT sky objects and uses
+/// the identical transform as SkyRenderer. This is an opt-in render-pack
+/// enhancement; it never changes retail SceneLighting or world state.
+///
+internal static class AuthoredCelestialShadowSourceResolver
+{
+ internal const uint SunGfxObjId = 0x01001348u;
+ internal const uint DominantMoonGfxObjId = 0x01001F6Au;
+ internal const uint SecondaryMoonGfxObjId = 0x01001F67u;
+
+ internal static AuthoredCelestialShadowSource Resolve(
+ DayGroupData? dayGroup,
+ float dayFraction,
+ in SkyKeyframe sky)
+ {
+ float energy = Math.Clamp(
+ MathF.Max(sky.SunColor.X, MathF.Max(sky.SunColor.Y, sky.SunColor.Z)),
+ 0f,
+ 1f);
+ if (dayGroup is null || !float.IsFinite(dayFraction))
+ return AuthoredCelestialShadowSource.None(energy);
+
+ if (TryResolve(
+ dayGroup,
+ dayFraction,
+ SunGfxObjId,
+ AuthoredCelestialShadowSourceKind.Sun,
+ energy,
+ out var source)
+ || TryResolve(
+ dayGroup,
+ dayFraction,
+ DominantMoonGfxObjId,
+ AuthoredCelestialShadowSourceKind.DominantMoon,
+ energy,
+ out source)
+ || TryResolve(
+ dayGroup,
+ dayFraction,
+ SecondaryMoonGfxObjId,
+ AuthoredCelestialShadowSourceKind.SecondaryMoon,
+ energy,
+ out source))
+ {
+ return source;
+ }
+
+ return AuthoredCelestialShadowSource.None(energy);
+ }
+
+ private static bool TryResolve(
+ DayGroupData dayGroup,
+ float dayFraction,
+ uint roleGfxObjId,
+ AuthoredCelestialShadowSourceKind kind,
+ float energy,
+ out AuthoredCelestialShadowSource source)
+ {
+ for (int index = 0; index < dayGroup.SkyObjects.Count; index++)
+ {
+ SkyObjectData skyObject = dayGroup.SkyObjects[index];
+ if (skyObject.GfxObjId != roleGfxObjId
+ || !skyObject.IsVisible(dayFraction))
+ {
+ continue;
+ }
+
+ SkyObjectReplaceData? replace = ActiveReplace(
+ dayGroup,
+ dayFraction,
+ checked((uint)index));
+ if (replace is not null && replace.Transparent >= 1f - 1e-5f)
+ continue;
+
+ uint effectiveGfxObjId = replace is { GfxObjId: not 0u }
+ ? replace.GfxObjId
+ : skyObject.GfxObjId;
+ Vector3 anchor = replace is { GfxObjId: not 0u }
+ ? replace.AuthoredSortCenter
+ : skyObject.AuthoredSortCenter;
+ if (!IsFiniteDirection(anchor))
+ continue;
+
+ float headingRadians = (replace?.Rotate ?? 0f) * (MathF.PI / 180f);
+ float rotationRadians = skyObject.CurrentAngle(dayFraction)
+ * (MathF.PI / 180f);
+ Matrix4x4 model = Matrix4x4.CreateRotationZ(-headingRadians)
+ * Matrix4x4.CreateRotationY(-rotationRadians);
+ Vector3 transformed = Vector3.TransformNormal(anchor, model);
+ float length = transformed.Length();
+ if (!float.IsFinite(length) || length <= 1e-5f)
+ continue;
+
+ Vector3 direction = transformed / length;
+ if (!IsFiniteDirection(direction) || direction.Z <= 0f)
+ continue;
+
+ source = new AuthoredCelestialShadowSource(
+ kind,
+ index,
+ effectiveGfxObjId,
+ direction,
+ direction.Z,
+ energy);
+ return true;
+ }
+
+ source = default;
+ return false;
+ }
+
+ private static SkyObjectReplaceData? ActiveReplace(
+ DayGroupData dayGroup,
+ float dayFraction,
+ uint objectIndex)
+ {
+ if (dayGroup.SkyTimes.Count == 0)
+ return null;
+
+ DatSkyKeyframeData active = dayGroup.SkyTimes[^1];
+ for (int i = 0; i < dayGroup.SkyTimes.Count; i++)
+ {
+ if (dayGroup.SkyTimes[i].Keyframe.Begin <= dayFraction)
+ active = dayGroup.SkyTimes[i];
+ else
+ break;
+ }
+
+ SkyObjectReplaceData? result = null;
+ foreach (SkyObjectReplaceData replace in active.Replaces)
+ {
+ if (replace.ObjectIndex == objectIndex)
+ result = replace;
+ }
+ return result;
+ }
+
+ private static bool IsFiniteDirection(Vector3 value) =>
+ float.IsFinite(value.X)
+ && float.IsFinite(value.Y)
+ && float.IsFinite(value.Z)
+ && value.LengthSquared() > 1e-10f;
+}
diff --git a/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs b/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs
new file mode 100644
index 00000000..b52c3716
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs
@@ -0,0 +1,519 @@
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.App.Rendering.Packs;
+
+///
+/// The built-in Atmospheric Rendering pack is expressed through the same
+/// public declaration consumed by third-party packs. Renderer implementation
+/// code resolves public enum semantics and never recognizes this pack's IDs,
+/// so the built-in receives no private capability or lifecycle shortcut.
+///
+internal static class BuiltInAtmosphericRenderPack
+{
+ internal const string Id = "acdream.atmospheric";
+
+ internal static RenderPackDescriptor Descriptor { get; } = new RenderPackDescriptor(
+ Id,
+ "Atmospheric Rendering",
+ new Version(1, 0, 0),
+ RenderPackApi.Current,
+ RenderPackTier.Tier2Plus,
+ [
+ RenderCapability.MainWorldColorIntermediate,
+ RenderCapability.FullscreenPasses,
+ RenderCapability.SceneDepthSampling,
+ RenderCapability.AuthoredSunDirection,
+ RenderCapability.AuthoredSunScreenPosition,
+ RenderCapability.AuthoredWeather,
+ RenderCapability.DirectionalShadowMaps,
+ RenderCapability.OutdoorDirectionalShadowCasterReplay,
+ RenderCapability.AnimatedCasterTransforms,
+ RenderCapability.AlphaCutoutShadowCasters,
+ RenderCapability.AuthoredCelestialDirectionalLight,
+ ],
+ [RenderCapability.GpuTimestampQueries],
+ Resources(),
+ Passes(),
+ SceneReplays(),
+ PipelineVariants(),
+ QualityPresets(),
+ Settings(),
+ AtmospherePolicy())
+ {
+ FeatureSummary = "Filmic HDR atmosphere, moving sun-and-moon shadows from terrain, "
+ + "trees, buildings, players, and monsters, plus optional volumetric shafts.",
+ };
+
+ internal static IRenderPackAssets CreateAssets(string shaderDirectory) =>
+ new DirectoryRenderPackAssets(shaderDirectory);
+
+ private static IReadOnlyList Resources() =>
+ [
+ Image("world-hdr", RenderResourceSemantic.MainWorldHdr,
+ RenderFormatClass.HdrColor, 1.0, 1.0, 32L * 1024 * 1024),
+ Image("bloom-a", RenderResourceSemantic.BloomPing,
+ RenderFormatClass.HdrColor, 0.5, 0.5, 8L * 1024 * 1024),
+ Image("bloom-b", RenderResourceSemantic.BloomPong,
+ RenderFormatClass.HdrColor, 0.5, 0.5, 8L * 1024 * 1024),
+ Image("sun-mask", RenderResourceSemantic.SunOcclusionMask,
+ RenderFormatClass.SingleChannel, 0.25, 0.25, 2L * 1024 * 1024),
+ Image("sun-rays", RenderResourceSemantic.SunRays,
+ RenderFormatClass.HdrColor, 0.25, 0.25, 2L * 1024 * 1024),
+ new RenderResourceDeclaration(
+ "directional-shadow-depth",
+ RenderResourceKind.Image2DArray,
+ RenderFormatClass.DirectionalDepth,
+ new RenderExtentDeclaration(RenderExtentMode.AbsolutePixels, 1024, 1024, Layers: 2),
+ SizeBytes: 0,
+ RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment,
+ RenderResourceLifetime.ActivePack,
+ EstimatedResidentBytes: 8L * 1024 * 1024)
+ with { Semantic = RenderResourceSemantic.DirectionalShadowDepth },
+ Image("volumetric", RenderResourceSemantic.VolumetricShafts,
+ RenderFormatClass.HdrColor, 0.25, 0.25, 2L * 1024 * 1024),
+ ];
+
+ private static IReadOnlyList Passes() =>
+ [
+ Pass(
+ "directional-shadow-depth",
+ RenderPassSemantic.DirectionalShadowDepth,
+ RenderPassHook.ShadowDepthBeforeWorld,
+ "directional_shadow_world_opaque.vert.spv",
+ "directional_shadow_world_opaque.frag.spv",
+ [RenderSemanticInput.CameraMatrices,
+ RenderSemanticInput.SelectedCelestialDirectionalLight,
+ RenderSemanticInput.ShadowCasterTransforms, RenderSemanticInput.ActiveDayGroup,
+ RenderSemanticInput.Weather],
+ [],
+ ["directional-shadow-depth"]),
+ Pass(
+ "sun-occlusion",
+ RenderPassSemantic.SunOcclusion,
+ RenderPassHook.AtmosphereBeforeToneMap,
+ "atmospheric_sun_occlusion.vert.spv",
+ "atmospheric_sun_occlusion.frag.spv",
+ [RenderSemanticInput.SceneDepth, RenderSemanticInput.SunScreenPosition,
+ RenderSemanticInput.ActiveDayGroup, RenderSemanticInput.Weather],
+ [],
+ ["sun-mask"]),
+ Pass(
+ "sun-rays",
+ RenderPassSemantic.SunRays,
+ RenderPassHook.AtmosphereBeforeToneMap,
+ "atmospheric_sun_rays.vert.spv",
+ "atmospheric_sun_rays.frag.spv",
+ [RenderSemanticInput.SunScreenPosition, RenderSemanticInput.FrameTime],
+ ["sun-mask"],
+ ["sun-rays"]),
+ Pass(
+ "volumetric-shafts",
+ RenderPassSemantic.VolumetricShafts,
+ RenderPassHook.AtmosphereBeforeToneMap,
+ "atmospheric_volumetric.vert.spv",
+ "atmospheric_volumetric.frag.spv",
+ [RenderSemanticInput.SceneDepth, RenderSemanticInput.CameraMatrices,
+ RenderSemanticInput.SunDirection, RenderSemanticInput.DirectionalShadowMaps,
+ RenderSemanticInput.ActiveDayGroup, RenderSemanticInput.Weather],
+ ["directional-shadow-depth"],
+ ["volumetric"]),
+ Pass(
+ "bloom-downsample",
+ RenderPassSemantic.BloomDownsample,
+ RenderPassHook.AtmosphereBeforeToneMap,
+ "atmospheric_bloom_downsample.vert.spv",
+ "atmospheric_bloom_downsample.frag.spv",
+ [RenderSemanticInput.WorldColor],
+ ["sun-rays", "volumetric"],
+ ["bloom-a"]),
+ Pass(
+ "bloom-blur-horizontal",
+ RenderPassSemantic.BloomBlurHorizontal,
+ RenderPassHook.AtmosphereBeforeToneMap,
+ "atmospheric_bloom_blur.vert.spv",
+ "atmospheric_bloom_blur.frag.spv",
+ [RenderSemanticInput.FrameTime],
+ ["bloom-a"],
+ ["bloom-b"]),
+ Pass(
+ "bloom-blur-vertical",
+ RenderPassSemantic.BloomBlurVertical,
+ RenderPassHook.AtmosphereBeforeToneMap,
+ "atmospheric_bloom_blur.vert.spv",
+ "atmospheric_bloom_blur.frag.spv",
+ [RenderSemanticInput.FrameTime],
+ ["bloom-b"],
+ ["bloom-a"]),
+ Pass(
+ "filmic-composite",
+ RenderPassSemantic.FilmicComposite,
+ RenderPassHook.ToneMap,
+ "atmospheric_filmic.vert.spv",
+ "atmospheric_filmic.frag.spv",
+ [RenderSemanticInput.WorldColor, RenderSemanticInput.FrameTime],
+ ["bloom-a", "sun-rays", "volumetric"],
+ []),
+ ];
+
+ private static IReadOnlyList SceneReplays() =>
+ [
+ new SceneReplayDeclaration(
+ "outdoor-directional-shadow-casters",
+ RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters,
+ RenderCasterClass.Terrain
+ | RenderCasterClass.OpaqueWorld
+ | RenderCasterClass.AlphaCutoutWorld
+ | RenderCasterClass.AnimatedOpaque
+ | RenderCasterClass.AnimatedAlphaCutout,
+ ViewCount: 4),
+ ];
+
+ private static IReadOnlyList PipelineVariants() =>
+ [
+ Variant("terrain-shadow-caster", RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.Terrain,
+ "directional_shadow_terrain.vert.spv", "directional_shadow_terrain.frag.spv",
+ RenderMaterialClass.Opaque,
+ [RenderSemanticInput.CameraMatrices]),
+ Variant("world-shadow-opaque", RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.WorldMesh,
+ "directional_shadow_world_opaque.vert.spv", "directional_shadow_world_opaque.frag.spv",
+ RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque,
+ [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]),
+ Variant("world-shadow-cutout", RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.WorldMesh,
+ "directional_shadow_world_cutout.vert.spv", "directional_shadow_world_cutout.frag.spv",
+ RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout,
+ [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]),
+ Variant("terrain-shadow-caster-multiview", RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.Terrain,
+ "directional_shadow_terrain_multiview.vert.spv", "directional_shadow_terrain_multiview.frag.spv",
+ RenderMaterialClass.Opaque,
+ [RenderSemanticInput.CameraMatrices]),
+ Variant("world-shadow-opaque-multiview", RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.WorldMesh,
+ "directional_shadow_world_opaque_multiview.vert.spv", "directional_shadow_world_opaque_multiview.frag.spv",
+ RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque,
+ [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]),
+ Variant("world-shadow-cutout-multiview", RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.WorldMesh,
+ "directional_shadow_world_cutout_multiview.vert.spv", "directional_shadow_world_cutout_multiview.frag.spv",
+ RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout,
+ [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]),
+ Variant("terrain-shadow-receiver", RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver,
+ RenderPipelineBaseSemantic.Terrain,
+ "terrain_atmospheric.vert.spv", "terrain_atmospheric.frag.spv",
+ RenderMaterialClass.Opaque,
+ [RenderSemanticInput.DirectionalShadowMaps,
+ RenderSemanticInput.SelectedCelestialDirectionalLight]),
+ Variant("world-shadow-receiver", RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver,
+ RenderPipelineBaseSemantic.WorldMesh,
+ "mesh_atmospheric.vert.spv", "mesh_atmospheric.frag.spv",
+ RenderMaterialClass.Opaque | RenderMaterialClass.AlphaCutout
+ | RenderMaterialClass.AnimatedOpaque | RenderMaterialClass.AnimatedAlphaCutout,
+ [RenderSemanticInput.DirectionalShadowMaps,
+ RenderSemanticInput.SelectedCelestialDirectionalLight]),
+ ];
+
+ private static IReadOnlyList QualityPresets() =>
+ [
+ Preset("low", "Low", RenderQualitySemantic.Low,
+ 64, 2.0, 3.0, 0.15, 0.50, 768, 2, 72, 0.25) with
+ {
+ ExecutionHints =
+ RenderQualityExecutionHints.MultiviewDirectionalShadowCascades,
+ },
+ Preset("medium", "Medium", RenderQualitySemantic.Medium,
+ 128, 3.25, 4.50, 0.25, 0.75, 1536, 3, 144, 0.5),
+ Preset("high", "High", RenderQualitySemantic.High,
+ 256, 4.50, 6.00, 0.35, 1.00, 2048, 4, 240, 0.5),
+ Preset("auto", "Auto", RenderQualitySemantic.Automatic,
+ 128, 3.25, 4.50, 0.25, 0.75, 1536, 3, 144, 0.5)
+ with
+ {
+ SettingOverrides =
+ [
+ new RenderQualitySettingOverride("automatic-quality", "true"),
+ new RenderQualitySettingOverride("volumetric-strength", "0.35"),
+ new RenderQualitySettingOverride("volumetric-ray-steps", "40"),
+ new RenderQualitySettingOverride("sun-shadow-strength", "0.72"),
+ new RenderQualitySettingOverride("sun-shadow-reach-metres", "144"),
+ new RenderQualitySettingOverride("sun-shadow-pcf-taps", "9"),
+ new RenderQualitySettingOverride("sun-ray-strength", "0.55"),
+ ],
+ AutoEligible = false,
+ },
+ ];
+
+ private static IReadOnlyList Settings() =>
+ [
+ Float("bloom-strength", "Bloom strength", RenderSettingSemantic.BloomStrength,
+ 0.65, 0, 2, 0.05),
+ Float("filmic-strength", "Filmic tonemap strength", RenderSettingSemantic.FilmicStrength,
+ 1.0, 0, 1, 0.05),
+ Float("exposure", "Exposure", RenderSettingSemantic.Exposure,
+ 0.80, 0.25, 4, 0.05),
+ Float("grade-saturation", "Colour saturation", RenderSettingSemantic.GradeSaturation,
+ 1.0, 0, 2, 0.05),
+ Float("grade-contrast", "Colour contrast", RenderSettingSemantic.GradeContrast,
+ 1.0, 0.5, 2, 0.05),
+ Float("vignette-strength", "Vignette strength", RenderSettingSemantic.VignetteStrength,
+ 0.12, 0, 1, 0.01),
+ Float("sun-ray-strength", "Sun-ray strength", RenderSettingSemantic.SunRayStrength,
+ 0.55, 0, 2, 0.05),
+ Float("sun-shadow-strength", "Directional-shadow strength",
+ RenderSettingSemantic.DirectionalShadowStrength, 0.72, 0, 1, 0.02),
+ Integer("sun-shadow-reach-metres", "Directional-shadow reach (metres)",
+ RenderSettingSemantic.DirectionalShadowReachMetres, 240, 16, 240, 1),
+ Choice("sun-shadow-pcf-taps", "Directional-shadow filter taps",
+ RenderSettingSemantic.DirectionalShadowPcfTaps, "9", ["1", "9", "25"]),
+ Float("volumetric-strength", "Volumetric-shaft strength",
+ RenderSettingSemantic.VolumetricStrength, 0.35, 0, 1, 0.01),
+ Integer("volumetric-ray-steps", "Volumetric ray-march steps",
+ RenderSettingSemantic.VolumetricRayMarchSteps, 40, 8, 64, 8),
+ new RenderSettingDeclaration(
+ "automatic-quality",
+ "Automatic quality",
+ RenderSettingKind.Boolean,
+ "false",
+ null,
+ null,
+ null,
+ [])
+ with { Semantic = RenderSettingSemantic.AutomaticQuality },
+ ];
+
+ private static AtmospherePolicyDeclaration AtmospherePolicy() => new(
+ [
+ new SunElevationResponsePoint(-90, 0),
+ new SunElevationResponsePoint(-3, 0),
+ new SunElevationResponsePoint(4, 1),
+ new SunElevationResponsePoint(22, 0.75),
+ new SunElevationResponsePoint(55, 0),
+ new SunElevationResponsePoint(90, 0),
+ ],
+ [
+ new ActiveDayGroupMultiplier(0, 1.0),
+ new ActiveDayGroupMultiplier(1, 0.35),
+ new ActiveDayGroupMultiplier(2, 0.20),
+ ])
+ {
+ DirectionalShadowLightElevationResponse =
+ [
+ new SunElevationResponsePoint(-90, 0),
+ new SunElevationResponsePoint(1, 0),
+ new SunElevationResponsePoint(12, 1),
+ new SunElevationResponsePoint(90, 1),
+ ],
+ VolumetricShaftSunElevationResponse =
+ [
+ new SunElevationResponsePoint(-90, 0),
+ new SunElevationResponsePoint(0, 0),
+ new SunElevationResponsePoint(6, 1),
+ new SunElevationResponsePoint(18, 1),
+ new SunElevationResponsePoint(70, 0),
+ new SunElevationResponsePoint(90, 0),
+ ],
+ };
+
+ private static RenderResourceDeclaration Image(
+ string id,
+ RenderResourceSemantic semantic,
+ RenderFormatClass format,
+ double widthScale,
+ double heightScale,
+ long estimatedBytes) => new RenderResourceDeclaration(
+ id,
+ RenderResourceKind.Image2D,
+ format,
+ new RenderExtentDeclaration(
+ RenderExtentMode.RelativeToMainWorld,
+ widthScale,
+ heightScale),
+ SizeBytes: 0,
+ RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment,
+ RenderResourceLifetime.ActivePack,
+ estimatedBytes)
+ { Semantic = semantic };
+
+ private static RenderPassDeclaration Pass(
+ string id,
+ RenderPassSemantic semantic,
+ RenderPassHook hook,
+ string vertex,
+ string fragment,
+ IReadOnlyList semantics,
+ IReadOnlyList reads,
+ IReadOnlyList writes) =>
+ new(id, hook, vertex, fragment, semantics, reads, writes)
+ {
+ Semantic = semantic,
+ };
+
+ private static PipelineVariantDeclaration Variant(
+ string id,
+ RenderPipelineVariantSemantic variantSemantic,
+ RenderPipelineBaseSemantic semantic,
+ string vertex,
+ string fragment,
+ RenderMaterialClass materials,
+ IReadOnlyList inputs) =>
+ new(id, semantic, vertex, fragment, materials, inputs)
+ {
+ Semantic = variantSemantic,
+ };
+
+ private static RenderQualityPreset Preset(
+ string id,
+ string displayName,
+ RenderQualitySemantic semantic,
+ long maxMiB,
+ double gpuP50,
+ double gpuP99,
+ double cpuP50,
+ double cpuP99,
+ int shadowResolution,
+ int cascades,
+ int shadowReachMetres,
+ double postScale) => new RenderQualityPreset(
+ id,
+ displayName,
+ semantic == RenderQualitySemantic.Low
+ ? [RenderCapability.DirectionalShadowMaps,
+ RenderCapability.MultiviewDirectionalShadowCascades]
+ : [RenderCapability.DirectionalShadowMaps],
+ [
+ Override("directional-shadow-depth", shadowResolution, shadowResolution, cascades,
+ 4L * shadowResolution * shadowResolution * cascades),
+ RelativeOverride("bloom-a", postScale),
+ RelativeOverride("bloom-b", postScale),
+ RelativeOverride("sun-mask", id == "low" ? 0.25 : 0.5),
+ RelativeOverride("sun-rays", id == "low" ? 0.25 : 0.5),
+ RelativeOverride("volumetric", id == "high" ? 0.5 : 0.25),
+ ],
+ [
+ new RenderQualitySettingOverride("automatic-quality", "false"),
+ new RenderQualitySettingOverride("volumetric-strength", id == "low" ? "0" : "0.35"),
+ new RenderQualitySettingOverride(
+ "volumetric-ray-steps",
+ semantic switch
+ {
+ RenderQualitySemantic.Low => "24",
+ RenderQualitySemantic.High => "56",
+ _ => "40",
+ }),
+ new RenderQualitySettingOverride("sun-shadow-strength", "0.72"),
+ new RenderQualitySettingOverride("sun-shadow-reach-metres", shadowReachMetres.ToString()),
+ new RenderQualitySettingOverride(
+ "sun-shadow-pcf-taps",
+ semantic switch
+ {
+ RenderQualitySemantic.Low => "1",
+ RenderQualitySemantic.High => "25",
+ _ => "9",
+ }),
+ // The renderer recognizes this bounded preset fact; it remains
+ // visible here instead of becoming a hidden cascade constant.
+ new RenderQualitySettingOverride("sun-ray-strength", id == "low" ? "0.4" : "0.55"),
+ ],
+ maxMiB * 1024 * 1024,
+ gpuP50,
+ gpuP99,
+ cpuP50,
+ cpuP99)
+ { Semantic = semantic };
+
+ private static RenderQualityResourceOverride Override(
+ string id,
+ int width,
+ int height,
+ int layers,
+ long bytes) => new(
+ id,
+ new RenderExtentDeclaration(RenderExtentMode.AbsolutePixels, width, height, layers),
+ SizeBytes: 0,
+ EstimatedResidentBytes: bytes);
+
+ private static RenderQualityResourceOverride RelativeOverride(string id, double scale) =>
+ new(
+ id,
+ new RenderExtentDeclaration(RenderExtentMode.RelativeToMainWorld, scale, scale),
+ SizeBytes: 0,
+ EstimatedResidentBytes: 0);
+
+ private static RenderSettingDeclaration Float(
+ string id,
+ string displayName,
+ RenderSettingSemantic semantic,
+ double defaultValue,
+ double min,
+ double max,
+ double step) => new RenderSettingDeclaration(
+ id,
+ displayName,
+ RenderSettingKind.Float,
+ defaultValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ min,
+ max,
+ step,
+ [])
+ { Semantic = semantic };
+
+ private static RenderSettingDeclaration Integer(
+ string id,
+ string displayName,
+ RenderSettingSemantic semantic,
+ int defaultValue,
+ int min,
+ int max,
+ int step) => new RenderSettingDeclaration(
+ id,
+ displayName,
+ RenderSettingKind.Integer,
+ defaultValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ min,
+ max,
+ step,
+ [])
+ { Semantic = semantic };
+
+ private static RenderSettingDeclaration Choice(
+ string id,
+ string displayName,
+ RenderSettingSemantic semantic,
+ string defaultValue,
+ IReadOnlyList choices) => new RenderSettingDeclaration(
+ id,
+ displayName,
+ RenderSettingKind.Choice,
+ defaultValue,
+ null,
+ null,
+ null,
+ choices)
+ { Semantic = semantic };
+}
+
+internal sealed class DirectoryRenderPackAssets : IRenderPackAssets
+{
+ private readonly string _root;
+
+ internal DirectoryRenderPackAssets(string root)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(root);
+ _root = Path.GetFullPath(root);
+ }
+
+ public Stream OpenRead(string assetKey)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(assetKey);
+ string normalized = assetKey.Replace('/', Path.DirectorySeparatorChar);
+ string path = Path.GetFullPath(Path.Combine(_root, normalized));
+ string relative = Path.GetRelativePath(_root, path);
+ if (Path.IsPathRooted(relative)
+ || relative == ".."
+ || relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
+ throw new UnauthorizedAccessException("The asset key escapes the render-pack root.");
+ return File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs b/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs
new file mode 100644
index 00000000..0f005236
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs
@@ -0,0 +1,965 @@
+using System.Numerics;
+using System.Runtime.InteropServices;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Scene;
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.World;
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.App.Rendering.Packs;
+
+///
+/// API-v1 executor for declaration-only fullscreen graphs. It supports the
+/// portable Tier-1 hooks/resources without recognizing a pack id or shader
+/// filename. Scene replay and renderer-pipeline variants remain separate host
+/// facilities and are rejected by the factory before this runtime is built.
+///
+internal class DeclaredFullscreenRenderPackGraph :
+ IAtmosphericWorldGraphRuntime,
+ IRenderPackRuntimePerformanceSource,
+ IRenderPackRuntimeDiagnosticsSource
+{
+ private readonly IGpuDevice _device;
+ private readonly IDisposable _hdrLease;
+ private readonly IGpuSampler _sampler;
+ private readonly Node[] _nodes;
+ private readonly IReadOnlyDictionary _resources;
+ private readonly PackSettingsUniforms _settings;
+ private readonly DirectionalSunShadowRenderer? _directionalShadows;
+ private readonly DirectionalShadowCasterFrame _shadowCasters = new();
+ private readonly RenderPassDeclaration? _shadowPass;
+ private readonly float _shadowStrength;
+ private TargetSet? _targets;
+ private RenderPackResourceBudget _resourceBudget;
+ private long _resourceGeneration;
+ private long _residentGpuBudgetBytes;
+ private AtmosphericFrameInputs _lastInputs;
+ private DirectionalSunShadowDiagnostics _lastShadowDiagnostics;
+ private int _lastShadowCasterCount;
+ private int _lastShadowClassificationCalls;
+ private WbDrawDispatcher? _lastShadowWorldMeshes;
+ private long _lastShadowFrameSerial = -1;
+ private bool _renderedFrame;
+ private bool _disposed;
+
+ internal DeclaredFullscreenRenderPackGraph(
+ IGpuDevice device,
+ RenderPackDescriptor descriptor,
+ IRenderPackAssets assets,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary userSettingOverrides)
+ : this(
+ device,
+ descriptor,
+ RenderPackShaderAssets.Validate(descriptor, assets),
+ preset,
+ userSettingOverrides)
+ {
+ }
+
+ internal DeclaredFullscreenRenderPackGraph(
+ IGpuDevice device,
+ RenderPackDescriptor descriptor,
+ ValidatedRenderPackShaderAssets assets,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary userSettingOverrides)
+ {
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor));
+ ArgumentNullException.ThrowIfNull(assets);
+ Preset = preset ?? throw new ArgumentNullException(nameof(preset));
+ ArgumentNullException.ThrowIfNull(userSettingOverrides);
+ if (device is not IGpuPipelineFormatVariantHost variants)
+ throw new NotSupportedException("The active RHI cannot build an HDR world intermediate.");
+
+ _resources = descriptor.Resources.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
+ RenderPassDeclaration[] passes = descriptor.Passes
+ .OrderBy(value => value.Hook)
+ .ToArray();
+ RenderPassDeclaration[] fullscreenPasses = passes
+ .Where(static value =>
+ value.Semantic != RenderPassSemantic.DirectionalShadowDepth)
+ .ToArray();
+ if (!fullscreenPasses.Any(value => value.Hook == RenderPassHook.ToneMap
+ && value.ResourceWrites.Count == 0))
+ {
+ throw new NotSupportedException(
+ $"Fullscreen pack '{descriptor.Id}' must declare a ToneMap pass that writes the output surface.");
+ }
+
+ IDisposable? lease = null;
+ DirectionalSunShadowRenderer? directionalShadows = null;
+ var nodes = new List(fullscreenPasses.Length);
+ try
+ {
+ lease = variants.AcquirePipelineColorFormat(GpuTextureFormat.Rgba16FloatRenderTarget);
+ _sampler = device.CreateSampler(GpuSamplerDescription.WorldClamp);
+ foreach (RenderPassDeclaration pass in fullscreenPasses)
+ {
+ if (pass.Hook is not RenderPassHook.AtmosphereBeforeToneMap
+ and not RenderPassHook.ToneMap)
+ throw new NotSupportedException($"Fullscreen executor does not support hook '{pass.Hook}'.");
+ RenderSemanticInput? unsupported = pass.SemanticInputs.FirstOrDefault(value =>
+ value is RenderSemanticInput.SceneNormals
+ or RenderSemanticInput.ShadowCasterTransforms
+ or RenderSemanticInput.DirectionalShadowMaps);
+ if (unsupported is RenderSemanticInput.SceneNormals
+ or RenderSemanticInput.ShadowCasterTransforms
+ or RenderSemanticInput.DirectionalShadowMaps)
+ {
+ throw new NotSupportedException(
+ $"Tier-1 fullscreen pass '{pass.Id}' requires unsupported semantic '{unsupported}'.");
+ }
+ if (pass.ResourceWrites.Count > 1)
+ throw new NotSupportedException($"Pass '{pass.Id}' writes more than one colour target.");
+ GpuTextureFormat format = pass.ResourceWrites.Count == 0
+ ? GpuTextureFormat.Rgba8UnormRenderTarget
+ : ValidateOutput(Resource(pass.ResourceWrites[0]));
+ var pipeline = device.CreatePipeline(new GpuPipelineDescription
+ {
+ Name = $"render-pack-{descriptor.Id}-{pass.Id}",
+ Shaders = RenderPackShaderAssets.LoadPass(descriptor, assets, pass),
+ VertexLayout = GpuVertexLayout.None,
+ Blend = GpuBlendMode.None,
+ Depth = GpuDepthState.Disabled,
+ Cull = GpuCullMode.None,
+ ColorFormat = format,
+ AllowColorFormatVariants = false,
+ SampleCount = 1,
+ UsesRenderPackShaderAbi = true,
+ });
+ string timerName = $"render-pack-{descriptor.Id}-{pass.Id}";
+ nodes.Add(new Node(
+ pass,
+ pipeline,
+ [.. RenderPackTextureBindingResolver.Resolve(pass, _resources)],
+ timerName));
+ }
+ _nodes = [.. nodes];
+ _settings = PackSettingsUniforms.Create(
+ descriptor,
+ preset,
+ userSettingOverrides);
+ _shadowPass = passes.SingleOrDefault(static value =>
+ value.Semantic == RenderPassSemantic.DirectionalShadowDepth);
+ _shadowStrength = _shadowPass is null
+ ? 0f
+ : ReadSemanticSetting(
+ descriptor,
+ preset,
+ userSettingOverrides,
+ RenderSettingSemantic.DirectionalShadowStrength);
+ if (_shadowPass is not null)
+ {
+ directionalShadows = new DirectionalSunShadowRenderer(
+ device,
+ ResolveShadowQuality(
+ descriptor,
+ preset,
+ userSettingOverrides),
+ RenderPackAtmospherePolicyEvaluation.NeutralDirectionalShadowElevation,
+ LoadDirectionalShadowShaders(descriptor, assets),
+ multiviewCascades: (preset.ExecutionHints
+ & RenderQualityExecutionHints
+ .MultiviewDirectionalShadowCascades) != 0);
+ }
+ _directionalShadows = directionalShadows;
+ directionalShadows = null;
+ _hdrLease = lease;
+ lease = null;
+ }
+ catch
+ {
+ directionalShadows?.Dispose();
+ for (int i = nodes.Count - 1; i >= 0; i--)
+ nodes[i].Pipeline.Dispose();
+ lease?.Dispose();
+ throw;
+ }
+ }
+
+ public RenderPackDescriptor Descriptor { get; }
+
+ public RenderQualityPreset Preset { get; }
+
+ internal IDirectionalShadowReceiverSource DeclaredDirectionalShadowReceivers =>
+ _directionalShadows
+ ?? throw new InvalidOperationException(
+ $"Pack '{Descriptor.Id}' has no declared directional-shadow executor.");
+
+ internal DirectionalSunShadowDiagnostics RenderDeclaredDirectionalShadows(
+ IGpuFrame frame,
+ in RenderFrameFoundation foundation,
+ in WorldRenderFrame world,
+ int activeDayGroup,
+ in RenderSceneQuery scene,
+ WbDrawDispatcher worldMeshes,
+ TerrainModernRenderer terrain)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ DirectionalSunShadowRenderer renderer = _directionalShadows
+ ?? throw new InvalidOperationException(
+ $"Pack '{Descriptor.Id}' has no declared directional-shadow executor.");
+ _shadowCasters.Build(in scene);
+ AuthoredCelestialShadowSource source = world.CelestialShadowSource;
+ float elevationStrength = RenderPackAtmospherePolicyEvaluation
+ .DirectionalShadowFromSin(
+ Descriptor.AtmospherePolicy!.DirectionalShadowLightElevationResponse,
+ source.ElevationSin,
+ fallback: 0f);
+ var environment = new DirectionalShadowEnvironmentInput(
+ PackEnabled: true,
+ PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
+ PlayerInsideCell: world.Roots.PlayerInsideCell
+ || world.Roots.CameraInsideCell,
+ source,
+ foundation.Atmosphere,
+ ActiveDayGroupMultiplier: Math.Clamp(
+ EvaluateDayGroupPolicy(activeDayGroup)
+ * elevationStrength
+ * _shadowStrength,
+ 0f,
+ 1f));
+ var input = new DirectionalSunShadowRenderInput(
+ environment,
+ world.Camera.Camera.View,
+ world.Camera.Projection,
+ _shadowCasters,
+ ResidentMaximumReachMeters:
+ world.ResidentStreamingWindow.MaximumReachMeters);
+ _lastShadowCasterCount = _shadowCasters.Stats.Accepted;
+ _lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0;
+ _lastShadowDiagnostics = renderer.Render(
+ frame,
+ in input,
+ worldMeshes,
+ terrain);
+ _lastShadowWorldMeshes = worldMeshes;
+ _lastShadowFrameSerial = frame.Serial;
+ RequireRetainedGpuBudget(renderer);
+ return _lastShadowDiagnostics;
+ }
+
+ public IGpuRenderTarget PrepareWorldTarget(int width, int height, int sampleCount)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_targets is { } current
+ && current.Width == width
+ && current.Height == height
+ && current.SampleCount == sampleCount)
+ return current.World;
+ RenderPackHostCapabilities capabilities =
+ RenderPackCapabilityResolver.Resolve(_device.Capabilities);
+ RenderPackResourceBudget budget = RenderPackResourceBudgetPlanner.RequireWithinHost(
+ Descriptor,
+ Preset,
+ width,
+ height,
+ sampleCount,
+ capabilities);
+ TargetSet candidate = TargetSet.Create(
+ _device,
+ Descriptor,
+ Preset,
+ _sampler,
+ width,
+ height,
+ sampleCount);
+ TargetSet? prior = _targets;
+ _targets = candidate;
+ _resourceBudget = budget;
+ _residentGpuBudgetBytes = Math.Min(
+ Preset.MaxResidentGpuBytes,
+ capabilities.MaxPackResidentBytes);
+ _resourceGeneration = checked(_resourceGeneration + 1);
+ _renderedFrame = false;
+ prior?.Dispose();
+ return candidate.World;
+ }
+
+ public void RenderPostProcess(IGpuFrame frame, in AtmosphericFrameInputs inputs)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ TargetSet targets = _targets
+ ?? throw new InvalidOperationException("PrepareWorldTarget must run before the fullscreen graph.");
+ if (inputs.ViewportWidth != targets.Width || inputs.ViewportHeight != targets.Height)
+ throw new InvalidOperationException("Fullscreen graph inputs and targets belong to different frames.");
+
+ float elevationPolicy = EvaluateSunElevationPolicy(inputs.SunElevationDegrees);
+ float dayGroupPolicy = EvaluateDayGroupPolicy(inputs.ActiveDayGroup);
+ IReadOnlyList shadowCurve =
+ Descriptor.AtmospherePolicy?.DirectionalShadowLightElevationResponse ?? [];
+ IReadOnlyList volumetricCurve =
+ Descriptor.AtmospherePolicy?.VolumetricShaftSunElevationResponse ?? [];
+ float shadowElevationPolicy = shadowCurve.Count == 0
+ ? elevationPolicy
+ : RenderPackAtmospherePolicyEvaluation.DirectionalShadow(
+ shadowCurve,
+ inputs.SunElevationDegrees,
+ elevationPolicy);
+ float volumetricElevationPolicy = volumetricCurve.Count == 0
+ ? 0f
+ : RenderPackAtmospherePolicyEvaluation.VolumetricShaft(
+ volumetricCurve,
+ inputs.SunElevationDegrees,
+ 0f);
+ float sunPolicy = EvaluateSunPolicy(
+ inputs,
+ elevationPolicy,
+ dayGroupPolicy);
+ var frameValues = new AtmosphericFrameUniforms(
+ new Vector4(inputs.SunScreenUv, sunPolicy, inputs.SunElevationDegrees),
+ new Vector4(inputs.SunColor, sunPolicy),
+ new Vector4(targets.Width, targets.Height, 1f / targets.Width, 1f / targets.Height),
+ new Vector4((float)inputs.Weather, inputs.WeatherIntensity,
+ (float)Math.Clamp(inputs.DeltaSeconds, 0d, 1d), inputs.IsOutdoor ? 1f : 0f),
+ new Vector4(inputs.SunDirection, inputs.SunDirectionalBrightness),
+ new Vector4(
+ inputs.ActiveDayGroup,
+ dayGroupPolicy,
+ shadowElevationPolicy,
+ volumetricElevationPolicy),
+ inputs.InverseViewProjection);
+ GpuRingAllocation frameBlock = frame.AllocateRing(AtmosphericFrameUniforms.SizeInBytes, GpuRingUsage.Uniform);
+ MemoryMarshal.Write(frameBlock.Data, in frameValues);
+ GpuRingAllocation settingsBlock = frame.AllocateRing(PackSettingsUniforms.SizeInBytes, GpuRingUsage.Uniform);
+ PackSettingsUniforms settings = _settings;
+ MemoryMarshal.Write(settingsBlock.Data, in settings);
+
+ foreach (Node node in _nodes)
+ Draw(frame, node, targets, frameBlock, settingsBlock);
+ _lastInputs = inputs;
+ _renderedFrame = true;
+ }
+
+ public RenderPackRuntimeDiagnostics CaptureDiagnostics()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ TargetSet? targets = _targets;
+ if (!_renderedFrame || targets is null)
+ return RenderPackRuntimeDiagnostics.Empty(Preset.Id);
+
+ int shadowPassCount = _shadowPass is null ? 0 : 1;
+ var passes = new RenderPackPassDiagnostics[_nodes.Length + shadowPassCount];
+ int passIndex = 0;
+ if (_shadowPass is not null)
+ {
+ passes[passIndex++] = new RenderPackPassDiagnostics(
+ _shadowPass.Id,
+ _lastShadowDiagnostics.LastResolvedGpuMilliseconds,
+ _lastShadowDiagnostics.DrawCalls,
+ DispatchCalls: 0);
+ }
+ for (int i = 0; i < _nodes.Length; i++)
+ {
+ Node node = _nodes[i];
+ _device.Timers.TryResolve(node.TimerName, out double milliseconds);
+ passes[passIndex++] = new RenderPackPassDiagnostics(
+ node.Pass.Id,
+ milliseconds,
+ DrawCalls: 1,
+ DispatchCalls: 0);
+ }
+
+ return new RenderPackRuntimeDiagnostics(
+ Preset.Id,
+ checked(
+ _resourceBudget.RetainedGpuBytes
+ + (_directionalShadows?.RetainedGpuBufferBytes ?? 0L)),
+ _resourceBudget.MultisampleGpuBytes,
+ targets.ImageCount + shadowPassCount,
+ BufferCount: _directionalShadows?.RetainedGpuBufferCount ?? 0,
+ DrawCalls: _nodes.Length + _lastShadowDiagnostics.DrawCalls,
+ DispatchCalls: 0,
+ ShadowCasterCount: _lastShadowCasterCount,
+ CascadeDrawCount: _lastShadowDiagnostics.CascadeCount,
+ CpuClassificationCalls: _lastShadowClassificationCalls,
+ _lastInputs.SunElevationDegrees,
+ _lastInputs.ActiveDayGroup,
+ _lastInputs.Weather.ToString(),
+ _lastInputs.WeatherIntensity,
+ _lastInputs.IsOutdoor,
+ DirectionalShadowStrength: _lastShadowDiagnostics.Strength,
+ passes)
+ {
+ DirectionalShadowSourceKind = _lastShadowDiagnostics.SourceKind,
+ DirectionalShadowSourceObjectIndex =
+ _lastShadowDiagnostics.SourceObjectIndex,
+ DirectionalShadowSourceGfxObjId =
+ _lastShadowDiagnostics.SourceGfxObjId,
+ DirectionalShadowSurfaceToLightDirection =
+ _lastShadowDiagnostics.SurfaceToLightDirection,
+ DirectionalShadowLightElevationSin =
+ _lastShadowDiagnostics.LightElevationSin,
+ ShadowTransformChurn = _lastShadowDiagnostics.TransformChurn,
+ SharedWorldTransformUsedInstances =
+ _lastShadowWorldMeshes is not null
+ && _lastShadowWorldMeshes.HasDirectionalShadowTransformFrame(
+ _lastShadowFrameSerial)
+ ? _lastShadowWorldMeshes
+ .DirectionalShadowTransformFrameUsedInstances
+ : 0u,
+ };
+ }
+
+ public RenderPackRuntimePerformanceMetrics CapturePerformanceMetrics()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ double gpuMilliseconds = 0d;
+ bool resolved = _targets is not null;
+ for (int i = 0; i < _nodes.Length; i++)
+ {
+ if (!_device.Timers.TryTakeResolved(
+ _nodes[i].TimerName,
+ out double milliseconds))
+ {
+ resolved = false;
+ }
+ else
+ {
+ gpuMilliseconds += milliseconds;
+ }
+ }
+ if (_directionalShadows is not null)
+ {
+ if (!_device.Timers.TryTakeResolved(
+ RenderPackPerformanceScopeNames.EnhancedWorldReceiver,
+ out double receiverMilliseconds))
+ {
+ resolved = false;
+ }
+ else
+ {
+ gpuMilliseconds += receiverMilliseconds;
+ }
+ int shadowTimerCount = _directionalShadows.MultiviewCascadesEnabled
+ && _lastShadowDiagnostics.CascadeCount > 0
+ ? 1
+ : _lastShadowDiagnostics.CascadeCount;
+ for (int i = 0; i < shadowTimerCount; i++)
+ {
+ if (!_device.Timers.TryTakeResolved(
+ _directionalShadows.MultiviewCascadesEnabled
+ ? DirectionalSunShadowRenderer.MultiviewTimerName
+ : DirectionalSunShadowRenderer.TimerName(i),
+ out double milliseconds))
+ {
+ resolved = false;
+ }
+ else
+ {
+ gpuMilliseconds += milliseconds;
+ }
+ }
+ }
+ return new RenderPackRuntimePerformanceMetrics(
+ _resourceGeneration,
+ resolved,
+ resolved ? gpuMilliseconds : 0d,
+ checked(
+ _resourceBudget.RetainedGpuBytes
+ + (_directionalShadows?.RetainedGpuBufferBytes ?? 0L)),
+ _resourceBudget.MultisampleGpuBytes);
+ }
+
+ private void RequireRetainedGpuBudget(
+ DirectionalSunShadowRenderer renderer)
+ {
+ long total = checked(
+ _resourceBudget.RetainedGpuBytes
+ + renderer.RetainedGpuBufferBytes);
+ if (total <= _residentGpuBudgetBytes)
+ return;
+ throw new NotSupportedException(
+ $"Render pack preset '{Preset.Id}' needs {total} resident GPU bytes "
+ + "after materializing its scene-dependent shadow command buffers; "
+ + $"the active pack budget is {_residentGpuBudgetBytes} bytes.");
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ _targets?.Dispose();
+ _directionalShadows?.Dispose();
+ for (int i = _nodes.Length - 1; i >= 0; i--)
+ _nodes[i].Pipeline.Dispose();
+ _hdrLease.Dispose();
+ }
+
+ private void Draw(
+ IGpuFrame frame,
+ Node node,
+ TargetSet targets,
+ GpuRingAllocation frameBlock,
+ GpuRingAllocation settingsBlock)
+ {
+ IGpuRenderTarget? output = node.Pass.ResourceWrites.Count == 0
+ ? null
+ : targets.Resource(node.Pass.ResourceWrites[0]).Target;
+ using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
+ {
+ Name = node.TimerName,
+ Color = new GpuColorAttachment(output, GpuLoadOp.Clear, GpuStoreOp.Store, Vector4.Zero),
+ Depth = null,
+ SampleCount = 1,
+ });
+ using IDisposable timer = encoder.BeginTimerScope(node.TimerName);
+ encoder.BindPipeline(node.Pipeline);
+ encoder.BindUniformBuffer(GpuBindingModel.UniformAtmosphericFrame,
+ frameBlock.Buffer, frameBlock.OffsetBytes, AtmosphericFrameUniforms.SizeInBytes);
+ GpuRingAllocation passBlock = frame.AllocateRing(
+ AtmosphericPackPassUniforms.SizeInBytes,
+ GpuRingUsage.Uniform);
+ var zero = AtmosphericPackPassUniforms.From(Vector4.Zero);
+ MemoryMarshal.Write(passBlock.Data, in zero);
+ encoder.BindUniformBuffer(GpuBindingModel.UniformPackPass,
+ passBlock.Buffer, passBlock.OffsetBytes, AtmosphericPackPassUniforms.SizeInBytes);
+ encoder.BindUniformBuffer(GpuBindingModel.UniformPackSettings,
+ settingsBlock.Buffer, settingsBlock.OffsetBytes, PackSettingsUniforms.SizeInBytes);
+
+ Span slots = stackalloc GpuTextureSlot[4];
+ slots.Fill(GpuTextureSlot.Unassigned);
+ for (int i = 0; i < node.Inputs.Length; i++)
+ slots[i] = Resolve(node.Inputs[i], targets);
+ GpuPushConstants push = GpuPushConstants.Default;
+ push.TextureIndexA = slots[0].Index;
+ push.TextureIndexB = slots[1].Index;
+ push.ParamA = BitConverter.UInt32BitsToSingle(slots[2].Index);
+ push.ParamB = BitConverter.UInt32BitsToSingle(slots[3].Index);
+ encoder.SetPushConstants(in push);
+ encoder.Draw(3, 1, 0, 0);
+ }
+
+ private static GpuTextureSlot Resolve(RenderPackTextureInput input, TargetSet targets)
+ {
+ if (input.Semantic is { } semantic)
+ {
+ return semantic switch
+ {
+ RenderSemanticInput.WorldColor => targets.WorldColor,
+ RenderSemanticInput.SceneDepth => targets.WorldDepth,
+ _ => throw new NotSupportedException($"Texture semantic '{semantic}' is unsupported by Tier-1."),
+ };
+ }
+ return targets.Resource(input.ResourceId!).Slot;
+ }
+
+ private float EvaluateSunElevationPolicy(float elevation)
+ {
+ IReadOnlyList? points =
+ Descriptor.AtmospherePolicy?.SunElevationResponse;
+ return RenderPackAtmospherePolicyEvaluation.Ray(points, elevation);
+ }
+
+ private float EvaluateDayGroupPolicy(int activeDayGroup)
+ {
+ ActiveDayGroupMultiplier? value = Descriptor.AtmospherePolicy?
+ .ActiveDayGroupMultipliers
+ .FirstOrDefault(entry => entry.ActiveDayGroup == activeDayGroup);
+ return value is null ? 1f : (float)value.Multiplier;
+ }
+
+ private static float EvaluateSunPolicy(
+ in AtmosphericFrameInputs inputs,
+ float elevationPolicy,
+ float dayGroupPolicy)
+ {
+ if (!inputs.IsOutdoor || !inputs.SunIsOnScreen)
+ return 0f;
+ return Math.Clamp(
+ elevationPolicy
+ * dayGroupPolicy
+ * EvaluateWeatherPolicy(inputs.Weather, inputs.WeatherIntensity),
+ 0f,
+ 4f);
+ }
+
+ private static float EvaluateWeatherPolicy(
+ AcDream.Core.World.WeatherKind weather,
+ float intensity)
+ {
+ float weatherTarget = weather switch
+ {
+ AcDream.Core.World.WeatherKind.Clear => 1f,
+ AcDream.Core.World.WeatherKind.Overcast => 0.18f,
+ AcDream.Core.World.WeatherKind.Rain => 0.10f,
+ AcDream.Core.World.WeatherKind.Snow => 0.16f,
+ AcDream.Core.World.WeatherKind.Storm => 0.06f,
+ _ => 0f,
+ };
+ return 1f + ((weatherTarget - 1f) * Math.Clamp(intensity, 0f, 1f));
+ }
+
+ private static DirectionalShadowPipelineShaders LoadDirectionalShadowShaders(
+ RenderPackDescriptor descriptor,
+ ValidatedRenderPackShaderAssets assets)
+ {
+ DirectionalShadowPipelineShaders shaders = new(
+ Variant(RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster),
+ Variant(RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster),
+ Variant(RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster),
+ Variant(RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver),
+ Variant(RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver));
+ if (descriptor.PipelineVariants.Any(value =>
+ value.Semantic == RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster))
+ {
+ shaders = shaders with
+ {
+ MultiviewCasters = new DirectionalShadowMultiviewPipelineShaders(
+ Variant(RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster),
+ Variant(RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster),
+ Variant(RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster)),
+ };
+ }
+ return shaders;
+
+ GpuShaderSet Variant(RenderPipelineVariantSemantic semantic)
+ {
+ PipelineVariantDeclaration variant = descriptor.PipelineVariants
+ .Single(value => value.Semantic == semantic);
+ return RenderPackShaderAssets.LoadVariant(descriptor, assets, variant);
+ }
+ }
+
+ private static DirectionalShadowQuality ResolveShadowQuality(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary userSettingOverrides)
+ {
+ DirectionalShadowPreset shadowPreset = preset.Semantic switch
+ {
+ RenderQualitySemantic.Low => DirectionalShadowPreset.Low,
+ RenderQualitySemantic.High => DirectionalShadowPreset.High,
+ _ => DirectionalShadowPreset.Medium,
+ };
+ DirectionalShadowQuality quality = DirectionalShadowQuality.For(shadowPreset);
+ RenderResourceDeclaration resource = descriptor.Resources.Single(value =>
+ value.Semantic == RenderResourceSemantic.DirectionalShadowDepth);
+ RenderExtentDeclaration extent = preset.ResourceOverrides.FirstOrDefault(value =>
+ string.Equals(
+ value.ResourceId,
+ resource.Id,
+ StringComparison.OrdinalIgnoreCase))?.Extent
+ ?? resource.Extent
+ ?? throw new NotSupportedException(
+ "The DirectionalShadowDepth semantic resource has no image extent.");
+ if (extent.Mode != RenderExtentMode.AbsolutePixels
+ || extent.Width != extent.Height
+ || extent.Width != Math.Truncate(extent.Width)
+ || extent.Width is < 1 or > 16_384
+ || extent.Layers is < 1 or > 4)
+ {
+ throw new NotSupportedException(
+ "The DirectionalShadowDepth semantic resource must be a square "
+ + "absolute 1..16384 image with 1..4 array layers.");
+ }
+
+ float reach = ReadSemanticSetting(
+ descriptor,
+ preset,
+ userSettingOverrides,
+ RenderSettingSemantic.DirectionalShadowReachMetres);
+ int taps = ReadShadowPcfTaps(descriptor, preset, userSettingOverrides);
+ int radius = taps switch
+ {
+ 1 => 0,
+ 9 => 1,
+ 25 => 2,
+ _ => throw new NotSupportedException(
+ "DirectionalShadowPcfTaps must resolve to exactly 1, 9, or 25 samples."),
+ };
+ int resolution = checked((int)extent.Width);
+ int cascades = extent.Layers;
+ return quality with
+ {
+ CascadeCount = cascades,
+ MapResolution = resolution,
+ MaximumReachMeters = Math.Clamp(reach, 1f, 10_000f),
+ PcfRadiusTexels = radius,
+ ApproximateDepthMapBytes = checked(
+ (long)cascades * resolution * resolution * sizeof(float)),
+ IncrementalGpuP50BudgetMilliseconds = preset.MaxIncrementalGpuMillisecondsP50,
+ IncrementalGpuP99BudgetMilliseconds = preset.MaxIncrementalGpuMillisecondsP99,
+ IncrementalCpuP50BudgetMilliseconds = preset.MaxIncrementalCpuMillisecondsP50,
+ IncrementalCpuP99BudgetMilliseconds = preset.MaxIncrementalCpuMillisecondsP99,
+ PackResidentGpuByteBudget = preset.MaxResidentGpuBytes,
+ };
+ }
+
+ private static int ReadShadowPcfTaps(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary userSettingOverrides)
+ {
+ RenderSettingDeclaration setting = descriptor.Settings.Single(value =>
+ value.Semantic == RenderSettingSemantic.DirectionalShadowPcfTaps);
+ string value = RenderPackSettingResolution.Resolve(
+ setting,
+ preset,
+ userSettingOverrides);
+ return int.TryParse(
+ value,
+ System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out int taps)
+ ? taps
+ : throw new NotSupportedException(
+ "DirectionalShadowPcfTaps must resolve to an integer sample count.");
+ }
+
+ private static float ReadSemanticSetting(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary userSettingOverrides,
+ RenderSettingSemantic semantic)
+ {
+ RenderSettingDeclaration setting = descriptor.Settings.Single(value =>
+ value.Semantic == semantic);
+ string value = RenderPackSettingResolution.Resolve(
+ setting,
+ preset,
+ userSettingOverrides);
+ if (!RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded)
+ || !float.IsFinite(encoded))
+ {
+ throw new NotSupportedException(
+ $"Setting semantic '{semantic}' did not resolve to a finite value.");
+ }
+ return encoded;
+ }
+
+ private RenderResourceDeclaration Resource(string id) =>
+ _resources.TryGetValue(id, out RenderResourceDeclaration? value)
+ ? value
+ : throw new InvalidOperationException($"Unknown render-pack resource '{id}'.");
+
+ private static GpuTextureFormat FormatOf(RenderResourceDeclaration resource) => resource.Format switch
+ {
+ RenderFormatClass.HdrColor => GpuTextureFormat.Rgba16FloatRenderTarget,
+ RenderFormatClass.LdrColor or RenderFormatClass.SingleChannel =>
+ GpuTextureFormat.Rgba8UnormRenderTarget,
+ _ => throw new NotSupportedException(
+ $"Fullscreen resource '{resource.Id}' has unsupported format '{resource.Format}'."),
+ };
+
+ private static GpuTextureFormat ValidateOutput(RenderResourceDeclaration resource)
+ {
+ if (resource.Kind != RenderResourceKind.Image2D
+ || (resource.Usage & RenderResourceUsage.ColorAttachment) == 0
+ || resource.Extent is null)
+ {
+ throw new NotSupportedException(
+ $"Fullscreen output '{resource.Id}' must be an extent-declared colour Image2D.");
+ }
+ return FormatOf(resource);
+ }
+
+ private sealed record Node(
+ RenderPassDeclaration Pass,
+ IGpuPipeline Pipeline,
+ RenderPackTextureInput[] Inputs,
+ string TimerName);
+
+ private sealed class TargetSet : IDisposable
+ {
+ private readonly IGpuDevice _device;
+ private readonly Dictionary _resources;
+ private readonly GpuTextureSlot[] _slots;
+ private readonly string? _mainWorldResourceId;
+
+ private TargetSet(
+ IGpuDevice device,
+ int width,
+ int height,
+ int sampleCount,
+ IGpuRenderTarget world,
+ GpuTextureSlot worldColor,
+ GpuTextureSlot worldDepth,
+ Dictionary resources,
+ GpuTextureSlot[] slots,
+ string? mainWorldResourceId)
+ {
+ _device = device;
+ Width = width;
+ Height = height;
+ SampleCount = sampleCount;
+ World = world;
+ WorldColor = worldColor;
+ WorldDepth = worldDepth;
+ _resources = resources;
+ _slots = slots;
+ _mainWorldResourceId = mainWorldResourceId;
+ }
+
+ internal int Width { get; }
+ internal int Height { get; }
+ internal int SampleCount { get; }
+ internal IGpuRenderTarget World { get; }
+ internal GpuTextureSlot WorldColor { get; }
+ internal GpuTextureSlot WorldDepth { get; }
+ internal int ImageCount => checked(
+ 2
+ + _resources.Count
+ + (SampleCount > 1 ? (WorldDepth.IsAssigned ? 2 : 1) : 0));
+
+ internal ResourceTarget Resource(string id) =>
+ string.Equals(id, _mainWorldResourceId, StringComparison.OrdinalIgnoreCase)
+ ? new ResourceTarget(World, WorldColor)
+ : _resources.TryGetValue(id, out ResourceTarget? value)
+ ? value
+ : throw new InvalidOperationException($"Resource '{id}' has no produced image.");
+
+ internal static TargetSet Create(
+ IGpuDevice device,
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IGpuSampler sampler,
+ int width,
+ int height,
+ int samples)
+ {
+ var targets = new List();
+ var slots = new List();
+ try
+ {
+ bool needsDepth = descriptor.Passes.Any(pass =>
+ pass.SemanticInputs.Contains(RenderSemanticInput.SceneDepth));
+ IGpuRenderTarget world = device.CreateRenderTarget(new GpuRenderTargetDescription(
+ $"render-pack-{descriptor.Id}-world-hdr", width, height,
+ GpuTextureFormat.Rgba16FloatRenderTarget,
+ GpuTextureFormat.Depth24Stencil8,
+ samples,
+ needsDepth));
+ targets.Add(world);
+ GpuTextureSlot worldColor = Register(device, world.ColorTexture, sampler, slots);
+ GpuTextureSlot worldDepth = needsDepth
+ ? Register(device, world.DepthTexture!, sampler, slots)
+ : GpuTextureSlot.Unassigned;
+ var resources = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ string? mainWorldResourceId = descriptor.Resources.SingleOrDefault(resource =>
+ resource.Semantic == RenderResourceSemantic.MainWorldHdr)?.Id;
+ HashSet written = descriptor.Passes
+ .SelectMany(pass => pass.ResourceWrites)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (RenderResourceDeclaration resource in descriptor.Resources)
+ {
+ if (!written.Contains(resource.Id)
+ || resource.Semantic is RenderResourceSemantic.MainWorldHdr
+ or RenderResourceSemantic.DirectionalShadowDepth)
+ continue;
+ if (resource.Kind != RenderResourceKind.Image2D
+ || (resource.Usage & RenderResourceUsage.ColorAttachment) == 0)
+ throw new NotSupportedException($"Fullscreen resource '{resource.Id}' is not a colour image.");
+ (int resourceWidth, int resourceHeight) = Extent(resource, preset, width, height);
+ IGpuRenderTarget target = device.CreateRenderTarget(new GpuRenderTargetDescription(
+ $"render-pack-{descriptor.Id}-{resource.Id}", resourceWidth, resourceHeight,
+ FormatOf(resource), null, 1));
+ targets.Add(target);
+ resources.Add(resource.Id, new ResourceTarget(
+ target,
+ Register(device, target.ColorTexture, sampler, slots)));
+ }
+ return new TargetSet(
+ device, width, height, samples, world, worldColor, worldDepth,
+ resources, [.. slots], mainWorldResourceId);
+ }
+ catch
+ {
+ for (int i = slots.Count - 1; i >= 0; i--)
+ device.ReleaseTextureSlot(slots[i]);
+ for (int i = targets.Count - 1; i >= 0; i--)
+ targets[i].Dispose();
+ throw;
+ }
+ }
+
+ public void Dispose()
+ {
+ for (int i = _slots.Length - 1; i >= 0; i--)
+ _device.ReleaseTextureSlot(_slots[i]);
+ foreach (ResourceTarget resource in _resources.Values.Reverse())
+ resource.Target.Dispose();
+ World.Dispose();
+ }
+
+ private static (int Width, int Height) Extent(
+ RenderResourceDeclaration resource,
+ RenderQualityPreset preset,
+ int width,
+ int height)
+ {
+ RenderExtentDeclaration extent = preset.ResourceOverrides.FirstOrDefault(value =>
+ string.Equals(value.ResourceId, resource.Id, StringComparison.OrdinalIgnoreCase))?.Extent
+ ?? resource.Extent
+ ?? throw new NotSupportedException($"Image resource '{resource.Id}' has no extent.");
+ return extent.Mode switch
+ {
+ RenderExtentMode.AbsolutePixels =>
+ (checked((int)extent.Width), checked((int)extent.Height)),
+ RenderExtentMode.RelativeToMainWorld or RenderExtentMode.RelativeToOutput =>
+ (Math.Max(1, (int)Math.Ceiling(width * extent.Width)),
+ Math.Max(1, (int)Math.Ceiling(height * extent.Height))),
+ _ => throw new NotSupportedException($"Resource '{resource.Id}' has unsupported extent mode."),
+ };
+ }
+
+ private static GpuTextureSlot Register(
+ IGpuDevice device,
+ IGpuTexture texture,
+ IGpuSampler sampler,
+ List slots)
+ {
+ GpuTextureSlot slot = device.RegisterTexture(texture, sampler);
+ slots.Add(slot);
+ return slot;
+ }
+ }
+
+ internal sealed record ResourceTarget(IGpuRenderTarget Target, GpuTextureSlot Slot);
+}
+
+internal sealed class DeclaredDirectionalShadowRenderPackGraph :
+ DeclaredFullscreenRenderPackGraph,
+ IDirectionalShadowWorldGraphRuntime
+{
+ internal DeclaredDirectionalShadowRenderPackGraph(
+ IGpuDevice device,
+ RenderPackDescriptor descriptor,
+ IRenderPackAssets assets,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary userSettingOverrides)
+ : base(device, descriptor, assets, preset, userSettingOverrides)
+ {
+ }
+
+ internal DeclaredDirectionalShadowRenderPackGraph(
+ IGpuDevice device,
+ RenderPackDescriptor descriptor,
+ ValidatedRenderPackShaderAssets assets,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary userSettingOverrides)
+ : base(device, descriptor, assets, preset, userSettingOverrides)
+ {
+ }
+
+ public IDirectionalShadowReceiverSource DirectionalShadowReceivers =>
+ DeclaredDirectionalShadowReceivers;
+
+ public DirectionalSunShadowDiagnostics RenderDirectionalShadows(
+ IGpuFrame frame,
+ in RenderFrameFoundation foundation,
+ in WorldRenderFrame world,
+ int activeDayGroup,
+ in RenderSceneQuery scene,
+ WbDrawDispatcher worldMeshes,
+ TerrainModernRenderer terrain) => RenderDeclaredDirectionalShadows(
+ frame,
+ in foundation,
+ in world,
+ activeDayGroup,
+ in scene,
+ worldMeshes,
+ terrain);
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs b/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs
new file mode 100644
index 00000000..410a7914
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackAtmospherePolicyEvaluation.cs
@@ -0,0 +1,87 @@
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.App.Rendering.Packs;
+
+///
+/// Host evaluation for the public data-only atmosphere curves. Keeping the
+/// three interpolation contracts here prevents a pack declaration from being
+/// reinterpreted differently by the declared, shadow, and volumetric graphs.
+///
+internal static class RenderPackAtmospherePolicyEvaluation
+{
+ internal static DirectionalShadowAtmospherePolicy NeutralDirectionalShadowElevation { get; } =
+ DirectionalShadowAtmospherePolicy.BuiltIn with
+ {
+ MinimumLightElevationSin = -1.001f,
+ FullStrengthLightElevationSin = -1f,
+ };
+
+ internal static float Ray(
+ IReadOnlyList? points,
+ float elevationDegrees,
+ float fallback = 1f) => Evaluate(
+ points,
+ elevationDegrees,
+ static value => (float)value,
+ static value => (float)value,
+ fallback);
+
+ internal static float DirectionalShadow(
+ IReadOnlyList? points,
+ float elevationDegrees,
+ float fallback = 0f) => DirectionalShadowFromSin(
+ points,
+ MathF.Sin(elevationDegrees * (MathF.PI / 180f)),
+ fallback);
+
+ internal static float DirectionalShadowFromSin(
+ IReadOnlyList? points,
+ float lightElevationSin,
+ float fallback = 0f) => Evaluate(
+ points,
+ Math.Clamp(lightElevationSin, -1f, 1f),
+ static degrees => MathF.Sin((float)degrees * (MathF.PI / 180f)),
+ static value => (float)value,
+ fallback);
+
+ internal static float VolumetricShaft(
+ IReadOnlyList? points,
+ float elevationDegrees,
+ float fallback = 0f) => Evaluate(
+ points,
+ elevationDegrees,
+ static value => (float)value,
+ static value => value * value * (3f - (2f * value)),
+ fallback);
+
+ private static float Evaluate(
+ IReadOnlyList? points,
+ float input,
+ Func transformPoint,
+ Func transformInterpolation,
+ float fallback)
+ {
+ if (points is null || points.Count == 0)
+ return fallback;
+ float first = transformPoint(points[0].ElevationDegrees);
+ if (input <= first)
+ return (float)points[0].Multiplier;
+ for (int i = 1; i < points.Count; i++)
+ {
+ SunElevationResponsePoint upper = points[i];
+ float upperInput = transformPoint(upper.ElevationDegrees);
+ if (input > upperInput)
+ continue;
+ SunElevationResponsePoint lower = points[i - 1];
+ float lowerInput = transformPoint(lower.ElevationDegrees);
+ float span = upperInput - lowerInput;
+ float t = span <= 0f
+ ? 0f
+ : Math.Clamp((input - lowerInput) / span, 0f, 1f);
+ t = transformInterpolation(t);
+ return (float)(lower.Multiplier
+ + ((upper.Multiplier - lower.Multiplier) * t));
+ }
+ return (float)points[^1].Multiplier;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackCapabilityResolver.cs b/src/AcDream.App/Rendering/Packs/RenderPackCapabilityResolver.cs
new file mode 100644
index 00000000..bfd46732
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackCapabilityResolver.cs
@@ -0,0 +1,66 @@
+using AcDream.App.Rendering.Gpu;
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.App.Rendering.Packs;
+
+internal static class RenderPackCapabilityResolver
+{
+ internal const long AbsoluteResidentByteCeiling = 256L * 1024 * 1024;
+ internal const long AbsoluteTransientByteCeiling = 512L * 1024 * 1024;
+ internal const int DeviceLocalShareDenominator = 8;
+
+ internal static RenderPackHostCapabilities Resolve(GpuCapabilityRecord gpu)
+ {
+ ArgumentNullException.ThrowIfNull(gpu);
+ var available = new HashSet
+ {
+ RenderCapability.FullscreenPasses,
+ RenderCapability.AuthoredSunDirection,
+ RenderCapability.AuthoredCelestialDirectionalLight,
+ RenderCapability.AuthoredSunScreenPosition,
+ RenderCapability.AuthoredWeather,
+ RenderCapability.OutdoorDirectionalShadowCasterReplay,
+ RenderCapability.AnimatedCasterTransforms,
+ RenderCapability.AlphaCutoutShadowCasters,
+ };
+
+ if (gpu.SupportsRgba16FloatRenderTargets)
+ available.Add(RenderCapability.MainWorldColorIntermediate);
+ if (gpu.SupportsSampledDepth)
+ {
+ available.Add(RenderCapability.SceneDepthSampling);
+ available.Add(RenderCapability.DirectionalShadowMaps);
+ }
+ if (gpu.SupportsTimestampQueries)
+ available.Add(RenderCapability.GpuTimestampQueries);
+ if (gpu.SupportsMultiview)
+ available.Add(RenderCapability.MultiviewDirectionalShadowCascades);
+
+ long residentBytes = DeviceLocalShare(
+ gpu.DeviceLocalMemoryBytes,
+ AbsoluteResidentByteCeiling);
+ long transientBytes = DeviceLocalShare(
+ gpu.DeviceLocalMemoryBytes,
+ AbsoluteTransientByteCeiling);
+ return new RenderPackHostCapabilities(
+ available,
+ MaxImageDimension2D: checked((int)Math.Min(
+ gpu.MaxImageDimension2D,
+ (uint)int.MaxValue)),
+ MaxImageArrayLayers: checked((int)Math.Min(
+ gpu.MaxImageArrayLayers,
+ (uint)int.MaxValue)),
+ MaxPackResidentBytes: residentBytes,
+ MaxPackTransientBytes: transientBytes,
+ MemoryPolicyDescription:
+ $"one eighth of {gpu.DeviceLocalMemoryBytes} device-local bytes, "
+ + $"capped at {AbsoluteResidentByteCeiling} resident and "
+ + $"{AbsoluteTransientByteCeiling} transient bytes");
+ }
+
+ private static long DeviceLocalShare(ulong deviceLocalBytes, long ceiling)
+ {
+ ulong share = deviceLocalBytes / DeviceLocalShareDenominator;
+ return (long)Math.Min(share, checked((ulong)ceiling));
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackCatalogSource.cs b/src/AcDream.App/Rendering/Packs/RenderPackCatalogSource.cs
new file mode 100644
index 00000000..60e031ba
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackCatalogSource.cs
@@ -0,0 +1,37 @@
+using AcDream.App.Plugins;
+
+namespace AcDream.App.Rendering.Packs;
+
+///
+/// Production catalog authority shared by retained UI and the activation
+/// controller. It deliberately retains no catalog snapshot (and therefore no
+/// plugin asset source): withdrawal immediately releases the registry's last
+/// catalog reference, while consumers rebuild only after the revision event or
+/// an explicit UI interaction.
+///
+internal sealed class RenderPackCatalogSource
+{
+ private readonly BufferedRenderPackRegistry _registry;
+ private readonly RenderPackHostCapabilities _capabilities;
+
+ internal RenderPackCatalogSource(
+ BufferedRenderPackRegistry registry,
+ RenderPackHostCapabilities capabilities)
+ {
+ _registry = registry ?? throw new ArgumentNullException(nameof(registry));
+ _capabilities = capabilities
+ ?? throw new ArgumentNullException(nameof(capabilities));
+ }
+
+ internal long Revision => _registry.Revision;
+
+ internal event Action Changed
+ {
+ add => _registry.Changed += value;
+ remove => _registry.Changed -= value;
+ }
+
+ internal RenderPackCatalog Snapshot() => RenderPackCatalog.Build(
+ _registry.Snapshot(),
+ _capabilities);
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackController.cs b/src/AcDream.App/Rendering/Packs/RenderPackController.cs
new file mode 100644
index 00000000..55e7fc2e
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackController.cs
@@ -0,0 +1,1404 @@
+using AcDream.App.Plugins;
+using AcDream.App.Rendering.Gpu.Vk;
+using AcDream.Plugin.Abstractions.Rendering;
+using AcDream.UI.Abstractions.Panels.Settings;
+
+namespace AcDream.App.Rendering.Packs;
+
+internal sealed record RenderPackCatalogEntry(
+ RenderPackDescriptor Descriptor,
+ IRenderPackAssets Assets,
+ long RegistrationId,
+ bool IsCompatible,
+ string? IncompatibilityReason,
+ IReadOnlyDictionary PresetIncompatibilityReasons);
+
+internal sealed class RenderPackCatalog
+{
+ private readonly Dictionary _entries;
+
+ private RenderPackCatalog(Dictionary entries)
+ {
+ _entries = entries;
+ }
+
+ internal IReadOnlyList Entries =>
+ _entries.Values
+ .OrderBy(static value => value.Descriptor.DisplayName, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(static value => value.Descriptor.Id, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ internal bool TryGet(string id, out RenderPackCatalogEntry entry) =>
+ _entries.TryGetValue(id, out entry!);
+
+ internal static RenderPackCatalog Build(
+ IEnumerable registrations,
+ RenderPackHostCapabilities capabilities)
+ {
+ ArgumentNullException.ThrowIfNull(registrations);
+ ArgumentNullException.ThrowIfNull(capabilities);
+ var entries = new Dictionary(
+ StringComparer.OrdinalIgnoreCase);
+
+ foreach (BufferedRenderPackRegistration registration in registrations)
+ {
+ RenderPackDescriptor descriptor = registration.Descriptor;
+ RenderPackValidationResult validation =
+ RenderPackValidator.ValidateDescriptor(descriptor, capabilities);
+ if (entries.ContainsKey(descriptor.Id))
+ continue;
+
+ IReadOnlyDictionary presetReasons =
+ descriptor.QualityPresets is null
+ ? new Dictionary(StringComparer.OrdinalIgnoreCase)
+ : descriptor.QualityPresets
+ .Where(static preset => preset is not null
+ && !string.IsNullOrWhiteSpace(preset.Id))
+ .GroupBy(static preset => preset.Id, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(
+ static group => group.Key,
+ group =>
+ {
+ RenderPackValidationResult result =
+ RenderPackValidator.ValidatePresetCompatibility(
+ descriptor,
+ group.First(),
+ capabilities);
+ return result.Success ? null : result.Reason;
+ },
+ StringComparer.OrdinalIgnoreCase);
+ entries.Add(
+ descriptor.Id,
+ new RenderPackCatalogEntry(
+ descriptor,
+ registration.Assets,
+ registration.RegistrationId,
+ validation.Success,
+ validation.Reason,
+ presetReasons));
+ }
+
+ return new RenderPackCatalog(entries);
+ }
+}
+
+internal interface IRenderPackRuntime : IDisposable
+{
+ RenderPackDescriptor Descriptor { get; }
+
+ RenderQualityPreset Preset { get; }
+}
+
+///
+/// An active conformance pack which deliberately contributes no render graph.
+/// Production keeps its selected identity/diagnostics active while delegating
+/// the world frame to acdream's unchanged default rendering path.
+///
+internal interface IDefaultWorldPathRenderPackRuntime : IRenderPackRuntime
+{
+}
+
+internal interface IRenderPackRuntimeFactory
+{
+ IRenderPackRuntime Build(
+ RenderPackDescriptor descriptor,
+ ValidatedRenderPackShaderAssets assets,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary userSettingOverrides);
+}
+
+internal enum RenderPackActivationState
+{
+ Retail,
+ CandidatePending,
+ Active,
+ FailedToRetail,
+}
+
+internal readonly record struct RenderPackActivationSnapshot(
+ RenderPackActivationState State,
+ RenderPackSelectionSettings Selection,
+ string? ActivePackDisplayName,
+ string? Reason,
+ long ActivationGeneration);
+
+internal readonly record struct RenderPackActivationExtent(
+ int Width,
+ int Height,
+ int SampleCount)
+{
+ internal void Validate()
+ {
+ if (Width <= 0 || Height <= 0 || SampleCount <= 0)
+ throw new ArgumentOutOfRangeException(nameof(Width), "Activation extent must be positive.");
+ }
+}
+
+///
+/// Owns asynchronous candidate preparation and atomic render-frame-boundary
+/// activation. The active runtime remains published while shader validation,
+/// GPU resource creation, target sizing, and receiver-pipeline preparation run
+/// off-side. A failed selection is remembered for its exact live registration
+/// and never retried in a loop; a corrected re-registration can be selected
+/// without recreating this controller.
+///
+internal sealed class RenderPackController :
+ IDisposable,
+ IRenderPackDiagnosticsSnapshotSource
+{
+ private readonly Func _catalog;
+ private readonly IRenderPackRuntimeFactory _factory;
+ private readonly IRenderPackReceiverPipelineCoordinator? _receiverPipelines;
+ private readonly IRenderPackPreparationScheduler _preparationScheduler;
+ private readonly RenderPackCatalogSource? _catalogSource;
+ private readonly Dictionary _failedSelections = [];
+ private readonly RenderPackPerformanceWindow _performance = new();
+ private RenderPackSelectionSettings? _pending;
+ private AtmosphericAutoQualityController? _autoQuality;
+ private AtmosphericQualityLevel? _pendingAutoQuality;
+ private string? _pendingAutoFallbackReason;
+ private PendingPreparation? _preparation;
+ private IRenderPackRuntime? _active;
+ private long _activeRegistrationId;
+ private IRenderPackRuntime? _observedPerformanceRuntime;
+ private long _observedResourceGeneration = -1;
+ private RenderPackActivationSnapshot _snapshot = new(
+ RenderPackActivationState.Retail,
+ RenderPackSelectionSettings.Retail,
+ ActivePackDisplayName: null,
+ Reason: null,
+ ActivationGeneration: 0);
+ private bool _disposed;
+ private int _catalogChanged;
+
+ internal RenderPackController(
+ Func catalog,
+ IRenderPackRuntimeFactory factory,
+ IRenderPackReceiverPipelineCoordinator? receiverPipelines = null,
+ IRenderPackPreparationScheduler? preparationScheduler = null,
+ RenderPackCatalogSource? catalogSource = null)
+ {
+ _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
+ _factory = factory ?? throw new ArgumentNullException(nameof(factory));
+ _receiverPipelines = receiverPipelines;
+ _preparationScheduler = preparationScheduler
+ ?? ThreadPoolRenderPackPreparationScheduler.Instance;
+ _catalogSource = catalogSource;
+ if (catalogSource is not null)
+ catalogSource.Changed += OnCatalogChanged;
+ }
+
+ internal RenderPackActivationSnapshot Snapshot => _snapshot;
+
+ internal IRenderPackRuntime? ActiveRuntime => _active;
+
+ internal RenderPackPerformanceSnapshot Performance => _performance.Snapshot();
+
+ internal int MinimumPerformanceSampleCount =>
+ _performance.MinimumSampleCount;
+
+ internal bool TryResetPerformanceEvidence(out string error)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_snapshot.State != RenderPackActivationState.Active
+ || _active is not IRenderPackRuntimePerformanceSource source)
+ {
+ error = "an active render pack with performance diagnostics is required";
+ return false;
+ }
+ if (_autoQuality is not null)
+ {
+ error = "performance evidence reset requires an explicit quality preset";
+ return false;
+ }
+
+ RenderPackRuntimePerformanceMetrics metrics =
+ source.CapturePerformanceMetrics();
+ Validate(in metrics);
+ _performance.Reset();
+ _observedPerformanceRuntime = _active;
+ _observedResourceGeneration = metrics.ResourceGeneration;
+ error = string.Empty;
+ return true;
+ }
+
+ internal AtmosphericAutoQualitySnapshot? AutoQuality => _autoQuality?.Snapshot;
+
+ public RenderPackDiagnosticsSnapshot CaptureDiagnostics()
+ {
+ RenderPackRuntimeDiagnostics runtime =
+ (_active as IRenderPackRuntimeDiagnosticsSource)?.CaptureDiagnostics()
+ ?? RenderPackRuntimeDiagnostics.Empty(_snapshot.Selection.PresetId);
+ RenderPackPerformanceSnapshot performance = _performance.Snapshot();
+ return new RenderPackDiagnosticsSnapshot(
+ _snapshot.State,
+ _snapshot.Selection.PackId,
+ _snapshot.Selection.PackVersion,
+ _snapshot.Selection.PresetId,
+ _active?.Preset.Id ?? runtime.EffectiveQuality,
+ _snapshot.Reason,
+ _snapshot.ActivationGeneration,
+ runtime.RetainedGpuBytes,
+ runtime.TransientGpuBytes,
+ runtime.ImageCount,
+ runtime.BufferCount,
+ runtime.DrawCalls,
+ runtime.DispatchCalls,
+ runtime.ShadowCasterCount,
+ runtime.CascadeDrawCount,
+ runtime.CpuClassificationCalls,
+ runtime.SunElevationDegrees,
+ runtime.ActiveDayGroup,
+ runtime.Weather,
+ runtime.WeatherIntensity,
+ runtime.Outdoor,
+ runtime.DirectionalShadowStrength,
+ runtime.Passes,
+ performance)
+ {
+ CpuStages = runtime.CpuStages,
+ DirectionalShadowSourceKind = runtime.DirectionalShadowSourceKind,
+ DirectionalShadowSourceObjectIndex =
+ runtime.DirectionalShadowSourceObjectIndex,
+ DirectionalShadowSourceGfxObjId =
+ runtime.DirectionalShadowSourceGfxObjId,
+ DirectionalShadowSurfaceToLightDirection =
+ runtime.DirectionalShadowSurfaceToLightDirection,
+ DirectionalShadowLightElevationSin =
+ runtime.DirectionalShadowLightElevationSin,
+ ShadowTransformChurn = runtime.ShadowTransformChurn,
+ SharedWorldTransformUsedInstances =
+ runtime.SharedWorldTransformUsedInstances,
+ };
+ }
+
+ internal void Request(RenderPackSelectionSettings? selection)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ RenderPackSelectionSettings normalized = Normalize(selection);
+ if (normalized == _snapshot.Selection
+ && _pending is null)
+ return;
+ _pendingAutoQuality = null;
+ _pendingAutoFallbackReason = null;
+ _pending = normalized;
+ _snapshot = _snapshot with
+ {
+ State = RenderPackActivationState.CandidatePending,
+ Selection = normalized,
+ ActivePackDisplayName = _active?.Descriptor.DisplayName,
+ Reason = $"Preparing render pack '{normalized.PackId}'.",
+ };
+ }
+
+ ///
+ /// Poll once at a stable render-frame boundary, before any pass opens.
+ /// This method never waits for candidate work. It only starts preparation,
+ /// observes completion, and publishes an already-complete candidate. The
+ /// existing runtime therefore remains renderable throughout preparation.
+ ///
+ internal RenderPackActivationSnapshot ApplyAtFrameBoundary(
+ RenderPackActivationExtent extent)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ extent.Validate();
+
+ if (ApplyCatalogChangeAtFrameBoundary() is { } catalogFailure)
+ return catalogFailure;
+
+ if (_preparation is { } preparation)
+ {
+ if (!preparation.Work.IsCompleted)
+ return _snapshot;
+
+ _preparation = null;
+ if (preparation.Work.IsCanceled)
+ {
+ preparation.Dispose();
+ return Fail(
+ preparation.Plan.Selection,
+ PreparationFailure(
+ preparation.Plan.Selection,
+ "candidate preparation was cancelled"),
+ preparation.Plan.Entry.RegistrationId);
+ }
+ if (preparation.Work.IsFaulted)
+ {
+ Exception failure = preparation.Work.Exception!.GetBaseException();
+ preparation.Dispose();
+ if (VulkanRenderFailurePolicy.IsFatal(failure))
+ throw failure;
+ return Fail(
+ preparation.Plan.Selection,
+ PreparationFailure(preparation.Plan.Selection, failure.Message),
+ preparation.Plan.Entry.RegistrationId);
+ }
+
+ if (_pending is not null
+ || preparation.Plan.Selection != _snapshot.Selection)
+ {
+ preparation.Dispose();
+ if (_pending is null
+ && _snapshot.State == RenderPackActivationState.FailedToRetail
+ && _snapshot.Selection.IsRetail)
+ {
+ return _snapshot;
+ }
+ if (_pending is null)
+ _pending = _snapshot.Selection;
+ }
+ else if (preparation.Plan.Extent != extent)
+ {
+ preparation.Dispose();
+ StartPreparation(preparation.Plan with { Extent = extent });
+ return _snapshot;
+ }
+ else
+ {
+ PreparationOutcome outcome = preparation.TakeOutcome();
+ if (outcome.FailureReason is not null)
+ {
+ string? retirementFailure = outcome.DisposeResources();
+ return Fail(
+ preparation.Plan.Selection,
+ outcome.FailureReason
+ + FormatRetirementFailure(retirementFailure),
+ preparation.Plan.Entry.RegistrationId);
+ }
+ return PublishPreparedCandidate(preparation.Plan, outcome);
+ }
+ }
+
+ if (_pending is { } selection)
+ {
+ _pending = null;
+ if (selection.IsRetail)
+ {
+ string? retirementFailure = RetireActive();
+ return PublishRetail(selection, retirementFailure);
+ }
+
+ ActivationPlan? plan = PlanSelection(selection, in extent);
+ if (plan is null)
+ return _snapshot;
+ StartPreparation(plan);
+ if (_preparation!.Work.IsCompleted)
+ return ApplyAtFrameBoundary(extent);
+ return _snapshot;
+ }
+
+ if (_pendingAutoFallbackReason is { } fallbackReason)
+ {
+ _pendingAutoFallbackReason = null;
+ return Fail(_snapshot.Selection, fallbackReason);
+ }
+
+ if (_pendingAutoQuality is { } quality)
+ {
+ _pendingAutoQuality = null;
+ ActivationPlan? plan = PlanAutomaticQuality(quality, in extent);
+ if (plan is null)
+ return _snapshot;
+ StartPreparation(plan);
+ if (_preparation!.Work.IsCompleted)
+ return ApplyAtFrameBoundary(extent);
+ }
+ return _snapshot;
+ }
+
+ private ActivationPlan? PlanSelection(
+ RenderPackSelectionSettings selection,
+ in RenderPackActivationExtent extent)
+ {
+ RenderPackCatalog catalog = _catalog();
+ if (!catalog.TryGet(selection.PackId, out RenderPackCatalogEntry entry))
+ {
+ if (AlreadyFailed(selection, registrationId: 0))
+ return null;
+ Fail(selection, $"Render pack '{selection.PackId}' is not installed.", 0);
+ return null;
+ }
+ if (AlreadyFailed(selection, entry.RegistrationId))
+ return null;
+ if (!entry.IsCompatible)
+ {
+ Fail(selection, entry.IncompatibilityReason ?? "The render pack is incompatible.");
+ return null;
+ }
+ if (!string.Equals(
+ entry.Descriptor.PackVersion.ToString(),
+ selection.PackVersion,
+ StringComparison.Ordinal))
+ {
+ Fail(
+ selection,
+ $"Render pack '{selection.PackId}' version {selection.PackVersion ?? "(missing)"} "
+ + $"was selected, but version {entry.Descriptor.PackVersion} is installed.");
+ return null;
+ }
+
+ RenderQualityPreset? selectedPreset = entry.Descriptor.QualityPresets.FirstOrDefault(
+ value => string.Equals(value.Id, selection.PresetId, StringComparison.OrdinalIgnoreCase));
+ if (selectedPreset is null)
+ {
+ Fail(selection, $"Render pack preset '{selection.PresetId}' is not available.");
+ return null;
+ }
+ if (entry.PresetIncompatibilityReasons.TryGetValue(
+ selectedPreset.Id,
+ out string? presetFailure)
+ && presetFailure is not null)
+ {
+ Fail(selection, presetFailure);
+ return null;
+ }
+
+ RenderPackValidationResult userSettings =
+ RenderPackSettingResolution.ValidateUserOverrides(
+ entry.Descriptor,
+ selection.SettingOverrides);
+ if (!userSettings.Success)
+ {
+ Fail(selection, userSettings.Reason!);
+ return null;
+ }
+
+ bool automaticSelector =
+ selectedPreset.Semantic == RenderQualitySemantic.Automatic;
+ bool automatic = TryResolveAutomaticSetting(
+ entry.Descriptor,
+ selectedPreset,
+ selection.SettingOverrides,
+ out bool configuredAutomatic)
+ ? configuredAutomatic
+ : automaticSelector;
+ RenderQualityPreset preset = selectedPreset;
+ AtmosphericQualityLevel autoInitial = AtmosphericQualityLevel.Medium;
+ AtmosphericQualityLevel autoMaximum = AtmosphericQualityLevel.High;
+ if (automaticSelector || automatic)
+ {
+ if (!TryResolveAutomaticRange(
+ entry,
+ out preset,
+ out autoInitial,
+ out autoMaximum,
+ out string? automaticFailure))
+ {
+ Fail(selection, automaticFailure!);
+ return null;
+ }
+ if (automatic
+ && !automaticSelector
+ && TryQualityLevel(selectedPreset.Semantic, out AtmosphericQualityLevel preferred))
+ {
+ preset = selectedPreset;
+ autoInitial = preferred;
+ }
+ }
+
+ AutomaticPublication automaticPublication = automatic
+ ? new AutomaticPublication(
+ AutomaticPublicationMode.Initialise,
+ AutomaticBudgets(entry),
+ autoInitial,
+ autoMaximum)
+ : AutomaticPublication.Disabled;
+ return new ActivationPlan(
+ entry,
+ selection,
+ preset,
+ extent,
+ RequirePerformanceSource: automatic,
+ automaticPublication);
+ }
+
+ ///
+ /// Consumes one completed pack frame without allocating or waiting for the
+ /// GPU. A quality decision only queues an off-side candidate; publication
+ /// remains owned by the next stable frame boundary.
+ ///
+ internal void ObserveActiveFrame(
+ in RenderPackFramePerformanceObservation observation)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ Validate(in observation);
+ IRenderPackRuntime? active = _active;
+ if (active is not IRenderPackRuntimePerformanceSource source)
+ return;
+
+ RenderPackRuntimePerformanceMetrics metrics =
+ source.CapturePerformanceMetrics();
+ Validate(in metrics);
+ if (!ReferenceEquals(active, _observedPerformanceRuntime)
+ || metrics.ResourceGeneration != _observedResourceGeneration)
+ {
+ _performance.Reset();
+ _observedPerformanceRuntime = active;
+ _observedResourceGeneration = metrics.ResourceGeneration;
+ return;
+ }
+ if (!observation.StableFrameBoundary
+ || !metrics.HasResolvedGpuMeasurement)
+ {
+ return;
+ }
+
+ _performance.Observe(
+ observation.PackAddedCpuMilliseconds,
+ observation.AbsoluteEnhancedWorldReceiverCpuMilliseconds,
+ hasResolvedGpuMeasurement: true,
+ metrics.InclusiveResolvedGpuMilliseconds,
+ metrics.RetainedGpuBytes,
+ metrics.TransientGpuBytes);
+ if (_autoQuality is null
+ || _pendingAutoQuality is not null
+ || _pendingAutoFallbackReason is not null
+ || _snapshot.State != RenderPackActivationState.Active)
+ {
+ return;
+ }
+
+ RenderPackPerformanceSnapshot performance = _performance.Snapshot();
+ var measurement = new AtmosphericQualityMeasurement(
+ performance.InclusiveGpuMillisecondsP99,
+ performance.IncrementalCpuMillisecondsP99,
+ performance.ResidentGpuBytes,
+ StableFrameBoundary: true);
+ AtmosphericQualityBudget budget = _autoQuality.CurrentBudget;
+ long priorGeneration = _autoQuality.Snapshot.ChangeGeneration;
+ AtmosphericAutoQualitySnapshot quality = _autoQuality.Observe(in measurement);
+ if (quality.ChangeGeneration == priorGeneration)
+ return;
+
+ if (quality.SafeFallbackToRetailRequested)
+ {
+ _pendingAutoFallbackReason = string.Format(
+ System.Globalization.CultureInfo.InvariantCulture,
+ "Automatic quality disabled render pack '{0}' because {1} remained "
+ + "over its declared performance budget for {2} stable samples: "
+ + "GPU p99 {3:F3} ms (budget {4:F3} ms), CPU p99 {5:F3} ms "
+ + "(budget {6:F3} ms), resident GPU bytes {7} (budget {8}).",
+ _snapshot.Selection.PackId,
+ quality.Current,
+ AtmosphericAutoQualityController.DowngradeHysteresisFrames,
+ measurement.InclusivePackGpuMillisecondsP99,
+ budget.GpuMillisecondsP99,
+ measurement.IncrementalCpuMillisecondsP99,
+ budget.CpuMillisecondsP99,
+ measurement.ResidentGpuBytes,
+ budget.ResidentGpuBytes);
+ return;
+ }
+
+ _pendingAutoQuality = quality.Current;
+ }
+
+ private ActivationPlan? PlanAutomaticQuality(
+ AtmosphericQualityLevel quality,
+ in RenderPackActivationExtent extent)
+ {
+ RenderPackSelectionSettings selection = _snapshot.Selection;
+ if (_active is null
+ || _autoQuality is null
+ || _snapshot.State != RenderPackActivationState.Active)
+ {
+ return null;
+ }
+
+ RenderPackCatalog catalog = _catalog();
+ if (!catalog.TryGet(selection.PackId, out RenderPackCatalogEntry entry))
+ {
+ Fail(selection, $"Render pack '{selection.PackId}' is no longer installed.");
+ return null;
+ }
+ if (!entry.IsCompatible)
+ {
+ Fail(selection, entry.IncompatibilityReason ?? "The render pack is incompatible.");
+ return null;
+ }
+ if (!string.Equals(
+ entry.Descriptor.PackVersion.ToString(),
+ selection.PackVersion,
+ StringComparison.Ordinal))
+ {
+ Fail(
+ selection,
+ $"Render pack '{selection.PackId}' changed version during automatic quality selection.");
+ return null;
+ }
+
+ RenderQualityPreset? preset = FindEffectivePreset(entry, quality);
+ if (preset is null)
+ {
+ Fail(
+ selection,
+ $"Automatic quality cannot select a missing or ineligible "
+ + $"'{QualitySemantic(quality)}' semantic preset.");
+ return null;
+ }
+ if (entry.PresetIncompatibilityReasons.TryGetValue(
+ preset.Id,
+ out string? presetFailure)
+ && presetFailure is not null)
+ {
+ Fail(selection, presetFailure);
+ return null;
+ }
+
+ RenderPackValidationResult userSettings =
+ RenderPackSettingResolution.ValidateUserOverrides(
+ entry.Descriptor,
+ selection.SettingOverrides);
+ if (!userSettings.Success)
+ {
+ Fail(selection, userSettings.Reason!);
+ return null;
+ }
+
+ _snapshot = _snapshot with
+ {
+ State = RenderPackActivationState.CandidatePending,
+ ActivePackDisplayName = _active.Descriptor.DisplayName,
+ Reason = $"Preparing automatic quality preset '{preset.DisplayName}'.",
+ };
+ return new ActivationPlan(
+ entry,
+ selection,
+ preset,
+ extent,
+ RequirePerformanceSource: true,
+ AutomaticPublication.Preserve);
+ }
+
+ private void StartPreparation(ActivationPlan plan)
+ {
+ var preparation = new PendingPreparation(plan);
+ _preparation = preparation;
+ _snapshot = _snapshot with
+ {
+ State = RenderPackActivationState.CandidatePending,
+ Selection = plan.Selection,
+ ActivePackDisplayName = _active?.Descriptor.DisplayName,
+ Reason = $"Preparing render pack '{plan.Selection.PackId}' preset "
+ + $"'{plan.Preset.DisplayName}'.",
+ };
+ try
+ {
+ preparation.Work = _preparationScheduler.Schedule(
+ () => preparation.Outcome = PrepareCandidate(plan));
+ }
+ catch (Exception error) when (VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ _preparation = null;
+ preparation.Dispose();
+ throw;
+ }
+ catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ preparation.Outcome = PreparationOutcome.Failed(
+ PreparationFailure(plan.Selection, error.GetBaseException().Message));
+ preparation.Work = Task.CompletedTask;
+ }
+ }
+
+ private PreparationOutcome PrepareCandidate(ActivationPlan plan)
+ {
+ IRenderPackRuntime? candidate = null;
+ IRenderPackReceiverPipelineCandidate? receiverCandidate = null;
+ try
+ {
+ RenderPackValidationResult assets = RenderPackValidator.ValidateSelectedAssets(
+ plan.Entry.Descriptor,
+ plan.Entry.Assets,
+ out ValidatedRenderPackShaderAssets? validatedAssets);
+ if (!assets.Success)
+ return PreparationOutcome.Failed(assets.Reason!);
+
+ candidate = _factory.Build(
+ plan.Entry.Descriptor,
+ validatedAssets!,
+ plan.Preset,
+ plan.Selection.SettingOverrides);
+ if (candidate is null)
+ throw new InvalidOperationException("The render-pack factory returned no candidate.");
+ if (!ReferenceEquals(candidate.Descriptor, plan.Entry.Descriptor)
+ && candidate.Descriptor != plan.Entry.Descriptor)
+ throw new InvalidOperationException("The candidate does not represent the selected descriptor.");
+ if (!ReferenceEquals(candidate.Preset, plan.Preset)
+ && candidate.Preset != plan.Preset)
+ throw new InvalidOperationException("The candidate does not represent the selected preset.");
+ if (plan.RequirePerformanceSource
+ && candidate is not IRenderPackRuntimePerformanceSource)
+ {
+ throw new NotSupportedException(
+ "Automatic quality requires allocation-free runtime performance metrics.");
+ }
+ if (candidate is IAtmosphericWorldGraphRuntime graph)
+ {
+ _ = graph.PrepareWorldTarget(
+ plan.Extent.Width,
+ plan.Extent.Height,
+ plan.Extent.SampleCount);
+ }
+ else if (plan.RequirePerformanceSource)
+ {
+ throw new NotSupportedException(
+ "Automatic quality requires a complete off-side world graph candidate.");
+ }
+
+ IDirectionalShadowReceiverSource? receiverSource =
+ candidate is IDirectionalShadowWorldGraphRuntime directional
+ ? directional.DirectionalShadowReceivers
+ : null;
+ if (receiverSource is not null && _receiverPipelines is null)
+ {
+ throw new NotSupportedException(
+ "Directional-shadow activation requires an atomic receiver-pipeline coordinator.");
+ }
+ if (_receiverPipelines is not null)
+ {
+ receiverCandidate = _receiverPipelines.Prepare(
+ receiverSource,
+ plan.Extent.SampleCount);
+ }
+ PreparationOutcome outcome = PreparationOutcome.Ready(
+ candidate,
+ receiverCandidate);
+ candidate = null;
+ receiverCandidate = null;
+ return outcome;
+ }
+ catch (Exception error) when (VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ BestEffortDisposeForFatal(receiverCandidate);
+ BestEffortDisposeForFatal(candidate);
+ throw;
+ }
+ catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ return new PreparationOutcome(
+ candidate,
+ receiverCandidate,
+ PreparationFailure(
+ plan.Selection,
+ error.GetBaseException().Message));
+ }
+ }
+
+ private RenderPackActivationSnapshot PublishPreparedCandidate(
+ ActivationPlan plan,
+ PreparationOutcome outcome)
+ {
+ IRenderPackRuntime? candidate = outcome.TakeRuntime();
+ IRenderPackReceiverPipelineCandidate? receiverCandidate =
+ outcome.TakeReceiverCandidate();
+ try
+ {
+ IRenderPackRuntime? previous = _active;
+ if (receiverCandidate is not null)
+ {
+ _receiverPipelines!.Publish(receiverCandidate);
+ receiverCandidate = null;
+ }
+ _active = candidate;
+ _activeRegistrationId = plan.Entry.RegistrationId;
+ candidate = null;
+ ResetPerformanceTracking(_active);
+ previous?.Dispose();
+ ApplyAutomaticPublication(plan.AutomaticPublication);
+ _snapshot = new RenderPackActivationSnapshot(
+ RenderPackActivationState.Active,
+ plan.Selection,
+ plan.Entry.Descriptor.DisplayName,
+ Reason: null,
+ ActivationGeneration: checked(_snapshot.ActivationGeneration + 1));
+ return _snapshot;
+ }
+ catch (Exception error) when (VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ BestEffortDisposeForFatal(receiverCandidate);
+ BestEffortDisposeForFatal(candidate);
+ throw;
+ }
+ catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ string? receiverRetirement = TryDisposeReceiverCandidate(receiverCandidate);
+ string? candidateRetirement = TryDispose(candidate);
+ return Fail(
+ plan.Selection,
+ $"Render pack '{plan.Selection.PackId}' could not be published: "
+ + error.GetBaseException().Message
+ + FormatRetirementFailure(receiverRetirement)
+ + FormatRetirementFailure(candidateRetirement),
+ plan.Entry.RegistrationId);
+ }
+ finally
+ {
+ outcome.Dispose();
+ }
+ }
+
+ private void ApplyAutomaticPublication(AutomaticPublication publication)
+ {
+ if (publication.Mode == AutomaticPublicationMode.Preserve)
+ return;
+ _pendingAutoQuality = null;
+ _pendingAutoFallbackReason = null;
+ _autoQuality = publication.Mode == AutomaticPublicationMode.Initialise
+ ? new AtmosphericAutoQualityController(
+ publication.Budgets!,
+ publication.Initial,
+ AtmosphericQualityLevel.Low,
+ publication.Maximum)
+ : null;
+ }
+
+ private static string PreparationFailure(
+ RenderPackSelectionSettings selection,
+ string reason) =>
+ $"Render pack '{selection.PackId}' could not be prepared: {reason}";
+
+ internal void OnRuntimeFailure(string reason)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentException.ThrowIfNullOrWhiteSpace(reason);
+ RenderPackSelectionSettings failed = _snapshot.Selection;
+ if (!failed.IsRetail)
+ _failedSelections[failed] = _activeRegistrationId;
+ string? retirementFailure = RetireActive();
+ ClearAutomaticQuality();
+ _snapshot = new RenderPackActivationSnapshot(
+ RenderPackActivationState.FailedToRetail,
+ RenderPackSelectionSettings.Retail,
+ ActivePackDisplayName: null,
+ reason + FormatRetirementFailure(retirementFailure),
+ ActivationGeneration: checked(_snapshot.ActivationGeneration + 1));
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ if (_catalogSource is not null)
+ _catalogSource.Changed -= OnCatalogChanged;
+ _pending = null;
+ if (_preparation is { } preparation)
+ {
+ _preparation = null;
+ try
+ {
+ preparation.Work.GetAwaiter().GetResult();
+ }
+ catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ // The controller is terminal. Waiting prevents a candidate
+ // worker from racing device teardown; the active/fallback
+ // diagnostic was already published before shutdown began.
+ }
+ finally
+ {
+ preparation.Dispose();
+ }
+ }
+ ClearAutomaticQuality();
+ RetireActive();
+ }
+
+ private RenderPackActivationSnapshot Fail(
+ RenderPackSelectionSettings selection,
+ string reason,
+ long? registrationId = null)
+ {
+ _failedSelections[selection] = registrationId
+ ?? CurrentRegistrationId(selection);
+ string? retirementFailure = RetireActive();
+ ClearAutomaticQuality();
+ _snapshot = new RenderPackActivationSnapshot(
+ RenderPackActivationState.FailedToRetail,
+ RenderPackSelectionSettings.Retail,
+ ActivePackDisplayName: null,
+ reason + FormatRetirementFailure(retirementFailure),
+ ActivationGeneration: checked(_snapshot.ActivationGeneration + 1));
+ return _snapshot;
+ }
+
+ private RenderPackActivationSnapshot PublishRetail(
+ RenderPackSelectionSettings requested,
+ string? reason)
+ {
+ ClearAutomaticQuality();
+ _snapshot = new RenderPackActivationSnapshot(
+ reason is null
+ ? RenderPackActivationState.Retail
+ : RenderPackActivationState.FailedToRetail,
+ RenderPackSelectionSettings.Retail,
+ ActivePackDisplayName: null,
+ reason,
+ ActivationGeneration: checked(_snapshot.ActivationGeneration + 1));
+ return _snapshot;
+ }
+
+ private string? RetireActive()
+ {
+ IRenderPackRuntime? active = _active;
+ _active = null;
+ _activeRegistrationId = 0;
+ ResetPerformanceTracking(null);
+ string? receiverFailure = TryClearReceiverPipelines();
+ string? runtimeFailure = TryDispose(active);
+ return CombineRetirementFailures(receiverFailure, runtimeFailure);
+ }
+
+ private bool AlreadyFailed(
+ RenderPackSelectionSettings selection,
+ long registrationId)
+ {
+ if (!_failedSelections.TryGetValue(selection, out long failedRegistrationId))
+ return false;
+ if (failedRegistrationId != registrationId)
+ {
+ // Stable pack/version/preset IDs are persistence keys, not a
+ // process-lifetime quarantine key. A corrected registration gets
+ // one fresh explicit activation attempt.
+ _failedSelections.Remove(selection);
+ return false;
+ }
+
+ RetireActive();
+ PublishRetail(
+ selection,
+ "This pack selection already failed for the current registration "
+ + "and will not be retried.");
+ return true;
+ }
+
+ private long CurrentRegistrationId(RenderPackSelectionSettings selection)
+ {
+ if (selection.IsRetail)
+ return 0;
+ return _catalog().TryGet(selection.PackId, out RenderPackCatalogEntry entry)
+ ? entry.RegistrationId
+ : 0;
+ }
+
+ private void OnCatalogChanged(long revision)
+ {
+ _ = revision;
+ Interlocked.Exchange(ref _catalogChanged, 1);
+ }
+
+ ///
+ /// Registry notifications may originate on plugin load/unload threads.
+ /// The notification only raises a flag; candidate invalidation, receiver
+ /// retirement, and runtime disposal remain atomic at this frame boundary.
+ ///
+ private RenderPackActivationSnapshot? ApplyCatalogChangeAtFrameBoundary()
+ {
+ RenderPackCatalogSource? source = _catalogSource;
+ if (source is null || Interlocked.Exchange(ref _catalogChanged, 0) == 0)
+ return null;
+
+ RenderPackSelectionSettings selection = _snapshot.Selection;
+ if (selection.IsRetail)
+ return null;
+
+ long selectedRegistrationId = _preparation?.Plan.Entry.RegistrationId
+ ?? _activeRegistrationId;
+ if (selectedRegistrationId == 0)
+ {
+ // A catalog event can precede an ordinary user/settings request.
+ // PlanSelection owns first-time admission and its precise reason;
+ // there is no published/candidate registration to retire here.
+ return null;
+ }
+ RenderPackCatalog catalog = source.Snapshot();
+ if (!catalog.TryGet(selection.PackId, out RenderPackCatalogEntry current))
+ {
+ _pending = null;
+ return Fail(
+ selection,
+ $"Render pack '{selection.PackId}' was withdrawn; acdream's default renderer is active.",
+ selectedRegistrationId);
+ }
+ if (!string.Equals(
+ current.Descriptor.PackVersion.ToString(),
+ selection.PackVersion,
+ StringComparison.Ordinal)
+ || (selectedRegistrationId != 0
+ && current.RegistrationId != selectedRegistrationId))
+ {
+ _pending = null;
+ return Fail(
+ selection,
+ $"Render pack '{selection.PackId}' was replaced by registration "
+ + $"version {current.Descriptor.PackVersion}; reselect it to activate the update.",
+ selectedRegistrationId);
+ }
+ return null;
+ }
+
+ private void ResetPerformanceTracking(IRenderPackRuntime? runtime)
+ {
+ _performance.Reset();
+ _observedPerformanceRuntime = runtime;
+ _observedResourceGeneration =
+ runtime is IRenderPackRuntimePerformanceSource source
+ ? source.CapturePerformanceMetrics().ResourceGeneration
+ : -1;
+ }
+
+ private void ClearAutomaticQuality()
+ {
+ _autoQuality = null;
+ _pendingAutoQuality = null;
+ _pendingAutoFallbackReason = null;
+ }
+
+ private static RenderQualityPreset? FindEffectivePreset(
+ RenderPackCatalogEntry entry,
+ AtmosphericQualityLevel quality) =>
+ entry.Descriptor.QualityPresets.FirstOrDefault(preset =>
+ preset.AutoEligible
+ && preset.Semantic == QualitySemantic(quality));
+
+ private static bool TryResolveAutomaticSetting(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary userOverrides,
+ out bool automatic)
+ {
+ RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(value =>
+ value.Semantic == RenderSettingSemantic.AutomaticQuality);
+ if (setting is null)
+ {
+ automatic = false;
+ return false;
+ }
+
+ automatic = bool.Parse(RenderPackSettingResolution.Resolve(
+ setting,
+ preset,
+ userOverrides));
+ return true;
+ }
+
+ private static bool TryResolveAutomaticRange(
+ RenderPackCatalogEntry entry,
+ out RenderQualityPreset preset,
+ out AtmosphericQualityLevel initial,
+ out AtmosphericQualityLevel maximum,
+ out string? failure)
+ {
+ preset = null!;
+ initial = AtmosphericQualityLevel.Low;
+ maximum = AtmosphericQualityLevel.Low;
+ failure = null;
+
+ RenderQualityPreset? low = FindEffectivePreset(
+ entry,
+ AtmosphericQualityLevel.Low);
+ if (low is null)
+ {
+ failure = "Automatic quality requires an AutoEligible Low semantic preset as its safe fallback.";
+ return false;
+ }
+ if (entry.PresetIncompatibilityReasons.TryGetValue(
+ low.Id,
+ out string? lowFailure)
+ && lowFailure is not null)
+ {
+ failure = "Automatic quality cannot support Low on this host: " + lowFailure;
+ return false;
+ }
+
+ preset = low;
+ RenderQualityPreset? medium = FindEffectivePreset(
+ entry,
+ AtmosphericQualityLevel.Medium);
+ if (medium is null
+ || (entry.PresetIncompatibilityReasons.TryGetValue(
+ medium.Id,
+ out string? mediumFailure)
+ && mediumFailure is not null))
+ {
+ return true;
+ }
+
+ preset = medium;
+ initial = AtmosphericQualityLevel.Medium;
+ maximum = AtmosphericQualityLevel.Medium;
+ RenderQualityPreset? high = FindEffectivePreset(
+ entry,
+ AtmosphericQualityLevel.High);
+ if (high is not null
+ && (!entry.PresetIncompatibilityReasons.TryGetValue(
+ high.Id,
+ out string? highFailure)
+ || highFailure is null))
+ {
+ maximum = AtmosphericQualityLevel.High;
+ }
+ return true;
+ }
+
+ private static AtmosphericQualityBudget[] AutomaticBudgets(
+ RenderPackCatalogEntry entry) =>
+ [
+ Budget(entry, AtmosphericQualityLevel.Low),
+ Budget(entry, AtmosphericQualityLevel.Medium),
+ Budget(entry, AtmosphericQualityLevel.High),
+ ];
+
+ private static AtmosphericQualityBudget Budget(
+ RenderPackCatalogEntry entry,
+ AtmosphericQualityLevel level) =>
+ AtmosphericQualityBudget.FromPreset(
+ entry.Descriptor.QualityPresets.Single(preset =>
+ preset.Semantic == QualitySemantic(level)));
+
+ private static RenderQualitySemantic QualitySemantic(
+ AtmosphericQualityLevel quality) => quality switch
+ {
+ AtmosphericQualityLevel.Low => RenderQualitySemantic.Low,
+ AtmosphericQualityLevel.Medium => RenderQualitySemantic.Medium,
+ AtmosphericQualityLevel.High => RenderQualitySemantic.High,
+ _ => throw new ArgumentOutOfRangeException(nameof(quality)),
+ };
+
+ private static bool TryQualityLevel(
+ RenderQualitySemantic semantic,
+ out AtmosphericQualityLevel quality)
+ {
+ quality = semantic switch
+ {
+ RenderQualitySemantic.Low => AtmosphericQualityLevel.Low,
+ RenderQualitySemantic.Medium => AtmosphericQualityLevel.Medium,
+ RenderQualitySemantic.High => AtmosphericQualityLevel.High,
+ _ => default,
+ };
+ return semantic is RenderQualitySemantic.Low
+ or RenderQualitySemantic.Medium
+ or RenderQualitySemantic.High;
+ }
+
+ private enum AutomaticPublicationMode : byte
+ {
+ Disable,
+ Initialise,
+ Preserve,
+ }
+
+ private readonly record struct AutomaticPublication(
+ AutomaticPublicationMode Mode,
+ AtmosphericQualityBudget[]? Budgets,
+ AtmosphericQualityLevel Initial,
+ AtmosphericQualityLevel Maximum)
+ {
+ internal static AutomaticPublication Disabled { get; } = new(
+ AutomaticPublicationMode.Disable,
+ Budgets: null,
+ AtmosphericQualityLevel.Low,
+ AtmosphericQualityLevel.Low);
+
+ internal static AutomaticPublication Preserve { get; } = new(
+ AutomaticPublicationMode.Preserve,
+ Budgets: null,
+ AtmosphericQualityLevel.Low,
+ AtmosphericQualityLevel.Low);
+ }
+
+ private sealed record ActivationPlan(
+ RenderPackCatalogEntry Entry,
+ RenderPackSelectionSettings Selection,
+ RenderQualityPreset Preset,
+ RenderPackActivationExtent Extent,
+ bool RequirePerformanceSource,
+ AutomaticPublication AutomaticPublication);
+
+ private sealed class PendingPreparation(ActivationPlan plan) : IDisposable
+ {
+ private PreparationOutcome? _outcome;
+
+ internal ActivationPlan Plan { get; } = plan;
+
+ internal Task Work { get; set; } = Task.CompletedTask;
+
+ internal PreparationOutcome? Outcome
+ {
+ set => _outcome = value;
+ }
+
+ internal PreparationOutcome TakeOutcome()
+ {
+ if (!Work.IsCompleted)
+ throw new InvalidOperationException("Render-pack preparation is not complete.");
+ PreparationOutcome outcome = _outcome
+ ?? throw new InvalidOperationException(
+ "Render-pack preparation completed without an outcome.");
+ _outcome = null;
+ return outcome;
+ }
+
+ public void Dispose()
+ {
+ _outcome?.Dispose();
+ _outcome = null;
+ }
+ }
+
+ private sealed class PreparationOutcome(
+ IRenderPackRuntime? runtime,
+ IRenderPackReceiverPipelineCandidate? receiverCandidate,
+ string? failureReason) : IDisposable
+ {
+ private IRenderPackRuntime? _runtime = runtime;
+ private IRenderPackReceiverPipelineCandidate? _receiverCandidate = receiverCandidate;
+
+ internal string? FailureReason { get; } = failureReason;
+
+ internal static PreparationOutcome Ready(
+ IRenderPackRuntime runtime,
+ IRenderPackReceiverPipelineCandidate? receiverCandidate) =>
+ new(runtime, receiverCandidate, null);
+
+ internal static PreparationOutcome Failed(string reason) =>
+ new(null, null, reason);
+
+ internal IRenderPackRuntime TakeRuntime()
+ {
+ IRenderPackRuntime runtime = _runtime
+ ?? throw new InvalidOperationException(
+ "The prepared render-pack outcome has no runtime.");
+ _runtime = null;
+ return runtime;
+ }
+
+ internal IRenderPackReceiverPipelineCandidate? TakeReceiverCandidate()
+ {
+ IRenderPackReceiverPipelineCandidate? candidate = _receiverCandidate;
+ _receiverCandidate = null;
+ return candidate;
+ }
+
+ internal string? DisposeResources()
+ {
+ string? receiverFailure = TryDisposeReceiverCandidate(_receiverCandidate);
+ _receiverCandidate = null;
+ string? runtimeFailure = TryDispose(_runtime);
+ _runtime = null;
+ return CombineRetirementFailures(receiverFailure, runtimeFailure);
+ }
+
+ public void Dispose() => _ = DisposeResources();
+ }
+
+ private static void Validate(in RenderPackFramePerformanceObservation value)
+ {
+ if (!double.IsFinite(value.PackAddedCpuMilliseconds)
+ || value.PackAddedCpuMilliseconds < 0d
+ || !double.IsFinite(value.AbsoluteEnhancedWorldReceiverCpuMilliseconds)
+ || value.AbsoluteEnhancedWorldReceiverCpuMilliseconds < 0d
+ || value.ViewportWidth <= 0
+ || value.ViewportHeight <= 0
+ || value.SampleCount <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(value),
+ "Render-pack frame measurements and target dimensions must be valid.");
+ }
+ }
+
+ private static void Validate(in RenderPackRuntimePerformanceMetrics value)
+ {
+ if (value.ResourceGeneration < 0
+ || (value.HasResolvedGpuMeasurement
+ && (!double.IsFinite(value.InclusiveResolvedGpuMilliseconds)
+ || value.InclusiveResolvedGpuMilliseconds < 0d))
+ || value.RetainedGpuBytes < 0
+ || value.TransientGpuBytes < 0)
+ {
+ throw new InvalidOperationException(
+ "The render-pack runtime published invalid performance metrics.");
+ }
+ }
+
+ private static string? TryDispose(IRenderPackRuntime? runtime)
+ {
+ if (runtime is null)
+ return null;
+ try
+ {
+ runtime.Dispose();
+ return null;
+ }
+ catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ return error.GetBaseException().Message;
+ }
+ }
+
+ private static string? TryDisposeReceiverCandidate(
+ IRenderPackReceiverPipelineCandidate? candidate)
+ {
+ if (candidate is null)
+ return null;
+ try
+ {
+ candidate.Dispose();
+ return null;
+ }
+ catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ return error.GetBaseException().Message;
+ }
+ }
+
+ private string? TryClearReceiverPipelines()
+ {
+ if (_receiverPipelines is null)
+ return null;
+ try
+ {
+ _receiverPipelines.Clear();
+ return null;
+ }
+ catch (Exception error) when (!VulkanRenderFailurePolicy.IsFatal(error))
+ {
+ return error.GetBaseException().Message;
+ }
+ }
+
+ private static string? CombineRetirementFailures(string? first, string? second) =>
+ first is null ? second : second is null ? first : first + "; " + second;
+
+ private static void BestEffortDisposeForFatal(IDisposable? value)
+ {
+ if (value is null)
+ return;
+ try
+ {
+ value.Dispose();
+ }
+ catch
+ {
+ // Preserve the original terminal device/memory exception. Host
+ // teardown owns the remaining best-effort backend cleanup.
+ }
+ }
+
+ private static string FormatRetirementFailure(string? reason) =>
+ reason is null ? string.Empty : $" Pack resource retirement also failed: {reason}";
+
+ private static RenderPackSelectionSettings Normalize(
+ RenderPackSelectionSettings? selection)
+ {
+ if (selection is null
+ || string.IsNullOrWhiteSpace(selection.PackId)
+ || string.IsNullOrWhiteSpace(selection.PresetId))
+ return RenderPackSelectionSettings.Retail;
+ if (selection.IsRetail)
+ return RenderPackSelectionSettings.Retail;
+ return selection;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackDiagnostics.cs b/src/AcDream.App/Rendering/Packs/RenderPackDiagnostics.cs
new file mode 100644
index 00000000..4275ad35
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackDiagnostics.cs
@@ -0,0 +1,284 @@
+using System.Numerics;
+
+namespace AcDream.App.Rendering.Packs;
+
+internal readonly record struct RenderPackPassDiagnostics(
+ string PassId,
+ double GpuMilliseconds,
+ int DrawCalls,
+ int DispatchCalls);
+
+///
+/// Pack-owned facts sampled after a successful frame. Implementations expose
+/// already-resolved asynchronous timestamp results; capturing this value must
+/// never wait for the GPU.
+///
+internal sealed record RenderPackRuntimeDiagnostics(
+ string EffectiveQuality,
+ long RetainedGpuBytes,
+ long TransientGpuBytes,
+ int ImageCount,
+ int BufferCount,
+ int DrawCalls,
+ int DispatchCalls,
+ int ShadowCasterCount,
+ int CascadeDrawCount,
+ int CpuClassificationCalls,
+ double SunElevationDegrees,
+ int ActiveDayGroup,
+ string Weather,
+ double WeatherIntensity,
+ bool Outdoor,
+ double DirectionalShadowStrength,
+ IReadOnlyList Passes)
+{
+ ///
+ /// Number of matrices addressed through the one shared world-transform
+ /// binding after the enhanced world receiver has appended its ordinary
+ /// draws to the directional-shadow prefix. Zero means that no shared
+ /// directional-shadow frame was active for the sampled frame.
+ ///
+ public uint SharedWorldTransformUsedInstances { get; init; }
+
+ public IReadOnlyList CpuStages { get; init; } = [];
+ public AuthoredCelestialShadowSourceKind DirectionalShadowSourceKind
+ {
+ get;
+ init;
+ }
+ public int DirectionalShadowSourceObjectIndex { get; init; } = -1;
+ public uint DirectionalShadowSourceGfxObjId { get; init; }
+ public Vector3 DirectionalShadowSurfaceToLightDirection { get; init; }
+ public float DirectionalShadowLightElevationSin { get; init; }
+ public DirectionalShadowTransformChurnDiagnostics ShadowTransformChurn
+ {
+ get;
+ init;
+ }
+
+ internal static RenderPackRuntimeDiagnostics Empty(string quality) => new(
+ quality,
+ RetainedGpuBytes: 0,
+ TransientGpuBytes: 0,
+ ImageCount: 0,
+ BufferCount: 0,
+ DrawCalls: 0,
+ DispatchCalls: 0,
+ ShadowCasterCount: 0,
+ CascadeDrawCount: 0,
+ CpuClassificationCalls: 0,
+ SunElevationDegrees: 0,
+ ActiveDayGroup: -1,
+ Weather: "unknown",
+ WeatherIntensity: 0,
+ Outdoor: false,
+ DirectionalShadowStrength: 0,
+ Passes: []);
+}
+
+internal interface IRenderPackRuntimeDiagnosticsSource
+{
+ RenderPackRuntimeDiagnostics CaptureDiagnostics();
+}
+
+internal sealed record RenderPackDiagnosticsSnapshot(
+ RenderPackActivationState State,
+ string PackId,
+ string? PackVersion,
+ string PresetId,
+ string EffectiveQuality,
+ string? FailureReason,
+ long ActivationGeneration,
+ long RetainedGpuBytes,
+ long TransientGpuBytes,
+ int ImageCount,
+ int BufferCount,
+ int DrawCalls,
+ int DispatchCalls,
+ int ShadowCasterCount,
+ int CascadeDrawCount,
+ int CpuClassificationCalls,
+ double SunElevationDegrees,
+ int ActiveDayGroup,
+ string Weather,
+ double WeatherIntensity,
+ bool Outdoor,
+ double DirectionalShadowStrength,
+ IReadOnlyList Passes,
+ RenderPackPerformanceSnapshot Performance = default)
+{
+ public uint SharedWorldTransformUsedInstances { get; init; }
+
+ public IReadOnlyList CpuStages { get; init; } = [];
+ public AuthoredCelestialShadowSourceKind DirectionalShadowSourceKind
+ {
+ get;
+ init;
+ }
+ public int DirectionalShadowSourceObjectIndex { get; init; } = -1;
+ public uint DirectionalShadowSourceGfxObjId { get; init; }
+ public Vector3 DirectionalShadowSurfaceToLightDirection { get; init; }
+ public float DirectionalShadowLightElevationSin { get; init; }
+ public DirectionalShadowTransformChurnDiagnostics ShadowTransformChurn
+ {
+ get;
+ init;
+ }
+
+ internal static RenderPackDiagnosticsSnapshot Retail { get; } = new(
+ RenderPackActivationState.Retail,
+ PackId: "retail",
+ PackVersion: null,
+ PresetId: "off",
+ EffectiveQuality: "off",
+ FailureReason: null,
+ ActivationGeneration: 0,
+ RetainedGpuBytes: 0,
+ TransientGpuBytes: 0,
+ ImageCount: 0,
+ BufferCount: 0,
+ DrawCalls: 0,
+ DispatchCalls: 0,
+ ShadowCasterCount: 0,
+ CascadeDrawCount: 0,
+ CpuClassificationCalls: 0,
+ SunElevationDegrees: 0,
+ ActiveDayGroup: -1,
+ Weather: "unknown",
+ WeatherIntensity: 0,
+ Outdoor: false,
+ DirectionalShadowStrength: 0,
+ Passes: []);
+
+ internal bool IsRetail =>
+ string.Equals(PackId, "retail", StringComparison.Ordinal);
+}
+
+internal interface IRenderPackDiagnosticsSnapshotSource
+{
+ RenderPackDiagnosticsSnapshot CaptureDiagnostics();
+}
+
+///
+/// Construction-order bridge used by screenshot and retained-UI diagnostics.
+/// Until the render-thread controller is composed, it reports the exact
+/// resource-free retail selection.
+///
+internal sealed class DeferredRenderPackDiagnosticsSource
+ : IRenderPackDiagnosticsSnapshotSource
+{
+ private IRenderPackDiagnosticsSnapshotSource? _target;
+
+ public RenderPackDiagnosticsSnapshot CaptureDiagnostics() =>
+ _target?.CaptureDiagnostics() ?? RenderPackDiagnosticsSnapshot.Retail;
+
+ internal IDisposable BindOwned(IRenderPackDiagnosticsSnapshotSource target)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ if (_target is not null && !ReferenceEquals(_target, target))
+ throw new InvalidOperationException("Render-pack diagnostics are already bound.");
+ _target = target;
+ return new Binding(this, target);
+ }
+
+ private void Unbind(IRenderPackDiagnosticsSnapshotSource target)
+ {
+ if (ReferenceEquals(_target, target))
+ _target = null;
+ }
+
+ private sealed class Binding(
+ DeferredRenderPackDiagnosticsSource owner,
+ IRenderPackDiagnosticsSnapshotSource target) : IDisposable
+ {
+ private DeferredRenderPackDiagnosticsSource? _owner = owner;
+
+ public void Dispose() =>
+ Interlocked.Exchange(ref _owner, null)?.Unbind(target);
+ }
+}
+
+internal static class RenderPackDiagnosticsFormatter
+{
+ internal static string Format(RenderPackDiagnosticsSnapshot value) =>
+ $"[render-pack] state={value.State} "
+ + $"pack={value.PackId}@{value.PackVersion ?? "(missing)"} "
+ + $"preset={value.PresetId} effective={value.EffectiveQuality} "
+ + $"generation={value.ActivationGeneration} "
+ + $"gpuBytes={value.RetainedGpuBytes}/{value.TransientGpuBytes} "
+ + $"resources={value.ImageCount}i/{value.BufferCount}b "
+ + $"submit={value.DrawCalls}d/{value.DispatchCalls}c "
+ + $"worldTransforms={value.SharedWorldTransformUsedInstances}used "
+ + $"shadow={value.ShadowCasterCount}casters/{value.CascadeDrawCount}cascadeDraws/"
+ + $"{value.CpuClassificationCalls}classify "
+ + $"shadowSource={value.DirectionalShadowSourceKind}/"
+ + $"obj{value.DirectionalShadowSourceObjectIndex}/"
+ + $"0x{value.DirectionalShadowSourceGfxObjId:X8}/"
+ + $"dir({Invariant(value.DirectionalShadowSurfaceToLightDirection.X, "F4")},"
+ + $"{Invariant(value.DirectionalShadowSurfaceToLightDirection.Y, "F4")},"
+ + $"{Invariant(value.DirectionalShadowSurfaceToLightDirection.Z, "F4")})/"
+ + $"elevSin={Invariant(value.DirectionalShadowLightElevationSin, "F4")} "
+ + $"atmosphere={Invariant(value.SunElevationDegrees, "F2")}deg/day{value.ActiveDayGroup}/"
+ + $"{value.Weather}:{Invariant(value.WeatherIntensity, "F3")}/outdoor={value.Outdoor}/"
+ + $"shadowStrength={Invariant(value.DirectionalShadowStrength, "F3")} "
+ + $"perf=cpu-added:{Invariant(value.Performance.IncrementalCpuMillisecondsP50, "F3")}/"
+ + $"{Invariant(value.Performance.IncrementalCpuMillisecondsP95, "F3")}/"
+ + $"{Invariant(value.Performance.IncrementalCpuMillisecondsP99, "F3")}ms,"
+ + $"receiver-cpu-absolute:{Invariant(value.Performance.AbsoluteReceiverCpuMillisecondsP50, "F3")}/"
+ + $"{Invariant(value.Performance.AbsoluteReceiverCpuMillisecondsP95, "F3")}/"
+ + $"{Invariant(value.Performance.AbsoluteReceiverCpuMillisecondsP99, "F3")}ms,"
+ + $"gpu-inclusive:{Invariant(value.Performance.InclusiveGpuMillisecondsP50, "F3")}/"
+ + $"{Invariant(value.Performance.InclusiveGpuMillisecondsP95, "F3")}/"
+ + $"{Invariant(value.Performance.InclusiveGpuMillisecondsP99, "F3")}ms "
+ + $"passes={FormatPasses(value.Passes)} "
+ + $"cpuStages={FormatCpuStages(value.CpuStages)} "
+ + $"shadowTransformChurn={FormatShadowTransformChurn(value.ShadowTransformChurn)} "
+ + $"reason={value.FailureReason ?? "none"}";
+
+ private static string FormatShadowTransformChurn(
+ DirectionalShadowTransformChurnDiagnostics value) =>
+ $"scene={value.CopiedSceneChanges}[transform={value.UpdateTransformChanges},"
+ + $"appearance={value.UpdateAppearanceChanges},sync={value.DynamicSynchronizationChanges};"
+ + $"animated={value.ActiveAnimatedStaticChanges},live={value.LiveDynamicRootChanges},"
+ + $"equipped={value.EquippedChildChanges}]/"
+ + $"casters={value.DedupedCasterSlots}/sceneFallback={value.SceneJournalFullRefresh}/"
+ + $"densityBulk={value.DensityBulkRefresh}/batchCopies={value.BatchedProjectionCopyCalls}/"
+ + $"matrices={value.ChangedMatrixSlots}/flightCurrent={value.FlightCurrentChangedMatrices}/"
+ + $"flightReplay={value.FlightPendingReplayMatrices}/uploaded={value.FlightUploadedMatrices}/"
+ + $"ranges={value.FlightUploadRanges}/bytes={value.FlightBytesWritten}/"
+ + $"flightFallback={value.FlightFullDynamicFallback}/denseDirect={value.DenseDirectUpload}/"
+ + $"denseReplay={value.DenseFlightReplay}/"
+ + $"classes=[terrain={value.CasterClasses.TerrainCommands},"
+ + $"outdoorStatic={value.CasterClasses.OutdoorStatics},"
+ + $"building={value.CasterClasses.Buildings},"
+ + $"animated={value.CasterClasses.AnimatedStatics},"
+ + $"localPlayer={value.CasterClasses.LocalPlayers},"
+ + $"remotePlayer={value.CasterClasses.RemotePlayers},"
+ + $"nonPlayerCreature={value.CasterClasses.NonPlayerCreatures},"
+ + $"otherLive={value.CasterClasses.OtherLiveDynamics},"
+ + $"equipped={value.CasterClasses.EquippedChildren}]";
+
+ private static string FormatPasses(IReadOnlyList passes) =>
+ passes.Count == 0
+ ? "none"
+ : string.Join(
+ ',',
+ passes.Select(static pass =>
+ $"{pass.PassId}:{Invariant(pass.GpuMilliseconds, "F3")}ms/"
+ + $"{pass.DrawCalls}d/{pass.DispatchCalls}c"));
+
+ private static string FormatCpuStages(
+ IReadOnlyList stages) =>
+ stages.Count == 0
+ ? "none"
+ : string.Join(
+ ',',
+ stages.Select(static stage =>
+ $"{stage.Stage}:{stage.SampleCount}n/"
+ + $"{Invariant(stage.CpuMillisecondsP50, "F3")}/"
+ + $"{Invariant(stage.CpuMillisecondsP95, "F3")}/"
+ + $"{Invariant(stage.CpuMillisecondsP99, "F3")}ms"));
+
+ private static string Invariant(double value, string format) =>
+ value.ToString(format, System.Globalization.CultureInfo.InvariantCulture);
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackPerformanceWindow.cs b/src/AcDream.App/Rendering/Packs/RenderPackPerformanceWindow.cs
new file mode 100644
index 00000000..974bc49a
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackPerformanceWindow.cs
@@ -0,0 +1,166 @@
+using AcDream.App.Diagnostics;
+
+namespace AcDream.App.Rendering.Packs;
+
+internal readonly record struct RenderPackPerformanceSnapshot(
+ int CpuSampleCount,
+ int AbsoluteReceiverCpuSampleCount,
+ int GpuSampleCount,
+ double IncrementalCpuMillisecondsP50,
+ double IncrementalCpuMillisecondsP95,
+ double IncrementalCpuMillisecondsP99,
+ double AbsoluteReceiverCpuMillisecondsP50,
+ double AbsoluteReceiverCpuMillisecondsP95,
+ double AbsoluteReceiverCpuMillisecondsP99,
+ double InclusiveGpuMillisecondsP50,
+ double InclusiveGpuMillisecondsP95,
+ double InclusiveGpuMillisecondsP99,
+ long ResidentGpuBytes,
+ long TransientGpuBytes)
+{
+ internal bool HasStableAutoWindow(int minimumSamples) =>
+ minimumSamples > 0
+ && CpuSampleCount >= minimumSamples
+ && GpuSampleCount >= minimumSamples;
+}
+
+///
+/// Allocation-free facts captured from the active runtime after it has
+/// submitted one complete frame. GPU time is the inclusive sum of already-
+/// resolved asynchronous pack timers, including the enhanced-world receiver
+/// pass; this contract never waits for the device.
+///
+internal readonly record struct RenderPackRuntimePerformanceMetrics(
+ long ResourceGeneration,
+ bool HasResolvedGpuMeasurement,
+ double InclusiveResolvedGpuMilliseconds,
+ long RetainedGpuBytes,
+ long TransientGpuBytes);
+
+internal interface IRenderPackRuntimePerformanceSource
+{
+ RenderPackRuntimePerformanceMetrics CapturePerformanceMetrics();
+}
+
+internal readonly record struct RenderPackFramePerformanceObservation(
+ double PackAddedCpuMilliseconds,
+ bool StableFrameBoundary,
+ int ViewportWidth,
+ int ViewportHeight,
+ int SampleCount,
+ double AbsoluteEnhancedWorldReceiverCpuMilliseconds = 0d);
+
+internal static class RenderPackPerformanceScopeNames
+{
+ ///
+ /// The enhanced main-world pass uses the pack's receiver pipelines. Its
+ /// timestamp is intentionally part of the same total consumed by
+ /// diagnostics and Auto; measuring only the extra shadow/post passes would
+ /// hide the receiver shader's GPU cost.
+ ///
+ internal const string EnhancedWorldReceiver = "atmospheric-world-receiver";
+}
+
+///
+/// Allocation-free rolling evidence for one active pack runtime. Incremental
+/// CPU samples bracket only work added by the pack. The complete enhanced-world
+/// receiver recording is retained as a separate absolute diagnostic because it
+/// is not an incremental delta and must never be compared with the pack's
+/// incremental CPU budget. GPU samples are the already-resolved asynchronous
+/// total including the receiver pass for the frame that issued them. The owner
+/// resets this window on activation or quality generation changes so Auto can
+/// never compare measurements from mixed resource layouts.
+///
+internal sealed class RenderPackPerformanceWindow
+{
+ internal const int DefaultCapacity = 2048;
+
+ private readonly FrameStatsBuffer _cpuMicroseconds;
+ private readonly FrameStatsBuffer _absoluteReceiverCpuMicroseconds;
+ private readonly FrameStatsBuffer _gpuMicroseconds;
+ private long _residentGpuBytes;
+ private long _transientGpuBytes;
+
+ internal RenderPackPerformanceWindow(int capacity = DefaultCapacity)
+ {
+ if (capacity <= 0)
+ throw new ArgumentOutOfRangeException(nameof(capacity));
+ _cpuMicroseconds = new FrameStatsBuffer(capacity);
+ _absoluteReceiverCpuMicroseconds = new FrameStatsBuffer(capacity);
+ _gpuMicroseconds = new FrameStatsBuffer(capacity);
+ }
+
+ internal void Observe(
+ double incrementalCpuMilliseconds,
+ double absoluteReceiverCpuMilliseconds,
+ bool hasResolvedGpuMeasurement,
+ double inclusiveResolvedGpuMilliseconds,
+ long residentGpuBytes,
+ long transientGpuBytes)
+ {
+ if (!double.IsFinite(incrementalCpuMilliseconds) || incrementalCpuMilliseconds < 0d)
+ throw new ArgumentOutOfRangeException(nameof(incrementalCpuMilliseconds));
+ if (!double.IsFinite(absoluteReceiverCpuMilliseconds)
+ || absoluteReceiverCpuMilliseconds < 0d)
+ {
+ throw new ArgumentOutOfRangeException(nameof(absoluteReceiverCpuMilliseconds));
+ }
+ if (hasResolvedGpuMeasurement
+ && (!double.IsFinite(inclusiveResolvedGpuMilliseconds)
+ || inclusiveResolvedGpuMilliseconds < 0d))
+ {
+ throw new ArgumentOutOfRangeException(nameof(inclusiveResolvedGpuMilliseconds));
+ }
+ if (residentGpuBytes < 0)
+ throw new ArgumentOutOfRangeException(nameof(residentGpuBytes));
+ if (transientGpuBytes < 0)
+ throw new ArgumentOutOfRangeException(nameof(transientGpuBytes));
+
+ _cpuMicroseconds.Push(ToMicroseconds(incrementalCpuMilliseconds));
+ _absoluteReceiverCpuMicroseconds.Push(
+ ToMicroseconds(absoluteReceiverCpuMilliseconds));
+ if (hasResolvedGpuMeasurement)
+ _gpuMicroseconds.Push(ToMicroseconds(inclusiveResolvedGpuMilliseconds));
+ _residentGpuBytes = residentGpuBytes;
+ _transientGpuBytes = transientGpuBytes;
+ }
+
+ internal RenderPackPerformanceSnapshot Snapshot() => new(
+ _cpuMicroseconds.Count,
+ _absoluteReceiverCpuMicroseconds.Count,
+ _gpuMicroseconds.Count,
+ ToMilliseconds(_cpuMicroseconds.Percentile(0.50)),
+ ToMilliseconds(_cpuMicroseconds.Percentile(0.95)),
+ ToMilliseconds(_cpuMicroseconds.Percentile(0.99)),
+ ToMilliseconds(_absoluteReceiverCpuMicroseconds.Percentile(0.50)),
+ ToMilliseconds(_absoluteReceiverCpuMicroseconds.Percentile(0.95)),
+ ToMilliseconds(_absoluteReceiverCpuMicroseconds.Percentile(0.99)),
+ ToMilliseconds(_gpuMicroseconds.Percentile(0.50)),
+ ToMilliseconds(_gpuMicroseconds.Percentile(0.95)),
+ ToMilliseconds(_gpuMicroseconds.Percentile(0.99)),
+ _residentGpuBytes,
+ _transientGpuBytes);
+
+ internal int MinimumSampleCount => Math.Min(
+ _cpuMicroseconds.Count,
+ Math.Min(
+ _absoluteReceiverCpuMicroseconds.Count,
+ _gpuMicroseconds.Count));
+
+ internal void Reset()
+ {
+ _cpuMicroseconds.Reset();
+ _absoluteReceiverCpuMicroseconds.Reset();
+ _gpuMicroseconds.Reset();
+ _residentGpuBytes = 0;
+ _transientGpuBytes = 0;
+ }
+
+ private static long ToMicroseconds(double milliseconds) =>
+ checked((long)Math.Round(
+ milliseconds * 1000d,
+ MidpointRounding.AwayFromZero));
+
+ private static double ToMilliseconds(long microseconds) =>
+ microseconds / 1000d;
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackPreparationScheduler.cs b/src/AcDream.App/Rendering/Packs/RenderPackPreparationScheduler.cs
new file mode 100644
index 00000000..7e08b872
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackPreparationScheduler.cs
@@ -0,0 +1,49 @@
+namespace AcDream.App.Rendering.Packs;
+
+///
+/// Schedules one complete, unpublished render-pack candidate preparation.
+/// Production uses the worker scheduler so shader I/O/validation and Vulkan
+/// resource creation cannot block the render-frame boundary. Tests can inject
+/// a deterministic scheduler without adding sleeps or timing races.
+///
+internal interface IRenderPackPreparationScheduler
+{
+ Task Schedule(Action preparation);
+}
+
+internal sealed class ThreadPoolRenderPackPreparationScheduler :
+ IRenderPackPreparationScheduler
+{
+ internal static ThreadPoolRenderPackPreparationScheduler Instance { get; } = new();
+
+ private ThreadPoolRenderPackPreparationScheduler()
+ {
+ }
+
+ public Task Schedule(Action preparation)
+ {
+ ArgumentNullException.ThrowIfNull(preparation);
+ return Task.Run(preparation);
+ }
+}
+
+///
+/// Synchronous fixture scheduler. Production composition must use
+/// .
+///
+internal sealed class InlineRenderPackPreparationScheduler :
+ IRenderPackPreparationScheduler
+{
+ internal static InlineRenderPackPreparationScheduler Instance { get; } = new();
+
+ private InlineRenderPackPreparationScheduler()
+ {
+ }
+
+ public Task Schedule(Action preparation)
+ {
+ ArgumentNullException.ThrowIfNull(preparation);
+ preparation();
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackReceiverPipelineCoordinator.cs b/src/AcDream.App/Rendering/Packs/RenderPackReceiverPipelineCoordinator.cs
new file mode 100644
index 00000000..d583312e
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackReceiverPipelineCoordinator.cs
@@ -0,0 +1,124 @@
+using AcDream.App.Rendering.Wb;
+
+namespace AcDream.App.Rendering.Packs;
+
+///
+/// One complete, unpublished receiver-pipeline product. Candidate resources
+/// stay owned here until the render-pack controller commits them at a stable
+/// frame boundary.
+///
+internal interface IRenderPackReceiverPipelineCandidate : IDisposable
+{
+}
+
+internal interface IRenderPackReceiverPipelineCoordinator
+{
+ IRenderPackReceiverPipelineCandidate Prepare(
+ IDirectionalShadowReceiverSource? source,
+ int sampleCount);
+
+ void Publish(IRenderPackReceiverPipelineCandidate candidate);
+
+ void Clear();
+}
+
+///
+/// Couples terrain and world-mesh receiver pipelines into the same activation
+/// transaction as their producing render-pack runtime. Preparation may compile
+/// pipelines, publication only swaps already-complete state objects, and old
+/// pipelines retire after both renderer owners point at the new generation.
+///
+internal sealed class RenderPackReceiverPipelineCoordinator(
+ TerrainModernRenderer terrain,
+ WbDrawDispatcher worldMeshes) : IRenderPackReceiverPipelineCoordinator
+{
+ private readonly TerrainModernRenderer _terrain = terrain
+ ?? throw new ArgumentNullException(nameof(terrain));
+ private readonly WbDrawDispatcher _worldMeshes = worldMeshes
+ ?? throw new ArgumentNullException(nameof(worldMeshes));
+
+ public IRenderPackReceiverPipelineCandidate Prepare(
+ IDirectionalShadowReceiverSource? source,
+ int sampleCount)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleCount);
+ TerrainModernRenderer.DirectionalShadowReceiverPipelineState? terrainState =
+ _terrain.PrepareDirectionalShadowReceiver(source, sampleCount);
+ try
+ {
+ WbDrawDispatcher.DirectionalShadowReceiverPipelineState? worldState =
+ _worldMeshes.PrepareDirectionalShadowReceiver(source, sampleCount);
+ return new Candidate(this, terrainState, worldState);
+ }
+ catch
+ {
+ terrainState?.Dispose();
+ throw;
+ }
+ }
+
+ public void Publish(IRenderPackReceiverPipelineCandidate candidate)
+ {
+ ArgumentNullException.ThrowIfNull(candidate);
+ if (candidate is not Candidate prepared || !ReferenceEquals(prepared.Owner, this))
+ throw new ArgumentException("Receiver candidate belongs to another coordinator.", nameof(candidate));
+
+ (TerrainModernRenderer.DirectionalShadowReceiverPipelineState? terrainState,
+ WbDrawDispatcher.DirectionalShadowReceiverPipelineState? worldState) = prepared.Take();
+ TerrainModernRenderer.DirectionalShadowReceiverPipelineState? oldTerrain =
+ _terrain.SwapDirectionalShadowReceiver(terrainState);
+ WbDrawDispatcher.DirectionalShadowReceiverPipelineState? oldWorld =
+ _worldMeshes.SwapDirectionalShadowReceiver(worldState);
+
+ // Vulkan pipeline disposal is flight-fence retirement. Do this only
+ // after both owners publish the complete new generation.
+ oldTerrain?.Dispose();
+ oldWorld?.Dispose();
+ }
+
+ public void Clear()
+ {
+ TerrainModernRenderer.DirectionalShadowReceiverPipelineState? oldTerrain =
+ _terrain.SwapDirectionalShadowReceiver(null);
+ WbDrawDispatcher.DirectionalShadowReceiverPipelineState? oldWorld =
+ _worldMeshes.SwapDirectionalShadowReceiver(null);
+ oldTerrain?.Dispose();
+ oldWorld?.Dispose();
+ }
+
+ private sealed class Candidate(
+ RenderPackReceiverPipelineCoordinator owner,
+ TerrainModernRenderer.DirectionalShadowReceiverPipelineState? terrain,
+ WbDrawDispatcher.DirectionalShadowReceiverPipelineState? world) :
+ IRenderPackReceiverPipelineCandidate
+ {
+ private TerrainModernRenderer.DirectionalShadowReceiverPipelineState? _terrain = terrain;
+ private WbDrawDispatcher.DirectionalShadowReceiverPipelineState? _world = world;
+ private bool _taken;
+
+ internal RenderPackReceiverPipelineCoordinator Owner { get; } = owner;
+
+ internal (TerrainModernRenderer.DirectionalShadowReceiverPipelineState?,
+ WbDrawDispatcher.DirectionalShadowReceiverPipelineState?) Take()
+ {
+ ObjectDisposedException.ThrowIf(_taken, this);
+ _taken = true;
+ TerrainModernRenderer.DirectionalShadowReceiverPipelineState? terrainState = _terrain;
+ WbDrawDispatcher.DirectionalShadowReceiverPipelineState? worldState = _world;
+ _terrain = null;
+ _world = null;
+ return (terrainState, worldState);
+ }
+
+ public void Dispose()
+ {
+ if (_taken)
+ return;
+ _taken = true;
+ _terrain?.Dispose();
+ _world?.Dispose();
+ _terrain = null;
+ _world = null;
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackResourceBudgetPlanner.cs b/src/AcDream.App/Rendering/Packs/RenderPackResourceBudgetPlanner.cs
new file mode 100644
index 00000000..8ddff76e
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackResourceBudgetPlanner.cs
@@ -0,0 +1,270 @@
+using AcDream.Plugin.Abstractions.Rendering;
+using AcDream.App.Rendering.Wb;
+
+namespace AcDream.App.Rendering.Packs;
+
+internal readonly record struct RenderPackResourceBudget(
+ long RetainedGpuBytes,
+ long MultisampleGpuBytes,
+ int LargestImageWidth,
+ int LargestImageHeight,
+ int LargestImageLayerCount)
+{
+ internal long TotalGpuBytes => checked(RetainedGpuBytes + MultisampleGpuBytes);
+}
+
+///
+/// Resolves declaration extents against the real main-world size before an
+/// executor allocates any size-dependent image. Declared byte estimates are
+/// useful during discovery, but cannot prove a 1080p/1440p/4K preset ceiling.
+/// This is the allocation-time authority for the images API-v1 executors
+/// actually keep alive.
+///
+internal static class RenderPackResourceBudgetPlanner
+{
+ private const int HdrColorBytesPerPixel = 8;
+ private const int LdrColorBytesPerPixel = 4;
+ private const int DirectionalDepthBytesPerPixel = 4;
+ private const int MainWorldDepthBytesPerPixel = 4;
+ // Production Vulkan owns two frame-flight slots. Directional shadows
+ // materialize one shared demand-growth N.5 transform arena in each slot before an
+ // ordinary world frame can consume the pack, so admission must include
+ // those mandatory buffers rather than discovering them after the first
+ // shadow pass has already published a borrow.
+ private const int DirectionalShadowTransformFlightSlots = 2;
+
+ internal static RenderPackResourceBudget Resolve(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ int mainWorldWidth,
+ int mainWorldHeight,
+ int sampleCount)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(preset);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(mainWorldWidth);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(mainWorldHeight);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleCount);
+
+ // Every executable graph replaces the main world attachment with one
+ // RGBA16F colour image and one D24S8 depth image. The resolve images
+ // remain alive for the complete active target set.
+ long mainPixels = checked((long)mainWorldWidth * mainWorldHeight);
+ long retained = checked(mainPixels
+ * (HdrColorBytesPerPixel + MainWorldDepthBytesPerPixel));
+ long multisample = sampleCount > 1
+ ? checked(mainPixels
+ * (HdrColorBytesPerPixel + MainWorldDepthBytesPerPixel)
+ * sampleCount)
+ : 0L;
+ int largestWidth = mainWorldWidth;
+ int largestHeight = mainWorldHeight;
+ int largestLayers = 1;
+
+ HashSet writtenResources = descriptor.Passes
+ .SelectMany(static pass => pass.ResourceWrites)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (RenderResourceDeclaration resource in descriptor.Resources)
+ {
+ if (resource.Semantic == RenderResourceSemantic.MainWorldHdr
+ || !writtenResources.Contains(resource.Id))
+ {
+ continue;
+ }
+ if (UsesFusedAtmosphericPostProcess(preset)
+ && resource.Semantic is RenderResourceSemantic.BloomPing
+ or RenderResourceSemantic.BloomPong)
+ {
+ // The fused Low filmic shader evaluates the declared bloom
+ // extraction/filter directly from world colour + sun rays.
+ // These ping/pong images have no executing writer or reader.
+ continue;
+ }
+
+ if (resource.Kind is not RenderResourceKind.Image2D
+ and not RenderResourceKind.Image2DArray)
+ {
+ throw new NotSupportedException(
+ $"Resource '{resource.Id}' is not an API-v1 image resource.");
+ }
+
+ RenderQualityResourceOverride? resourceOverride = preset.ResourceOverrides
+ .FirstOrDefault(value => string.Equals(
+ value.ResourceId,
+ resource.Id,
+ StringComparison.OrdinalIgnoreCase));
+ RenderExtentDeclaration extent = resourceOverride?.Extent
+ ?? resource.Extent
+ ?? throw new NotSupportedException(
+ $"Image resource '{resource.Id}' has no extent.");
+ (int width, int height) = ResolveExtent(
+ resource.Id,
+ extent,
+ mainWorldWidth,
+ mainWorldHeight);
+ int layers = extent.Layers;
+ if (layers <= 0)
+ {
+ throw new NotSupportedException(
+ $"Image resource '{resource.Id}' has no image layers.");
+ }
+
+ int bytesPerPixel = resource.Format switch
+ {
+ RenderFormatClass.HdrColor => HdrColorBytesPerPixel,
+ RenderFormatClass.LdrColor or RenderFormatClass.SingleChannel =>
+ LdrColorBytesPerPixel,
+ RenderFormatClass.DirectionalDepth => DirectionalDepthBytesPerPixel,
+ _ => throw new NotSupportedException(
+ $"Image resource '{resource.Id}' has unsupported format "
+ + $"'{resource.Format}'."),
+ };
+ retained = checked(retained
+ + ((long)width * height * layers * bytesPerPixel));
+ largestWidth = Math.Max(largestWidth, width);
+ largestHeight = Math.Max(largestHeight, height);
+ largestLayers = Math.Max(largestLayers, layers);
+ }
+
+ if (descriptor.Passes.Any(pass =>
+ pass.Semantic == RenderPassSemantic.DirectionalShadowDepth))
+ {
+ retained = checked(
+ retained
+ + DirectionalShadowTransformFlightSlots
+ * WorldTransformCapacityPolicy.InitialBindingSizeBytes);
+ }
+
+ return new RenderPackResourceBudget(
+ retained,
+ multisample,
+ largestWidth,
+ largestHeight,
+ largestLayers);
+ }
+
+ private static bool UsesFusedAtmosphericPostProcess(
+ RenderQualityPreset preset) =>
+ (preset.ExecutionHints
+ & RenderQualityExecutionHints.FusedAtmosphericPostProcess) != 0;
+
+ internal static RenderPackResourceBudget RequireWithinPreset(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ int mainWorldWidth,
+ int mainWorldHeight,
+ int sampleCount)
+ {
+ RenderPackResourceBudget budget = Resolve(
+ descriptor,
+ preset,
+ mainWorldWidth,
+ mainWorldHeight,
+ sampleCount);
+ if (budget.RetainedGpuBytes > preset.MaxResidentGpuBytes)
+ {
+ throw new NotSupportedException(
+ $"Render pack preset '{preset.Id}' needs "
+ + $"{budget.RetainedGpuBytes} resident GPU bytes at "
+ + $"{mainWorldWidth}x{mainWorldHeight}; its declared ceiling is "
+ + $"{preset.MaxResidentGpuBytes}. Select a compatible preset or "
+ + "reduce the main-world resolution.");
+ }
+ return budget;
+ }
+
+ ///
+ /// Allocation-time gate against the selected adapter and the host's
+ /// explicit optional-memory share. Catalog checks can reject absolute
+ /// preset extents, but only this point knows the resolved viewport-relative
+ /// sizes and multisample attachment bytes.
+ ///
+ internal static RenderPackResourceBudget RequireWithinHost(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ int mainWorldWidth,
+ int mainWorldHeight,
+ int sampleCount,
+ RenderPackHostCapabilities capabilities)
+ {
+ ArgumentNullException.ThrowIfNull(capabilities);
+ RenderPackResourceBudget budget = RequireWithinPreset(
+ descriptor,
+ preset,
+ mainWorldWidth,
+ mainWorldHeight,
+ sampleCount);
+ if (budget.LargestImageWidth > capabilities.MaxImageDimension2D
+ || budget.LargestImageHeight > capabilities.MaxImageDimension2D)
+ {
+ throw new NotSupportedException(
+ $"Render pack preset '{preset.Id}' resolves an image to "
+ + $"{budget.LargestImageWidth}x{budget.LargestImageHeight} at "
+ + $"{mainWorldWidth}x{mainWorldHeight}; this device's maximum "
+ + $"2-D image edge is {capabilities.MaxImageDimension2D}.");
+ }
+ if (budget.LargestImageLayerCount > capabilities.MaxImageArrayLayers)
+ {
+ throw new NotSupportedException(
+ $"Render pack preset '{preset.Id}' needs "
+ + $"{budget.LargestImageLayerCount} image-array layers; this "
+ + $"device provides {capabilities.MaxImageArrayLayers}.");
+ }
+ if (budget.RetainedGpuBytes > capabilities.MaxPackResidentBytes)
+ {
+ throw new NotSupportedException(
+ $"Render pack preset '{preset.Id}' needs "
+ + $"{budget.RetainedGpuBytes} resident GPU bytes at "
+ + $"{mainWorldWidth}x{mainWorldHeight}; this host permits "
+ + $"{capabilities.MaxPackResidentBytes} under its "
+ + $"{capabilities.MemoryPolicyDescription} policy.");
+ }
+ if (budget.MultisampleGpuBytes > capabilities.MaxPackTransientBytes)
+ {
+ throw new NotSupportedException(
+ $"Render pack preset '{preset.Id}' needs "
+ + $"{budget.MultisampleGpuBytes} transient multisample GPU bytes "
+ + $"at {mainWorldWidth}x{mainWorldHeight} x{sampleCount}; this "
+ + $"host permits {capabilities.MaxPackTransientBytes} under its "
+ + $"{capabilities.MemoryPolicyDescription} policy.");
+ }
+ return budget;
+ }
+
+ private static (int Width, int Height) ResolveExtent(
+ string resourceId,
+ RenderExtentDeclaration extent,
+ int mainWorldWidth,
+ int mainWorldHeight)
+ {
+ if (!double.IsFinite(extent.Width)
+ || !double.IsFinite(extent.Height)
+ || extent.Width <= 0d
+ || extent.Height <= 0d)
+ {
+ throw new NotSupportedException(
+ $"Image resource '{resourceId}' has an invalid extent.");
+ }
+
+ try
+ {
+ return extent.Mode switch
+ {
+ RenderExtentMode.AbsolutePixels =>
+ (checked((int)extent.Width), checked((int)extent.Height)),
+ RenderExtentMode.RelativeToMainWorld or RenderExtentMode.RelativeToOutput =>
+ (Math.Max(1, checked((int)Math.Ceiling(mainWorldWidth * extent.Width))),
+ Math.Max(1, checked((int)Math.Ceiling(mainWorldHeight * extent.Height)))),
+ _ => throw new NotSupportedException(
+ $"Image resource '{resourceId}' has unsupported extent mode "
+ + $"'{extent.Mode}'."),
+ };
+ }
+ catch (OverflowException error)
+ {
+ throw new NotSupportedException(
+ $"Image resource '{resourceId}' extent overflows the host image range.",
+ error);
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackSelectionBinding.cs b/src/AcDream.App/Rendering/Packs/RenderPackSelectionBinding.cs
new file mode 100644
index 00000000..23bc4537
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackSelectionBinding.cs
@@ -0,0 +1,85 @@
+using AcDream.App.Settings;
+using AcDream.UI.Abstractions.Panels.Settings;
+
+namespace AcDream.App.Rendering.Packs;
+
+///
+/// Bridges committed Display settings to the render-thread controller. The
+/// controller performs all GPU work at the explicit frame boundary; this
+/// binding only queues stable logical selections and persists a safe retail
+/// fallback once per failed activation generation.
+///
+internal sealed class RenderPackSelectionBinding : IDisposable
+{
+ private readonly RuntimeSettingsController _settings;
+ private readonly RenderPackController _controller;
+ private readonly Action _log;
+ private long _fallbackPersistedGeneration = -1;
+ private bool _suppressDisplayEdge;
+ private bool _disposed;
+
+ internal RenderPackSelectionBinding(
+ RuntimeSettingsController settings,
+ RenderPackController controller,
+ Action? log = null)
+ {
+ _settings = settings ?? throw new ArgumentNullException(nameof(settings));
+ _controller = controller ?? throw new ArgumentNullException(nameof(controller));
+ _log = log ?? (_ => { });
+ _settings.DisplayChanged += OnDisplayChanged;
+ _controller.Request(_settings.Display.RenderPack);
+ }
+
+ internal RenderPackActivationSnapshot ApplyAtFrameBoundary(
+ RenderPackActivationExtent extent)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ RenderPackActivationSnapshot snapshot = _controller.ApplyAtFrameBoundary(extent);
+ if (snapshot.State != RenderPackActivationState.FailedToRetail
+ || snapshot.ActivationGeneration == _fallbackPersistedGeneration
+ || _settings.Display.RenderPack.IsRetail)
+ return snapshot;
+
+ _fallbackPersistedGeneration = snapshot.ActivationGeneration;
+ _suppressDisplayEdge = true;
+ try
+ {
+ _settings.SaveDisplay(_settings.Display with
+ {
+ RenderPack = RenderPackSelectionSettings.Retail,
+ });
+ }
+ finally
+ {
+ _suppressDisplayEdge = false;
+ }
+
+ if (_settings.Display.RenderPack.IsRetail)
+ {
+ _log(
+ $"[render-pack] selection failed; persisted acdream default (retail-faithful): "
+ + snapshot.Reason);
+ }
+ else
+ {
+ _log(
+ $"[render-pack] selection failed and retail fallback could not be persisted: "
+ + snapshot.Reason);
+ }
+ return snapshot;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ _settings.DisplayChanged -= OnDisplayChanged;
+ }
+
+ private void OnDisplayChanged(DisplaySettings display)
+ {
+ if (!_disposed && !_suppressDisplayEdge)
+ _controller.Request(display.RenderPack);
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackSettingResolution.cs b/src/AcDream.App/Rendering/Packs/RenderPackSettingResolution.cs
new file mode 100644
index 00000000..9c563ac6
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackSettingResolution.cs
@@ -0,0 +1,77 @@
+using AcDream.Plugin.Abstractions.Rendering;
+using AcDream.UI.Abstractions.Panels.Settings;
+
+namespace AcDream.App.Rendering.Packs;
+
+internal static class RenderPackSettingResolution
+{
+ internal static RenderPackValidationResult ValidateUserOverrides(
+ RenderPackDescriptor descriptor,
+ RenderPackSettingOverrides overrides)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ if (overrides is null)
+ return Invalid($"Render pack '{descriptor.Id}' has a null user-setting override map.");
+
+ Dictionary settings = descriptor.Settings
+ .ToDictionary(setting => setting.Id, StringComparer.OrdinalIgnoreCase);
+ foreach ((string id, string value) in overrides)
+ {
+ if (!settings.TryGetValue(id, out RenderSettingDeclaration? setting))
+ {
+ return Invalid(
+ $"Render pack '{descriptor.Id}' has a user override for unknown "
+ + $"setting '{id}'.");
+ }
+ if (!RenderPackSettingValueCodec.TryEncode(setting, value, out _))
+ {
+ return Invalid(
+ $"Render pack '{descriptor.Id}' user override '{id}' has invalid "
+ + $"{setting.Kind} value '{value}'.");
+ }
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ internal static string Resolve(
+ RenderSettingDeclaration setting,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary? userOverrides)
+ {
+ ArgumentNullException.ThrowIfNull(setting);
+ ArgumentNullException.ThrowIfNull(preset);
+ if (TryGet(userOverrides, setting.Id, out string? user))
+ return user;
+ RenderQualitySettingOverride? presetValue = preset.SettingOverrides
+ .FirstOrDefault(value => string.Equals(
+ value.SettingId,
+ setting.Id,
+ StringComparison.OrdinalIgnoreCase));
+ return presetValue?.Value ?? setting.DefaultValue;
+ }
+
+ private static bool TryGet(
+ IReadOnlyDictionary? values,
+ string id,
+ out string value)
+ {
+ if (values is not null && values.TryGetValue(id, out value!))
+ return true;
+ if (values is not null)
+ {
+ foreach ((string key, string candidate) in values)
+ {
+ if (string.Equals(key, id, StringComparison.OrdinalIgnoreCase))
+ {
+ value = candidate;
+ return true;
+ }
+ }
+ }
+ value = string.Empty;
+ return false;
+ }
+
+ private static RenderPackValidationResult Invalid(string reason) =>
+ RenderPackValidationResult.Invalid(reason);
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackShaderAssets.cs b/src/AcDream.App/Rendering/Packs/RenderPackShaderAssets.cs
new file mode 100644
index 00000000..cb33d382
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackShaderAssets.cs
@@ -0,0 +1,71 @@
+using System.Collections.Immutable;
+using AcDream.App.Rendering.Gpu;
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.App.Rendering.Packs;
+
+internal static class RenderPackShaderAssets
+{
+ internal static ValidatedRenderPackShaderAssets Validate(
+ RenderPackDescriptor descriptor,
+ IRenderPackAssets assets)
+ {
+ RenderPackValidationResult result = RenderPackValidator.ValidateSelectedAssets(
+ descriptor,
+ assets,
+ out ValidatedRenderPackShaderAssets? validated);
+ if (!result.Success)
+ throw new InvalidDataException(result.Reason);
+ return validated!;
+ }
+
+ internal static GpuShaderSet LoadPass(
+ RenderPackDescriptor descriptor,
+ ValidatedRenderPackShaderAssets assets,
+ RenderPassDeclaration pass) => new(
+ $"{descriptor.Id}:{pass.Id}",
+ assets.Copy(pass.VertexShaderAsset),
+ assets.Copy(pass.FragmentShaderAsset));
+
+ internal static GpuShaderSet LoadVariant(
+ RenderPackDescriptor descriptor,
+ ValidatedRenderPackShaderAssets assets,
+ PipelineVariantDeclaration variant) => new(
+ $"{descriptor.Id}:{variant.Id}",
+ assets.Copy(variant.VertexShaderAsset),
+ assets.Copy(variant.FragmentShaderAsset));
+}
+
+///
+/// Candidate-owned immutable shader snapshot. The plugin asset provider is
+/// read exactly once during selected-candidate validation; pipeline creation
+/// only copies bytes from this snapshot and cannot reopen a mutable plugin
+/// stream or resolve a second path.
+///
+internal sealed class ValidatedRenderPackShaderAssets
+{
+ private readonly IReadOnlyDictionary> _assets;
+
+ internal ValidatedRenderPackShaderAssets(
+ IReadOnlyDictionary assets)
+ {
+ ArgumentNullException.ThrowIfNull(assets);
+ var owned = new Dictionary>(
+ assets.Count,
+ StringComparer.Ordinal);
+ foreach ((string key, byte[] bytes) in assets)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(key);
+ ArgumentNullException.ThrowIfNull(bytes);
+ owned.Add(key, [.. bytes]);
+ }
+ _assets = owned;
+ }
+
+ internal byte[] Copy(string key)
+ {
+ if (!_assets.TryGetValue(key, out ImmutableArray bytes))
+ throw new InvalidDataException($"Validated render-pack shader '{key}' is missing.");
+ return [.. bytes];
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackTextureBindingResolver.cs b/src/AcDream.App/Rendering/Packs/RenderPackTextureBindingResolver.cs
new file mode 100644
index 00000000..55e5df0c
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackTextureBindingResolver.cs
@@ -0,0 +1,54 @@
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.App.Rendering.Packs;
+
+internal readonly record struct RenderPackTextureInput(
+ RenderSemanticInput? Semantic,
+ string? ResourceId)
+{
+ internal static RenderPackTextureInput FromSemantic(RenderSemanticInput value) =>
+ new(value, null);
+
+ internal static RenderPackTextureInput FromResource(string value) =>
+ new(null, value);
+}
+
+///
+/// Binary API-v1 texture-slot rule. Ordinary sampled inputs occupy push
+/// TextureIndexA..D in declaration order: sampled semantic inputs first, then
+/// declared resource reads. Directional depth uses its dedicated binding-6
+/// texture slot and therefore does not consume A..D.
+///
+internal static class RenderPackTextureBindingResolver
+{
+ internal static IReadOnlyList Resolve(
+ RenderPassDeclaration pass,
+ IReadOnlyDictionary resources)
+ {
+ ArgumentNullException.ThrowIfNull(pass);
+ ArgumentNullException.ThrowIfNull(resources);
+ var result = new List(4);
+ foreach (RenderSemanticInput semantic in pass.SemanticInputs)
+ {
+ if (semantic is RenderSemanticInput.WorldColor
+ or RenderSemanticInput.SceneDepth
+ or RenderSemanticInput.SceneNormals)
+ result.Add(RenderPackTextureInput.FromSemantic(semantic));
+ }
+ foreach (string resourceId in pass.ResourceReads)
+ {
+ if (!resources.TryGetValue(resourceId, out RenderResourceDeclaration? resource))
+ throw new InvalidOperationException($"Unknown render-pack resource '{resourceId}'.");
+ if (resource.Format == RenderFormatClass.DirectionalDepth
+ && pass.SemanticInputs.Contains(RenderSemanticInput.DirectionalShadowMaps))
+ continue;
+ result.Add(RenderPackTextureInput.FromResource(resourceId));
+ }
+ if (result.Count > 4)
+ {
+ throw new InvalidOperationException(
+ $"Render-pack pass '{pass.Id}' exceeds the four API-v1 texture slots.");
+ }
+ return result;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Packs/RenderPackValidation.cs b/src/AcDream.App/Rendering/Packs/RenderPackValidation.cs
new file mode 100644
index 00000000..c16e67e1
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/RenderPackValidation.cs
@@ -0,0 +1,1604 @@
+using System.Buffers.Binary;
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.App.Rendering.Packs;
+
+internal sealed record RenderPackHostCapabilities(
+ IReadOnlySet Available,
+ int MaxImageDimension2D,
+ int MaxImageArrayLayers,
+ long MaxPackResidentBytes,
+ long MaxPackTransientBytes = 512L * 1024 * 1024,
+ string MemoryPolicyDescription = "API-v1 conformance ceiling")
+{
+ internal static RenderPackHostCapabilities Conformance { get; } = new(
+ Enum.GetValues().ToHashSet(),
+ MaxImageDimension2D: 16_384,
+ MaxImageArrayLayers: 256,
+ MaxPackResidentBytes: 256L * 1024 * 1024,
+ MaxPackTransientBytes: 512L * 1024 * 1024);
+}
+
+internal readonly record struct RenderPackValidationResult(
+ bool Success,
+ string? Reason)
+{
+ internal static RenderPackValidationResult Valid() => new(true, null);
+
+ internal static RenderPackValidationResult Invalid(string reason) =>
+ new(false, reason);
+}
+
+///
+/// Complete side-effect-free declaration validation plus explicitly-selected
+/// asset validation. Discovery calls only ;
+/// is intentionally separate so merely
+/// installing a pack never opens files or creates GPU work.
+///
+internal static class RenderPackValidator
+{
+ private const long AbsolutePackByteCeiling = 256L * 1024 * 1024;
+ private const int AbsoluteImageDimension2DCeiling = 16_384;
+ private const int AbsoluteImageArrayLayerCeiling = 256;
+ private const int MaximumShaderBytes = RenderPackShaderAbi.MaximumShaderAssetBytes;
+ private const uint SpirvMagic = 0x0723_0203u;
+
+ internal static RenderPackValidationResult ValidateDescriptor(
+ RenderPackDescriptor? descriptor,
+ RenderPackHostCapabilities capabilities)
+ {
+ ArgumentNullException.ThrowIfNull(capabilities);
+ if (descriptor is null)
+ return Invalid("The pack descriptor is missing.");
+ if (!IsStableId(descriptor.Id))
+ return Invalid("The pack id must be a stable lowercase logical id.");
+ if (string.IsNullOrWhiteSpace(descriptor.DisplayName))
+ return Invalid($"Pack '{descriptor.Id}' has no display name.");
+ if (string.IsNullOrWhiteSpace(descriptor.FeatureSummary))
+ return Invalid($"Pack '{descriptor.Id}' has no feature summary.");
+ if (descriptor.PackVersion is null)
+ return Invalid($"Pack '{descriptor.Id}' has no version.");
+ if (!RenderPackApi.IsSupported(descriptor.PackApiVersion))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' requires render-pack API "
+ + $"{descriptor.PackApiVersion}; this client supports "
+ + $"{RenderPackApi.MinimumSupported}..{RenderPackApi.Current}.");
+ }
+
+ string? nullList = FirstNullList(descriptor);
+ if (nullList is not null)
+ return Invalid($"Pack '{descriptor.Id}' has a null {nullList} declaration list.");
+
+ foreach (RenderCapability required in descriptor.RequiredCapabilities)
+ {
+ if (!capabilities.Available.Contains(required))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' requires unsupported capability "
+ + $"'{required}'.");
+ }
+ }
+
+ RenderPackValidationResult semanticCapabilities =
+ ValidateSemanticCapabilities(descriptor, capabilities);
+ if (!semanticCapabilities.Success)
+ return semanticCapabilities;
+
+ RenderPackValidationResult ids = ValidateUniqueIds(descriptor);
+ if (!ids.Success)
+ return ids;
+ RenderPackValidationResult resources = ValidateResources(descriptor, capabilities);
+ if (!resources.Success)
+ return resources;
+ RenderPackValidationResult passes = ValidatePasses(descriptor);
+ if (!passes.Success)
+ return passes;
+ RenderPackValidationResult replays = ValidateReplays(descriptor);
+ if (!replays.Success)
+ return replays;
+ RenderPackValidationResult variants = ValidateVariants(descriptor);
+ if (!variants.Success)
+ return variants;
+ RenderPackValidationResult settings = ValidateSettings(descriptor);
+ if (!settings.Success)
+ return settings;
+ RenderPackValidationResult presets = ValidatePresets(descriptor, capabilities);
+ if (!presets.Success)
+ return presets;
+ RenderPackValidationResult semantics = ValidateSemanticRoles(descriptor);
+ if (!semantics.Success)
+ return semantics;
+ return ValidateAtmosphere(descriptor);
+ }
+
+ internal static RenderPackValidationResult ValidateSelectedAssets(
+ RenderPackDescriptor descriptor,
+ IRenderPackAssets assets) =>
+ ValidateSelectedAssets(descriptor, assets, out _);
+
+ internal static RenderPackValidationResult ValidateSelectedAssets(
+ RenderPackDescriptor descriptor,
+ IRenderPackAssets assets,
+ out ValidatedRenderPackShaderAssets? validatedAssets)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(assets);
+ validatedAssets = null;
+
+ ShaderValidationRequest[] requests = descriptor.Passes
+ .SelectMany(static pass => new[]
+ {
+ new ShaderValidationRequest(
+ pass.VertexShaderAsset, RenderPackShaderStage.Vertex, pass, null),
+ new ShaderValidationRequest(
+ pass.FragmentShaderAsset, RenderPackShaderStage.Fragment, pass, null),
+ })
+ .Concat(descriptor.PipelineVariants.SelectMany(static variant => new[]
+ {
+ new ShaderValidationRequest(
+ variant.VertexShaderAsset, RenderPackShaderStage.Vertex, null, variant),
+ new ShaderValidationRequest(
+ variant.FragmentShaderAsset, RenderPackShaderStage.Fragment, null, variant),
+ }))
+ .ToArray();
+
+ byte[] readBuffer = new byte[4096];
+ var validated = new Dictionary(StringComparer.Ordinal);
+ foreach (IGrouping group in
+ requests.GroupBy(static request => request.Key, StringComparer.Ordinal))
+ {
+ string key = group.Key;
+ if (!IsSafeAssetKey(key))
+ return Invalid($"Pack '{descriptor.Id}' declares unsafe asset key '{key}'.");
+
+ try
+ {
+ using Stream stream = assets.OpenRead(key);
+ if (stream is null || !stream.CanRead)
+ return Invalid($"Pack '{descriptor.Id}' asset '{key}' is not readable.");
+ using var destination = new MemoryStream();
+ while (true)
+ {
+ int count = stream.Read(readBuffer);
+ if (count == 0)
+ break;
+ if (destination.Length + count > MaximumShaderBytes)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' asset '{key}' exceeds "
+ + $"the {MaximumShaderBytes}-byte shader ceiling.");
+ }
+ destination.Write(readBuffer, 0, count);
+ }
+
+ byte[] spirv = destination.ToArray();
+ if (spirv.Length < 4
+ || (spirv.Length & 3) != 0
+ || BinaryPrimitives.ReadUInt32LittleEndian(spirv) != SpirvMagic)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' asset '{key}' is not valid SPIR-V.");
+ }
+
+ foreach (ShaderValidationRequest request in group)
+ {
+ RenderPackSpirvValidationResult validation = request.Pass is not null
+ ? RenderPackSpirvValidator.ValidatePassShader(
+ spirv, request.Stage, request.Pass)
+ : RenderPackSpirvValidator.ValidatePipelineVariantShader(
+ spirv, request.Stage, request.Variant!);
+ if (!validation.Success)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' asset '{key}' fails render-pack shader ABI v1: "
+ + validation.Reason + ".");
+ }
+ }
+ validated.Add(key, spirv);
+ }
+ catch (Exception error) when (error is not OutOfMemoryException)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' asset '{key}' could not be opened: "
+ + error.GetBaseException().Message);
+ }
+ }
+
+ validatedAssets = new ValidatedRenderPackShaderAssets(validated);
+ return RenderPackValidationResult.Valid();
+ }
+
+ private sealed record ShaderValidationRequest(
+ string Key,
+ RenderPackShaderStage Stage,
+ RenderPassDeclaration? Pass,
+ PipelineVariantDeclaration? Variant);
+
+ private static RenderPackValidationResult ValidateSemanticRoles(
+ RenderPackDescriptor descriptor)
+ {
+ RenderPackValidationResult unique = UniqueNonCustomSemantics(
+ descriptor,
+ descriptor.Resources,
+ static value => value.Semantic,
+ RenderResourceSemantic.Custom,
+ "resource");
+ if (!unique.Success) return unique;
+ unique = UniqueNonCustomSemantics(
+ descriptor,
+ descriptor.Passes,
+ static value => value.Semantic,
+ RenderPassSemantic.CustomFullscreen,
+ "pass");
+ if (!unique.Success) return unique;
+ unique = UniqueNonCustomSemantics(
+ descriptor,
+ descriptor.PipelineVariants,
+ static value => value.Semantic,
+ RenderPipelineVariantSemantic.Custom,
+ "pipeline variant");
+ if (!unique.Success) return unique;
+ unique = UniqueNonCustomSemantics(
+ descriptor,
+ descriptor.QualityPresets,
+ static value => value.Semantic,
+ RenderQualitySemantic.Custom,
+ "quality preset");
+ if (!unique.Success) return unique;
+ unique = UniqueNonCustomSemantics(
+ descriptor,
+ descriptor.Settings,
+ static value => value.Semantic,
+ RenderSettingSemantic.Custom,
+ "setting");
+ if (!unique.Success) return unique;
+
+ RenderSettingDeclaration? automaticSetting = descriptor.Settings.FirstOrDefault(
+ static value => value.Semantic == RenderSettingSemantic.AutomaticQuality);
+ if (automaticSetting is not null && automaticSetting.Kind != RenderSettingKind.Boolean)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' AutomaticQuality setting must be Boolean.");
+ }
+ if (automaticSetting is not null
+ || descriptor.QualityPresets.Any(static value =>
+ value.Semantic == RenderQualitySemantic.Automatic))
+ {
+ foreach (RenderQualitySemantic semantic in new[]
+ {
+ RenderQualitySemantic.Low,
+ RenderQualitySemantic.Medium,
+ RenderQualitySemantic.High,
+ })
+ {
+ if (!descriptor.QualityPresets.Any(value =>
+ value.Semantic == semantic && value.AutoEligible))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' declares Automatic quality but has no "
+ + $"AutoEligible '{semantic}' semantic preset.");
+ }
+ }
+ }
+
+ bool atmosphericExecutor = descriptor.Passes.Any(static value =>
+ value.Semantic != RenderPassSemantic.CustomFullscreen);
+ if (!atmosphericExecutor)
+ return RenderPackValidationResult.Valid();
+
+ bool directionalShadowOnly = descriptor.Passes.Any(static value =>
+ value.Semantic == RenderPassSemantic.DirectionalShadowDepth)
+ && descriptor.Passes.All(static value =>
+ value.Semantic is RenderPassSemantic.CustomFullscreen
+ or RenderPassSemantic.DirectionalShadowDepth);
+ if (directionalShadowOnly)
+ return ValidateDirectionalShadowProfile(descriptor);
+
+ RenderPassSemantic[] requiredPasses =
+ [
+ RenderPassSemantic.DirectionalShadowDepth,
+ RenderPassSemantic.SunOcclusion,
+ RenderPassSemantic.SunRays,
+ RenderPassSemantic.VolumetricShafts,
+ RenderPassSemantic.BloomDownsample,
+ RenderPassSemantic.BloomBlurHorizontal,
+ RenderPassSemantic.BloomBlurVertical,
+ RenderPassSemantic.FilmicComposite,
+ ];
+ foreach (RenderPassSemantic semantic in requiredPasses)
+ {
+ if (!descriptor.Passes.Any(value => value.Semantic == semantic))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' requests the atmospheric executor but "
+ + $"does not declare required pass semantic '{semantic}'.");
+ }
+ }
+
+ RenderResourceSemantic[] requiredResources =
+ [
+ RenderResourceSemantic.MainWorldHdr,
+ RenderResourceSemantic.BloomPing,
+ RenderResourceSemantic.BloomPong,
+ RenderResourceSemantic.SunOcclusionMask,
+ RenderResourceSemantic.SunRays,
+ RenderResourceSemantic.DirectionalShadowDepth,
+ RenderResourceSemantic.VolumetricShafts,
+ ];
+ foreach (RenderResourceSemantic semantic in requiredResources)
+ {
+ if (!descriptor.Resources.Any(value => value.Semantic == semantic))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' requests the atmospheric executor but "
+ + $"does not declare required resource semantic '{semantic}'.");
+ }
+ }
+
+ if (descriptor.SceneReplays.Count(value =>
+ value.Semantic == RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters) != 1)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' must declare exactly one outdoor directional-shadow replay.");
+ }
+
+ bool usesMultiview = descriptor.QualityPresets.Any(preset =>
+ (preset.ExecutionHints & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0);
+ List requiredVariants =
+ [
+ RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster,
+ RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster,
+ RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster,
+ RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver,
+ RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver,
+ ];
+ if (usesMultiview)
+ {
+ requiredVariants.Add(RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster);
+ requiredVariants.Add(RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster);
+ requiredVariants.Add(RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster);
+ }
+ foreach (RenderPipelineVariantSemantic semantic in requiredVariants)
+ {
+ if (!descriptor.PipelineVariants.Any(value => value.Semantic == semantic))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' does not declare required pipeline-variant "
+ + $"semantic '{semantic}'.");
+ }
+ }
+
+ RenderSettingSemantic[] requiredSettings =
+ [
+ RenderSettingSemantic.BloomStrength,
+ RenderSettingSemantic.FilmicStrength,
+ RenderSettingSemantic.Exposure,
+ RenderSettingSemantic.GradeSaturation,
+ RenderSettingSemantic.GradeContrast,
+ RenderSettingSemantic.VignetteStrength,
+ RenderSettingSemantic.SunRayStrength,
+ RenderSettingSemantic.DirectionalShadowStrength,
+ RenderSettingSemantic.DirectionalShadowReachMetres,
+ RenderSettingSemantic.DirectionalShadowPcfTaps,
+ RenderSettingSemantic.VolumetricStrength,
+ RenderSettingSemantic.VolumetricRayMarchSteps,
+ ];
+ foreach (RenderSettingSemantic semantic in requiredSettings)
+ {
+ if (!descriptor.Settings.Any(value => value.Semantic == semantic))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' does not declare required atmospheric "
+ + $"setting semantic '{semantic}'.");
+ }
+ }
+ if (descriptor.AtmospherePolicy is null)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' must declare its visible sun/day-group "
+ + "atmosphere policy.");
+ }
+ if (descriptor.AtmospherePolicy.DirectionalShadowLightElevationResponse is not { Count: >= 2 })
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' must declare a directional-shadow "
+ + "light-elevation response curve.");
+ }
+ if (descriptor.AtmospherePolicy.VolumetricShaftSunElevationResponse is not { Count: >= 2 })
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' must declare a volumetric-shaft "
+ + "sun-elevation response curve.");
+ }
+
+ RenderPackValidationResult shapes = ValidateAtmosphericSemanticShapes(descriptor);
+ if (!shapes.Success) return shapes;
+ return ValidateAtmosphericSemanticEdges(descriptor);
+ }
+
+ private static RenderPackValidationResult ValidateDirectionalShadowProfile(
+ RenderPackDescriptor descriptor)
+ {
+ if (descriptor.HighestTier < RenderPackTier.Tier2)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' declares directional shadows below Tier2.");
+ }
+
+ RenderCapability[] requiredCapabilities =
+ [
+ RenderCapability.MainWorldColorIntermediate,
+ RenderCapability.FullscreenPasses,
+ RenderCapability.AuthoredCelestialDirectionalLight,
+ RenderCapability.AuthoredWeather,
+ RenderCapability.DirectionalShadowMaps,
+ RenderCapability.OutdoorDirectionalShadowCasterReplay,
+ RenderCapability.AnimatedCasterTransforms,
+ RenderCapability.AlphaCutoutShadowCasters,
+ ];
+ foreach (RenderCapability capability in requiredCapabilities)
+ {
+ if (!descriptor.RequiredCapabilities.Contains(capability))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' directional shadows must explicitly require "
+ + $"capability '{capability}'.");
+ }
+ }
+
+ if (descriptor.Passes.Count(static value =>
+ value.Semantic == RenderPassSemantic.DirectionalShadowDepth) != 1)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' must declare exactly one directional-shadow pass.");
+ }
+ if (descriptor.Resources.Count(static value =>
+ value.Semantic == RenderResourceSemantic.DirectionalShadowDepth) != 1)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' must declare exactly one directional-shadow resource.");
+ }
+ if (descriptor.SceneReplays.Count != 1
+ || descriptor.SceneReplays[0].Semantic
+ != RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' must declare exactly one outdoor "
+ + "directional-shadow replay.");
+ }
+
+ bool usesMultiview = descriptor.QualityPresets.Any(preset =>
+ (preset.ExecutionHints & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0);
+ List requiredVariants =
+ [
+ RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster,
+ RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster,
+ RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster,
+ RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver,
+ RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver,
+ ];
+ if (usesMultiview)
+ {
+ requiredVariants.Add(RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster);
+ requiredVariants.Add(RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster);
+ requiredVariants.Add(RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster);
+ }
+ if (descriptor.PipelineVariants.Count != requiredVariants.Count)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' directional shadows require exactly {requiredVariants.Count} "
+ + "semantic pipeline variants for its execution hints.");
+ }
+ foreach (RenderPipelineVariantSemantic semantic in requiredVariants)
+ {
+ if (!descriptor.PipelineVariants.Any(value => value.Semantic == semantic))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' does not declare required pipeline-variant "
+ + $"semantic '{semantic}'.");
+ }
+ }
+
+ foreach (RenderSettingSemantic semantic in new[]
+ {
+ RenderSettingSemantic.DirectionalShadowStrength,
+ RenderSettingSemantic.DirectionalShadowReachMetres,
+ RenderSettingSemantic.DirectionalShadowPcfTaps,
+ })
+ {
+ if (!descriptor.Settings.Any(value => value.Semantic == semantic))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' does not declare required directional-shadow "
+ + $"setting semantic '{semantic}'.");
+ }
+ }
+
+ if (descriptor.AtmospherePolicy?.DirectionalShadowLightElevationResponse
+ is not { Count: >= 2 })
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' must declare a directional-shadow "
+ + "light-elevation response curve.");
+ }
+
+ RenderPassDeclaration shadow = descriptor.Passes.Single(value =>
+ value.Semantic == RenderPassSemantic.DirectionalShadowDepth);
+ RenderResourceDeclaration depth = descriptor.Resources.Single(value =>
+ value.Semantic == RenderResourceSemantic.DirectionalShadowDepth);
+ if (shadow.Hook != RenderPassHook.ShadowDepthBeforeWorld
+ || shadow.ResourceReads.Count != 0
+ || shadow.ResourceWrites.Count != 1
+ || !string.Equals(
+ shadow.ResourceWrites[0], depth.Id, StringComparison.OrdinalIgnoreCase))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' directional-shadow pass must run before the world, "
+ + "read no declared resource, and write its directional-depth resource.");
+ }
+ if (depth.Kind != RenderResourceKind.Image2DArray
+ || depth.Format != RenderFormatClass.DirectionalDepth
+ || depth.Extent?.Mode != RenderExtentMode.AbsolutePixels
+ || depth.Usage != (RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment)
+ || depth.Lifetime != RenderResourceLifetime.ActivePack)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' directional-shadow resource does not match the "
+ + "host executor's array-depth contract.");
+ }
+
+ RenderPackValidationResult shapes = ValidateDirectionalShadowShapes(descriptor);
+ if (!shapes.Success)
+ return shapes;
+
+ RenderPassDeclaration[] outputPasses = descriptor.Passes
+ .Where(static value => value.Hook == RenderPassHook.ToneMap
+ && value.ResourceWrites.Count == 0)
+ .ToArray();
+ if (outputPasses.Length != 1
+ || outputPasses[0].Semantic != RenderPassSemantic.CustomFullscreen
+ || !outputPasses[0].SemanticInputs.Contains(RenderSemanticInput.WorldColor))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' directional shadows require exactly one custom "
+ + "ToneMap output-copy pass sampling WorldColor.");
+ }
+ if (descriptor.Passes.Any(value =>
+ value.Semantic == RenderPassSemantic.CustomFullscreen
+ && value.Hook is RenderPassHook.ShadowDepthBeforeWorld
+ or RenderPassHook.AfterToneMapBeforePrivateViewports))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' uses an unsupported custom pass hook in its "
+ + "directional-shadow graph.");
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ private static RenderPackValidationResult ValidateDirectionalShadowShapes(
+ RenderPackDescriptor descriptor)
+ {
+ RenderPipelineVariantSemantic[] semantics =
+ [
+ RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster,
+ RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster,
+ RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster,
+ RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver,
+ RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver,
+ ];
+ RenderPipelineBaseSemantic[] bases =
+ [
+ RenderPipelineBaseSemantic.Terrain,
+ RenderPipelineBaseSemantic.WorldMesh,
+ RenderPipelineBaseSemantic.WorldMesh,
+ RenderPipelineBaseSemantic.Terrain,
+ RenderPipelineBaseSemantic.WorldMesh,
+ ];
+ RenderMaterialClass[] materials =
+ [
+ RenderMaterialClass.Opaque,
+ RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque,
+ RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout,
+ RenderMaterialClass.Opaque,
+ RenderMaterialClass.Opaque | RenderMaterialClass.AlphaCutout
+ | RenderMaterialClass.AnimatedOpaque
+ | RenderMaterialClass.AnimatedAlphaCutout,
+ ];
+ RenderSemanticInput[][] inputs =
+ [
+ [RenderSemanticInput.CameraMatrices],
+ [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms],
+ [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms],
+ [RenderSemanticInput.DirectionalShadowMaps,
+ RenderSemanticInput.SelectedCelestialDirectionalLight],
+ [RenderSemanticInput.DirectionalShadowMaps,
+ RenderSemanticInput.SelectedCelestialDirectionalLight],
+ ];
+ for (int i = 0; i < semantics.Length; i++)
+ {
+ PipelineVariantDeclaration variant = descriptor.PipelineVariants.Single(value =>
+ value.Semantic == semantics[i]);
+ if (variant.BaseSemantic != bases[i]
+ || variant.CompatibleMaterials != materials[i]
+ || !variant.SemanticInputs.SequenceEqual(inputs[i]))
+ {
+ return Invalid(
+ $"Pipeline variant semantic '{semantics[i]}' does not match the fixed "
+ + "directional-shadow executor contract.");
+ }
+ }
+
+ if (descriptor.QualityPresets.Any(preset =>
+ (preset.ExecutionHints & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0))
+ {
+ RenderPipelineVariantSemantic[] multiview =
+ [
+ RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster,
+ RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster,
+ RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster,
+ ];
+ for (int i = 0; i < multiview.Length; i++)
+ {
+ PipelineVariantDeclaration variant = descriptor.PipelineVariants.Single(value =>
+ value.Semantic == multiview[i]);
+ if (variant.BaseSemantic != bases[i]
+ || variant.CompatibleMaterials != materials[i]
+ || !variant.SemanticInputs.SequenceEqual(inputs[i]))
+ {
+ return Invalid(
+ $"Pipeline variant semantic '{multiview[i]}' does not match the fixed "
+ + "multiview directional-shadow executor contract.");
+ }
+ }
+ }
+
+ SceneReplayDeclaration replay = descriptor.SceneReplays[0];
+ const RenderCasterClass requiredCasters = RenderCasterClass.Terrain
+ | RenderCasterClass.OpaqueWorld
+ | RenderCasterClass.AlphaCutoutWorld
+ | RenderCasterClass.AnimatedOpaque
+ | RenderCasterClass.AnimatedAlphaCutout;
+ if (replay.CasterClasses != requiredCasters || replay.ViewCount != 4)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' outdoor directional-shadow replay must declare "
+ + "all five headline caster classes and four maximum cascade views.");
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ private static RenderPackValidationResult ValidateAtmosphericSemanticShapes(
+ RenderPackDescriptor descriptor)
+ {
+ RenderPackValidationResult result = Resource(
+ RenderResourceSemantic.MainWorldHdr,
+ RenderResourceKind.Image2D,
+ RenderFormatClass.HdrColor,
+ RenderExtentMode.RelativeToMainWorld,
+ RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment);
+ if (!result.Success) return result;
+ foreach (RenderResourceSemantic semantic in new[]
+ {
+ RenderResourceSemantic.BloomPing,
+ RenderResourceSemantic.BloomPong,
+ RenderResourceSemantic.SunRays,
+ RenderResourceSemantic.VolumetricShafts,
+ })
+ {
+ result = Resource(
+ semantic,
+ RenderResourceKind.Image2D,
+ RenderFormatClass.HdrColor,
+ RenderExtentMode.RelativeToMainWorld,
+ RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment);
+ if (!result.Success) return result;
+ }
+ result = Resource(
+ RenderResourceSemantic.SunOcclusionMask,
+ RenderResourceKind.Image2D,
+ RenderFormatClass.SingleChannel,
+ RenderExtentMode.RelativeToMainWorld,
+ RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment);
+ if (!result.Success) return result;
+ result = Resource(
+ RenderResourceSemantic.DirectionalShadowDepth,
+ RenderResourceKind.Image2DArray,
+ RenderFormatClass.DirectionalDepth,
+ RenderExtentMode.AbsolutePixels,
+ RenderResourceUsage.Sampled | RenderResourceUsage.DepthAttachment);
+ if (!result.Success) return result;
+
+ result = Variant(
+ RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.Terrain,
+ RenderMaterialClass.Opaque,
+ [RenderSemanticInput.CameraMatrices]);
+ if (!result.Success) return result;
+ result = Variant(
+ RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.WorldMesh,
+ RenderMaterialClass.Opaque | RenderMaterialClass.AnimatedOpaque,
+ [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]);
+ if (!result.Success) return result;
+ result = Variant(
+ RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster,
+ RenderPipelineBaseSemantic.WorldMesh,
+ RenderMaterialClass.AlphaCutout | RenderMaterialClass.AnimatedAlphaCutout,
+ [RenderSemanticInput.CameraMatrices, RenderSemanticInput.ShadowCasterTransforms]);
+ if (!result.Success) return result;
+ result = Variant(
+ RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver,
+ RenderPipelineBaseSemantic.Terrain,
+ RenderMaterialClass.Opaque,
+ [RenderSemanticInput.DirectionalShadowMaps,
+ RenderSemanticInput.SelectedCelestialDirectionalLight]);
+ if (!result.Success) return result;
+ result = Variant(
+ RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver,
+ RenderPipelineBaseSemantic.WorldMesh,
+ RenderMaterialClass.Opaque | RenderMaterialClass.AlphaCutout
+ | RenderMaterialClass.AnimatedOpaque
+ | RenderMaterialClass.AnimatedAlphaCutout,
+ [RenderSemanticInput.DirectionalShadowMaps,
+ RenderSemanticInput.SelectedCelestialDirectionalLight]);
+ if (!result.Success) return result;
+
+ SceneReplayDeclaration replay = descriptor.SceneReplays.Single(value =>
+ value.Semantic == RenderSceneReplaySemantic.OutdoorDirectionalShadowCasters);
+ const RenderCasterClass requiredCasters = RenderCasterClass.Terrain
+ | RenderCasterClass.OpaqueWorld
+ | RenderCasterClass.AlphaCutoutWorld
+ | RenderCasterClass.AnimatedOpaque
+ | RenderCasterClass.AnimatedAlphaCutout;
+ if (replay.CasterClasses != requiredCasters || replay.ViewCount != 4)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' outdoor directional-shadow replay must declare "
+ + "all five headline caster classes and four maximum cascade views.");
+ }
+
+ return RenderPackValidationResult.Valid();
+
+ RenderPackValidationResult Resource(
+ RenderResourceSemantic semantic,
+ RenderResourceKind kind,
+ RenderFormatClass format,
+ RenderExtentMode extentMode,
+ RenderResourceUsage usage)
+ {
+ RenderResourceDeclaration resource = descriptor.Resources.Single(value =>
+ value.Semantic == semantic);
+ if (resource.Kind != kind
+ || resource.Format != format
+ || resource.Extent?.Mode != extentMode
+ || resource.Usage != usage
+ || resource.Lifetime != RenderResourceLifetime.ActivePack)
+ {
+ return Invalid(
+ $"Resource semantic '{semantic}' does not match the fixed atmospheric "
+ + "executor's kind, format, extent, usage, and lifetime contract.");
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ RenderPackValidationResult Variant(
+ RenderPipelineVariantSemantic semantic,
+ RenderPipelineBaseSemantic baseSemantic,
+ RenderMaterialClass materials,
+ IReadOnlyList inputs)
+ {
+ PipelineVariantDeclaration variant = descriptor.PipelineVariants.Single(value =>
+ value.Semantic == semantic);
+ if (variant.BaseSemantic != baseSemantic
+ || variant.CompatibleMaterials != materials
+ || !variant.SemanticInputs.SequenceEqual(inputs))
+ {
+ return Invalid(
+ $"Pipeline variant semantic '{semantic}' does not match the fixed "
+ + "atmospheric executor's base, material, and input contract.");
+ }
+ return RenderPackValidationResult.Valid();
+ }
+ }
+
+ private static RenderPackValidationResult ValidateAtmosphericSemanticEdges(
+ RenderPackDescriptor descriptor)
+ {
+ if (descriptor.Passes.Count != 8
+ || descriptor.Resources.Count != 7
+ || descriptor.PipelineVariants.Count != (descriptor.QualityPresets.Any(preset =>
+ (preset.ExecutionHints & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0)
+ ? 8 : 5)
+ || descriptor.SceneReplays.Count != 1)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' requests the fixed atmospheric executor; API v1 "
+ + "requires exactly 8 semantic passes, 7 semantic resources, and the declared semantic "
+ + "pipeline variants, and 1 semantic scene replay.");
+ }
+
+ RenderPackValidationResult Hook(RenderPassSemantic semantic, RenderPassHook hook)
+ {
+ RenderPassDeclaration pass = descriptor.Passes.Single(value =>
+ value.Semantic == semantic);
+ return pass.Hook == hook
+ ? RenderPackValidationResult.Valid()
+ : Invalid(
+ $"Pass semantic '{semantic}' must run at hook '{hook}', not '{pass.Hook}'.");
+ }
+
+ RenderPackValidationResult result = Hook(
+ RenderPassSemantic.DirectionalShadowDepth,
+ RenderPassHook.ShadowDepthBeforeWorld);
+ if (!result.Success) return result;
+ foreach (RenderPassSemantic semantic in new[]
+ {
+ RenderPassSemantic.SunOcclusion,
+ RenderPassSemantic.SunRays,
+ RenderPassSemantic.VolumetricShafts,
+ RenderPassSemantic.BloomDownsample,
+ RenderPassSemantic.BloomBlurHorizontal,
+ RenderPassSemantic.BloomBlurVertical,
+ })
+ {
+ result = Hook(semantic, RenderPassHook.AtmosphereBeforeToneMap);
+ if (!result.Success) return result;
+ }
+ result = Hook(RenderPassSemantic.FilmicComposite, RenderPassHook.ToneMap);
+ if (!result.Success) return result;
+
+ RenderPassSemantic[] declaredOrder = descriptor.Passes
+ .Where(static pass => pass.Hook is RenderPassHook.AtmosphereBeforeToneMap
+ or RenderPassHook.ToneMap)
+ .Select(static pass => pass.Semantic)
+ .ToArray();
+ RenderPassSemantic[] requiredOrder =
+ [
+ RenderPassSemantic.SunOcclusion,
+ RenderPassSemantic.SunRays,
+ RenderPassSemantic.VolumetricShafts,
+ RenderPassSemantic.BloomDownsample,
+ RenderPassSemantic.BloomBlurHorizontal,
+ RenderPassSemantic.BloomBlurVertical,
+ RenderPassSemantic.FilmicComposite,
+ ];
+ if (!declaredOrder.SequenceEqual(requiredOrder))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' atmospheric pass order does not match the "
+ + "renderer-owned semantic execution order.");
+ }
+
+ result = Edge(RenderPassSemantic.DirectionalShadowDepth, [],
+ RenderResourceSemantic.DirectionalShadowDepth);
+ if (!result.Success) return result;
+ result = Edge(RenderPassSemantic.SunOcclusion, [],
+ RenderResourceSemantic.SunOcclusionMask);
+ if (!result.Success) return result;
+ result = Edge(RenderPassSemantic.SunRays,
+ [RenderResourceSemantic.SunOcclusionMask], RenderResourceSemantic.SunRays);
+ if (!result.Success) return result;
+ result = Edge(RenderPassSemantic.VolumetricShafts,
+ [RenderResourceSemantic.DirectionalShadowDepth],
+ RenderResourceSemantic.VolumetricShafts);
+ if (!result.Success) return result;
+ result = Edge(RenderPassSemantic.BloomDownsample,
+ [RenderResourceSemantic.SunRays, RenderResourceSemantic.VolumetricShafts],
+ RenderResourceSemantic.BloomPing);
+ if (!result.Success) return result;
+ result = Edge(RenderPassSemantic.BloomBlurHorizontal,
+ [RenderResourceSemantic.BloomPing], RenderResourceSemantic.BloomPong);
+ if (!result.Success) return result;
+ result = Edge(RenderPassSemantic.BloomBlurVertical,
+ [RenderResourceSemantic.BloomPong], RenderResourceSemantic.BloomPing);
+ if (!result.Success) return result;
+ return Edge(RenderPassSemantic.FilmicComposite,
+ [RenderResourceSemantic.BloomPing, RenderResourceSemantic.SunRays,
+ RenderResourceSemantic.VolumetricShafts],
+ output: null);
+
+ RenderPackValidationResult Edge(
+ RenderPassSemantic passSemantic,
+ IReadOnlyList reads,
+ RenderResourceSemantic? output)
+ {
+ RenderPassDeclaration pass = descriptor.Passes.Single(value =>
+ value.Semantic == passSemantic);
+ RenderResourceSemantic[] actualReads = pass.ResourceReads
+ .Select(id => descriptor.Resources.Single(resource => string.Equals(
+ resource.Id,
+ id,
+ StringComparison.OrdinalIgnoreCase)).Semantic)
+ .ToArray();
+ if (!actualReads.SequenceEqual(reads))
+ {
+ return Invalid(
+ $"Pass semantic '{passSemantic}' declares resource reads that do not "
+ + "match its renderer-owned execution edges.");
+ }
+ RenderResourceSemantic[] actualWrites = pass.ResourceWrites
+ .Select(id => descriptor.Resources.Single(resource => string.Equals(
+ resource.Id,
+ id,
+ StringComparison.OrdinalIgnoreCase)).Semantic)
+ .ToArray();
+ RenderResourceSemantic[] expectedWrites = output is { } semantic
+ ? [semantic]
+ : [];
+ return actualWrites.SequenceEqual(expectedWrites)
+ ? RenderPackValidationResult.Valid()
+ : Invalid(
+ $"Pass semantic '{passSemantic}' declares a resource output that does "
+ + "not match its renderer-owned execution edge.");
+ }
+ }
+
+ private static RenderPackValidationResult UniqueNonCustomSemantics(
+ RenderPackDescriptor descriptor,
+ IEnumerable values,
+ Func select,
+ TSemantic custom,
+ string kind)
+ where T : class
+ where TSemantic : struct, Enum
+ {
+ var seen = new HashSet();
+ foreach (T? value in values)
+ {
+ if (value is null)
+ continue;
+ TSemantic semantic = select(value);
+ if (!Enum.IsDefined(semantic))
+ return Invalid($"Pack '{descriptor.Id}' declares an unknown {kind} semantic.");
+ if (!EqualityComparer.Default.Equals(semantic, custom)
+ && !seen.Add(semantic))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' declares duplicate {kind} semantic '{semantic}'.");
+ }
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ internal static RenderPackValidationResult ValidatePresetCompatibility(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ RenderPackHostCapabilities capabilities)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(preset);
+ ArgumentNullException.ThrowIfNull(capabilities);
+ foreach (RenderCapability required in preset.RequiredCapabilities)
+ {
+ if (!capabilities.Available.Contains(required))
+ {
+ return Invalid(
+ $"Preset '{preset.Id}' requires unsupported capability '{required}'.");
+ }
+ }
+ if (preset.Semantic == RenderQualitySemantic.Automatic
+ && !capabilities.Available.Contains(RenderCapability.GpuTimestampQueries))
+ {
+ return Invalid(
+ $"Preset '{preset.Id}' requires asynchronous GPU timestamp queries "
+ + "because Auto evaluates the complete CPU/GPU pack cost; explicit "
+ + "Low remains available when its resource limits fit.");
+ }
+ // Automatic is a logical selector, not an allocated resource layout.
+ // Its effective Low/Medium/High presets are checked independently and
+ // the controller chooses only from the compatible contiguous range.
+ if (preset.Semantic == RenderQualitySemantic.Automatic)
+ return RenderPackValidationResult.Valid();
+
+ if (preset.MaxResidentGpuBytes > capabilities.MaxPackResidentBytes)
+ {
+ return Invalid(
+ $"Preset '{preset.Id}' declares a {preset.MaxResidentGpuBytes}-byte "
+ + $"resident GPU ceiling, but this host permits "
+ + $"{capabilities.MaxPackResidentBytes} bytes under its "
+ + $"{capabilities.MemoryPolicyDescription} policy.");
+ }
+
+ Dictionary overrides = preset.ResourceOverrides
+ .ToDictionary(value => value.ResourceId, StringComparer.OrdinalIgnoreCase);
+ foreach (RenderResourceDeclaration resource in descriptor.Resources)
+ {
+ RenderExtentDeclaration? extent = overrides.TryGetValue(
+ resource.Id,
+ out RenderQualityResourceOverride? resourceOverride)
+ ? resourceOverride.Extent ?? resource.Extent
+ : resource.Extent;
+ if (extent is null)
+ continue;
+ if (extent.Layers > capabilities.MaxImageArrayLayers)
+ {
+ return Invalid(
+ $"Preset '{preset.Id}' resource '{resource.Id}' needs "
+ + $"{extent.Layers} image-array layers; this device provides "
+ + $"{capabilities.MaxImageArrayLayers}.");
+ }
+ if (extent.Mode == RenderExtentMode.AbsolutePixels
+ && (extent.Width > capabilities.MaxImageDimension2D
+ || extent.Height > capabilities.MaxImageDimension2D))
+ {
+ return Invalid(
+ $"Preset '{preset.Id}' resource '{resource.Id}' needs "
+ + $"{extent.Width:G}x{extent.Height:G}; this device's maximum "
+ + $"2-D image edge is {capabilities.MaxImageDimension2D}.");
+ }
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ private static RenderPackValidationResult ValidateUniqueIds(RenderPackDescriptor descriptor)
+ {
+ var ids = new HashSet(StringComparer.OrdinalIgnoreCase);
+ IEnumerable<(string Kind, string? Id)> declarations =
+ descriptor.Resources.Select(static value => ("resource", value?.Id))
+ .Concat(descriptor.Passes.Select(static value => ("pass", value?.Id)))
+ .Concat(descriptor.SceneReplays.Select(static value => ("scene replay", value?.Id)))
+ .Concat(descriptor.PipelineVariants.Select(static value => ("pipeline variant", value?.Id)))
+ .Concat(descriptor.QualityPresets.Select(static value => ("quality preset", value?.Id)))
+ .Concat(descriptor.Settings.Select(static value => ("setting", value?.Id)));
+
+ foreach ((string kind, string? id) in declarations)
+ {
+ if (!IsStableId(id))
+ return Invalid($"Pack '{descriptor.Id}' has an invalid {kind} id.");
+ if (!ids.Add($"{kind}:{id}"))
+ return Invalid($"Pack '{descriptor.Id}' declares duplicate {kind} id '{id}'.");
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ private static RenderPackValidationResult ValidateResources(
+ RenderPackDescriptor descriptor,
+ RenderPackHostCapabilities capabilities)
+ {
+ long declaredBytes = 0;
+ foreach (RenderResourceDeclaration? resource in descriptor.Resources)
+ {
+ if (resource is null)
+ return Invalid($"Pack '{descriptor.Id}' contains a null resource declaration.");
+ if (resource.Kind == RenderResourceKind.Buffer
+ || resource.Format == RenderFormatClass.StructuredData
+ || resource.Usage.HasFlag(RenderResourceUsage.Storage))
+ {
+ return Invalid(
+ $"Resource '{resource.Id}' uses a buffer/storage declaration reserved "
+ + "for a future render-pack API; API v1 binds image resources only.");
+ }
+ if (resource.Kind == RenderResourceKind.Image2DArray
+ && resource.Format != RenderFormatClass.DirectionalDepth)
+ {
+ return Invalid(
+ $"Resource '{resource.Id}' uses a color image array; render-pack API "
+ + "v1 reserves image arrays for directional depth maps.");
+ }
+ if (resource.EstimatedResidentBytes < 0 || resource.SizeBytes < 0)
+ return Invalid($"Resource '{resource.Id}' declares negative bytes.");
+ if (resource.Usage == RenderResourceUsage.None)
+ return Invalid($"Resource '{resource.Id}' declares no usage.");
+ if (resource.Kind == RenderResourceKind.Buffer && resource.Extent is not null)
+ return Invalid($"Buffer resource '{resource.Id}' must not declare an image extent.");
+ if (resource.Kind != RenderResourceKind.Buffer)
+ {
+ if (resource.Extent is null)
+ return Invalid($"Image resource '{resource.Id}' has no extent.");
+ RenderExtentDeclaration extent = resource.Extent;
+ if (!IsFinitePositive(extent.Width)
+ || !IsFinitePositive(extent.Height)
+ || extent.Layers <= 0
+ || extent.Layers > AbsoluteImageArrayLayerCeiling)
+ {
+ return Invalid($"Image resource '{resource.Id}' has an invalid extent.");
+ }
+ if (extent.Mode == RenderExtentMode.AbsolutePixels
+ && (extent.Width > AbsoluteImageDimension2DCeiling
+ || extent.Height > AbsoluteImageDimension2DCeiling))
+ {
+ return Invalid($"Image resource '{resource.Id}' exceeds the device image limit.");
+ }
+ if (extent.Mode != RenderExtentMode.AbsolutePixels
+ && (extent.Width > 1.0 || extent.Height > 1.0))
+ {
+ return Invalid($"Relative resource '{resource.Id}' must use a scale in (0, 1].");
+ }
+ }
+
+ if (!TryAdd(ref declaredBytes, resource.EstimatedResidentBytes))
+ return Invalid($"Pack '{descriptor.Id}' resource byte total overflows.");
+ }
+
+ if (declaredBytes > AbsolutePackByteCeiling)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' declares {declaredBytes} resident bytes; "
+ + $"the render-pack API ceiling is {AbsolutePackByteCeiling}.");
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ private static RenderPackValidationResult ValidatePasses(RenderPackDescriptor descriptor)
+ {
+ Dictionary resources = descriptor.Resources
+ .ToDictionary(static value => value.Id, StringComparer.OrdinalIgnoreCase);
+ var written = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (RenderPassDeclaration? pass in descriptor.Passes)
+ {
+ if (pass is null)
+ return Invalid($"Pack '{descriptor.Id}' contains a null pass declaration.");
+ if (!IsSafeAssetKey(pass.VertexShaderAsset)
+ || !IsSafeAssetKey(pass.FragmentShaderAsset))
+ return Invalid($"Pass '{pass.Id}' declares an unsafe shader asset key.");
+ if (pass.SemanticInputs is null || pass.ResourceReads is null || pass.ResourceWrites is null)
+ return Invalid($"Pass '{pass.Id}' contains a null binding list.");
+ if (pass.ResourceWrites.Count > 1)
+ {
+ return Invalid(
+ $"Pass '{pass.Id}' writes {pass.ResourceWrites.Count} resources; "
+ + "render-pack API v1 supports one attachment per declared pass.");
+ }
+ if (pass.ResourceWrites.Count == 0
+ && pass.Hook is not RenderPassHook.ToneMap
+ and not RenderPassHook.AfterToneMapBeforePrivateViewports)
+ {
+ return Invalid(
+ $"Pass '{pass.Id}' has no declared output at hook '{pass.Hook}'.");
+ }
+ int sampledInputs = pass.SemanticInputs.Count(static semantic =>
+ semantic is RenderSemanticInput.WorldColor
+ or RenderSemanticInput.SceneDepth
+ or RenderSemanticInput.SceneNormals);
+ foreach (string read in pass.ResourceReads)
+ {
+ if (!resources.TryGetValue(read, out RenderResourceDeclaration? resource))
+ return Invalid($"Pass '{pass.Id}' reads unknown resource '{read}'.");
+ if (!written.Contains(read))
+ return Invalid($"Pass '{pass.Id}' reads resource '{read}' before it is written.");
+ if (!resource.Usage.HasFlag(RenderResourceUsage.Sampled))
+ return Invalid($"Pass '{pass.Id}' samples non-sampled resource '{read}'.");
+ bool dedicatedDirectionalSlot =
+ resource.Format == RenderFormatClass.DirectionalDepth
+ && pass.SemanticInputs.Contains(RenderSemanticInput.DirectionalShadowMaps);
+ if (!dedicatedDirectionalSlot)
+ sampledInputs++;
+ }
+ if (sampledInputs > 4)
+ {
+ return Invalid(
+ $"Pass '{pass.Id}' needs {sampledInputs} ordinary sampled images; "
+ + "render-pack API v1 provides four ordered texture slots (A..D).");
+ }
+ foreach (string write in pass.ResourceWrites)
+ {
+ if (!resources.TryGetValue(write, out RenderResourceDeclaration? resource))
+ return Invalid($"Pass '{pass.Id}' writes unknown resource '{write}'.");
+ RenderResourceUsage attachment = resource.Format == RenderFormatClass.DirectionalDepth
+ ? RenderResourceUsage.DepthAttachment
+ : RenderResourceUsage.ColorAttachment;
+ if (!resource.Usage.HasFlag(attachment))
+ return Invalid($"Pass '{pass.Id}' writes non-attachment resource '{write}'.");
+ written.Add(write);
+ }
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ private static RenderPackValidationResult ValidateSemanticCapabilities(
+ RenderPackDescriptor descriptor,
+ RenderPackHostCapabilities capabilities)
+ {
+ foreach (RenderPassDeclaration shadowPass in descriptor.Passes.Where(static pass =>
+ pass.Semantic == RenderPassSemantic.DirectionalShadowDepth))
+ {
+ if (!shadowPass.SemanticInputs.Contains(
+ RenderSemanticInput.SelectedCelestialDirectionalLight)
+ || shadowPass.SemanticInputs.Contains(RenderSemanticInput.SunDirection))
+ {
+ return Invalid(
+ $"Directional-shadow pass '{shadowPass.Id}' must declare "
+ + $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' and must not "
+ + "alias the sun-specific atmospheric direction.");
+ }
+ }
+
+ IEnumerable inputs = descriptor.Passes
+ .SelectMany(static pass => pass?.SemanticInputs ?? [])
+ .Concat(descriptor.PipelineVariants.SelectMany(
+ static variant => variant?.SemanticInputs ?? []));
+ foreach (RenderSemanticInput input in inputs.Distinct())
+ {
+ RenderCapability? required = input switch
+ {
+ RenderSemanticInput.WorldColor =>
+ RenderCapability.MainWorldColorIntermediate,
+ RenderSemanticInput.SceneDepth =>
+ RenderCapability.SceneDepthSampling,
+ RenderSemanticInput.SceneNormals =>
+ RenderCapability.SceneNormalSampling,
+ RenderSemanticInput.SunDirection =>
+ RenderCapability.AuthoredSunDirection,
+ RenderSemanticInput.SelectedCelestialDirectionalLight =>
+ RenderCapability.AuthoredCelestialDirectionalLight,
+ RenderSemanticInput.SunScreenPosition =>
+ RenderCapability.AuthoredSunScreenPosition,
+ RenderSemanticInput.ActiveDayGroup or RenderSemanticInput.Weather =>
+ RenderCapability.AuthoredWeather,
+ RenderSemanticInput.CameraMatrices or RenderSemanticInput.FrameTime =>
+ RenderCapability.FullscreenPasses,
+ RenderSemanticInput.ShadowCasterTransforms =>
+ RenderCapability.AnimatedCasterTransforms,
+ RenderSemanticInput.DirectionalShadowMaps =>
+ RenderCapability.DirectionalShadowMaps,
+ _ => null,
+ };
+ if (input is RenderSemanticInput.SelectedCelestialDirectionalLight
+ && required is { } declaredCapability
+ && !descriptor.RequiredCapabilities.Contains(declaredCapability))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' declares semantic '{input}' but does not "
+ + $"require capability '{declaredCapability}'.");
+ }
+ if (required is { } capability
+ && !capabilities.Available.Contains(capability))
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' declares semantic '{input}' but the host "
+ + $"does not provide capability '{capability}'.");
+ }
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ private static RenderPackValidationResult ValidateReplays(RenderPackDescriptor descriptor)
+ {
+ foreach (SceneReplayDeclaration? replay in descriptor.SceneReplays)
+ {
+ if (replay is null)
+ return Invalid($"Pack '{descriptor.Id}' contains a null scene replay.");
+ if (replay.ViewCount <= 0 || replay.ViewCount > 4)
+ return Invalid($"Scene replay '{replay.Id}' must request 1..4 views.");
+ if (replay.CasterClasses == RenderCasterClass.None)
+ return Invalid($"Scene replay '{replay.Id}' declares no caster classes.");
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ private static RenderPackValidationResult ValidateVariants(RenderPackDescriptor descriptor)
+ {
+ foreach (PipelineVariantDeclaration? variant in descriptor.PipelineVariants)
+ {
+ if (variant is null)
+ return Invalid($"Pack '{descriptor.Id}' contains a null pipeline variant.");
+ if (!IsSafeAssetKey(variant.VertexShaderAsset)
+ || !IsSafeAssetKey(variant.FragmentShaderAsset))
+ return Invalid($"Pipeline variant '{variant.Id}' declares an unsafe shader asset key.");
+ if (variant.CompatibleMaterials == RenderMaterialClass.None)
+ return Invalid($"Pipeline variant '{variant.Id}' declares no compatible materials.");
+ if (variant.SemanticInputs is null)
+ return Invalid($"Pipeline variant '{variant.Id}' has a null semantic-input list.");
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ private static RenderPackValidationResult ValidateSettings(RenderPackDescriptor descriptor)
+ {
+ if (descriptor.Settings.Count > RenderPackShaderAbi.PackSettingScalarCapacity)
+ return Invalid(
+ $"Pack '{descriptor.Id}' exceeds the "
+ + $"{RenderPackShaderAbi.PackSettingScalarCapacity}-setting API-v1 ceiling.");
+ foreach (RenderSettingDeclaration? setting in descriptor.Settings)
+ {
+ if (setting is null)
+ return Invalid($"Pack '{descriptor.Id}' contains a null setting.");
+ if (!Enum.IsDefined(setting.Kind))
+ return Invalid($"Setting '{setting.Id}' declares an unknown kind.");
+ if (string.IsNullOrWhiteSpace(setting.DisplayName))
+ return Invalid($"Setting '{setting.Id}' has no display name.");
+ if (setting.DefaultValue is null || setting.Choices is null)
+ return Invalid($"Setting '{setting.Id}' contains a null value list.");
+ if (setting.Minimum is { } min && !double.IsFinite(min)
+ || setting.Maximum is { } max && !double.IsFinite(max)
+ || setting.Step is { } step && (!double.IsFinite(step) || step <= 0))
+ return Invalid($"Setting '{setting.Id}' has invalid bounds.");
+ if (setting.Minimum is { } minimum
+ && setting.Maximum is { } maximum
+ && minimum > maximum)
+ return Invalid($"Setting '{setting.Id}' has an inverted range.");
+ if (setting.Kind == RenderSettingKind.Choice
+ && (setting.Choices is null || setting.Choices.Count == 0))
+ return Invalid($"Choice setting '{setting.Id}' declares no choices.");
+ if (!RenderPackSettingValueCodec.TryEncode(
+ setting,
+ setting.DefaultValue,
+ out _))
+ {
+ return Invalid($"Setting '{setting.Id}' has an invalid default value.");
+ }
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ private static RenderPackValidationResult ValidatePresets(
+ RenderPackDescriptor descriptor,
+ RenderPackHostCapabilities capabilities)
+ {
+ if (descriptor.QualityPresets.Count == 0)
+ return Invalid($"Pack '{descriptor.Id}' declares no quality presets.");
+ HashSet resources = descriptor.Resources
+ .Select(static value => value.Id)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ HashSet settings = descriptor.Settings
+ .Select(static value => value.Id)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ Dictionary settingDeclarations =
+ descriptor.Settings.ToDictionary(
+ value => value.Id,
+ StringComparer.OrdinalIgnoreCase);
+ const long ceiling = AbsolutePackByteCeiling;
+
+ foreach (RenderQualityPreset? preset in descriptor.QualityPresets)
+ {
+ if (preset is null)
+ return Invalid($"Pack '{descriptor.Id}' contains a null quality preset.");
+ if (string.IsNullOrWhiteSpace(preset.DisplayName))
+ return Invalid($"Quality preset '{preset.Id}' has no display name.");
+ if (preset.MaxResidentGpuBytes < 0 || preset.MaxResidentGpuBytes > ceiling)
+ return Invalid($"Quality preset '{preset.Id}' exceeds the pack memory ceiling.");
+ const RenderQualityExecutionHints supportedExecutionHints =
+ RenderQualityExecutionHints.FusedAtmosphericPostProcess
+ | RenderQualityExecutionHints.MultiviewDirectionalShadowCascades;
+ if ((preset.ExecutionHints & ~supportedExecutionHints) != 0)
+ return Invalid($"Quality preset '{preset.Id}' declares an unknown execution hint.");
+ if ((preset.ExecutionHints
+ & RenderQualityExecutionHints.FusedAtmosphericPostProcess) != 0
+ && preset.Semantic != RenderQualitySemantic.Low)
+ {
+ return Invalid(
+ $"Quality preset '{preset.Id}' may only use fused atmospheric post-processing "
+ + "with the Low quality semantic.");
+ }
+ if ((preset.ExecutionHints
+ & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0
+ && preset.Semantic != RenderQualitySemantic.Low)
+ {
+ return Invalid(
+ $"Quality preset '{preset.Id}' may only use multiview directional-shadow "
+ + "cascades with the Low quality semantic.");
+ }
+ if ((preset.ExecutionHints
+ & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0
+ && descriptor.Passes.Count(pass =>
+ pass.Semantic == RenderPassSemantic.DirectionalShadowDepth) != 1)
+ {
+ return Invalid(
+ $"Quality preset '{preset.Id}' requests multiview directional-shadow "
+ + "cascades without the directional-shadow graph.");
+ }
+ if ((preset.ExecutionHints
+ & RenderQualityExecutionHints.MultiviewDirectionalShadowCascades) != 0
+ && !preset.RequiredCapabilities.Contains(
+ RenderCapability.MultiviewDirectionalShadowCascades))
+ {
+ return Invalid(
+ $"Quality preset '{preset.Id}' must require multiview directional-shadow capability.");
+ }
+ if ((preset.ExecutionHints
+ & RenderQualityExecutionHints.FusedAtmosphericPostProcess) != 0)
+ {
+ RenderPassSemantic[] requiredFusedPasses =
+ [
+ RenderPassSemantic.SunOcclusion,
+ RenderPassSemantic.SunRays,
+ RenderPassSemantic.BloomDownsample,
+ RenderPassSemantic.BloomBlurHorizontal,
+ RenderPassSemantic.BloomBlurVertical,
+ RenderPassSemantic.FilmicComposite,
+ ];
+ if (requiredFusedPasses.Any(semantic =>
+ descriptor.Passes.Count(pass => pass.Semantic == semantic) != 1))
+ {
+ return Invalid(
+ $"Quality preset '{preset.Id}' requests fused atmospheric "
+ + "post-processing without the complete standard pass graph.");
+ }
+ }
+ if (!IsFiniteNonNegative(preset.MaxIncrementalGpuMillisecondsP50)
+ || !IsFiniteNonNegative(preset.MaxIncrementalGpuMillisecondsP99)
+ || !IsFiniteNonNegative(preset.MaxIncrementalCpuMillisecondsP50)
+ || !IsFiniteNonNegative(preset.MaxIncrementalCpuMillisecondsP99)
+ || preset.MaxIncrementalGpuMillisecondsP50 > preset.MaxIncrementalGpuMillisecondsP99
+ || preset.MaxIncrementalCpuMillisecondsP50 > preset.MaxIncrementalCpuMillisecondsP99)
+ return Invalid($"Quality preset '{preset.Id}' has invalid performance budgets.");
+ foreach (RenderQualityResourceOverride value in preset.ResourceOverrides)
+ {
+ if (!resources.Contains(value.ResourceId))
+ return Invalid($"Quality preset '{preset.Id}' overrides unknown resource '{value.ResourceId}'.");
+ if (value.SizeBytes < 0 || value.EstimatedResidentBytes < 0)
+ return Invalid($"Quality preset '{preset.Id}' declares negative resource bytes.");
+ }
+ var overriddenSettings = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (RenderQualitySettingOverride value in preset.SettingOverrides)
+ {
+ if (!settings.Contains(value.SettingId))
+ return Invalid($"Quality preset '{preset.Id}' overrides unknown setting '{value.SettingId}'.");
+ if (!overriddenSettings.Add(value.SettingId))
+ return Invalid($"Quality preset '{preset.Id}' overrides setting '{value.SettingId}' more than once.");
+ if (!RenderPackSettingValueCodec.TryEncode(
+ settingDeclarations[value.SettingId],
+ value.Value,
+ out _))
+ {
+ return Invalid(
+ $"Quality preset '{preset.Id}' supplies an invalid value "
+ + $"for setting '{value.SettingId}'.");
+ }
+ }
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ private static RenderPackValidationResult ValidateAtmosphere(RenderPackDescriptor descriptor)
+ {
+ AtmospherePolicyDeclaration? policy = descriptor.AtmospherePolicy;
+ if (policy is null)
+ return RenderPackValidationResult.Valid();
+ if (policy.SunElevationResponse is null
+ || policy.ActiveDayGroupMultipliers is null
+ || policy.DirectionalShadowLightElevationResponse is null
+ || policy.VolumetricShaftSunElevationResponse is null)
+ return Invalid($"Pack '{descriptor.Id}' has a null atmosphere-policy list.");
+
+ RenderPackValidationResult curve = ValidateCurve(
+ policy.SunElevationResponse,
+ "sun-elevation");
+ if (!curve.Success)
+ return curve;
+ curve = ValidateCurve(
+ policy.DirectionalShadowLightElevationResponse,
+ "directional-shadow light-elevation",
+ unitInterval: true);
+ if (!curve.Success)
+ return curve;
+ curve = ValidateDirectionalShadowHorizon(
+ policy.DirectionalShadowLightElevationResponse);
+ if (!curve.Success)
+ return curve;
+ curve = ValidateCurve(
+ policy.VolumetricShaftSunElevationResponse,
+ "volumetric-shaft sun-elevation",
+ unitInterval: true);
+ if (!curve.Success)
+ return curve;
+
+ var groups = new HashSet();
+ foreach (ActiveDayGroupMultiplier value in policy.ActiveDayGroupMultipliers)
+ {
+ if (!groups.Add(value.ActiveDayGroup)
+ || !IsFiniteNonNegative(value.Multiplier))
+ return Invalid($"Pack '{descriptor.Id}' has an invalid active-day-group mapping.");
+ }
+ return RenderPackValidationResult.Valid();
+
+ RenderPackValidationResult ValidateCurve(
+ IReadOnlyList points,
+ string name,
+ bool unitInterval = false)
+ {
+ double priorElevation = double.NegativeInfinity;
+ foreach (SunElevationResponsePoint point in points)
+ {
+ if (point is null
+ || !double.IsFinite(point.ElevationDegrees)
+ || point.ElevationDegrees < -90
+ || point.ElevationDegrees > 90
+ || !IsFiniteNonNegative(point.Multiplier)
+ || (unitInterval && point.Multiplier > 1)
+ || point.ElevationDegrees <= priorElevation)
+ {
+ return Invalid(
+ $"Pack '{descriptor.Id}' has an invalid {name} response curve.");
+ }
+ priorElevation = point.ElevationDegrees;
+ }
+ return RenderPackValidationResult.Valid();
+ }
+
+ RenderPackValidationResult ValidateDirectionalShadowHorizon(
+ IReadOnlyList points)
+ {
+ bool hasExactHorizonPoint = false;
+ SunElevationResponsePoint? firstAboveHorizon = null;
+ foreach (SunElevationResponsePoint point in points)
+ {
+ if (point.ElevationDegrees <= 0d)
+ {
+ if (point.Multiplier != 0d)
+ return InvalidHorizon();
+ hasExactHorizonPoint |= point.ElevationDegrees == 0d;
+ continue;
+ }
+
+ firstAboveHorizon = point;
+ break;
+ }
+
+ // Curves clamp outside their endpoints and interpolate between
+ // adjacent points. Without an exact zero-degree point, the first
+ // positive point must therefore also be zero to keep the value at
+ // the authored horizon (and the complete below-horizon interval)
+ // at zero.
+ return !hasExactHorizonPoint
+ && firstAboveHorizon is { Multiplier: not 0d }
+ ? InvalidHorizon()
+ : RenderPackValidationResult.Valid();
+
+ RenderPackValidationResult InvalidHorizon() => Invalid(
+ $"Pack '{descriptor.Id}' directional-shadow light-elevation "
+ + "curve must resolve to zero at and below the 0-degree "
+ + "authored horizon.");
+ }
+ }
+
+ private static string? FirstNullList(RenderPackDescriptor value)
+ {
+ if (value.RequiredCapabilities is null) return "required-capability";
+ if (value.OptionalCapabilities is null) return "optional-capability";
+ if (value.Resources is null) return "resource";
+ if (value.Passes is null) return "pass";
+ if (value.SceneReplays is null) return "scene-replay";
+ if (value.PipelineVariants is null) return "pipeline-variant";
+ if (value.QualityPresets is null) return "quality-preset";
+ if (value.Settings is null) return "setting";
+ return null;
+ }
+
+ private static bool IsStableId(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length > 128)
+ return false;
+ if (value[0] is < 'a' or > 'z')
+ return false;
+ foreach (char character in value)
+ {
+ if (character is >= 'a' and <= 'z'
+ || character is >= '0' and <= '9'
+ || character is '.' or '-' or '_')
+ continue;
+ return false;
+ }
+ return true;
+ }
+
+ private static bool IsSafeAssetKey(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length > 512)
+ return false;
+ if (Path.IsPathRooted(value) || value.Contains('\\'))
+ return false;
+ string[] segments = value.Split('/');
+ return segments.All(static segment =>
+ segment.Length > 0 && segment is not "." and not "..");
+ }
+
+ private static bool IsFinitePositive(double value) =>
+ double.IsFinite(value) && value > 0;
+
+ private static bool IsFiniteNonNegative(double value) =>
+ double.IsFinite(value) && value >= 0;
+
+ private static bool TryAdd(ref long total, long value)
+ {
+ if (value > long.MaxValue - total)
+ return false;
+ total += value;
+ return true;
+ }
+
+ private static RenderPackValidationResult Invalid(string reason) =>
+ RenderPackValidationResult.Invalid(reason);
+}
diff --git a/src/AcDream.App/Rendering/Packs/VolumetricShaftRenderer.cs b/src/AcDream.App/Rendering/Packs/VolumetricShaftRenderer.cs
new file mode 100644
index 00000000..da67b20d
--- /dev/null
+++ b/src/AcDream.App/Rendering/Packs/VolumetricShaftRenderer.cs
@@ -0,0 +1,471 @@
+using System.Diagnostics;
+using System.Numerics;
+using System.Runtime.InteropServices;
+using AcDream.App.Rendering.Gpu;
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.App.Rendering.Packs;
+
+internal enum VolumetricShaftGateReason : byte
+{
+ Rendered,
+ DisabledByPreset,
+ NoCurrentDirectionalShadow,
+ NoSceneDepth,
+ Indoor,
+ SunOffScreen,
+ SunBelowHorizon,
+ AtmosphereSuppressed,
+}
+
+internal readonly record struct VolumetricShaftDiagnostics(
+ VolumetricShaftGateReason GateReason,
+ int Width,
+ int Height,
+ int RayMarchSteps,
+ float Density,
+ float Strength,
+ long RetainedGpuBytes,
+ double LastResolvedGpuMilliseconds,
+ bool HasResolvedGpuMeasurement,
+ int DrawCalls);
+
+internal readonly record struct VolumetricShaftOutput(
+ GpuTextureSlot TextureSlot,
+ VolumetricShaftDiagnostics Diagnostics)
+{
+ internal bool HasTexture => TextureSlot.IsAssigned;
+}
+
+///
+/// Tier-2+ shadow-map volumetric producer. It consumes only the current frame's
+/// b5/b6/b8 facts and scene depth, and owns one preset-scaled HDR result. It has
+/// no clock, weather state, caster traversal, or independent sun policy.
+///
+internal sealed class VolumetricShaftRenderer : IDisposable
+{
+ internal const string TimerName = "atmospheric-volumetric-shafts";
+
+ private readonly IGpuDevice _device;
+ private readonly VolumetricShaftQuality _quality;
+ private readonly float _declaredStrength;
+ private readonly AtmospherePolicyDeclaration _atmospherePolicy;
+ private readonly IReadOnlyDictionary _dayGroupMultipliers;
+ private readonly IGpuSampler _sampler;
+ private readonly IGpuPipeline _pipeline;
+ private readonly PackSettingsUniforms _settings;
+ private readonly RenderPackPerformanceWindow _performance = new();
+ private Target? _target;
+ private bool _disposed;
+
+ internal VolumetricShaftRenderer(
+ IGpuDevice device,
+ RenderPackDescriptor descriptor,
+ IRenderPackAssets assets,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary? userSettingOverrides = null)
+ : this(
+ device,
+ descriptor,
+ RenderPackShaderAssets.Validate(descriptor, assets),
+ preset,
+ userSettingOverrides)
+ {
+ }
+
+ internal VolumetricShaftRenderer(
+ IGpuDevice device,
+ RenderPackDescriptor descriptor,
+ ValidatedRenderPackShaderAssets assets,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary? userSettingOverrides = null)
+ {
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(assets);
+ ArgumentNullException.ThrowIfNull(preset);
+ _quality = ResolveQuality(
+ descriptor,
+ preset,
+ userSettingOverrides);
+ _declaredStrength = ReadSetting(
+ descriptor,
+ preset,
+ userSettingOverrides,
+ RenderSettingSemantic.VolumetricStrength,
+ 0.35f);
+ _atmospherePolicy = descriptor.AtmospherePolicy
+ ?? throw new NotSupportedException(
+ $"Pack '{descriptor.Id}' declares no atmosphere policy.");
+ if (_atmospherePolicy.VolumetricShaftSunElevationResponse.Count < 2)
+ {
+ throw new NotSupportedException(
+ $"Pack '{descriptor.Id}' declares no volumetric-shaft elevation curve.");
+ }
+ _dayGroupMultipliers = _atmospherePolicy.ActiveDayGroupMultipliers
+ .ToDictionary(value => value.ActiveDayGroup, value => (float)value.Multiplier);
+ _settings = PackSettingsUniforms.Create(descriptor, preset, userSettingOverrides);
+ RenderPassDeclaration pass = descriptor.Passes.FirstOrDefault(value =>
+ value.Semantic == RenderPassSemantic.VolumetricShafts)
+ ?? throw new NotSupportedException(
+ $"Pack '{descriptor.Id}' declares no VolumetricShafts pass semantic.");
+
+ _sampler = device.CreateSampler(GpuSamplerDescription.WorldClamp);
+ _pipeline = device.CreatePipeline(new GpuPipelineDescription
+ {
+ Name = $"render-pack-{descriptor.Id}-volumetric-shafts",
+ Shaders = RenderPackShaderAssets.LoadPass(descriptor, assets, pass),
+ VertexLayout = GpuVertexLayout.None,
+ Blend = GpuBlendMode.None,
+ Depth = GpuDepthState.Disabled,
+ Cull = GpuCullMode.None,
+ ColorFormat = GpuTextureFormat.Rgba16FloatRenderTarget,
+ AllowColorFormatVariants = false,
+ SampleCount = 1,
+ UsesRenderPackShaderAbi = true,
+ });
+ LastDiagnostics = Disabled(VolumetricShaftGateReason.DisabledByPreset);
+ }
+
+ internal VolumetricShaftDiagnostics LastDiagnostics { get; private set; }
+
+ internal VolumetricShaftQuality Quality => _quality;
+
+ internal RenderPackPerformanceSnapshot Performance => _performance.Snapshot();
+
+ ///
+ /// Builds the selected preset's optional shaft target during off-side pack
+ /// activation/resize. A disabled preset owns no target; enabling it later
+ /// through a user override is reflected in .
+ ///
+ internal void PrepareTarget(int outputWidth, int outputHeight)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(outputWidth);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(outputHeight);
+ if (_declaredStrength > 0f)
+ _ = Prepare(outputWidth, outputHeight);
+ }
+
+ internal VolumetricShaftOutput Render(
+ IGpuFrame frame,
+ in AtmosphericFrameInputs inputs,
+ in DirectionalShadowFrameBinding shadow,
+ GpuTextureSlot sceneDepth)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(frame);
+ VolumetricShaftGateReason reason = Gate(frame, inputs, shadow, sceneDepth);
+ if (reason != VolumetricShaftGateReason.Rendered)
+ {
+ LastDiagnostics = Disabled(reason);
+ return new VolumetricShaftOutput(GpuTextureSlot.Unassigned, LastDiagnostics);
+ }
+
+ (float density, float strength) = Parameters(inputs);
+ if (strength <= 1e-4f)
+ {
+ LastDiagnostics = Disabled(VolumetricShaftGateReason.AtmosphereSuppressed);
+ return new VolumetricShaftOutput(GpuTextureSlot.Unassigned, LastDiagnostics);
+ }
+
+ Target target = Prepare(inputs.ViewportWidth, inputs.ViewportHeight);
+ long started = Stopwatch.GetTimestamp();
+ AtmosphericFrameUniforms atmospheric = FrameUniforms(inputs, strength);
+ GpuRingAllocation frameBlock = frame.AllocateRing(
+ AtmosphericFrameUniforms.SizeInBytes,
+ GpuRingUsage.Uniform);
+ MemoryMarshal.Write(frameBlock.Data, in atmospheric);
+ GpuRingAllocation passBlock = frame.AllocateRing(
+ AtmosphericPackPassUniforms.SizeInBytes,
+ GpuRingUsage.Uniform);
+ var passValues = new AtmosphericPackPassUniforms(
+ new Vector4(density, strength, _quality.RayMarchSteps, 1f),
+ Vector4.Zero,
+ Vector4.Zero,
+ Vector4.Zero);
+ MemoryMarshal.Write(passBlock.Data, in passValues);
+ GpuRingAllocation settingsBlock = frame.AllocateRing(
+ PackSettingsUniforms.SizeInBytes,
+ GpuRingUsage.Uniform);
+ PackSettingsUniforms settings = _settings;
+ MemoryMarshal.Write(settingsBlock.Data, in settings);
+
+ using (IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
+ {
+ Name = TimerName,
+ Color = new GpuColorAttachment(
+ target.RenderTarget,
+ GpuLoadOp.Clear,
+ GpuStoreOp.Store,
+ Vector4.Zero),
+ Depth = null,
+ SampleCount = 1,
+ }))
+ using (encoder.BeginTimerScope(TimerName))
+ {
+ encoder.BindPipeline(_pipeline);
+ encoder.BindUniformBuffer(
+ GpuBindingModel.UniformAtmosphericFrame,
+ frameBlock.Buffer,
+ frameBlock.OffsetBytes,
+ AtmosphericFrameUniforms.SizeInBytes);
+ encoder.BindUniformBuffer(
+ GpuBindingModel.UniformDirectionalShadow,
+ shadow.Buffer!,
+ shadow.OffsetBytes,
+ shadow.SizeBytes);
+ encoder.BindUniformBuffer(
+ GpuBindingModel.UniformPackPass,
+ passBlock.Buffer,
+ passBlock.OffsetBytes,
+ AtmosphericPackPassUniforms.SizeInBytes);
+ encoder.BindUniformBuffer(
+ GpuBindingModel.UniformPackSettings,
+ settingsBlock.Buffer,
+ settingsBlock.OffsetBytes,
+ PackSettingsUniforms.SizeInBytes);
+ GpuPushConstants push = GpuPushConstants.Default;
+ push.TextureIndexA = sceneDepth.Index;
+ push.TextureIndexB = GpuTextureSlot.Unassigned.Index;
+ push.ParamA = BitConverter.UInt32BitsToSingle(GpuTextureSlot.Unassigned.Index);
+ push.ParamB = BitConverter.UInt32BitsToSingle(GpuTextureSlot.Unassigned.Index);
+ encoder.SetPushConstants(in push);
+ encoder.Draw(3, 1, 0, 0);
+ }
+
+ bool hasGpu = _device.Timers.TryResolve(TimerName, out double milliseconds);
+ LastDiagnostics = new VolumetricShaftDiagnostics(
+ VolumetricShaftGateReason.Rendered,
+ target.RenderTarget.Description.Width,
+ target.RenderTarget.Description.Height,
+ _quality.RayMarchSteps,
+ density,
+ strength,
+ target.RetainedBytes,
+ milliseconds,
+ hasGpu,
+ DrawCalls: 1);
+ _performance.Observe(
+ Stopwatch.GetElapsedTime(started).TotalMilliseconds,
+ absoluteReceiverCpuMilliseconds: 0d,
+ hasGpu,
+ milliseconds,
+ target.RetainedBytes,
+ transientGpuBytes: 0);
+ return new VolumetricShaftOutput(target.TextureSlot, LastDiagnostics);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ _target?.Dispose();
+ _target = null;
+ _pipeline.Dispose();
+ }
+
+ private Target Prepare(int outputWidth, int outputHeight)
+ {
+ int width = Math.Max(1, (int)MathF.Ceiling(outputWidth * _quality.ResolutionScale));
+ int height = Math.Max(1, (int)MathF.Ceiling(outputHeight * _quality.ResolutionScale));
+ if (_target is { } current
+ && current.RenderTarget.Description.Width == width
+ && current.RenderTarget.Description.Height == height)
+ return current;
+
+ IGpuRenderTarget? renderTarget = null;
+ GpuTextureSlot slot = GpuTextureSlot.Unassigned;
+ try
+ {
+ renderTarget = _device.CreateRenderTarget(new GpuRenderTargetDescription(
+ "atmospheric-volumetric",
+ width,
+ height,
+ GpuTextureFormat.Rgba16FloatRenderTarget,
+ DepthFormat: null,
+ SampleCount: 1));
+ slot = _device.RegisterTexture(renderTarget.ColorTexture, _sampler);
+ var candidate = new Target(_device, renderTarget, slot);
+ renderTarget = null;
+ slot = GpuTextureSlot.Unassigned;
+ Target? prior = _target;
+ _target = candidate;
+ prior?.Dispose();
+ _performance.Reset();
+ return candidate;
+ }
+ catch
+ {
+ if (slot.IsAssigned)
+ _device.ReleaseTextureSlot(slot);
+ renderTarget?.Dispose();
+ throw;
+ }
+ }
+
+ private VolumetricShaftGateReason Gate(
+ IGpuFrame frame,
+ in AtmosphericFrameInputs inputs,
+ in DirectionalShadowFrameBinding shadow,
+ GpuTextureSlot sceneDepth)
+ {
+ if (_declaredStrength <= 0f)
+ return VolumetricShaftGateReason.DisabledByPreset;
+ if (!shadow.IsValidFor(frame))
+ return VolumetricShaftGateReason.NoCurrentDirectionalShadow;
+ if (!sceneDepth.IsAssigned)
+ return VolumetricShaftGateReason.NoSceneDepth;
+ if (!inputs.IsOutdoor)
+ return VolumetricShaftGateReason.Indoor;
+ if (!inputs.SunIsOnScreen)
+ return VolumetricShaftGateReason.SunOffScreen;
+ return VolumetricShaftGateReason.Rendered;
+ }
+
+ private (float Density, float Strength) Parameters(in AtmosphericFrameInputs inputs)
+ {
+ float weatherTarget = inputs.Weather switch
+ {
+ AcDream.Core.World.WeatherKind.Clear => 1f,
+ AcDream.Core.World.WeatherKind.Overcast => 0.18f,
+ AcDream.Core.World.WeatherKind.Rain => 0.10f,
+ AcDream.Core.World.WeatherKind.Snow => 0.16f,
+ AcDream.Core.World.WeatherKind.Storm => 0.06f,
+ _ => 0f,
+ };
+ float weatherBlend = Math.Clamp(inputs.WeatherIntensity, 0f, 1f);
+ float weather = 1f + ((weatherTarget - 1f) * weatherBlend);
+ float elevation = RenderPackAtmospherePolicyEvaluation.VolumetricShaft(
+ _atmospherePolicy.VolumetricShaftSunElevationResponse,
+ inputs.SunElevationDegrees);
+ float authoredEnergy = Math.Clamp(inputs.SunDirectionalBrightness, 0f, 4f);
+ float dayGroup = _dayGroupMultipliers.TryGetValue(
+ inputs.ActiveDayGroup,
+ out float declaredDayGroup)
+ ? Math.Clamp(declaredDayGroup, 0f, 4f)
+ : 1f;
+ float strength = Math.Clamp(
+ _declaredStrength * weather * elevation * authoredEnergy * dayGroup,
+ 0f,
+ 1f);
+ return (0.035f * strength, strength);
+ }
+
+ private AtmosphericFrameUniforms FrameUniforms(
+ in AtmosphericFrameInputs inputs,
+ float strength) => new(
+ new Vector4(inputs.SunScreenUv, strength, inputs.SunElevationDegrees),
+ new Vector4(inputs.SunColor, strength),
+ new Vector4(inputs.ViewportWidth, inputs.ViewportHeight,
+ 1f / inputs.ViewportWidth, 1f / inputs.ViewportHeight),
+ new Vector4((float)inputs.Weather, inputs.WeatherIntensity,
+ (float)Math.Clamp(inputs.DeltaSeconds, 0d, 1d), inputs.IsOutdoor ? 1f : 0f),
+ new Vector4(inputs.SunDirection, inputs.SunDirectionalBrightness),
+ new Vector4(
+ inputs.ActiveDayGroup,
+ _dayGroupMultipliers.TryGetValue(inputs.ActiveDayGroup, out float dayGroup)
+ ? dayGroup
+ : 1f,
+ RenderPackAtmospherePolicyEvaluation.DirectionalShadow(
+ _atmospherePolicy.DirectionalShadowLightElevationResponse,
+ inputs.SunElevationDegrees),
+ RenderPackAtmospherePolicyEvaluation.VolumetricShaft(
+ _atmospherePolicy.VolumetricShaftSunElevationResponse,
+ inputs.SunElevationDegrees)),
+ inputs.InverseViewProjection);
+
+ private VolumetricShaftDiagnostics Disabled(VolumetricShaftGateReason reason) => new(
+ reason,
+ 0,
+ 0,
+ _quality.RayMarchSteps,
+ 0f,
+ 0f,
+ _target?.RetainedBytes ?? 0L,
+ 0d,
+ false,
+ 0);
+
+ private static DirectionalShadowPreset PresetOf(RenderQualityPreset preset) =>
+ preset.Semantic switch
+ {
+ RenderQualitySemantic.Low => DirectionalShadowPreset.Low,
+ RenderQualitySemantic.High => DirectionalShadowPreset.High,
+ _ => DirectionalShadowPreset.Medium,
+ };
+
+ private static VolumetricShaftQuality ResolveQuality(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary? userSettingOverrides)
+ {
+ VolumetricShaftQuality quality = VolumetricShaftQuality.For(PresetOf(preset));
+ RenderResourceDeclaration resource = descriptor.Resources.Single(value =>
+ value.Semantic == RenderResourceSemantic.VolumetricShafts);
+ RenderQualityResourceOverride? resourceOverride = preset.ResourceOverrides
+ .FirstOrDefault(value => string.Equals(
+ value.ResourceId,
+ resource.Id,
+ StringComparison.OrdinalIgnoreCase));
+ RenderExtentDeclaration extent = resourceOverride?.Extent
+ ?? resource.Extent
+ ?? throw new NotSupportedException(
+ "The VolumetricShafts semantic resource has no image extent.");
+ if (extent.Mode is not RenderExtentMode.RelativeToMainWorld
+ and not RenderExtentMode.RelativeToOutput)
+ {
+ throw new NotSupportedException(
+ "The VolumetricShafts semantic resource must use a relative extent.");
+ }
+ int steps = checked((int)MathF.Round(ReadSetting(
+ descriptor,
+ preset,
+ userSettingOverrides,
+ RenderSettingSemantic.VolumetricRayMarchSteps,
+ quality.RayMarchSteps)));
+ return quality with
+ {
+ ResolutionScale = (float)Math.Clamp(extent.Width, 0.0625, 1.0),
+ RayMarchSteps = Math.Clamp(steps, 8, 64),
+ };
+ }
+
+ private static float ReadSetting(
+ RenderPackDescriptor descriptor,
+ RenderQualityPreset preset,
+ IReadOnlyDictionary? userSettingOverrides,
+ RenderSettingSemantic semantic,
+ float fallback)
+ {
+ RenderSettingDeclaration? setting = descriptor.Settings.FirstOrDefault(candidate =>
+ candidate.Semantic == semantic);
+ if (setting is null)
+ return fallback;
+ string value = RenderPackSettingResolution.Resolve(
+ setting,
+ preset,
+ userSettingOverrides);
+ return RenderPackSettingValueCodec.TryEncode(setting, value, out float encoded)
+ ? Math.Max(0f, encoded)
+ : fallback;
+ }
+
+ private sealed class Target(
+ IGpuDevice device,
+ IGpuRenderTarget renderTarget,
+ GpuTextureSlot textureSlot) : IDisposable
+ {
+ internal IGpuRenderTarget RenderTarget { get; } = renderTarget;
+ internal GpuTextureSlot TextureSlot { get; } = textureSlot;
+ internal long RetainedBytes => checked(
+ (long)RenderTarget.Description.Width * RenderTarget.Description.Height * 8L);
+
+ public void Dispose()
+ {
+ device.ReleaseTextureSlot(TextureSlot);
+ RenderTarget.Dispose();
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs
index e3d2e350..7af1b277 100644
--- a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs
+++ b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs
@@ -1,4 +1,5 @@
using AcDream.Core.World;
+using AcDream.App.Rendering.Packs;
namespace AcDream.App.Rendering;
@@ -153,6 +154,7 @@ internal sealed class RenderFrameDiagnosticsController :
private readonly IRenderFrameResourceDiagnosticsSource? _resources;
private readonly IRenderFrameDiagnosticLog _log;
private readonly bool _publishResourceDiagnostics;
+ private readonly IRenderPackDiagnosticsSnapshotSource? _renderPack;
private double _elapsedSeconds;
private int _frameCount;
@@ -165,7 +167,8 @@ internal sealed class RenderFrameDiagnosticsController :
IRenderFrameTitleSink titleSink,
IRenderFrameDiagnosticLog log,
bool publishResourceDiagnostics,
- IRenderFrameResourceDiagnosticsSource? resources = null)
+ IRenderFrameResourceDiagnosticsSource? resources = null,
+ IRenderPackDiagnosticsSnapshotSource? renderPack = null)
{
_titleFacts = titleFacts ?? throw new ArgumentNullException(nameof(titleFacts));
_titleSink = titleSink ?? throw new ArgumentNullException(nameof(titleSink));
@@ -174,6 +177,7 @@ internal sealed class RenderFrameDiagnosticsController :
_resources = publishResourceDiagnostics
? resources ?? throw new ArgumentNullException(nameof(resources))
: resources;
+ _renderPack = renderPack;
}
public void Publish(RenderFrameInput input, RenderFrameOutcome outcome)
@@ -201,6 +205,11 @@ internal sealed class RenderFrameDiagnosticsController :
{
RenderFrameResourceDiagnosticsSnapshot resources = _resources!.Capture();
_log.WriteLine(FormatGpuStream(resources));
+ if (_renderPack is not null)
+ {
+ _log.WriteLine(RenderPackDiagnosticsFormatter.Format(
+ _renderPack.CaptureDiagnostics()));
+ }
}
Snapshot = new RenderFrameDiagnosticsSnapshot(
diff --git a/src/AcDream.App/Rendering/RetailDetailTextureContract.cs b/src/AcDream.App/Rendering/RetailDetailTextureContract.cs
new file mode 100644
index 00000000..613fbb8f
--- /dev/null
+++ b/src/AcDream.App/Rendering/RetailDetailTextureContract.cs
@@ -0,0 +1,47 @@
+using System.Numerics;
+using AcDream.App.Rendering.Gpu;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Testable CPU statement of retail's detail-pass gate and pixel math. The
+/// production pixels are produced by mesh_detail; keeping these facts in
+/// one small contract makes the setting, distance units, neutral point, and
+/// intentional brightening independently assertable without a GPU.
+///
+internal static class RetailDetailTextureContract
+{
+ internal const float FullDetailDistanceMetres = 10f;
+ internal const float ZeroDetailDistanceMetres = 50f;
+
+ internal static bool ShouldRender(
+ bool settingEnabled,
+ TerrainAtlas.RetailDetailTextureBinding binding) =>
+ settingEnabled && binding.IsAvailable;
+
+ ///
+ /// Opaque detail must compare equal against the depth written by its exact
+ /// base geometry. On an MSAA target that inherits the base pass's per-sample
+ /// alpha-to-coverage mask without applying A2C to the detail alpha itself.
+ /// Transparent bases do not write depth, so their adjacent detail uses the
+ /// accepted less-or-equal comparison instead.
+ ///
+ internal static GpuCompareOp DetailDepthCompare(bool transparent) =>
+ transparent ? GpuCompareOp.LessOrEqual : GpuCompareOp.Equal;
+
+ internal static float FadeForPositiveViewDepthMetres(float depthMetres) =>
+ Math.Clamp(
+ (ZeroDetailDistanceMetres - depthMetres)
+ / (ZeroDetailDistanceMetres - FullDetailDistanceMetres),
+ 0f,
+ 1f);
+
+ ///
+ /// Effective multiplier on the existing framebuffer after the shader
+ /// scales both detail RGB and alpha by fade and the pipeline applies
+ /// DstColor + OneMinusSrcAlpha.
+ ///
+ internal static Vector3 FramebufferFactor(Vector4 detail, float fade) =>
+ Vector3.One + fade * (new Vector3(detail.X, detail.Y, detail.Z)
+ - new Vector3(detail.W));
+}
diff --git a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs
index 07f7f3a2..c43a3690 100644
--- a/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs
+++ b/src/AcDream.App/Rendering/Scene/Arch/ArchRenderScene.cs
@@ -1,4 +1,6 @@
+using System.Numerics;
using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
using AcDream.App.Rendering;
using Arch.Core;
using ArchWorld = Arch.Core.World;
@@ -37,6 +39,12 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
private RenderProjectionCounts _counts;
private ulong _lastAppliedJournalSequence;
private ulong _indexRevision = 1;
+ private ulong _directionalShadowTopologyRevision = 1;
+ private DirectionalShadowTransformChange[]? _directionalShadowTransformChanges;
+ private Dictionary?
+ _directionalShadowPartPoses;
+ private ulong _directionalShadowTransformRevision;
+ private int _directionalShadowTransformChangeCount;
private bool _disposed;
public ArchRenderScene(RenderSceneGeneration initialGeneration)
@@ -76,6 +84,23 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
long lookupBytes =
(long)lookupCapacity * Unsafe.SizeOf();
long indexBytes = EstimateIndexBytes();
+ long directionalShadowJournalBytes =
+ _directionalShadowTransformChanges is null
+ ? 0
+ : checked((long)_directionalShadowTransformChanges.Length
+ * Unsafe.SizeOf());
+ if (_directionalShadowPartPoses is not null)
+ {
+ directionalShadowJournalBytes = checked(
+ directionalShadowJournalBytes
+ + (long)_directionalShadowPartPoses.EnsureCapacity(0)
+ * (sizeof(int)
+ + Unsafe.SizeOf>())
+ + _directionalShadowPartPoses.Values.Sum(static pose =>
+ (long)pose.Count * Unsafe.SizeOf()));
+ }
return new RenderSceneMemoryAccounting(
EntityCount: _world.Size,
@@ -86,7 +111,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
ProjectionLookupCapacity: lookupCapacity,
EstimatedProjectionLookupBytes: lookupBytes,
EstimatedIndexBytes: indexBytes,
- EstimatedJournalBufferBytes: 0,
+ EstimatedJournalBufferBytes: directionalShadowJournalBytes,
EstimatedSynchronizationSourceBytes: 0);
}
}
@@ -163,7 +188,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
ref _world.Get(entry.Entity);
ref RenderWorldBounds bounds =
ref _world.Get(entry.Entity);
- if (current == update.Transform && bounds == update.Bounds)
+ bool transformChanged = !TransformBitsEqual(current, update.Transform);
+ if (!transformChanged && bounds == update.Bounds)
continue;
_world.Set(
@@ -171,6 +197,15 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
new PreviousRenderTransform(current.LocalToWorld));
_world.Set(entry.Entity, update.Transform);
_world.Set(entry.Entity, update.Bounds);
+ if (transformChanged
+ && HasRefreshableDirectionalShadowTransforms(entry.ProjectionClass)
+ && _directionalShadowTransformChanges is not null)
+ {
+ RenderProjectionRecord currentRecord = ReadRecord(in entry);
+ PublishDirectionalShadowTransformChange(
+ in currentRecord,
+ DirectionalShadowTransformChangeKind.DynamicSynchronization);
+ }
ref RenderDirtyMask dirty =
ref _world.Get(entry.Entity);
@@ -226,8 +261,10 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
ClearIndices();
_counts = default;
_lastAppliedJournalSequence = 0;
+ ResetDirectionalShadowTransformChanges();
Generation = replacementGeneration;
AdvanceIndexRevision();
+ AdvanceDirectionalShadowTopologyRevision();
}
public void Dispose()
@@ -239,6 +276,10 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
ArchWorld.Destroy(_world);
_entries.Clear();
ClearIndices();
+ _directionalShadowTransformChanges = null;
+ _directionalShadowPartPoses = null;
+ _directionalShadowTransformRevision = 0;
+ _directionalShadowTransformChangeCount = 0;
_counts = default;
_disposed = true;
}
@@ -284,6 +325,99 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
return _indexRevision;
}
+ ulong IRenderSceneQuerySource.GetDirectionalShadowTopologyRevision(
+ RenderSceneGeneration generation)
+ {
+ EnsureQueryGeneration(generation);
+ return _directionalShadowTopologyRevision;
+ }
+
+ ulong IRenderSceneQuerySource.GetDirectionalShadowTransformRevision(
+ RenderSceneGeneration generation)
+ {
+ EnsureQueryGeneration(generation);
+ EnsureDirectionalShadowTransformJournal();
+ return _directionalShadowTransformRevision;
+ }
+
+ DirectionalShadowTransformChanges
+ IRenderSceneQuerySource.CopyDirectionalShadowTransformChanges(
+ RenderSceneGeneration generation,
+ ulong afterRevision,
+ Span destination)
+ {
+ EnsureQueryGeneration(generation);
+ EnsureDirectionalShadowTransformJournal();
+ ulong latest = _directionalShadowTransformRevision;
+ if (afterRevision == latest)
+ return new DirectionalShadowTransformChanges(latest, 0, false);
+ if (afterRevision == 0
+ || afterRevision > latest
+ || latest - afterRevision
+ > checked((ulong)_directionalShadowTransformChangeCount))
+ {
+ return new DirectionalShadowTransformChanges(latest, 0, true);
+ }
+
+ int count = checked((int)(latest - afterRevision));
+ if (destination.Length < count)
+ return new DirectionalShadowTransformChanges(latest, 0, true);
+ DirectionalShadowTransformChange[] journal =
+ _directionalShadowTransformChanges!;
+ int updateTransformCount = 0;
+ int updateAppearanceCount = 0;
+ int dynamicSynchronizationCount = 0;
+ int activeAnimatedStaticCount = 0;
+ int liveDynamicRootCount = 0;
+ int equippedChildCount = 0;
+ for (int index = 0; index < count; index++)
+ {
+ ulong revision = checked(afterRevision + (ulong)index + 1UL);
+ DirectionalShadowTransformChange change =
+ journal[(int)(revision % (ulong)journal.Length)];
+ if (change.Revision != revision)
+ return new DirectionalShadowTransformChanges(latest, 0, true);
+ destination[index] = change.Projection;
+ switch (change.Kind)
+ {
+ case DirectionalShadowTransformChangeKind.UpdateTransform:
+ updateTransformCount++;
+ break;
+ case DirectionalShadowTransformChangeKind.UpdateAppearance:
+ updateAppearanceCount++;
+ break;
+ case DirectionalShadowTransformChangeKind.DynamicSynchronization:
+ dynamicSynchronizationCount++;
+ break;
+ default:
+ throw new InvalidOperationException(
+ $"Unknown directional-shadow change kind {change.Kind}.");
+ }
+ switch (change.Projection.ProjectionClass)
+ {
+ case RenderProjectionClass.ActiveAnimatedStatic:
+ activeAnimatedStaticCount++;
+ break;
+ case RenderProjectionClass.LiveDynamicRoot:
+ liveDynamicRootCount++;
+ break;
+ case RenderProjectionClass.EquippedChild:
+ equippedChildCount++;
+ break;
+ }
+ }
+ return new DirectionalShadowTransformChanges(
+ latest,
+ count,
+ false,
+ updateTransformCount,
+ updateAppearanceCount,
+ dynamicSynchronizationCount,
+ activeAnimatedStaticCount,
+ liveDynamicRootCount,
+ equippedChildCount);
+ }
+
bool IRenderSceneQuerySource.TryGet(
RenderSceneGeneration generation,
RenderProjectionId id,
@@ -300,6 +434,30 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
return false;
}
+ int IRenderSceneQuerySource.CopyById(
+ RenderSceneGeneration generation,
+ ReadOnlySpan ids,
+ Span destination)
+ {
+ EnsureQueryGeneration(generation);
+ if (destination.Length < ids.Length)
+ {
+ throw new ArgumentException(
+ "The render-scene ID-copy destination is too small.",
+ nameof(destination));
+ }
+ for (int index = 0; index < ids.Length; index++)
+ {
+ if (!_entries.TryGetValue(ids[index], out SceneEntry entry))
+ {
+ throw new InvalidOperationException(
+ $"Render-scene projection {ids[index]} disappeared during a batched copy.");
+ }
+ destination[index] = ReadRecord(in entry);
+ }
+ return ids.Length;
+ }
+
int IRenderSceneQuerySource.CopyTo(
RenderSceneGeneration generation,
RenderProjectionClass? projectionClass,
@@ -391,6 +549,16 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
RenderProjectionRecord prior = ReadRecord(in existing);
WriteRecord(existing.Entity, in record);
UpdateIndices(in prior, in record);
+ if (HasRefreshableDirectionalShadowTransforms(record.ProjectionClass))
+ {
+ if (!TransformBitsEqual(prior.Transform, record.Transform))
+ {
+ PublishDirectionalShadowTransformChange(
+ in record,
+ DirectionalShadowTransformChangeKind.UpdateTransform);
+ }
+ PublishDirectionalShadowPartPoseChangeIfNeeded(in record);
+ }
result.Applied++;
result.Updated++;
return;
@@ -407,6 +575,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
record.ProjectionClass);
IncrementCount(record.ProjectionClass);
AddToIndices(in record);
+ SynchronizeDirectionalShadowPartPose(in record);
result.Applied++;
result.Registered++;
}
@@ -465,6 +634,20 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
RenderProjectionRecord current = ReadRecord(in entry);
UpdateIndices(in prior, in current);
+ if (HasRefreshableDirectionalShadowTransforms(current.ProjectionClass))
+ {
+ if (kind is RenderProjectionDeltaKind.UpdateTransform
+ && !TransformBitsEqual(prior.Transform, current.Transform))
+ {
+ PublishDirectionalShadowTransformChange(
+ in current,
+ DirectionalShadowTransformChangeKind.UpdateTransform);
+ }
+ else if (kind is RenderProjectionDeltaKind.UpdateAppearance)
+ {
+ PublishDirectionalShadowPartPoseChangeIfNeeded(in current);
+ }
+ }
result.Applied++;
result.Updated++;
}
@@ -582,6 +765,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
private void Destroy(in SceneEntry entry)
{
RenderProjectionRecord record = ReadRecord(in entry);
+ _directionalShadowPartPoses?.Remove(record.Id);
RemoveFromIndices(in record);
_world.Destroy(entry.Entity);
DecrementCount(entry.ProjectionClass);
@@ -603,6 +787,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
{
if (IndexMembershipEquals(in prior, in current))
{
+ if (!DirectionalShadowTopologyEquals(in prior, in current))
+ AdvanceDirectionalShadowTopologyRevision();
SynchronizeDirtyIndex(in current);
return;
}
@@ -652,6 +838,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
if (record.DirtyMask != RenderDirtyMask.None)
_dirty.Add(record.Id);
AdvanceIndexRevision();
+ AdvanceDirectionalShadowTopologyRevision();
}
private void RemoveFromIndices(in RenderProjectionRecord record)
@@ -668,6 +855,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
RemoveCell(_cellStatics, record.Residency.FullCellId, record.Id);
RemoveCell(_cellDynamics, record.Residency.FullCellId, record.Id);
AdvanceIndexRevision();
+ AdvanceDirectionalShadowTopologyRevision();
}
private static bool IndexMembershipEquals(
@@ -689,6 +877,235 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
&& left.SortKey == right.SortKey;
}
+ private static bool DirectionalShadowTopologyEquals(
+ in RenderProjectionRecord left,
+ in RenderProjectionRecord right)
+ {
+ const RenderProjectionFlags eligibilityFlags =
+ RenderProjectionFlags.Draw
+ | RenderProjectionFlags.SpatiallyResident
+ | RenderProjectionFlags.Translucent;
+
+ if (left.ProjectionClass != right.ProjectionClass
+ || left.OwnerIncarnation != right.OwnerIncarnation
+ || left.Source.ParentCellId != right.Source.ParentCellId
+ || (left.Flags & eligibilityFlags) != (right.Flags & eligibilityFlags)
+ || left.SortKey != right.SortKey
+ || left.MeshSet.MeshCount != right.MeshSet.MeshCount
+ || left.Material != right.Material
+ || left.DegradeState != right.DegradeState
+ || left.Source.AppearanceFingerprint
+ != right.Source.AppearanceFingerprint
+ || left.Source.DirectionalShadowTopologyFingerprint
+ != right.Source.DirectionalShadowTopologyFingerprint
+ || left.EntityPayload.IsBuildingShell
+ != right.EntityPayload.IsBuildingShell
+ || left.EntityPayload.CasterIdentity
+ != right.EntityPayload.CasterIdentity
+ || !PaletteEquals(
+ left.EntityPayload.PaletteOverride,
+ right.EntityPayload.PaletteOverride))
+ {
+ return false;
+ }
+
+ bool refreshableTransforms =
+ HasRefreshableDirectionalShadowTransforms(left.ProjectionClass);
+ if (!refreshableTransforms
+ && (left.Transform != right.Transform
+ || left.MeshSet != right.MeshSet
+ || left.Source.GeometryFingerprint
+ != right.Source.GeometryFingerprint))
+ {
+ return false;
+ }
+
+ IReadOnlyList? leftMeshes =
+ left.EntityPayload.MeshRefs;
+ IReadOnlyList? rightMeshes =
+ right.EntityPayload.MeshRefs;
+ if (ReferenceEquals(leftMeshes, rightMeshes))
+ return true;
+ if (leftMeshes is null
+ || rightMeshes is null
+ || leftMeshes.Count != rightMeshes.Count)
+ {
+ return false;
+ }
+
+ for (int meshIndex = 0; meshIndex < leftMeshes.Count; meshIndex++)
+ {
+ AcDream.Core.World.MeshRef leftMesh = leftMeshes[meshIndex];
+ AcDream.Core.World.MeshRef rightMesh = rightMeshes[meshIndex];
+ if (leftMesh.GfxObjId != rightMesh.GfxObjId
+ || !SurfaceOverridesEqual(
+ leftMesh.SurfaceOverrides,
+ rightMesh.SurfaceOverrides)
+ || (!refreshableTransforms
+ && leftMesh.PartTransform != rightMesh.PartTransform))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool HasRefreshableDirectionalShadowTransforms(
+ RenderProjectionClass projectionClass) =>
+ projectionClass is RenderProjectionClass.ActiveAnimatedStatic
+ or RenderProjectionClass.LiveDynamicRoot
+ or RenderProjectionClass.EquippedChild;
+
+ private static bool TransformBitsEqual(
+ in RenderTransform left,
+ in RenderTransform right)
+ {
+ Matrix4x4 leftMatrix = left.LocalToWorld;
+ Matrix4x4 rightMatrix = right.LocalToWorld;
+ ReadOnlySpan leftSpan = MemoryMarshal.CreateReadOnlySpan(
+ in leftMatrix,
+ 1);
+ ReadOnlySpan rightSpan = MemoryMarshal.CreateReadOnlySpan(
+ in rightMatrix,
+ 1);
+ return MemoryMarshal.AsBytes(leftSpan).SequenceEqual(
+ MemoryMarshal.AsBytes(rightSpan));
+ }
+
+ private void EnsureDirectionalShadowTransformJournal()
+ {
+ if (_directionalShadowTransformChanges is not null)
+ return;
+ _directionalShadowTransformChanges = new DirectionalShadowTransformChange[
+ DirectionalShadowTransformChangeJournal.Capacity];
+ _directionalShadowTransformRevision = 1;
+ _directionalShadowTransformChangeCount = 0;
+ _directionalShadowPartPoses = new Dictionary<
+ RenderProjectionId,
+ DirectionalShadowPartPoseSnapshot>();
+ foreach (SceneEntry entry in _entries.Values)
+ {
+ if (!HasRefreshableDirectionalShadowTransforms(entry.ProjectionClass))
+ continue;
+ RenderProjectionRecord record = ReadRecord(in entry);
+ SynchronizeDirectionalShadowPartPose(in record);
+ }
+ }
+
+ private void PublishDirectionalShadowTransformChange(
+ in RenderProjectionRecord projection,
+ DirectionalShadowTransformChangeKind kind)
+ {
+ DirectionalShadowTransformChange[]? journal =
+ _directionalShadowTransformChanges;
+ if (journal is null)
+ return;
+ if (_directionalShadowTransformRevision == ulong.MaxValue)
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow transform revision space was exhausted.");
+ }
+ ulong revision = ++_directionalShadowTransformRevision;
+ journal[(int)(revision % (ulong)journal.Length)] =
+ new DirectionalShadowTransformChange(
+ revision,
+ DirectionalShadowTransformSnapshot.Capture(in projection),
+ kind);
+ if (_directionalShadowTransformChangeCount < journal.Length)
+ _directionalShadowTransformChangeCount++;
+ }
+
+ private void ResetDirectionalShadowTransformChanges()
+ {
+ if (_directionalShadowTransformChanges is null)
+ return;
+ _directionalShadowTransformRevision = 1;
+ _directionalShadowTransformChangeCount = 0;
+ _directionalShadowPartPoses!.Clear();
+ }
+
+ private void SynchronizeDirectionalShadowPartPose(
+ in RenderProjectionRecord record)
+ {
+ Dictionary?
+ poses = _directionalShadowPartPoses;
+ if (poses is null
+ || !HasRefreshableDirectionalShadowTransforms(record.ProjectionClass))
+ {
+ return;
+ }
+ if (!poses.TryGetValue(record.Id, out DirectionalShadowPartPoseSnapshot? pose))
+ {
+ poses.Add(record.Id, DirectionalShadowPartPoseSnapshot.Capture(in record));
+ return;
+ }
+ pose.CaptureCurrent(in record);
+ }
+
+ private void PublishDirectionalShadowPartPoseChangeIfNeeded(
+ in RenderProjectionRecord record)
+ {
+ Dictionary?
+ poses = _directionalShadowPartPoses;
+ if (poses is null)
+ return;
+ if (!poses.TryGetValue(record.Id, out DirectionalShadowPartPoseSnapshot? pose))
+ {
+ poses.Add(record.Id, DirectionalShadowPartPoseSnapshot.Capture(in record));
+ return;
+ }
+ if (!pose.CaptureCurrent(in record))
+ return;
+ PublishDirectionalShadowTransformChange(
+ in record,
+ DirectionalShadowTransformChangeKind.UpdateAppearance);
+ }
+
+ private static bool PaletteEquals(
+ AcDream.Core.World.PaletteOverride? left,
+ AcDream.Core.World.PaletteOverride? right)
+ {
+ if (ReferenceEquals(left, right))
+ return true;
+ if (left is null
+ || right is null
+ || left.BasePaletteId != right.BasePaletteId
+ || left.SubPalettes.Count != right.SubPalettes.Count)
+ {
+ return false;
+ }
+
+ for (int index = 0; index < left.SubPalettes.Count; index++)
+ {
+ if (left.SubPalettes[index] != right.SubPalettes[index])
+ return false;
+ }
+
+ return true;
+ }
+
+ private static bool SurfaceOverridesEqual(
+ IReadOnlyDictionary? left,
+ IReadOnlyDictionary? right)
+ {
+ if (ReferenceEquals(left, right))
+ return true;
+ if (left is null || right is null || left.Count != right.Count)
+ return false;
+
+ foreach ((uint surfaceId, uint textureId) in left)
+ {
+ if (!right.TryGetValue(surfaceId, out uint candidate)
+ || candidate != textureId)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
private void SynchronizeDirtyIndex(
in RenderProjectionRecord record)
{
@@ -709,6 +1126,17 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
_indexRevision++;
}
+ private void AdvanceDirectionalShadowTopologyRevision()
+ {
+ if (_directionalShadowTopologyRevision == ulong.MaxValue)
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow topology revision space was exhausted.");
+ }
+
+ _directionalShadowTopologyRevision++;
+ }
+
private static bool IsDynamic(RenderProjectionClass projectionClass) =>
projectionClass is RenderProjectionClass.LiveDynamicRoot
or RenderProjectionClass.EquippedChild;
@@ -925,10 +1353,71 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
hash.Add(record.Source.AppearanceFingerprint.Low);
hash.Add(record.Source.AppearanceFingerprint.High);
hash.Add(record.Source.CurrentProjectionFlags);
+ hash.Add((byte)record.EntityPayload.CasterIdentity);
}
private readonly record struct ProjectionIdentity(RenderProjectionId Id);
+ private readonly record struct DirectionalShadowTransformChange(
+ ulong Revision,
+ DirectionalShadowTransformSnapshot Projection,
+ DirectionalShadowTransformChangeKind Kind);
+
+ private sealed class DirectionalShadowPartPoseSnapshot
+ {
+ private Matrix4x4[] _parts;
+
+ private DirectionalShadowPartPoseSnapshot(Matrix4x4[] parts) =>
+ _parts = parts;
+
+ internal int Count => _parts.Length;
+
+ internal static DirectionalShadowPartPoseSnapshot Capture(
+ in RenderProjectionRecord record)
+ {
+ IReadOnlyList? meshes =
+ record.EntityPayload.MeshRefs;
+ var parts = new Matrix4x4[meshes?.Count ?? 0];
+ for (int index = 0; index < parts.Length; index++)
+ parts[index] = meshes![index].PartTransform;
+ return new DirectionalShadowPartPoseSnapshot(parts);
+ }
+
+ internal bool CaptureCurrent(in RenderProjectionRecord record)
+ {
+ IReadOnlyList? meshes =
+ record.EntityPayload.MeshRefs;
+ int count = meshes?.Count ?? 0;
+ bool changed = _parts.Length != count;
+ if (changed)
+ _parts = new Matrix4x4[count];
+ for (int index = 0; index < count; index++)
+ {
+ Matrix4x4 current = meshes![index].PartTransform;
+ if (!MatrixBitsEqual(in _parts[index], in current))
+ {
+ _parts[index] = current;
+ changed = true;
+ }
+ }
+ return changed;
+ }
+
+ private static bool MatrixBitsEqual(
+ in Matrix4x4 left,
+ in Matrix4x4 right)
+ {
+ ReadOnlySpan leftSpan = MemoryMarshal.CreateReadOnlySpan(
+ in left,
+ 1);
+ ReadOnlySpan rightSpan = MemoryMarshal.CreateReadOnlySpan(
+ in right,
+ 1);
+ return MemoryMarshal.AsBytes(leftSpan).SequenceEqual(
+ MemoryMarshal.AsBytes(rightSpan));
+ }
+ }
+
private readonly record struct OutdoorStaticTag;
private readonly record struct IndoorCellStaticTag;
diff --git a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs
index ecea5872..5758a9d5 100644
--- a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs
+++ b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs
@@ -834,6 +834,22 @@ internal sealed class CurrentRenderSceneOracle :
geometry.Add(fingerprint.High);
}
+ internal static RenderSceneHash128
+ CreateDirectionalShadowTopologyFingerprint(WorldEntity entity)
+ {
+ ArgumentNullException.ThrowIfNull(entity);
+ StableRenderHash128 topology = StableRenderHash128.Create();
+ topology.Add(entity.MeshRefs.Count);
+ for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++)
+ {
+ MeshRef mesh = entity.MeshRefs[meshIndex];
+ topology.Add(mesh.GfxObjId);
+ AddSurfaceOverrides(ref topology, mesh.SurfaceOverrides);
+ }
+
+ return topology.Finish();
+ }
+
internal static RenderSceneHash128 CreateSurfaceOverrideFingerprint(
IReadOnlyDictionary? overrides)
{
diff --git a/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs b/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs
new file mode 100644
index 00000000..e3e77ed8
--- /dev/null
+++ b/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs
@@ -0,0 +1,542 @@
+namespace AcDream.App.Rendering.Scene;
+
+///
+/// Projection-level membership. Opaque versus alpha-cutout remains an exact
+/// mesh-batch decision in the dispatcher; this product deliberately does not
+/// guess from an entity's texture set.
+///
+internal enum DirectionalShadowCasterKind : byte
+{
+ OutdoorStatic,
+ Building,
+ AnimatedStatic,
+ LiveDynamic,
+ EquippedChild,
+}
+
+internal readonly record struct DirectionalShadowCaster(
+ RenderProjectionRecord Projection,
+ DirectionalShadowCasterKind Kind)
+{
+ public bool UsesCurrentAnimatedTransforms =>
+ Projection.ProjectionClass
+ is RenderProjectionClass.ActiveAnimatedStatic
+ or RenderProjectionClass.LiveDynamicRoot
+ or RenderProjectionClass.EquippedChild;
+}
+
+internal readonly struct DirectionalShadowChangedPose
+{
+ internal DirectionalShadowChangedPose(
+ int casterIndex,
+ in DirectionalShadowTransformSnapshot snapshot)
+ {
+ CasterIndex = casterIndex;
+ Snapshot = snapshot;
+ }
+
+ internal readonly int CasterIndex;
+ internal readonly DirectionalShadowTransformSnapshot Snapshot;
+}
+
+///
+/// Accepted caster counts by the strongest class proven at render publication.
+/// TerrainCommands is populated by the terrain command producer. OutdoorStatics
+/// includes trees and all other outdoor DAT scenery; NonPlayerCreatures includes
+/// hostile monsters and non-hostile NPC creatures because neither source carries
+/// a narrower authoritative render-only discriminator.
+///
+internal readonly record struct DirectionalShadowCasterClassDiagnostics(
+ int TerrainCommands,
+ int OutdoorStatics,
+ int Buildings,
+ int AnimatedStatics,
+ int LocalPlayers,
+ int RemotePlayers,
+ int NonPlayerCreatures,
+ int OtherLiveDynamics,
+ int EquippedChildren);
+
+internal readonly record struct DirectionalShadowCasterBuildStats(
+ int SourceOutdoorStatics,
+ int SourceOutdoorDynamics,
+ int Accepted,
+ int RejectedNotDrawable,
+ int RejectedNotResident,
+ int RejectedTransparent,
+ int RejectedIndoor,
+ int RejectedMissingMesh,
+ int IndexCopies,
+ int Classifications,
+ int DynamicTransformRefreshes,
+ bool TopologyRebuilt,
+ int CopiedTransformChanges = 0,
+ int DedupedChangedCasterSlots = 0,
+ bool TransformJournalFullRefresh = false,
+ int UpdateTransformChanges = 0,
+ int UpdateAppearanceChanges = 0,
+ int DynamicSynchronizationChanges = 0,
+ int ActiveAnimatedStaticChanges = 0,
+ int LiveDynamicRootChanges = 0,
+ int EquippedChildChanges = 0,
+ bool DensityBulkRefresh = false,
+ int BatchedProjectionCopyCalls = 0)
+{
+ public DirectionalShadowCasterClassDiagnostics CasterClasses { get; init; }
+}
+
+///
+/// Reusable, streaming-bounded caster product. It copies and classifies the
+/// render scene's two resident outdoor indices only when the scene's shadow
+/// topology revision changes. Stable frames retain those topology records and
+/// emit only deduplicated slim root/part pose changes for prepared matrix slots.
+///
+internal sealed class DirectionalShadowCasterFrame
+{
+ private RenderProjectionRecord[] _outdoorStaticScratch = [];
+ private RenderProjectionRecord[] _outdoorDynamicScratch = [];
+ private DirectionalShadowCaster[] _casters = [];
+ private int[] _refreshCasterSlots = [];
+ private DirectionalShadowChangedPose[] _changedCasterPoses = [];
+ private bool[] _changedCasterFlags = [];
+ private RenderProjectionId[] _casterIds = [];
+ private RenderProjectionClass[] _casterClasses = [];
+ private RenderProjectionId[] _denseIdScratch = [];
+ private RenderProjectionRecord[] _denseRecordScratch = [];
+ private readonly DirectionalShadowTransformSnapshot[] _transformChangeScratch =
+ new DirectionalShadowTransformSnapshot[
+ DirectionalShadowTransformChangeJournal.Capacity];
+ private readonly Dictionary _refreshCasterSlotById = [];
+ private int _casterCount;
+ private int _refreshCasterSlotCount;
+ private int _changedCasterPoseCount;
+ private ulong _topologyRevision;
+ private ulong _transformRevision;
+ private DirectionalShadowTransformChanges _lastTransformChanges;
+ private bool _lastDensityBulkRefresh;
+ private int _lastBatchedProjectionCopyCalls;
+
+ public RenderSceneGeneration Generation { get; private set; }
+
+ public ulong BuildSequence { get; private set; }
+
+ public ReadOnlySpan Casters =>
+ _casters.AsSpan(0, _casterCount);
+
+ internal ReadOnlySpan RefreshCasterSlots =>
+ _refreshCasterSlots.AsSpan(0, _refreshCasterSlotCount);
+
+ internal ReadOnlySpan ChangedCasterPoses =>
+ _changedCasterPoses.AsSpan(0, _changedCasterPoseCount);
+
+ internal ulong TransformRevision => _transformRevision;
+
+ public DirectionalShadowCasterBuildStats Stats { get; private set; }
+
+ public long RetainedScratchBytes =>
+ checked(
+ (long)_outdoorStaticScratch.Length
+ * System.Runtime.CompilerServices.Unsafe.SizeOf()
+ + (long)_outdoorDynamicScratch.Length
+ * System.Runtime.CompilerServices.Unsafe.SizeOf()
+ + (long)_casters.Length
+ * System.Runtime.CompilerServices.Unsafe.SizeOf()
+ + (long)_refreshCasterSlots.Length * sizeof(int)
+ + (long)_changedCasterPoses.Length
+ * System.Runtime.CompilerServices.Unsafe.SizeOf<
+ DirectionalShadowChangedPose>()
+ + _changedCasterFlags.Length
+ + (long)_casterIds.Length
+ * System.Runtime.CompilerServices.Unsafe.SizeOf<
+ RenderProjectionId>()
+ + (long)_casterClasses.Length
+ * System.Runtime.CompilerServices.Unsafe.SizeOf<
+ RenderProjectionClass>()
+ + (long)_denseIdScratch.Length
+ * System.Runtime.CompilerServices.Unsafe.SizeOf()
+ + (long)_denseRecordScratch.Length
+ * System.Runtime.CompilerServices.Unsafe.SizeOf()
+ + (long)_transformChangeScratch.Length
+ * System.Runtime.CompilerServices.Unsafe.SizeOf<
+ DirectionalShadowTransformSnapshot>()
+ + (long)_refreshCasterSlotById.EnsureCapacity(0)
+ * (sizeof(int)
+ + System.Runtime.CompilerServices.Unsafe.SizeOf<
+ KeyValuePair>()));
+
+ public void Build(in RenderSceneQuery query)
+ {
+ ulong topologyRevision = query.DirectionalShadowTopologyRevision;
+ if (BuildSequence != 0
+ && Generation == query.Generation
+ && _topologyRevision == topologyRevision)
+ {
+ int refreshes = RefreshChangedTransforms(in query);
+ Stats = Stats with
+ {
+ IndexCopies = 0,
+ Classifications = 0,
+ DynamicTransformRefreshes = refreshes,
+ TopologyRebuilt = false,
+ CopiedTransformChanges = _lastTransformChanges.Count,
+ DedupedChangedCasterSlots = _changedCasterPoseCount,
+ TransformJournalFullRefresh =
+ _lastTransformChanges.RequiresFullRefresh,
+ DensityBulkRefresh = _lastDensityBulkRefresh,
+ BatchedProjectionCopyCalls = _lastBatchedProjectionCopyCalls,
+ UpdateTransformChanges =
+ _lastTransformChanges.UpdateTransformCount,
+ UpdateAppearanceChanges =
+ _lastTransformChanges.UpdateAppearanceCount,
+ DynamicSynchronizationChanges =
+ _lastTransformChanges.DynamicSynchronizationCount,
+ ActiveAnimatedStaticChanges =
+ _lastTransformChanges.ActiveAnimatedStaticCount,
+ LiveDynamicRootChanges =
+ _lastTransformChanges.LiveDynamicRootCount,
+ EquippedChildChanges =
+ _lastTransformChanges.EquippedChildCount,
+ };
+ return;
+ }
+
+ RenderSceneIndexCounts counts = query.IndexCounts;
+ EnsureCapacity(ref _outdoorStaticScratch, counts.OutdoorStatic);
+ EnsureCapacity(ref _outdoorDynamicScratch, counts.OutdoorDynamic);
+ int staticCount = query.CopyIndexTo(
+ RenderSceneIndex.OutdoorStatic,
+ _outdoorStaticScratch.AsSpan(0, counts.OutdoorStatic));
+ int dynamicCount = query.CopyIndexTo(
+ RenderSceneIndex.OutdoorDynamic,
+ _outdoorDynamicScratch.AsSpan(0, counts.OutdoorDynamic));
+ EnsureCapacity(ref _casters, checked(staticCount + dynamicCount));
+ _casterCount = 0;
+
+ int rejectedNotDrawable = 0;
+ int rejectedNotResident = 0;
+ int rejectedTransparent = 0;
+ int rejectedIndoor = 0;
+ int rejectedMissingMesh = 0;
+ int outdoorStatics = 0;
+ int buildings = 0;
+ int animatedStatics = 0;
+ int localPlayers = 0;
+ int remotePlayers = 0;
+ int nonPlayerCreatures = 0;
+ int otherLiveDynamics = 0;
+ int equippedChildren = 0;
+ for (int i = 0; i < staticCount; i++)
+ Add(_outdoorStaticScratch[i]);
+ for (int i = 0; i < dynamicCount; i++)
+ Add(_outdoorDynamicScratch[i]);
+
+ Array.Sort(
+ _casters,
+ 0,
+ _casterCount,
+ DirectionalShadowCasterComparer.Instance);
+ int refreshCasterCount = 0;
+ for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++)
+ {
+ if (_casters[casterIndex].UsesCurrentAnimatedTransforms)
+ refreshCasterCount++;
+ }
+ EnsureCapacity(ref _refreshCasterSlots, refreshCasterCount);
+ EnsureCapacity(ref _changedCasterPoses, refreshCasterCount);
+ EnsureCapacity(ref _changedCasterFlags, _casterCount);
+ EnsureCapacity(ref _casterIds, _casterCount);
+ EnsureCapacity(ref _casterClasses, _casterCount);
+ EnsureCapacity(ref _denseIdScratch, refreshCasterCount);
+ EnsureCapacity(ref _denseRecordScratch, refreshCasterCount);
+ _refreshCasterSlotCount = 0;
+ _changedCasterPoseCount = 0;
+ _refreshCasterSlotById.Clear();
+ _refreshCasterSlotById.EnsureCapacity(refreshCasterCount);
+ for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++)
+ {
+ _casterIds[casterIndex] = _casters[casterIndex].Projection.Id;
+ _casterClasses[casterIndex] =
+ _casters[casterIndex].Projection.ProjectionClass;
+ if (_casters[casterIndex].UsesCurrentAnimatedTransforms)
+ {
+ _refreshCasterSlots[_refreshCasterSlotCount++] = casterIndex;
+ _refreshCasterSlotById.Add(
+ _casterIds[casterIndex],
+ casterIndex);
+ }
+ }
+ Generation = query.Generation;
+ _topologyRevision = topologyRevision;
+ _transformRevision = query.DirectionalShadowTransformRevision;
+ _lastTransformChanges = default;
+ _lastDensityBulkRefresh = false;
+ _lastBatchedProjectionCopyCalls = 0;
+ BuildSequence = checked(BuildSequence + 1);
+ Stats = new DirectionalShadowCasterBuildStats(
+ staticCount,
+ dynamicCount,
+ _casterCount,
+ rejectedNotDrawable,
+ rejectedNotResident,
+ rejectedTransparent,
+ rejectedIndoor,
+ rejectedMissingMesh,
+ IndexCopies: 2,
+ Classifications: _casterCount,
+ DynamicTransformRefreshes: 0,
+ TopologyRebuilt: true)
+ {
+ CasterClasses = new DirectionalShadowCasterClassDiagnostics(
+ TerrainCommands: 0,
+ outdoorStatics,
+ buildings,
+ animatedStatics,
+ localPlayers,
+ remotePlayers,
+ nonPlayerCreatures,
+ otherLiveDynamics,
+ equippedChildren),
+ };
+ return;
+
+ void Add(in RenderProjectionRecord projection)
+ {
+ if ((projection.Flags & RenderProjectionFlags.Draw) == 0)
+ {
+ rejectedNotDrawable++;
+ return;
+ }
+ if ((projection.Flags & RenderProjectionFlags.SpatiallyResident) == 0)
+ {
+ rejectedNotResident++;
+ return;
+ }
+ // Transparent means a true blended projection. ClipMap/foliage is
+ // retained here and separated from opaque batches later.
+ if ((projection.Flags & RenderProjectionFlags.Translucent) != 0)
+ {
+ rejectedTransparent++;
+ return;
+ }
+ if (projection.Source.ParentCellId != 0
+ && InteriorEntityPartition.IsIndoorCellId(
+ projection.Source.ParentCellId))
+ {
+ rejectedIndoor++;
+ return;
+ }
+ if (projection.MeshSet.MeshCount <= 0
+ || projection.EntityPayload.MeshRefs is null
+ || projection.EntityPayload.MeshRefs.Count == 0)
+ {
+ rejectedMissingMesh++;
+ return;
+ }
+
+ DirectionalShadowCasterKind kind = Classify(in projection);
+ _casters[_casterCount++] = new DirectionalShadowCaster(
+ projection,
+ kind);
+ switch (kind)
+ {
+ case DirectionalShadowCasterKind.OutdoorStatic:
+ outdoorStatics++;
+ break;
+ case DirectionalShadowCasterKind.Building:
+ buildings++;
+ break;
+ case DirectionalShadowCasterKind.AnimatedStatic:
+ animatedStatics++;
+ break;
+ case DirectionalShadowCasterKind.EquippedChild:
+ equippedChildren++;
+ break;
+ case DirectionalShadowCasterKind.LiveDynamic:
+ switch (projection.EntityPayload.CasterIdentity)
+ {
+ case RenderCasterIdentityKind.LocalPlayer:
+ localPlayers++;
+ break;
+ case RenderCasterIdentityKind.RemotePlayer:
+ remotePlayers++;
+ break;
+ case RenderCasterIdentityKind.NonPlayerCreature:
+ nonPlayerCreatures++;
+ break;
+ default:
+ otherLiveDynamics++;
+ break;
+ }
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(
+ nameof(kind), kind, "Unknown shadow caster kind.");
+ }
+ }
+ }
+
+ private int RefreshChangedTransforms(in RenderSceneQuery query)
+ {
+ _changedCasterPoseCount = 0;
+ ulong latest = query.DirectionalShadowTransformRevision;
+ if (latest == _transformRevision)
+ {
+ _lastTransformChanges = new DirectionalShadowTransformChanges(
+ latest,
+ 0,
+ false);
+ _lastDensityBulkRefresh = false;
+ _lastBatchedProjectionCopyCalls = 0;
+ return 0;
+ }
+
+ DirectionalShadowTransformChanges changes =
+ query.CopyDirectionalShadowTransformChanges(
+ _transformRevision,
+ _transformChangeScratch);
+ _lastTransformChanges = changes;
+ if (changes.RequiresFullRefresh)
+ {
+ _lastBatchedProjectionCopyCalls = 1;
+ for (int index = 0; index < _refreshCasterSlotCount; index++)
+ {
+ int casterIndex = _refreshCasterSlots[index];
+ _denseIdScratch[index] = _casters[casterIndex].Projection.Id;
+ }
+ int copied = query.CopyById(
+ _denseIdScratch.AsSpan(0, _refreshCasterSlotCount),
+ _denseRecordScratch.AsSpan(0, _refreshCasterSlotCount));
+ if (copied != _refreshCasterSlotCount)
+ {
+ throw new InvalidOperationException(
+ "Dense directional-shadow refresh returned an incomplete record batch.");
+ }
+ for (int index = 0; index < _refreshCasterSlotCount; index++)
+ {
+ int casterIndex = _refreshCasterSlots[index];
+ RefreshOne(in _denseRecordScratch[index], casterIndex);
+ DirectionalShadowTransformSnapshot snapshot =
+ DirectionalShadowTransformSnapshot.Capture(
+ in _denseRecordScratch[index]);
+ _changedCasterPoses[_changedCasterPoseCount++] =
+ new DirectionalShadowChangedPose(casterIndex, in snapshot);
+ }
+ _lastDensityBulkRefresh = false;
+ _transformRevision = changes.LatestRevision;
+ return _changedCasterPoseCount;
+ }
+ _lastBatchedProjectionCopyCalls = 0;
+
+ try
+ {
+ ReadOnlySpan records =
+ _transformChangeScratch.AsSpan(0, changes.Count);
+ // Newest-first makes repeated publications of the same projection
+ // resolve to the latest exact root/part payload without an ECS read.
+ for (int index = records.Length - 1; index >= 0; index--)
+ {
+ if (!_refreshCasterSlotById.TryGetValue(
+ records[index].Id,
+ out int casterIndex)
+ || _changedCasterFlags[casterIndex])
+ {
+ continue;
+ }
+ _changedCasterFlags[casterIndex] = true;
+ ValidateStablePose(in records[index], casterIndex);
+ _changedCasterPoses[_changedCasterPoseCount++] =
+ new DirectionalShadowChangedPose(
+ casterIndex,
+ in records[index]);
+ }
+ }
+ finally
+ {
+ for (int index = 0; index < _changedCasterPoseCount; index++)
+ {
+ _changedCasterFlags[
+ _changedCasterPoses[index].CasterIndex] = false;
+ }
+ }
+ _lastDensityBulkRefresh = _refreshCasterSlotCount >= 64
+ && _changedCasterPoseCount
+ >= checked((_refreshCasterSlotCount * 3) / 4);
+ _transformRevision = changes.LatestRevision;
+ return _changedCasterPoseCount;
+ }
+
+ private void ValidateStablePose(
+ in DirectionalShadowTransformSnapshot current,
+ int casterIndex)
+ {
+ if (current.Id != _casterIds[casterIndex]
+ || current.ProjectionClass != _casterClasses[casterIndex])
+ {
+ throw new InvalidOperationException(
+ $"Stable directional-shadow topology changed caster "
+ + $"{_casterIds[casterIndex]} identity or class.");
+ }
+ }
+
+ private void RefreshOne(
+ in RenderProjectionRecord current,
+ int casterIndex)
+ {
+ DirectionalShadowCaster retained = _casters[casterIndex];
+ if (current.Id != retained.Projection.Id
+ || current.ProjectionClass != retained.Projection.ProjectionClass)
+ {
+ throw new InvalidOperationException(
+ $"Stable directional-shadow topology changed caster "
+ + $"{retained.Projection.Id} identity or class.");
+ }
+ _casters[casterIndex] = retained with { Projection = current };
+ }
+
+ private static DirectionalShadowCasterKind Classify(
+ in RenderProjectionRecord projection)
+ {
+ if (projection.EntityPayload.IsBuildingShell)
+ return DirectionalShadowCasterKind.Building;
+ return projection.ProjectionClass switch
+ {
+ RenderProjectionClass.OutdoorStatic =>
+ DirectionalShadowCasterKind.OutdoorStatic,
+ RenderProjectionClass.ActiveAnimatedStatic =>
+ DirectionalShadowCasterKind.AnimatedStatic,
+ RenderProjectionClass.LiveDynamicRoot =>
+ DirectionalShadowCasterKind.LiveDynamic,
+ RenderProjectionClass.EquippedChild =>
+ DirectionalShadowCasterKind.EquippedChild,
+ _ => throw new InvalidOperationException(
+ $"Outdoor shadow index carried unsupported {projection.ProjectionClass}."),
+ };
+ }
+
+ private static void EnsureCapacity(ref T[] values, int required)
+ {
+ if (required < 0)
+ throw new ArgumentOutOfRangeException(nameof(required));
+ if (values.Length >= required)
+ return;
+ int capacity = values.Length == 0 ? 4 : values.Length;
+ while (capacity < required)
+ capacity = checked(capacity * 2);
+ Array.Resize(ref values, capacity);
+ }
+
+ private sealed class DirectionalShadowCasterComparer
+ : IComparer
+ {
+ public static DirectionalShadowCasterComparer Instance { get; } = new();
+
+ public int Compare(DirectionalShadowCaster left, DirectionalShadowCaster right)
+ {
+ int order = left.Projection.SortKey.Value.CompareTo(
+ right.Projection.SortKey.Value);
+ return order != 0
+ ? order
+ : left.Projection.Id.CompareTo(right.Projection.Id);
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs
index cad94968..c1125ec0 100644
--- a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs
+++ b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs
@@ -1,5 +1,7 @@
+using AcDream.App.Input;
using AcDream.App.Update;
using AcDream.App.World;
+using AcDream.Core.Items;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
@@ -26,6 +28,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
private readonly LiveEntityRuntime _runtime;
private readonly RenderProjectionJournal _journal;
private readonly IRenderTraversalOrderSource _traversalOrder;
+ private readonly ILocalPlayerIdentitySource? _localPlayer;
private readonly Dictionary _byKey = [];
private readonly List _activeRootScratch = [];
private readonly List _activeScratch = [];
@@ -33,12 +36,14 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
public LiveRenderProjectionJournal(
LiveEntityRuntime runtime,
RenderProjectionJournal journal,
- IRenderTraversalOrderSource traversalOrder)
+ IRenderTraversalOrderSource traversalOrder,
+ ILocalPlayerIdentitySource? localPlayer = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_journal = journal ?? throw new ArgumentNullException(nameof(journal));
_traversalOrder = traversalOrder
?? throw new ArgumentNullException(nameof(traversalOrder));
+ _localPlayer = localPlayer;
}
public int ProjectionCount => _byKey.Count;
@@ -286,7 +291,14 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
ownerLandblockId,
fullCellId,
entity,
- spatiallyVisible);
+ spatiallyVisible,
+ record.ProjectionKind is LiveEntityProjectionKind.Attached
+ ? RenderCasterIdentityKind.EquippedChild
+ : RenderCasterIdentityClassifier.Classify(
+ record.Snapshot.Guid,
+ record.Snapshot.ItemType,
+ record.Snapshot.ObjectDescriptionFlags,
+ _localPlayer?.ServerGuid ?? 0u));
if (_traversalOrder.TryGetTraversalSortKey(
entity,
out RenderSortKey sortKey))
@@ -336,6 +348,31 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
}
}
+internal static class RenderCasterIdentityClassifier
+{
+ private const uint PlayerDescriptionFlag = 0x8u;
+ private const uint PlayerGuidPrefix = 0x50000000u;
+
+ internal static RenderCasterIdentityKind Classify(
+ uint serverGuid,
+ uint? itemType,
+ uint? objectDescriptionFlags,
+ uint localPlayerGuid)
+ {
+ if (localPlayerGuid != 0 && serverGuid == localPlayerGuid)
+ return RenderCasterIdentityKind.LocalPlayer;
+ if ((objectDescriptionFlags.GetValueOrDefault()
+ & PlayerDescriptionFlag) != 0
+ || (serverGuid & 0xFF000000u) == PlayerGuidPrefix)
+ {
+ return RenderCasterIdentityKind.RemotePlayer;
+ }
+ if ((itemType.GetValueOrDefault() & (uint)ItemType.Creature) != 0)
+ return RenderCasterIdentityKind.NonPlayerCreature;
+ return RenderCasterIdentityKind.OtherLiveDynamic;
+ }
+}
+
internal sealed class LiveRenderProjectionResourceLifecycle(
ILiveRenderProjectionSink sink) : ILiveEntityResourceLifecycle
{
diff --git a/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs b/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs
index 10f4c3a0..5145e252 100644
--- a/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs
+++ b/src/AcDream.App/Rendering/Scene/RenderProjectionRecordFactory.cs
@@ -19,7 +19,9 @@ internal static class RenderProjectionRecordFactory
uint ownerLandblockId,
uint fullCellId,
WorldEntity entity,
- bool spatiallyVisible)
+ bool spatiallyVisible,
+ RenderCasterIdentityKind casterIdentity =
+ RenderCasterIdentityKind.Unclassified)
{
ArgumentNullException.ThrowIfNull(entity);
CurrentRenderProjectionFingerprint fingerprint =
@@ -80,11 +82,14 @@ internal static class RenderProjectionRecordFactory
fingerprint.Transform,
fingerprint.Geometry,
fingerprint.Appearance,
- fingerprint.Flags),
+ fingerprint.Flags,
+ CurrentRenderSceneOracle
+ .CreateDirectionalShadowTopologyFingerprint(entity)),
new RenderEntityPayload(
entity.MeshRefs,
entity.PaletteOverride,
- entity.IsBuildingShell));
+ entity.IsBuildingShell,
+ casterIdentity));
}
private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds(
diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs
index e3450ce2..bc410372 100644
--- a/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs
+++ b/src/AcDream.App/Rendering/Scene/RenderSceneContracts.cs
@@ -222,7 +222,26 @@ internal readonly record struct RenderSourceMetadata(
RenderSceneHash128 TransformFingerprint,
RenderSceneHash128 GeometryFingerprint,
RenderSceneHash128 AppearanceFingerprint,
- uint CurrentProjectionFlags = 0);
+ uint CurrentProjectionFlags = 0,
+ RenderSceneHash128 DirectionalShadowTopologyFingerprint = default);
+
+///
+/// Render-only identity facts retained from the authoritative publication edge.
+/// Outdoor DAT scenery has no tree discriminator, and the create-object payload
+/// does not distinguish hostile monsters from other non-player creatures, so
+/// neither narrower category is guessed here.
+///
+internal enum RenderCasterIdentityKind : byte
+{
+ Unclassified,
+ OutdoorStatic,
+ Building,
+ LocalPlayer,
+ RemotePlayer,
+ NonPlayerCreature,
+ OtherLiveDynamic,
+ EquippedChild,
+}
///
/// Borrowed immutable presentation payload captured at a scene publication
@@ -234,7 +253,9 @@ internal readonly record struct RenderSourceMetadata(
internal readonly record struct RenderEntityPayload(
IReadOnlyList MeshRefs,
PaletteOverride? PaletteOverride,
- bool IsBuildingShell);
+ bool IsBuildingShell,
+ RenderCasterIdentityKind CasterIdentity =
+ RenderCasterIdentityKind.Unclassified);
internal readonly record struct RenderProjectionRecord(
RenderProjectionId Id,
@@ -449,6 +470,66 @@ internal readonly record struct RenderSceneDigest(
RenderProjectionCounts Counts,
RenderSceneHash128 Hash);
+internal static class DirectionalShadowTransformChangeJournal
+{
+ // Larger than the measured 9,498-caster dense-Arwic row so one complete
+ // changed-pose publication fits without truncation. Overflow is explicit
+ // and makes the consumer take its exact full-refresh fallback.
+ internal const int Capacity = 16_384;
+}
+
+internal readonly struct DirectionalShadowTransformSnapshot
+{
+ internal DirectionalShadowTransformSnapshot(
+ RenderProjectionId id,
+ RenderProjectionClass projectionClass,
+ RenderTransform transform,
+ RenderEntityPayload entityPayload)
+ {
+ Id = id;
+ ProjectionClass = projectionClass;
+ Transform = transform;
+ EntityPayload = entityPayload;
+ }
+
+ internal readonly RenderProjectionId Id;
+ internal readonly RenderProjectionClass ProjectionClass;
+ internal readonly RenderTransform Transform;
+ internal readonly RenderEntityPayload EntityPayload;
+
+ internal static DirectionalShadowTransformSnapshot Capture(
+ in RenderProjectionRecord projection) =>
+ new(
+ projection.Id,
+ projection.ProjectionClass,
+ projection.Transform,
+ projection.EntityPayload);
+}
+
+internal readonly record struct DirectionalShadowTransformChanges(
+ ulong LatestRevision,
+ int Count,
+ bool RequiresFullRefresh,
+ int UpdateTransformCount = 0,
+ int UpdateAppearanceCount = 0,
+ int DynamicSynchronizationCount = 0,
+ int ActiveAnimatedStaticCount = 0,
+ int LiveDynamicRootCount = 0,
+ int EquippedChildCount = 0);
+
+// Transform-journal records carry the producer's already-current projection.
+// Root matrices are values; MeshRef payloads are borrowed under the render
+// publication ordering rule: a part-pose mutation must be followed by its
+// UpdateAppearance publication before the frame opens a scene query. Consumers
+// read newest-to-oldest, so repeated IDs always select the latest publication.
+
+internal enum DirectionalShadowTransformChangeKind : byte
+{
+ UpdateTransform,
+ UpdateAppearance,
+ DynamicSynchronization,
+}
+
internal sealed class RenderSceneDigestBuffer
{
internal List Records { get; } = [];
@@ -461,12 +542,26 @@ internal interface IRenderSceneQuerySource
RenderProjectionCounts GetCounts(RenderSceneGeneration generation);
RenderSceneIndexCounts GetIndexCounts(RenderSceneGeneration generation);
ulong GetIndexRevision(RenderSceneGeneration generation);
+ ulong GetDirectionalShadowTopologyRevision(
+ RenderSceneGeneration generation);
+ ulong GetDirectionalShadowTransformRevision(
+ RenderSceneGeneration generation);
+
+ DirectionalShadowTransformChanges CopyDirectionalShadowTransformChanges(
+ RenderSceneGeneration generation,
+ ulong afterRevision,
+ Span destination);
bool TryGet(
RenderSceneGeneration generation,
RenderProjectionId id,
out RenderProjectionRecord record);
+ int CopyById(
+ RenderSceneGeneration generation,
+ ReadOnlySpan ids,
+ Span destination);
+
int CopyTo(
RenderSceneGeneration generation,
RenderProjectionClass? projectionClass,
@@ -512,11 +607,30 @@ internal readonly struct RenderSceneQuery
public ulong IndexRevision =>
Source.GetIndexRevision(Generation);
+ public ulong DirectionalShadowTopologyRevision =>
+ Source.GetDirectionalShadowTopologyRevision(Generation);
+
+ public ulong DirectionalShadowTransformRevision =>
+ Source.GetDirectionalShadowTransformRevision(Generation);
+
+ public DirectionalShadowTransformChanges CopyDirectionalShadowTransformChanges(
+ ulong afterRevision,
+ Span destination) =>
+ Source.CopyDirectionalShadowTransformChanges(
+ Generation,
+ afterRevision,
+ destination);
+
public bool TryGet(
RenderProjectionId id,
out RenderProjectionRecord record) =>
Source.TryGet(Generation, id, out record);
+ public int CopyById(
+ ReadOnlySpan ids,
+ Span destination) =>
+ Source.CopyById(Generation, ids, destination);
+
public int CopyTo(Span destination) =>
Source.CopyTo(Generation, null, destination);
diff --git a/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs b/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs
index aad7adbd..58ce3b11 100644
--- a/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs
+++ b/src/AcDream.App/Rendering/Scene/RenderSceneShadowRuntime.cs
@@ -1,5 +1,6 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
+using AcDream.App.Input;
using AcDream.App.Rendering.Scene.Arch;
using AcDream.App.World;
using AcDream.Core.World;
@@ -95,7 +96,8 @@ internal sealed class RenderSceneShadowRuntime : IDisposable
public LiveRenderProjectionJournal BindLiveRuntime(
LiveEntityRuntime runtime,
- IRenderTraversalOrderSource traversalOrder)
+ IRenderTraversalOrderSource traversalOrder,
+ ILocalPlayerIdentitySource? localPlayer = null)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(runtime);
@@ -109,7 +111,8 @@ internal sealed class RenderSceneShadowRuntime : IDisposable
_live = new LiveRenderProjectionJournal(
runtime,
_journal,
- traversalOrder);
+ traversalOrder,
+ localPlayer);
return _live;
}
diff --git a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs
index e6140060..83c2b06b 100644
--- a/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs
+++ b/src/AcDream.App/Rendering/Scene/StaticRenderProjectionJournal.cs
@@ -122,7 +122,9 @@ internal sealed class StaticRenderProjectionJournal :
&& accepted.Source.AppearanceFingerprint
== retained.Record.Source.AppearanceFingerprint
&& accepted.EntityPayload.IsBuildingShell
- == retained.Record.EntityPayload.IsBuildingShell)
+ == retained.Record.EntityPayload.IsBuildingShell
+ && accepted.EntityPayload.CasterIdentity
+ == retained.Record.EntityPayload.CasterIdentity)
{
accepted = accepted with
{
@@ -245,7 +247,10 @@ internal sealed class StaticRenderProjectionJournal :
tracked.Record.Residency.OwnerLandblockId,
tracked.Record.Residency.FullCellId,
entity,
- spatiallyVisible: true) with
+ spatiallyVisible: true,
+ casterIdentity: entity.IsBuildingShell
+ ? RenderCasterIdentityKind.Building
+ : RenderCasterIdentityKind.OutdoorStatic) with
{
PreviousTransform = new PreviousRenderTransform(
tracked.Record.Transform.LocalToWorld),
@@ -317,7 +322,10 @@ internal sealed class StaticRenderProjectionJournal :
landblockId,
fullCellId,
entity,
- spatiallyVisible: true) with
+ spatiallyVisible: true,
+ casterIdentity: entity.IsBuildingShell
+ ? RenderCasterIdentityKind.Building
+ : RenderCasterIdentityKind.OutdoorStatic) with
{
SortKey = sortKey,
};
diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs
index 636cc3e6..e4d4ba9d 100644
--- a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs
+++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs
@@ -12,7 +12,7 @@ namespace AcDream.App.Rendering.Selection;
///
internal interface IWorldSceneSelectionFrame
{
- void BeginFrame();
+ void BeginFrame(FrustumPlanes? preparedViewFrustum = null);
void CompleteFrame();
@@ -76,7 +76,7 @@ internal sealed class RetailSelectionScene :
_lightingPulse.Clear();
}
- public void BeginFrame()
+ public void BeginFrame(FrustumPlanes? preparedViewFrustum = null)
{
if (_frameOpen)
{
@@ -87,7 +87,7 @@ internal sealed class RetailSelectionScene :
_frameOpen = true;
_building.Clear();
_buildingKeys.Clear();
- _viewFrustum = null;
+ _viewFrustum = preparedViewFrustum;
_currentRenderSceneObserver?.BeginSelectionFrame();
}
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.frag
new file mode 100644
index 00000000..84cf49c2
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.frag
@@ -0,0 +1,17 @@
+#version 430 core
+
+layout(location = 0) in vec2 vUv;
+layout(location = 0) out vec4 oColor;
+
+#include "atmospheric_common.glsl"
+
+void main()
+{
+ vec2 stepUv = uPackParams0.xy;
+ vec3 value = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb * 0.227027;
+ value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv + stepUv * 1.384615).rgb * 0.316216;
+ value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv - stepUv * 1.384615).rgb * 0.316216;
+ value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv + stepUv * 3.230769).rgb * 0.070270;
+ value += ACDREAM_SAMPLE_2D(uTextureIndexA, vUv - stepUv * 3.230769).rgb * 0.070270;
+ oColor = vec4(value, 1.0);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.vert b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.vert
new file mode 100644
index 00000000..9e98b26d
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_blur.vert
@@ -0,0 +1,10 @@
+#version 430 core
+
+layout(location = 0) out vec2 vUv;
+
+void main()
+{
+ vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
+ gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
+ vUv = vec2(triangle.x, 1.0 - triangle.y);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.frag
new file mode 100644
index 00000000..efff81e7
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.frag
@@ -0,0 +1,23 @@
+#version 430 core
+
+layout(location = 0) in vec2 vUv;
+layout(location = 0) out vec4 oColor;
+
+#include "atmospheric_common.glsl"
+
+void main()
+{
+ vec3 scene = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb
+ + ACDREAM_SAMPLE_2D(uTextureIndexB, vUv).rgb;
+ if (uPackParams0.w > 0.5)
+ scene += ACDREAM_SAMPLE_2D(uTextureIndexC, vUv).rgb;
+ float brightness = dot(scene, vec3(0.2126, 0.7152, 0.0722));
+ float threshold = uPackParams0.y;
+ float knee = max(uPackParams0.z, 0.0001);
+ float soft = clamp((brightness - threshold + knee) / (2.0 * knee), 0.0, 1.0);
+ soft = soft * soft;
+ float contribution = max(brightness - threshold, 0.0) + soft * knee;
+ contribution /= max(brightness, 0.0001);
+ vec3 bloom = scene * contribution * uPackParams0.x;
+ oColor = vec4(bloom, 1.0);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.vert b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.vert
new file mode 100644
index 00000000..9e98b26d
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_bloom_downsample.vert
@@ -0,0 +1,10 @@
+#version 430 core
+
+layout(location = 0) out vec2 vUv;
+
+void main()
+{
+ vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
+ gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
+ vUv = vec2(triangle.x, 1.0 - triangle.y);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_common.glsl b/src/AcDream.App/Rendering/Shaders/atmospheric_common.glsl
new file mode 100644
index 00000000..659fcd68
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_common.glsl
@@ -0,0 +1,34 @@
+#ifndef ACDREAM_ATMOSPHERIC_COMMON_GLSL
+#define ACDREAM_ATMOSPHERIC_COMMON_GLSL
+
+// Render-pack shader ABI v1. These declarations are the byte-level SSOT for
+// AtmosphericFrameUniforms (set 3/binding 5, 160 bytes) and
+// AtmosphericPackPassUniforms (set 3/binding 7). Binding 6 is intentionally
+// reserved for directional-shadow data in directional_shadow_common.glsl.
+layout(std140, ACDREAM_PACK_UBO_SET binding = 5) uniform AtmosphericFrame {
+ vec4 uAtmosphereSunScreen; // 0: uv.xy, ray strength, elevation degrees
+ vec4 uAtmosphereSunColor; // 16: authored linear rgb, policy multiplier
+ vec4 uAtmosphereViewport; // 32: width, height, reciprocal width/height
+ vec4 uAtmosphereWeather; // 48: kind, intensity, delta seconds, outdoor
+ vec4 uAtmosphereSunDirection; // 64: surface-to-sun xyz, authored brightness
+ vec4 uAtmospherePolicy; // 80: day group, group factor, shadow/shaft elevation factors
+ mat4 uAtmosphereInverseViewProjection; // 96: screen/depth to world
+};
+
+layout(std140, ACDREAM_PACK_UBO_SET binding = 7) uniform PackPass {
+ vec4 uPackParams0; // 0
+ vec4 uPackParams1; // 16
+ vec4 uPackParams2; // 32
+ vec4 uPackParams3; // 48
+};
+
+// FusedAtmosphericPostProcess PackPass ABI (opt-in Low preset only):
+// sun-rays: Params1 = (enabled, logical mask width, mask height, 0)
+// filmic: Params1.z = enabled; Params2 = bloom extraction parameters;
+// Params3.xy = logical bloom texel step
+
+layout(std140, ACDREAM_PACK_UBO_SET binding = 8) uniform PackSettings {
+ vec4 uPackSettings[16]; // 64 declaration-order scalar setting slots
+};
+
+#endif
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag
new file mode 100644
index 00000000..0e50f4f1
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.frag
@@ -0,0 +1,88 @@
+#version 430 core
+
+layout(location = 0) in vec2 vUv;
+layout(location = 0) out vec4 oColor;
+
+#include "atmospheric_common.glsl"
+
+vec3 acesFitted(vec3 value)
+{
+ const float a = 2.51;
+ const float b = 0.03;
+ const float c = 2.43;
+ const float d = 0.59;
+ const float e = 0.14;
+ return clamp((value * (a * value + b)) / (value * (c * value + d) + e), 0.0, 1.0);
+}
+
+vec3 sampleBloom(vec2 uv)
+{
+ return ACDREAM_SAMPLE_2D(uTextureIndexB, uv).rgb;
+}
+
+vec3 lowFusedScene(vec2 uv)
+{
+ vec3 scene = ACDREAM_SAMPLE_2D(uTextureIndexA, uv).rgb
+ + ACDREAM_SAMPLE_2D(uTextureIndexB, uv).rgb;
+ if (uPackParams2.w > 0.5)
+ scene += ACDREAM_SAMPLE_2D(uTextureIndexC, uv).rgb;
+ return scene;
+}
+
+vec3 lowFusedBloomExtract(vec3 scene)
+{
+ float brightness = dot(scene, vec3(0.2126, 0.7152, 0.0722));
+ float threshold = uPackParams2.y;
+ float knee = max(uPackParams2.z, 0.0001);
+ float soft = clamp((brightness - threshold + knee) / (2.0 * knee), 0.0, 1.0);
+ soft = soft * soft;
+ float contribution = max(brightness - threshold, 0.0) + soft * knee;
+ contribution /= max(brightness, 0.0001);
+ return scene * contribution * uPackParams2.x;
+}
+
+vec3 lowFusedBloom(vec3 centerScene)
+{
+ const float offsets[5] = float[5](
+ -3.230769, -1.384615, 0.0, 1.384615, 3.230769);
+ const float weights[5] = float[5](
+ 0.070270, 0.316216, 0.227027, 0.316216, 0.070270);
+ vec3 bloom = vec3(0.0);
+ for (int y = 0; y < 5; ++y) {
+ for (int x = 0; x < 5; ++x) {
+ vec3 scene = x == 2 && y == 2
+ ? centerScene
+ : lowFusedScene(vUv + vec2(offsets[x], offsets[y]) * uPackParams3.xy);
+ bloom += lowFusedBloomExtract(scene) * (weights[x] * weights[y]);
+ }
+ }
+ return bloom;
+}
+
+void main()
+{
+ vec3 hdr;
+ if (uPackParams1.z > 0.5) {
+ vec3 scene = lowFusedScene(vUv);
+ hdr = scene + lowFusedBloom(scene);
+ }
+ else {
+ hdr = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).rgb
+ + sampleBloom(vUv)
+ + ACDREAM_SAMPLE_2D(uTextureIndexC, vUv).rgb;
+ if (uPackParams1.y > 0.5)
+ hdr += ACDREAM_SAMPLE_2D(uTextureIndexD, vUv).rgb;
+ }
+ vec3 exposed = max(hdr * uPackParams0.x, vec3(0.0));
+ vec3 linearClamped = clamp(exposed, 0.0, 1.0);
+ vec3 color = mix(linearClamped, acesFitted(exposed), clamp(uPackParams1.x, 0.0, 1.0));
+
+ float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));
+ color = mix(vec3(luminance), color, uPackParams0.y);
+ color = (color - 0.5) * uPackParams0.z + 0.5;
+
+ vec2 centered = vUv * 2.0 - 1.0;
+ float vignette = smoothstep(1.25, 0.25, dot(centered, centered));
+ color *= mix(1.0, vignette, clamp(uPackParams0.w, 0.0, 1.0));
+ oColor = vec4(clamp(color, 0.0, 1.0), 1.0);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.vert b/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.vert
new file mode 100644
index 00000000..9e98b26d
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_filmic.vert
@@ -0,0 +1,10 @@
+#version 430 core
+
+layout(location = 0) out vec2 vUv;
+
+void main()
+{
+ vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
+ gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
+ vUv = vec2(triangle.x, 1.0 - triangle.y);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.frag
new file mode 100644
index 00000000..bb499838
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.frag
@@ -0,0 +1,14 @@
+#version 430 core
+
+layout(location = 0) in vec2 vUv;
+layout(location = 0) out vec4 oColor;
+
+#include "atmospheric_common.glsl"
+
+void main()
+{
+ float depth = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).r;
+ float unobstructedSky = smoothstep(0.9975, 0.99995, depth);
+ float enabled = uAtmosphereSunScreen.z * uAtmosphereWeather.w;
+ oColor = vec4(vec3(unobstructedSky * enabled), 1.0);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.vert b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.vert
new file mode 100644
index 00000000..b88fc359
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_occlusion.vert
@@ -0,0 +1,12 @@
+#version 430 core
+
+layout(location = 0) out vec2 vUv;
+
+void main()
+{
+ vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
+ gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
+ // Vulkan's negative viewport preserves GL world winding; flip the sampled
+ // image coordinate once here so row zero remains the screen top.
+ vUv = vec2(triangle.x, 1.0 - triangle.y);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.frag
new file mode 100644
index 00000000..a714590f
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.frag
@@ -0,0 +1,58 @@
+#version 430 core
+
+layout(location = 0) in vec2 vUv;
+layout(location = 0) out vec4 oColor;
+
+#include "atmospheric_common.glsl"
+
+float lowFusedMaskTexel(ivec2 coordinate, vec2 maskSize)
+{
+ ivec2 maximum = ivec2(maskSize) - ivec2(1);
+ ivec2 clampedCoordinate = clamp(coordinate, ivec2(0), maximum);
+ vec2 depthUv = (vec2(clampedCoordinate) + vec2(0.5)) / maskSize;
+ float depth = ACDREAM_SAMPLE_2D(uTextureIndexA, depthUv).r;
+ float unobstructedSky = smoothstep(0.9975, 0.99995, depth);
+ float enabled = uAtmosphereSunScreen.z * uAtmosphereWeather.w;
+ // Match the removed RGBA8_UNORM mask attachment before filtering it.
+ return round(clamp(unobstructedSky * enabled, 0.0, 1.0) * 255.0) / 255.0;
+}
+
+float sampleLowFusedMask(vec2 uv)
+{
+ vec2 maskSize = uPackParams1.yz;
+ vec2 texel = uv * maskSize - vec2(0.5);
+ ivec2 lower = ivec2(floor(texel));
+ vec2 fraction = fract(texel);
+ float topLeft = lowFusedMaskTexel(lower, maskSize);
+ float topRight = lowFusedMaskTexel(lower + ivec2(1, 0), maskSize);
+ float bottomLeft = lowFusedMaskTexel(lower + ivec2(0, 1), maskSize);
+ float bottomRight = lowFusedMaskTexel(lower + ivec2(1, 1), maskSize);
+ return mix(
+ mix(topLeft, topRight, fraction.x),
+ mix(bottomLeft, bottomRight, fraction.x),
+ fraction.y);
+}
+
+void main()
+{
+ const int SampleCount = 48;
+ float decay = uPackParams0.x;
+ float weight = uPackParams0.y;
+ float density = uPackParams0.z;
+ vec2 delta = (vUv - uAtmosphereSunScreen.xy) * (density / float(SampleCount));
+ vec2 sampleUv = vUv;
+ float illumination = 1.0;
+ float sum = 0.0;
+ for (int i = 0; i < SampleCount; ++i) {
+ sampleUv -= delta;
+ if (any(lessThan(sampleUv, vec2(0.0))) || any(greaterThan(sampleUv, vec2(1.0))))
+ break;
+ float mask = uPackParams1.x > 0.5
+ ? sampleLowFusedMask(sampleUv)
+ : ACDREAM_SAMPLE_2D(uTextureIndexA, sampleUv).r;
+ sum += mask * illumination;
+ illumination *= decay;
+ }
+ vec3 rays = uAtmosphereSunColor.rgb * (sum * weight / float(SampleCount));
+ oColor = vec4(rays, 1.0);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.vert b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.vert
new file mode 100644
index 00000000..9e98b26d
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_sun_rays.vert
@@ -0,0 +1,10 @@
+#version 430 core
+
+layout(location = 0) out vec2 vUv;
+
+void main()
+{
+ vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
+ gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
+ vUv = vec2(triangle.x, 1.0 - triangle.y);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.frag b/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.frag
new file mode 100644
index 00000000..94cd28ab
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.frag
@@ -0,0 +1,67 @@
+#version 430 core
+
+layout(location = 0) in vec2 vUv;
+layout(location = 0) out vec4 oColor;
+
+#include "atmospheric_common.glsl"
+#include "directional_shadow_common.glsl"
+
+vec3 reconstructWorld(vec2 uv, float depth)
+{
+ vec4 clip = vec4(uv.x * 2.0 - 1.0, (1.0 - uv.y) * 2.0 - 1.0, depth, 1.0);
+ vec4 world = uAtmosphereInverseViewProjection * clip;
+ return world.xyz / max(abs(world.w), 1e-6);
+}
+
+float directionalVisibility(vec3 worldPosition)
+{
+ // Volumetric shafts are a sun effect. A moon-oriented shadow map remains
+ // valid for world receivers but must not occlude the authored sun ray.
+ if (uint(round(uShadowLightDirectionAndSource.w)) != 1u)
+ return 1.0;
+ uint cascadeCount = clamp(uShadowTextureAndFlags.y, 1u, 4u);
+ vec3 surfaceToSun = normalize(uShadowLightDirectionAndSource.xyz);
+ vec3 biasedPosition = worldPosition
+ + surfaceToSun * max(uShadowBiasMeters.x, 0.0);
+ for (uint cascade = 0u; cascade < cascadeCount; ++cascade) {
+ vec4 clip = uShadowWorldToClip[cascade] * vec4(biasedPosition, 1.0);
+ vec3 ndc = clip.xyz / max(abs(clip.w), 1e-6);
+ vec2 uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
+ if (all(greaterThanEqual(uv, vec2(0.0)))
+ && all(lessThanEqual(uv, vec2(1.0)))
+ && ndc.z >= 0.0 && ndc.z <= 1.0) {
+ float stored = ACDREAM_SAMPLE_ARRAY(
+ uShadowTextureAndFlags.x,
+ vec3(uv, float(cascade))).r;
+ float visible = ndc.z <= stored ? 1.0 : 0.0;
+ return mix(1.0, visible, clamp(uShadowControl.x, 0.0, 1.0));
+ }
+ }
+ return 1.0;
+}
+
+void main()
+{
+ float sceneDepth = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv).r;
+ if (sceneDepth >= 0.999999 || uPackParams0.w <= 0.0) {
+ oColor = vec4(0.0);
+ return;
+ }
+
+ vec3 nearWorld = reconstructWorld(vUv, 0.0);
+ vec3 sceneWorld = reconstructWorld(vUv, sceneDepth);
+ int steps = clamp(int(uPackParams0.z + 0.5), 1, 64);
+ float lit = 0.0;
+ for (int step = 0; step < 64; ++step) {
+ if (step >= steps)
+ break;
+ float t = (float(step) + 0.5) / float(steps);
+ lit += directionalVisibility(mix(nearWorld, sceneWorld, t));
+ }
+
+ float integrated = lit / float(steps);
+ float extinction = 1.0 - exp(-uPackParams0.x * length(sceneWorld - nearWorld));
+ vec3 color = uAtmosphereSunColor.rgb
+ * (integrated * extinction * uPackParams0.y);
+ oColor = vec4(max(color, vec3(0.0)), 1.0);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.vert b/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.vert
new file mode 100644
index 00000000..9e98b26d
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/atmospheric_volumetric.vert
@@ -0,0 +1,10 @@
+#version 430 core
+
+layout(location = 0) out vec2 vUv;
+
+void main()
+{
+ vec2 triangle = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
+ gl_Position = vec4(triangle * 2.0 - 1.0, 0.0, 1.0);
+ vUv = vec2(triangle.x, 1.0 - triangle.y);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_common.glsl b/src/AcDream.App/Rendering/Shaders/directional_shadow_common.glsl
new file mode 100644
index 00000000..fe4f2065
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_common.glsl
@@ -0,0 +1,15 @@
+#ifndef ACDREAM_DIRECTIONAL_SHADOW_COMMON_GLSL
+#define ACDREAM_DIRECTIONAL_SHADOW_COMMON_GLSL
+
+// Render-pack shader ABI v1, set 3/binding 6. This is the byte-level SSOT for
+// DirectionalShadowUniforms (std140, 336 bytes).
+layout(std140, ACDREAM_PACK_UBO_SET binding = 6) uniform DirectionalShadow {
+ mat4 uShadowWorldToClip[4]; // 0, 64, 128, 192
+ vec4 uShadowSplitFarMeters; // 256
+ vec4 uShadowControl; // 272: strength, softness, reach m, blend m
+ vec4 uShadowBiasMeters; // 288: constant, slope, normal, caster pad
+ uvec4 uShadowTextureAndFlags; // 304: set2 slot, count, resolution, flags
+ vec4 uShadowLightDirectionAndSource; // 320: surface-to-light xyz, source kind
+};
+
+#endif
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_receiver.glsl b/src/AcDream.App/Rendering/Shaders/directional_shadow_receiver.glsl
new file mode 100644
index 00000000..435f5ca2
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_receiver.glsl
@@ -0,0 +1,176 @@
+#ifndef ACDREAM_DIRECTIONAL_SHADOW_RECEIVER_GLSL
+#define ACDREAM_DIRECTIONAL_SHADOW_RECEIVER_GLSL
+
+#include "directional_shadow_common.glsl"
+
+// Receiver policy shared byte-for-byte by terrain and world meshes. Cascade
+// choice is camera-distance based; the terminal blend band is measured in
+// world metres, so a projection/FOV change cannot move the seam.
+int acdreamShadowCascade(float cameraDistanceMeters) {
+ int count = int(uShadowTextureAndFlags.y);
+ for (int i = 0; i < count; ++i) {
+ if (cameraDistanceMeters <= uShadowSplitFarMeters[i])
+ return i;
+ }
+ return -1;
+}
+
+// The X/Y clip gradients are the reciprocal orthographic half extent. Their
+// ratio therefore gives the exact relative texel footprint without extending
+// the pinned v1 uniform block. The CPU publishes the conservative far-cascade
+// bias; inner maps scale it down instead of visibly detaching nearby feet,
+// foliage, and building edges from their receivers.
+float acdreamShadowBiasScale(int cascade) {
+ int farCascade = max(int(uShadowTextureAndFlags.y) - 1, 0);
+ mat4 cascadeMatrix = uShadowWorldToClip[cascade];
+ mat4 farMatrix = uShadowWorldToClip[farCascade];
+ float cascadeDensity = 0.5 * (
+ length(vec3(cascadeMatrix[0][0], cascadeMatrix[1][0], cascadeMatrix[2][0]))
+ + length(vec3(cascadeMatrix[0][1], cascadeMatrix[1][1], cascadeMatrix[2][1])));
+ float farDensity = 0.5 * (
+ length(vec3(farMatrix[0][0], farMatrix[1][0], farMatrix[2][0]))
+ + length(vec3(farMatrix[0][1], farMatrix[1][1], farMatrix[2][1])));
+ return clamp(farDensity / max(cascadeDensity, 1e-7), 0.0, 1.0);
+}
+
+// One textureGather returns the four depth texels surrounding the continuous
+// receiver coordinate. Comparing first and then interpolating is true
+// bilinear percentage-closer filtering; interpolating depth before comparing
+// would create false blockers at discontinuities.
+float acdreamShadowBilinearCompare(
+ int cascade,
+ vec2 uv,
+ float receiverDepth)
+{
+ float resolution = max(float(uShadowTextureAndFlags.z), 1.0);
+ vec2 texelPosition = uv * resolution - vec2(0.5);
+ vec2 blend = fract(texelPosition);
+ vec4 gatheredDepth = textureGather(
+ ACDREAM_TEXTURE(uShadowTextureAndFlags.x),
+ vec3(uv, float(cascade)),
+ 0);
+ vec4 compared = step(vec4(receiverDepth), gatheredDepth);
+ float lower = mix(compared.w, compared.z, blend.x);
+ float upper = mix(compared.x, compared.y, blend.x);
+ return mix(lower, upper, blend.y);
+}
+
+float acdreamShadowCascadePcf(
+ int cascade,
+ vec3 receiverWorldPosition,
+ vec3 worldNormal,
+ vec3 surfaceToLight)
+{
+ float ndl = clamp(dot(worldNormal, surfaceToLight), 0.0, 1.0);
+ vec3 cascadeBiasMeters = max(
+ uShadowBiasMeters.xyz * acdreamShadowBiasScale(cascade),
+ vec3(0.001));
+ float depthBiasMeters = cascadeBiasMeters.x
+ + cascadeBiasMeters.y * (1.0 - ndl);
+ vec3 biasedWorldPosition = receiverWorldPosition
+ + worldNormal * cascadeBiasMeters.z
+ + surfaceToLight * depthBiasMeters;
+
+ vec4 shadowClip = uShadowWorldToClip[cascade]
+ * vec4(biasedWorldPosition, 1.0);
+ vec3 shadowNdc = shadowClip.xyz / max(abs(shadowClip.w), 1e-7);
+ // Vulkan records these maps through a negative-height viewport so the
+ // conventional GL-up clip coordinate samples the top-left-origin image.
+ vec2 uv = vec2(
+ shadowNdc.x * 0.5 + 0.5,
+ 0.5 - shadowNdc.y * 0.5);
+ float receiverDepth = shadowNdc.z;
+ if (uv.x <= 0.0 || uv.x >= 1.0
+ || uv.y <= 0.0 || uv.y >= 1.0
+ || receiverDepth <= 0.0 || receiverDepth >= 1.0)
+ return 1.0;
+
+ int radius = int((uShadowTextureAndFlags.w >> 8u) & 0xFu);
+ radius = clamp(radius, 0, 2);
+ // Weather changes the physical softness envelope without changing the
+ // preset's bounded kernel. Bilinear PCF removes sub-texel stair stepping;
+ // the higher presets place four/nine bilinear lobes in separable tent
+ // patterns instead of issuing nine/twenty-five blocky nearest comparisons.
+ float texel = 1.0 / max(float(uShadowTextureAndFlags.z), 1.0);
+ float softness = max(uShadowControl.y, 1.0);
+ if (radius == 0)
+ return acdreamShadowBilinearCompare(cascade, uv, receiverDepth);
+
+ float lit = 0.0;
+ float weightSum = 0.0;
+ if (radius == 1) {
+ for (int y = 0; y < 2; ++y) {
+ for (int x = 0; x < 2; ++x) {
+ vec2 offset = (vec2(x, y) - vec2(0.5))
+ * softness * texel;
+ lit += acdreamShadowBilinearCompare(
+ cascade, uv + offset, receiverDepth);
+ weightSum += 1.0;
+ }
+ }
+ } else {
+ for (int y = -1; y <= 1; ++y) {
+ for (int x = -1; x <= 1; ++x) {
+ float weight = float(2 - abs(x)) * float(2 - abs(y));
+ vec2 offset = vec2(x, y) * 1.5 * softness * texel;
+ lit += acdreamShadowBilinearCompare(
+ cascade, uv + offset, receiverDepth) * weight;
+ weightSum += weight;
+ }
+ }
+ }
+ return lit / max(weightSum, 1.0);
+}
+
+float acdreamDirectionalShadowVisibility(
+ vec3 receiverWorldPosition,
+ vec3 worldNormal,
+ vec3 cameraWorldPosition,
+ vec3 surfaceToLight)
+{
+ if ((uShadowTextureAndFlags.w & 1u) == 0u)
+ return 1.0;
+
+ float cameraDistanceMeters = length(
+ receiverWorldPosition - cameraWorldPosition);
+ if (cameraDistanceMeters > uShadowControl.z)
+ return 1.0;
+
+ int cascade = acdreamShadowCascade(cameraDistanceMeters);
+ if (cascade < 0)
+ return 1.0;
+
+ float visibility = acdreamShadowCascadePcf(
+ cascade,
+ receiverWorldPosition,
+ worldNormal,
+ surfaceToLight);
+ int cascadeCount = int(uShadowTextureAndFlags.y);
+ if (cascade + 1 < cascadeCount) {
+ float split = uShadowSplitFarMeters[cascade];
+ float widthMeters = max(uShadowControl.w, 1e-4);
+ float blend = smoothstep(
+ max(0.0, split - widthMeters),
+ split,
+ cameraDistanceMeters);
+ if (blend > 0.0) {
+ float nextVisibility = acdreamShadowCascadePcf(
+ cascade + 1,
+ receiverWorldPosition,
+ worldNormal,
+ surfaceToLight);
+ visibility = mix(visibility, nextVisibility, blend);
+ }
+ }
+ // Fade the terminal cascade over the same world-metre band used for
+ // inter-cascade transitions. This removes the moving ring/pop at maximum
+ // reach while leaving every caster and both/three/four cascades intact.
+ float reachFade = 1.0 - smoothstep(
+ max(0.0, uShadowControl.z - max(uShadowControl.w, 1e-4)),
+ uShadowControl.z,
+ cameraDistanceMeters);
+ float shadowWeight = clamp(uShadowControl.x, 0.0, 1.0) * reachFade;
+ return mix(1.0, visibility, shadowWeight);
+}
+
+#endif
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.frag b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.frag
new file mode 100644
index 00000000..cc6c5cfc
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.frag
@@ -0,0 +1,4 @@
+#version 460 core
+
+void main() {
+}
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.vert
new file mode 100644
index 00000000..cf7b0b46
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain.vert
@@ -0,0 +1,16 @@
+#version 460 core
+
+#include "directional_shadow_common.glsl"
+
+layout(location = 0) in vec3 aPosition;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in uvec4 aPacked0;
+layout(location = 3) in uvec4 aPacked1;
+layout(location = 4) in uvec4 aPacked2;
+layout(location = 5) in uvec4 aPacked3;
+
+uniform int uRenderPass;
+
+void main() {
+ gl_Position = uShadowWorldToClip[uRenderPass] * vec4(aPosition, 1.0);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.frag b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.frag
new file mode 100644
index 00000000..cc6c5cfc
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.frag
@@ -0,0 +1,4 @@
+#version 460 core
+
+void main() {
+}
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.vert
new file mode 100644
index 00000000..463c474b
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_terrain_multiview.vert
@@ -0,0 +1,15 @@
+#version 460 core
+#extension GL_EXT_multiview : require
+
+#include "directional_shadow_common.glsl"
+
+layout(location = 0) in vec3 aPosition;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in uvec4 aPacked0;
+layout(location = 3) in uvec4 aPacked1;
+layout(location = 4) in uvec4 aPacked2;
+layout(location = 5) in uvec4 aPacked3;
+
+void main() {
+ gl_Position = uShadowWorldToClip[int(gl_ViewIndex)] * vec4(aPosition, 1.0);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.frag b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.frag
new file mode 100644
index 00000000..38e3ed06
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.frag
@@ -0,0 +1,14 @@
+#version 460 core
+#extension GL_ARB_bindless_texture : require
+
+in vec2 vShadowTexCoord;
+flat in uint vShadowTextureIndex;
+flat in uint vShadowTextureLayer;
+
+void main() {
+ vec4 texel = ACDREAM_SAMPLE_ARRAY(
+ vShadowTextureIndex,
+ vec3(vShadowTexCoord, float(vShadowTextureLayer)));
+ if (texel.a < 0.05)
+ discard;
+}
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.vert
new file mode 100644
index 00000000..aa6a9ff7
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout.vert
@@ -0,0 +1,41 @@
+#version 460 core
+#extension GL_ARB_shader_draw_parameters : require
+
+#include "directional_shadow_common.glsl"
+
+layout(location = 0) in vec3 aPosition;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in vec2 aTexCoord;
+
+struct InstanceData { mat4 transform; };
+struct BatchData {
+ uint textureIndex;
+ uint _pad;
+ uint textureLayer;
+ uint flags;
+};
+
+layout(std430, binding = 0) readonly buffer InstanceBuffer {
+ InstanceData Instances[];
+};
+layout(std430, binding = 1) readonly buffer BatchBuffer {
+ BatchData Batches[];
+};
+
+uniform int uDrawIDOffset;
+uniform int uRenderPass;
+
+out vec2 vShadowTexCoord;
+flat out uint vShadowTextureIndex;
+flat out uint vShadowTextureLayer;
+
+void main() {
+ int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
+ vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0);
+ gl_Position = uShadowWorldToClip[uRenderPass] * worldPosition;
+
+ BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB];
+ vShadowTexCoord = aTexCoord;
+ vShadowTextureIndex = batch.textureIndex;
+ vShadowTextureLayer = batch.textureLayer;
+}
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.frag b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.frag
new file mode 100644
index 00000000..38e3ed06
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.frag
@@ -0,0 +1,14 @@
+#version 460 core
+#extension GL_ARB_bindless_texture : require
+
+in vec2 vShadowTexCoord;
+flat in uint vShadowTextureIndex;
+flat in uint vShadowTextureLayer;
+
+void main() {
+ vec4 texel = ACDREAM_SAMPLE_ARRAY(
+ vShadowTextureIndex,
+ vec3(vShadowTexCoord, float(vShadowTextureLayer)));
+ if (texel.a < 0.05)
+ discard;
+}
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.vert
new file mode 100644
index 00000000..a90ab9ab
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_cutout_multiview.vert
@@ -0,0 +1,38 @@
+#version 460 core
+#extension GL_ARB_shader_draw_parameters : require
+#extension GL_EXT_multiview : require
+
+#include "directional_shadow_common.glsl"
+
+layout(location = 0) in vec3 aPosition;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in vec2 aTexCoord;
+
+struct InstanceData { mat4 transform; };
+struct BatchData {
+ uint textureIndex;
+ uint _pad;
+ uint textureLayer;
+ uint flags;
+};
+layout(std430, binding = 0) readonly buffer InstanceBuffer {
+ InstanceData Instances[];
+};
+layout(std430, binding = 1) readonly buffer BatchBuffer {
+ BatchData Batches[];
+};
+
+uniform int uDrawIDOffset;
+out vec2 vShadowTexCoord;
+flat out uint vShadowTextureIndex;
+flat out uint vShadowTextureLayer;
+
+void main() {
+ int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
+ vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0);
+ gl_Position = uShadowWorldToClip[int(gl_ViewIndex)] * worldPosition;
+ BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB];
+ vShadowTexCoord = aTexCoord;
+ vShadowTextureIndex = batch.textureIndex;
+ vShadowTextureLayer = batch.textureLayer;
+}
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.frag b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.frag
new file mode 100644
index 00000000..cc6c5cfc
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.frag
@@ -0,0 +1,4 @@
+#version 460 core
+
+void main() {
+}
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.vert
new file mode 100644
index 00000000..4bb615e9
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque.vert
@@ -0,0 +1,21 @@
+#version 460 core
+#extension GL_ARB_shader_draw_parameters : require
+
+#include "directional_shadow_common.glsl"
+
+layout(location = 0) in vec3 aPosition;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in vec2 aTexCoord;
+
+struct InstanceData { mat4 transform; };
+layout(std430, binding = 0) readonly buffer InstanceBuffer {
+ InstanceData Instances[];
+};
+
+uniform int uRenderPass;
+
+void main() {
+ int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
+ vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0);
+ gl_Position = uShadowWorldToClip[uRenderPass] * worldPosition;
+}
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.frag b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.frag
new file mode 100644
index 00000000..cc6c5cfc
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.frag
@@ -0,0 +1,4 @@
+#version 460 core
+
+void main() {
+}
diff --git a/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.vert b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.vert
new file mode 100644
index 00000000..17e85a72
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/directional_shadow_world_opaque_multiview.vert
@@ -0,0 +1,20 @@
+#version 460 core
+#extension GL_ARB_shader_draw_parameters : require
+#extension GL_EXT_multiview : require
+
+#include "directional_shadow_common.glsl"
+
+layout(location = 0) in vec3 aPosition;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in vec2 aTexCoord;
+
+struct InstanceData { mat4 transform; };
+layout(std430, binding = 0) readonly buffer InstanceBuffer {
+ InstanceData Instances[];
+};
+
+void main() {
+ int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
+ vec4 worldPosition = Instances[instanceIndex].transform * vec4(aPosition, 1.0);
+ gl_Position = uShadowWorldToClip[int(gl_ViewIndex)] * worldPosition;
+}
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag
new file mode 100644
index 00000000..32002dbf
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.frag
@@ -0,0 +1,130 @@
+#version 430 core
+#extension GL_ARB_bindless_texture : require
+
+in vec3 vNormal;
+in vec2 vTexCoord;
+in vec3 vWorldPos;
+in vec3 vAmbientLocalLit;
+in vec3 vDirectionalLit;
+// Campaign V slice V6e: the table slot, not the bindless handle — see
+// mesh_modern.vert. The lookup moved here because a Vulkan varying cannot
+// carry a descriptor.
+in flat uint vTextureIndex;
+in flat uint vTextureLayer;
+in flat float vOpacityMultiplier; // #188
+in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse
+in flat uint vReceivesDirectionalShadow;
+
+#include "directional_shadow_receiver.glsl"
+
+// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
+// 0 = opaque pass — discard fragments with alpha < 0.95
+// (lets the depth write succeed for solid pixels)
+// 1 = translucent pass — covers AlphaBlend / Additive / InvAlpha;
+// discard alpha >= 0.95 (already drawn opaque) and
+// alpha < 0.05 (skip empty fragments — large
+// transparent overdraw cost otherwise)
+uniform int uRenderPass;
+uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here
+
+// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1.
+struct Light {
+ vec4 posAndKind;
+ vec4 dirAndRange;
+ vec4 colorAndIntensity;
+ vec4 coneAngleEtc;
+};
+layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
+ Light uLights[8];
+ vec4 uCellAmbient;
+ vec4 uFogParams;
+ vec4 uFogColor;
+ vec4 uCameraAndTime;
+};
+
+// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match
+// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight"
+// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/
+// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed
+// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload.
+
+vec3 applyFog(vec3 lit, vec3 worldPos) {
+ int mode = int(uFogParams.w);
+ if (mode == 0) return lit;
+ float d = length(worldPos - uCameraAndTime.xyz);
+ float fogStart = uFogParams.x;
+ float fogEnd = uFogParams.y;
+ float span = max(1e-3, fogEnd - fogStart);
+ float fog = clamp((d - fogStart) / span, 0.0, 1.0);
+ return mix(lit, uFogColor.xyz, fog);
+}
+
+out vec4 FragColor;
+
+void main() {
+ vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer)));
+
+ // Two-pass alpha-test (N.5 Decision 2).
+ // A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE
+ // derives the MSAA sample mask from it — ClipMap foliage edges become smooth.
+ // Discard only fully-transparent (α < 0.05); the GPU handles coverage masking.
+ if (uRenderPass == 0) {
+ if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C)
+ } else {
+ // Transparent pass.
+ //
+ // Phase Post-A.5 (ISSUE #52, 2026-05-10): do NOT discard α≥0.95 here.
+ // Native AC transparent-flagged surfaces routinely include
+ // effectively-opaque pixels — e.g. the Holtburg lifestone crystal core
+ // (surface 0x080011DE) which the spawn manifest classifies as
+ // transparent (batch.IsTransparent=True) but whose decoded texture
+ // alpha lands ≥0.95 across the visible surface. Those pixels still
+ // compose correctly under (SrcAlpha, 1-SrcAlpha) alpha-blending, so
+ // discarding them here threw away the whole crystal. The original
+ // N.5 §2 rationale (high-α fragments belong in the opaque pass) does
+ // not apply when the SURFACE is dat-flagged transparent — those
+ // pixels can't reach the opaque pass at all.
+ //
+ // Keep the α<0.05 short-circuit as a fragment-cost optimization
+ // (skip fully-empty pixels — saves blend bandwidth on alpha-keyed
+ // sprites with large transparent margins).
+ if (color.a < 0.05) discard;
+ }
+
+ // Only the authored outdoor directional term is shadowed. Ambient, local
+ // lights and material selection pulses remain exactly on the retail path.
+ vec3 surfaceToLight = normalize(uShadowLightDirectionAndSource.xyz);
+ float directionalVisibility = vReceivesDirectionalShadow != 0u
+ ? acdreamDirectionalShadowVisibility(
+ vWorldPos,
+ normalize(vNormal),
+ uCameraAndTime.xyz,
+ surfaceToLight)
+ : 1.0;
+ vec3 sceneLit = vAmbientLocalLit
+ + vDirectionalLit * directionalVisibility;
+ vec3 lit = vec3(vSelectionLighting.x)
+ + vSelectionLighting.y * sceneLit;
+
+ // #176 stripe-hunt mode 3: show the raw per-vertex light field (texture
+ // ignored). Stripes visible HERE = a vertex-lighting artifact; absent =
+ // the pattern comes from texture/per-pixel machinery. Throwaway diagnostic.
+ if (uLightDebug == 3) {
+ FragColor = vec4(min(lit, vec3(1.0)), 1.0);
+ return;
+ }
+
+ // Lightning flash — additive scene bump (matches mesh_instanced.frag).
+ lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
+
+ // Retail clamp per-channel to 1.0 (r13 §13.1).
+ lit = min(lit, vec3(1.0));
+
+ vec3 rgb = color.rgb * lit;
+ rgb = applyFog(rgb, vWorldPos);
+ // #188: multiply the FINAL alpha only — the discard thresholds above stay
+ // keyed on the raw sampled color.a, so the last few frames of a fade
+ // (multiplier crossing under 0.05) still ramp smoothly toward zero rather
+ // than popping invisible early against the discard cutoff.
+ FragColor = vec4(rgb, color.a * vOpacityMultiplier);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert
new file mode 100644
index 00000000..a9816627
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert
@@ -0,0 +1,363 @@
+#version 430 core
+#extension GL_ARB_shader_draw_parameters : require
+
+#include "directional_shadow_common.glsl"
+
+layout(location = 0) in vec3 aPosition;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in vec2 aTexCoord;
+
+struct InstanceData {
+ mat4 transform;
+};
+
+// Campaign V slice V2 (2026-07-27): textureHandle (uvec2, a 64-bit
+// GL_ARB_bindless_texture handle) became textureIndex (uint) plus an explicit
+// pad word. textureIndex is a slot into the global texture table (set 2,
+// injected by tools/ShaderCompiler/VulkanGlslPreamble.cs — see
+// ACDREAM_TEXTURE_HANDLE/ACDREAM_SAMPLE_ARRAY) which main() below forwards to
+// the fragment stage. The pad word keeps textureLayer/flags at their original
+// std430 offsets (8/12), so the struct is still 16 bytes and every existing
+// CPU writer's layout is unchanged (GpuBindingModel.GpuBatchDataStrideBytes).
+struct BatchData {
+ uint textureIndex; // slot into the global texture table
+ uint _pad; // keeps textureLayer/flags at offsets 8/12
+ uint textureLayer; // layer in the shared WB or pooled composite array
+ uint flags; // reserved — N.5 dispatcher owns all blend state
+ // (glBlendFunc per pass). If a future phase wants
+ // shader-side per-batch additive flag (Decision 2
+ // fallback), encode it here as bit 0.
+};
+
+layout(std430, binding = 0) readonly buffer InstanceBuffer {
+ InstanceData Instances[];
+};
+
+// binding=1 here is the SSBO namespace — distinct from the UBO namespace.
+// SceneLighting UBO also uses binding=1 in the fragment shader; GL keeps
+// GL_SHADER_STORAGE_BUFFER and GL_UNIFORM_BUFFER binding tables separate.
+// Task 10 dispatcher binds:
+// glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, instanceSsbo)
+// glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, batchSsbo)
+// Existing SceneLightingUboBinding handles the UBO side.
+layout(std430, binding = 1) readonly buffer BatchBuffer {
+ BatchData Batches[];
+};
+
+// === Phase U.3: per-cell screen-space clip gate (gl_ClipDistance) =============
+// Two SSBOs add the clip mechanism without disturbing binding=0/1 above.
+//
+// binding=2 — SHARED per-frame clip regions, one CellClip per "slot". Uploaded
+// ONCE per frame by ClipFrame.UploadShared (shared across WbDrawDispatcher +
+// EnvCellRenderer). Slot 0 is RESERVED = no-clip (count 0 ⇒ every plane passes).
+//
+// binding=3 — PER-RENDERER per-instance slot index, parallel to the binding=0
+// instance buffer and indexed by the IDENTICAL per-instance index
+// (gl_BaseInstanceARB + gl_InstanceID). instanceClipSlot[i] selects which
+// CellClip region instance i is clipped against. Default all-zeros in U.3 ⇒
+// every instance maps to slot 0 ⇒ no clipping ⇒ identical render to pre-U.3.
+//
+// CellClip std430 layout (144 bytes/slot): a uint count + 3 pad uints (16 bytes)
+// then vec4 planes[8] (8 × 16 = 128 bytes). vec4 array stride is 16 under std430.
+// ClipFrame on the CPU side lays out the bytes to match exactly (verified by
+// ClipFrameLayoutTests). A clip-space vertex is INSIDE iff dot(plane, gl_Position)
+// >= 0 for every active plane (see ClipPlaneSet for the plane convention).
+struct CellClip {
+ uint count;
+ uint _p0;
+ uint _p1;
+ uint _p2;
+ vec4 planes[8];
+};
+layout(std430, binding = 2) readonly buffer ClipRegionBuf {
+ CellClip clipRegions[];
+};
+layout(std430, binding = 3) readonly buffer ClipSlotBuf {
+ uint instanceClipSlot[];
+};
+
+// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting =====
+// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position
+// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it
+// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA
+// UBO set (LightManager.Tick) made a wall brighten as the camera approached
+// (its torches swapping into the global top-8). Two SSBOs replace that for
+// point/spot lights (the SUN + ambient still come from the SceneLighting UBO):
+//
+// binding=4 — GLOBAL point/spot light array, uploaded once per frame from
+// LightManager.PointSnapshot. The index of a light here is stable for the frame.
+// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per
+// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0
+// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills
+// it once per entity (the set is constant across the entity's parts/tuples).
+struct GlobalLight {
+ vec4 posAndKind;
+ vec4 dirAndRange;
+ vec4 colorAndIntensity;
+ vec4 coneAngleEtc;
+};
+layout(std430, binding = 4) readonly buffer GlobalLightBuf {
+ GlobalLight gLights[];
+};
+layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
+ int instanceLightIdx[]; // 8 per instance; -1 = unused
+};
+
+// #142: per-instance "indoor" flag, 1 per instance, parallel to the binding=0
+// instance buffer (same instanceIndex). 1 = object parented to an EnvCell (skip the
+// sun — retail's useSunlight==0 interior stage); 0 = outdoor object (gets the sun).
+// Read ONLY inside the uniform `uLightingMode == 0` branch below, so the mode-1
+// (EnvCell shell) path provably never touches it — EnvCellRenderer need not bind it.
+layout(std430, binding = 6) readonly buffer InstanceIndoorBuf {
+ uint instanceIndoor[];
+};
+
+// #188: per-instance opacity multiplier, 1 per instance, parallel to the
+// binding=0 instance buffer (same instanceIndex). 1.0 = unmodified; <1.0
+// while a TransparentPartHook translucency fade is in flight for the
+// entity/part this instance belongs to (e.g. the "fading wall" secret-
+// passage doors). Multiplied against the sampled texture alpha in
+// mesh_modern.frag.
+layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
+ float instanceAlpha[];
+};
+
+// Retail SmartBox click confirmation. One vec2 per OBJECT instance, parallel
+// to binding=0: x = CMaterial luminosity, y = CMaterial diffuse. Normal
+// rendering is (0,1); SmartBox alternates LOW=(0,.35) and HIGH=(.99,1).
+// EnvCellRenderer uses uLightingMode=1 and deliberately never reads this
+// object-only binding.
+layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf {
+ vec2 instanceSelectionLighting[];
+};
+
+// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
+// alongside gl_Position. The array is sized 8 to match the CellClip plane budget
+// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
+// GL_CLIP_DISTANCE0..7 once at startup; unused planes are set to +1.0 below so
+// they pass everything (no clipping) when the slot's count < 8.
+out gl_PerVertex {
+ vec4 gl_Position;
+ float gl_ClipDistance[8];
+};
+
+uniform mat4 uViewProjection;
+// Absolute transform prefix in the shared shadow/world pose arena. Every
+// parallel per-instance array remains local to this submission, so only the
+// transform lookup keeps the absolute index.
+uniform uint uTextureIndexB;
+
+// Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[].
+// gl_DrawIDARB resets to 0 at the start of each glMultiDrawElementsIndirect
+// call, so the transparent pass — which begins later in the indirect buffer
+// — was fetching Batches[0..transparentCount) instead of its actual section
+// at Batches[opaqueCount..end). The lifestone crystal (a transparent draw)
+// ended up reading the FIRST OPAQUE batch's TextureHandle every frame. As
+// the camera moved and the opaque front-to-back sort reordered which group
+// landed at BatchData[0], the lifestone's apparent texture flickered to
+// whatever was first — frequently the player character's body parts.
+//
+// WbDrawDispatcher.Draw sets this to 0 before the opaque MDI call and to
+// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's
+// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
+uniform int uDrawIDOffset;
+uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun)
+// #176 stripe-hunt isolation modes (ACDREAM_LIGHT_DEBUG, throwaway diagnostic):
+// 0 = off; 1 = ambient-only vLit (all point/sun contributions killed);
+// 2 = DYNAMIC point lights killed (purples + viewer fill off, statics stay);
+// 3 = handled in the frag (raw vLit visualization, texture ignored).
+uniform int uLightDebug;
+
+// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO
+// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO
+// above). IDENTICAL std140 layout to mesh_modern.frag.
+//
+// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so
+// torch/point lights Gouraud-interpolate across each triangle the way retail's
+// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for
+// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls;
+// per-vertex spreads it into a soft, broad gradient with no hard edge.
+struct Light {
+ vec4 posAndKind;
+ vec4 dirAndRange;
+ vec4 colorAndIntensity;
+ vec4 coneAngleEtc;
+};
+layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
+ Light uLights[8];
+ vec4 uCellAmbient;
+ vec4 uFogParams;
+ vec4 uFogColor;
+ vec4 uCameraAndTime;
+};
+
+// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light —
+// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D =
+// light − vertex, used UN-normalised (length = dist); N is the unit vertex normal.
+// Returns the RGB to ADD, already per-channel capped to the light's own colour.
+vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
+ int kind = int(L.posAndKind.w);
+ vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised)
+ float distsq = dot(toL, toL);
+ float d = sqrt(distsq);
+ float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3 (static) / × 1.5 (dynamic)
+ if (d >= range || range <= 1e-4) return vec3(0.0);
+ float intensity = L.colorAndIntensity.w;
+ vec3 baseCol = L.colorAndIntensity.xyz;
+
+ // #143: DYNAMIC lights (viewer fill, portal, server-object lights — flagged by
+ // coneAngleEtc.y==1 from GlobalLightPacker) use retail's D3D hardware attenuation
+ // (config_hardware_light 0x0059ad30): a POINT light is given Attenuation1=1 ⇒
+ // att = 1/d (inverse-LINEAR), plain Lambert N·L, hard range cutoff. That spreads
+ // softly across the room (the portal tint, the viewer fill) instead of the static
+ // bake's 1/d³ distance-cube, which makes a tight concentrated pool. No per-light
+ // cap — D3D accumulates then saturates, which accumulateLights does via min(pointAcc,1).
+ if (L.coneAngleEtc.y > 0.5) {
+ if (uLightDebug == 2) return vec3(0.0); // #176 stripe hunt: dynamics killed
+ vec3 Ldir = toL / max(d, 1e-4);
+ float ndl = max(0.0, dot(N, Ldir));
+ if (ndl <= 0.0) return vec3(0.0);
+ if (kind == 2) { // dynamic spot: hard cos-cone gate
+ if (dot(-Ldir, L.dirAndRange.xyz) <= cos(L.coneAngleEtc.x * 0.5)) return vec3(0.0);
+ }
+ return (intensity * ndl / max(d, 1e-3)) * baseCol; // att = 1/d
+ }
+
+ // ── STATIC dat-baked lights: retail's per-vertex bake (calc_point_light 0x0059c8b0) ──
+ // A7 Fix D D-3: angular term by lighting path. ENVCELL bake (mode 1) keeps the
+ // half-Lambert wrap (lights surfaces angled away, retail calc_point_light); OBJECT
+ // mode (0) uses plain Lambert max(0,N·L) so a torch BEHIND a character contributes
+ // nothing (retail's hardware path). toL is un-normalised (length d).
+ float angular = (uLightingMode == 1)
+ ? (1.0 / 1.5) * (dot(N, toL) + 0.5 * d) // half-Lambert wrap (EnvCell bake)
+ : max(0.0, dot(N, toL)); // plain Lambert (object/hardware)
+ if (angular <= 0.0) return vec3(0.0);
+ // NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo;
+ // <1 m → just d (dodge the near singularity). "Punchy near, soft far."
+ float norm = (distsq > 1.0) ? (distsq * d) : d;
+ float scale = (1.0 - d / range) * intensity * (angular / norm);
+ if (kind == 2) {
+ // Spotlight: hard-edged cos-cone gate layered on the point ramp.
+ vec3 Ldir = toL / max(d, 1e-4);
+ float cos_edge = cos(L.coneAngleEtc.x * 0.5);
+ float cos_l = dot(-Ldir, L.dirAndRange.xyz);
+ if (cos_l <= cos_edge) scale = 0.0;
+ }
+ // Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled):
+ // a single light can't push a channel past its colour. Summed lit clamped in frag.
+ return min(scale * baseCol, baseCol);
+}
+
+vec3 accumulateAmbientLocalLights(
+ vec3 N,
+ vec3 worldPos,
+ int instanceIndex,
+ out vec3 directionalLit)
+{
+ vec3 lit = uCellAmbient.xyz;
+ directionalLit = vec3(0.0);
+ if (uLightDebug == 1) return lit; // #176 stripe hunt: ambient only
+
+ // SUN / directional — OBJECT path only (mode 0). retail's EnvCell path
+ // (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so
+ // EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4).
+ // #142: within mode 0, also skip the sun for indoor objects (ParentCellId is an
+ // EnvCell). This mirrors retail's per-draw-stage useSunlight toggle: the interior
+ // stage runs useSunlightSet(0) (PView::DrawCells 0x005a49f3), so indoor objects
+ // get no sun even in windowed buildings where the player's frame is not sun-killed.
+ if (uLightingMode == 0) {
+ if (instanceIndoor[instanceIndex] == 0u) { // #142: outdoor objects only get the sun
+ int activeLights = int(uCellAmbient.w);
+ for (int i = 0; i < 8; ++i) {
+ if (i >= activeLights) break;
+ if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
+ vec3 Ldir = normalize(uShadowLightDirectionAndSource.xyz);
+ float ndl = max(0.0, dot(N, Ldir));
+ directionalLit += uLights[i].colorAndIntensity.xyz
+ * uLights[i].colorAndIntensity.w * ndl;
+ }
+ }
+ }
+
+ // POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
+ // SetStaticLightingVertexColors sums the static point lights from BLACK and
+ // clamps the SUM to [0,1] before anything else (a baked emissive term), so a
+ // few warm intensity-100 torches can't push the whole pixel to white the way
+ // folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor
+ // (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged.
+ vec3 pointAcc = vec3(0.0);
+ int base = instanceIndex * 8;
+ for (int k = 0; k < 8; ++k) {
+ int gi = instanceLightIdx[base + k];
+ if (gi < 0) continue;
+ pointAcc += pointContribution(N, worldPos, gLights[gi]);
+ }
+ lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
+
+ return lit; // frag still does the final min(lit, 1.0)
+}
+
+out vec3 vNormal;
+out vec2 vTexCoord;
+out vec3 vWorldPos;
+out vec3 vAmbientLocalLit; // authored ambient + capped local/point lights
+out vec3 vDirectionalLit; // authored outdoor directional sun, shadowable
+// Campaign V slice V6e: was `flat uvec2 vTextureHandle` — a raw 64-bit
+// GL_ARB_bindless_texture handle handed across the stage boundary. A varying
+// cannot carry a Vulkan descriptor, so what travels is the table SLOT and the
+// fragment stage does the lookup (see mesh_modern.frag). Under GL the value
+// sampled is bit-for-bit the one the vertex stage used to forward; the SSBO
+// read simply happens one stage later, and `flat` keeps it one scalar load per
+// primitive rather than per fragment.
+out flat uint vTextureIndex;
+out flat uint vTextureLayer;
+out flat float vOpacityMultiplier; // #188
+out flat vec2 vSelectionLighting;
+out flat uint vReceivesDirectionalShadow;
+
+void main() {
+ int transformIndex = gl_BaseInstanceARB + gl_InstanceID;
+ int instanceIndex = transformIndex - int(uTextureIndexB);
+ mat4 model = Instances[transformIndex].transform;
+ vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188
+ vSelectionLighting = (uLightingMode == 0)
+ ? instanceSelectionLighting[instanceIndex]
+ : vec2(0.0, 1.0);
+
+ vec4 worldPos = model * vec4(aPosition, 1.0);
+ gl_Position = uViewProjection * worldPos;
+
+ // Phase U.3: per-instance clip gate. instanceClipSlot is indexed by the
+ // SAME instanceIndex used for the binding=0 transform above, so the slot
+ // travels with the instance through the MDI BaseInstance offsets. Slot 0
+ // (the U.3 default) has count 0 ⇒ the second loop sets all 8 distances to
+ // +1.0 ⇒ nothing is clipped.
+ uint _slot = instanceClipSlot[instanceIndex];
+ CellClip _c = clipRegions[_slot];
+ for (uint i = 0u; i < _c.count; ++i)
+ gl_ClipDistance[i] = dot(_c.planes[i], gl_Position);
+ for (uint i = _c.count; i < 8u; ++i)
+ gl_ClipDistance[i] = 1.0;
+
+ vWorldPos = worldPos.xyz;
+ vNormal = normalize(mat3(model) * aNormal);
+ vAmbientLocalLit = accumulateAmbientLocalLights(
+ vNormal,
+ vWorldPos,
+ instanceIndex,
+ vDirectionalLit);
+ // EnvCell-parented objects keep the authored indoor result. The separate
+ // EnvCell shell renderer never selects this receiver variant at all.
+ vReceivesDirectionalShadow = (uLightingMode == 0
+ && instanceIndoor[instanceIndex] == 0u)
+ ? 1u
+ : 0u;
+ vTexCoord = aTexCoord;
+
+ BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
+ // Campaign V slice V6e: forward the table SLOT untouched. V2 looked the
+ // handle up here and passed the handle; the lookup now lives at the sample
+ // site in mesh_modern.frag, which is the only form Vulkan can express.
+ vTextureIndex = b.textureIndex;
+ vTextureLayer = b.textureLayer;
+}
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.frag b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag
new file mode 100644
index 00000000..0ce6f64d
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag
@@ -0,0 +1,42 @@
+#version 430 core
+#extension GL_ARB_bindless_texture : require
+
+in vec2 vBaseUv;
+in vec2 vDetailUv;
+in float vDetailFade;
+in flat uint vBaseTextureIndex;
+in flat uint vBaseTextureLayer;
+in flat uint vBatchFlags;
+in flat uint vDetailCategory;
+
+uniform uint uTextureIndexA; // category detail texture, layer 0
+
+out vec4 FragColor;
+
+void main() {
+ // Object command replays may contain ordinary instances; only building
+ // shells survive. Bit 0 means this command came through retail's built-mesh
+ // DrawMesh path. Unlike the land-polygon path, RenderMeshSubset receives
+ // curr_detail_surface for every built-mesh material subset.
+ if (vDetailCategory == 0u || (vBatchFlags & 1u) == 0u)
+ discard;
+ if (vDetailFade <= 0.0)
+ discard;
+
+ vec4 base = ACDREAM_SAMPLE_ARRAY(
+ vBaseTextureIndex,
+ vec3(vBaseUv, float(vBaseTextureLayer)));
+ if (base.a < 0.05)
+ discard;
+
+ vec4 detail = ACDREAM_SAMPLE_ARRAY(
+ uTextureIndexA,
+ vec3(vDetailUv, 0.0));
+
+ // Pipeline blend is retail's DstColor + OneMinusSrcAlpha. Scaling both
+ // source colour and alpha makes fade=0 exactly neutral while fade=1 keeps
+ // retail's measured factor: dest * (detail.rgb + 1 - detail.a).
+ FragColor = vec4(
+ detail.rgb * vDetailFade,
+ detail.a * vDetailFade);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert
new file mode 100644
index 00000000..c71e2e54
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert
@@ -0,0 +1,94 @@
+#version 430 core
+#extension GL_ARB_shader_draw_parameters : require
+
+layout(location = 0) in vec3 aPosition;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in vec2 aTexCoord;
+
+struct InstanceData {
+ mat4 transform;
+};
+
+struct BatchData {
+ uint textureIndex;
+ uint _pad;
+ uint textureLayer;
+ uint flags;
+};
+
+layout(std430, binding = 0) readonly buffer InstanceBuffer {
+ InstanceData Instances[];
+};
+layout(std430, binding = 1) readonly buffer BatchBuffer {
+ BatchData Batches[];
+};
+
+struct CellClip {
+ uint count;
+ uint _p0;
+ uint _p1;
+ uint _p2;
+ vec4 planes[8];
+};
+layout(std430, binding = 2) readonly buffer ClipRegionBuf {
+ CellClip clipRegions[];
+};
+layout(std430, binding = 3) readonly buffer ClipSlotBuf {
+ uint instanceClipSlot[];
+};
+
+// Object renderer only: 1 for a retail building shell, 0 for ordinary
+// scenery/creatures/players. EnvCellRenderer sets uParamB=0 and ignores the
+// value, but still binds one valid word because Vulkan sees this static use.
+layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
+ uint instanceDetailCategory[];
+};
+
+out gl_PerVertex {
+ vec4 gl_Position;
+ float gl_ClipDistance[8];
+};
+
+uniform mat4 uViewProjection;
+uniform int uDrawIDOffset;
+uniform uint uTextureIndexB; // absolute transform prefix in the shared pose arena
+uniform float uParamA; // detail UV tiling
+uniform float uParamB; // 1 = require building instance, 0 = EnvCell category
+
+out vec2 vBaseUv;
+out vec2 vDetailUv;
+out float vDetailFade;
+out flat uint vBaseTextureIndex;
+out flat uint vBaseTextureLayer;
+out flat uint vBatchFlags;
+out flat uint vDetailCategory;
+
+void main() {
+ int transformIndex = gl_BaseInstanceARB + gl_InstanceID;
+ int instanceIndex = transformIndex - int(uTextureIndexB);
+ vec4 worldPos = Instances[transformIndex].transform * vec4(aPosition, 1.0);
+ gl_Position = uViewProjection * worldPos;
+
+ uint slot = instanceClipSlot[instanceIndex];
+ CellClip clip = clipRegions[slot];
+ for (uint i = 0u; i < clip.count; ++i)
+ gl_ClipDistance[i] = dot(clip.planes[i], gl_Position);
+ for (uint i = clip.count; i < 8u; ++i)
+ gl_ClipDistance[i] = 1.0;
+
+ // System.Numerics' perspective projection used by every gameplay camera
+ // makes clip.w the positive view-space depth. Retail get_alpha_for_z uses
+ // that same metric in metres: 255 through 10 m, linearly to 0 at 50 m.
+ float positiveViewDepthMetres = gl_Position.w;
+ vDetailFade = clamp((50.0 - positiveViewDepthMetres) / 40.0, 0.0, 1.0);
+ vBaseUv = aTexCoord;
+ vDetailUv = aTexCoord * uParamA;
+
+ BatchData batch = Batches[uDrawIDOffset + gl_DrawIDARB];
+ vBaseTextureIndex = batch.textureIndex;
+ vBaseTextureLayer = batch.textureLayer;
+ vBatchFlags = batch.flags;
+ vDetailCategory = uParamB > 0.5
+ ? instanceDetailCategory[instanceIndex]
+ : 1u;
+}
diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_blur.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_blur.frag.spv
new file mode 100644
index 00000000..081ba8dd
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_blur.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_blur.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_blur.vert.spv
new file mode 100644
index 00000000..c493caf5
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_blur.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_downsample.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_downsample.frag.spv
new file mode 100644
index 00000000..ac62a9dc
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_downsample.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_downsample.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_downsample.vert.spv
new file mode 100644
index 00000000..c493caf5
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_bloom_downsample.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.frag.spv
new file mode 100644
index 00000000..8950e78a
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.vert.spv
new file mode 100644
index 00000000..c493caf5
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_filmic.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_occlusion.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_occlusion.frag.spv
new file mode 100644
index 00000000..a5a50473
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_occlusion.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_occlusion.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_occlusion.vert.spv
new file mode 100644
index 00000000..c493caf5
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_occlusion.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_rays.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_rays.frag.spv
new file mode 100644
index 00000000..1dcc705f
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_rays.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_rays.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_rays.vert.spv
new file mode 100644
index 00000000..c493caf5
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_sun_rays.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_volumetric.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_volumetric.frag.spv
new file mode 100644
index 00000000..d782ffe1
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_volumetric.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/atmospheric_volumetric.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_volumetric.vert.spv
new file mode 100644
index 00000000..c493caf5
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/atmospheric_volumetric.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain.frag.spv
new file mode 100644
index 00000000..86b5c126
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain.vert.spv
new file mode 100644
index 00000000..c79fd88c
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain_multiview.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain_multiview.frag.spv
new file mode 100644
index 00000000..86b5c126
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain_multiview.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain_multiview.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain_multiview.vert.spv
new file mode 100644
index 00000000..1810e56b
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_terrain_multiview.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.frag.spv
new file mode 100644
index 00000000..16f7b307
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.vert.spv
new file mode 100644
index 00000000..6e077a14
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.frag.spv
new file mode 100644
index 00000000..16f7b307
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.vert.spv
new file mode 100644
index 00000000..028829ea
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_cutout_multiview.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.frag.spv
new file mode 100644
index 00000000..86b5c126
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.vert.spv
new file mode 100644
index 00000000..8b49cd0f
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.frag.spv
new file mode 100644
index 00000000..86b5c126
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.vert.spv
new file mode 100644
index 00000000..4b3f2f7b
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/directional_shadow_world_opaque_multiview.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv
new file mode 100644
index 00000000..78335661
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv
new file mode 100644
index 00000000..108e15fd
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv
new file mode 100644
index 00000000..75674e4a
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv
new file mode 100644
index 00000000..8a7e5999
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
index 586be187..7184bd38 100644
--- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
+++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
@@ -1,6 +1,102 @@
{
"note": "Campaign V slice V6c. Regenerate with tools/compile-shaders.ps1.",
"shaders": [
+ {
+ "name": "atmospheric_bloom_blur",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "4b22872f61b462cdbc82d4af38c7693212bc7446c7c68faa32dd5585b08c43ae",
+ "compiled": true
+ }
+ ]
+ },
+ {
+ "name": "atmospheric_bloom_downsample",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "a01f09dfcc62acc5376f6e61be6aa2e8c5b0506550c0fa318f4434cbf3e6a17a",
+ "compiled": true
+ }
+ ]
+ },
+ {
+ "name": "atmospheric_filmic",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "240d2fe5e13e3850ceb79f178f1c248c71875fdc1973ff8cab1c274660ebbc4b",
+ "compiled": true
+ }
+ ]
+ },
+ {
+ "name": "atmospheric_sun_occlusion",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "c21f381f2de05afc4415b2e348884a3cf6440da279b57d9a05a5d6ae5e9ac453",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "bfbc8c508b21dec84b21b2760877bfcb736c4f233e8557f6d1f8b83600683d5e",
+ "compiled": true
+ }
+ ]
+ },
+ {
+ "name": "atmospheric_sun_rays",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "13875d9f6fd28f1049d1f94741db13086f72cc7170659eb73c369b4c99b00a4f",
+ "compiled": true
+ }
+ ]
+ },
+ {
+ "name": "atmospheric_volumetric",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "81222e42a52d0b560f5916b0f312bcc5370e42e596c821477d71c61883c3d025",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "8d6177707a95cf0230881bbfb7467c0591632eb67dce3c758596c83cafb14ffc",
+ "compiled": true
+ }
+ ]
+ },
{
"name": "debug_line",
"vulkanReady": true,
@@ -17,6 +113,134 @@
}
]
},
+ {
+ "name": "directional_shadow_terrain",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "e863894aa1d66508daad86806af2b2a3509088109754c491a3607477f78ed644",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "2b9ebbabc96c3ba53ce58cea175c52932c81d1d93820550f29a0734131dba5a3",
+ "compiled": true
+ }
+ ]
+ },
+ {
+ "name": "directional_shadow_terrain_multiview",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "2f7d7bba3aa19a4891c30000e29c0bb84a2afdc26a44248d4c48bd0ffed8a04a",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "2b9ebbabc96c3ba53ce58cea175c52932c81d1d93820550f29a0734131dba5a3",
+ "compiled": true
+ }
+ ]
+ },
+ {
+ "name": "directional_shadow_world_cutout",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "8331583c1d63ee59b3f7898e25b2e9730df98fd9da28273661caad948fb46ae5",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "cd9c404a9379715061358cb18a99b9310acbdd5062f0ad982ee9a37cd9f99922",
+ "compiled": true
+ }
+ ]
+ },
+ {
+ "name": "directional_shadow_world_cutout_multiview",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "a4bb3b86283af310a22d3943dd1afadb8a72b42c9e5501efc9abf2b5124a1446",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "cd9c404a9379715061358cb18a99b9310acbdd5062f0ad982ee9a37cd9f99922",
+ "compiled": true
+ }
+ ]
+ },
+ {
+ "name": "directional_shadow_world_opaque",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "c2586df5f09518c40d052bbacdfff2c00a82b1eefec4868749d0580571198067",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "2b9ebbabc96c3ba53ce58cea175c52932c81d1d93820550f29a0734131dba5a3",
+ "compiled": true
+ }
+ ]
+ },
+ {
+ "name": "directional_shadow_world_opaque_multiview",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "35b4d524153623691ab523a049212aa35551f96c169c28c08f62d93e31d9e68d",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "2b9ebbabc96c3ba53ce58cea175c52932c81d1d93820550f29a0734131dba5a3",
+ "compiled": true
+ }
+ ]
+ },
+ {
+ "name": "mesh_atmospheric",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "d4f8bc12ee84379ced84f5b703cf8be95798a23a7063773e214bc1eb6ebbb65e",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "62b001a72080ea74bdaef0fb2a4ed92533feaeab76187ce4215813c6327432e7",
+ "compiled": true
+ }
+ ]
+ },
+ {
+ "name": "mesh_detail",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "0273312da9fefb5084d3aee120f33c542e1adcfb846e6c7b3fc2b068c1348fb3",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "1fbc3ffb12d260cbe0d111551f28c69f15754ba5e5e11fe3ec43747bebef4883",
+ "compiled": true
+ }
+ ]
+ },
{
"name": "mesh_modern",
"vulkanReady": true,
@@ -97,6 +321,22 @@
}
]
},
+ {
+ "name": "terrain_atmospheric",
+ "vulkanReady": true,
+ "stages": [
+ {
+ "stage": "vert",
+ "sourceSha256": "06258c7ead0e123740e325c802156987ed17dabe355d591d0e90facb420b8e04",
+ "compiled": true
+ },
+ {
+ "stage": "frag",
+ "sourceSha256": "9927abe9cc4fb83429d3e2ec8ee8d13f0b1936cebf484a4e529dcfdab431c3b8",
+ "compiled": true
+ }
+ ]
+ },
{
"name": "terrain_modern",
"vulkanReady": true,
diff --git a/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.frag.spv
new file mode 100644
index 00000000..bbf43111
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.frag.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.vert.spv
new file mode 100644
index 00000000..d672dcb9
Binary files /dev/null and b/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.frag b/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.frag
new file mode 100644
index 00000000..12b27b14
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.frag
@@ -0,0 +1,208 @@
+#version 460 core
+#extension GL_ARB_bindless_texture : require
+
+// Phase N.5b: terrain fragment shader on the modern bindless dispatcher.
+// Math identical to terrain.frag (Phase 3c per-cell maskBlend3 +
+// Phase G fog + lightning flash).
+//
+// Texture reads go through ACDREAM_SAMPLE_ARRAY, which
+// tools/ShaderCompiler/VulkanGlslPreamble.cs injects to index the set-2
+// descriptor array. GL is deleted (Campaign V slice V11); before that this
+// macro expanded (via the now-deleted common.glsl) to the uvec2-handle +
+// sampler2DArray-constructor pattern this shader used on that arm — the
+// documented "always works" form per the ARB_bindless_texture spec, and the
+// one that avoided the GL_INVALID_OPERATION the alternative (`uniform
+// sampler2DArray` set via glProgramUniformHandleARB) produced on at least one
+// driver in practice. The extension requirement above is dropped for Vulkan
+// by the compiler's preamble, where it would be an error rather than a no-op.
+
+in vec2 vBaseUV;
+in vec3 vWorldNormal;
+in vec3 vWorldPos;
+in vec3 vAmbientLocalLit;
+in vec3 vDirectionalLit;
+in vec4 vOverlay0;
+in vec4 vOverlay1;
+in vec4 vOverlay2;
+in vec4 vRoad0;
+in vec4 vRoad1;
+flat in float vBaseTexIdx;
+
+out vec4 fragColor;
+
+// Campaign V slice V2b (2026-07-27): uTerrainHandle/uAlphaHandle (uvec2, raw
+// ARB_bindless_texture handles) became uTextureIndexA/uTextureIndexB (slots
+// into the global texture table, ACDREAM_TEXTURE_HANDLE injected by
+// tools/ShaderCompiler/VulkanGlslPreamble.cs). Named to match the pinned
+// GpuPushConstants.TextureIndexA/B fields so V4d's
+// move to push constants is a rename, not a redesign — there is no
+// push-constant plumbing yet, so these stay plain uniforms for now.
+uniform uint uTextureIndexA;
+uniform uint uTextureIndexB;
+
+// Campaign V slice V6f-3: the two atlases are sampled through the
+// dialect-neutral table read instead of a GL sampler-from-handle constructor.
+// `sampler2DArray(handle)` is a GL_ARB_bindless_texture form with no Vulkan
+// equivalent — Vulkan's table is an opaque descriptor array in set 2, and there
+// is no handle to construct a sampler from. ACDREAM_SAMPLE_ARRAY asks the
+// question both dialects can answer ("sample table slot N at these
+// coordinates") and expands to the right thing on each.
+//
+// A SAMPLING macro, not a sampler-returning one, for the reason
+// VulkanGlslPreamble.cs records: under Vulkan the expansion carries `nonuniformEXT` on the indexing
+// expression, and binding the result to a local sampler2DArray first is exactly
+// where an implementation may drop that qualifier. The old `#define uTerrain
+// sampler2DArray(...)` was that shape textually, so keeping it would have
+// reintroduced the hazard at every use site.
+#define sampleTerrain(uvw) ACDREAM_SAMPLE_ARRAY(uTextureIndexA, uvw)
+#define sampleAlpha(uvw) ACDREAM_SAMPLE_ARRAY(uTextureIndexB, uvw)
+
+// Campaign V slice V6f-2: the 36 per-layer tiling factors moved out of a loose
+// `uniform float uTexTiling[36]` and into the uniform buffer GpuBindingModel
+// reserved binding 3 for. Vulkan GLSL has no default uniform block, so the loose
+// array was unspellable there, and at 144 bytes of payload it cannot ride the
+// 96-byte push-constant block either. A uniform buffer is the only legal home,
+// and the same declaration is legal in both dialects.
+//
+// The ELEMENT TYPE is deliberately unchanged. std140 pads every array element to
+// 16 bytes, so the block is 576 bytes rather than 144, and packing four floats
+// per vec4 would be tighter — but it would also change every use site below.
+// Keeping `float uTexTiling[36]` means `uTexTiling[int(layer)]` reads exactly as
+// it did before, so this commit's pixel gate is measuring the move to a uniform
+// buffer and nothing else. TerrainTextureTilingTable.UniformBufferBytes and the
+// stride beside it are the CPU half of this layout.
+layout(std140, ACDREAM_UBO_SET binding = 3) uniform TerrainTiling {
+ float uTexTiling[36];
+};
+
+struct Light {
+ vec4 posAndKind;
+ vec4 dirAndRange;
+ vec4 colorAndIntensity;
+ vec4 coneAngleEtc;
+};
+layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
+ Light uLights[8];
+ vec4 uCellAmbient;
+ vec4 uFogParams;
+ vec4 uFogColor;
+ vec4 uCameraAndTime;
+};
+
+#include "directional_shadow_receiver.glsl"
+
+// Retail TexMerge::CopyAndTile (0x00503580) and TexMerge::Merge
+// (0x005038C0) pass TerrainTex::tex_tiling to every terrain source before
+// the cell-scale alpha mask is applied. The atlas stores that value by layer.
+float terrainTiling(float layer) {
+ return uTexTiling[int(layer)];
+}
+
+vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) {
+ float a0 = h0 == 0.0 ? 1.0 : t0.a;
+ float a1 = h1 == 0.0 ? 1.0 : t1.a;
+ float a2 = h2 == 0.0 ? 1.0 : t2.a;
+ float aR = 1.0 - (a0 * a1 * a2);
+ float aRsafe = max(aR, 1e-6);
+ a0 = 1.0 - a0;
+ a1 = 1.0 - a1;
+ a2 = 1.0 - a2;
+ vec3 r0 = (a0 * t0.rgb + (1.0 - a0) * a1 * t1.rgb + (1.0 - a1) * a2 * t2.rgb);
+ return vec4(r0 / aRsafe, aR);
+}
+
+vec4 combineOverlays(vec2 baseUV, vec4 pOverlay0, vec4 pOverlay1, vec4 pOverlay2) {
+ float h0 = pOverlay0.z < 0.0 ? 0.0 : 1.0;
+ float h1 = pOverlay1.z < 0.0 ? 0.0 : 1.0;
+ float h2 = pOverlay2.z < 0.0 ? 0.0 : 1.0;
+ vec4 t0 = vec4(0.0), t1 = vec4(0.0), t2 = vec4(0.0);
+
+ if (h0 > 0.0) {
+ t0 = sampleTerrain(vec3(baseUV * terrainTiling(pOverlay0.z), pOverlay0.z));
+ if (pOverlay0.w >= 0.0) {
+ vec4 a = sampleAlpha(vec3(pOverlay0.xy, pOverlay0.w));
+ t0.a = a.a;
+ }
+ }
+ if (h1 > 0.0) {
+ t1 = sampleTerrain(vec3(baseUV * terrainTiling(pOverlay1.z), pOverlay1.z));
+ if (pOverlay1.w >= 0.0) {
+ vec4 a = sampleAlpha(vec3(pOverlay1.xy, pOverlay1.w));
+ t1.a = a.a;
+ }
+ }
+ if (h2 > 0.0) {
+ t2 = sampleTerrain(vec3(baseUV * terrainTiling(pOverlay2.z), pOverlay2.z));
+ if (pOverlay2.w >= 0.0) {
+ vec4 a = sampleAlpha(vec3(pOverlay2.xy, pOverlay2.w));
+ t2.a = a.a;
+ }
+ }
+ return maskBlend3(t0, t1, t2, h0, h1, h2);
+}
+
+vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
+ float h0 = pRoad0.z < 0.0 ? 0.0 : 1.0;
+ float h1 = pRoad1.z < 0.0 ? 0.0 : 1.0;
+ vec4 result = vec4(0.0);
+ if (h0 > 0.0) {
+ result = sampleTerrain(vec3(baseUV * terrainTiling(pRoad0.z), pRoad0.z));
+ if (pRoad0.w >= 0.0) {
+ vec4 a0 = sampleAlpha(vec3(pRoad0.xy, pRoad0.w));
+ result.a = 1.0 - a0.a;
+ if (h1 > 0.0 && pRoad1.w >= 0.0) {
+ vec4 a1 = sampleAlpha(vec3(pRoad1.xy, pRoad1.w));
+ result.a = 1.0 - (a0.a * a1.a);
+ }
+ }
+ }
+ return result;
+}
+
+vec3 applyFog(vec3 lit, vec3 worldPos) {
+ int mode = int(uFogParams.w);
+ if (mode == 0) return lit;
+ float d = length(worldPos - uCameraAndTime.xyz);
+ float fogStart = uFogParams.x;
+ float fogEnd = uFogParams.y;
+ float span = max(1e-3, fogEnd - fogStart);
+ float fog = clamp((d - fogStart) / span, 0.0, 1.0);
+ return mix(lit, uFogColor.xyz, fog);
+}
+
+void main() {
+ vec4 baseColor = vec4(0.0);
+ if (vBaseTexIdx >= 0.0) {
+ baseColor = sampleTerrain(vec3(vBaseUV * terrainTiling(vBaseTexIdx), vBaseTexIdx));
+ }
+
+ vec4 overlays = vec4(0.0);
+ if (vOverlay0.z >= 0.0)
+ overlays = combineOverlays(vBaseUV, vOverlay0, vOverlay1, vOverlay2);
+
+ vec4 roads = vec4(0.0);
+ if (vRoad0.z >= 0.0)
+ roads = combineRoad(vBaseUV, vRoad0, vRoad1);
+
+ vec3 baseMasked = baseColor.rgb * ((1.0 - overlays.a) * (1.0 - roads.a));
+ vec3 ovlMasked = overlays.rgb * (overlays.a * (1.0 - roads.a));
+ vec3 roadMasked = roads.rgb * roads.a;
+ vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0);
+
+ vec3 surfaceToLight = normalize(uShadowLightDirectionAndSource.xyz);
+ float directionalVisibility = acdreamDirectionalShadowVisibility(
+ vWorldPos,
+ normalize(vWorldNormal),
+ uCameraAndTime.xyz,
+ surfaceToLight);
+ vec3 lighting = vAmbientLocalLit
+ + vDirectionalLit * directionalVisibility;
+ vec3 lit = rgb * min(lighting, vec3(1.0));
+
+ float flash = uFogParams.z;
+ lit += flash * vec3(0.6, 0.6, 0.75);
+
+ lit = applyFog(lit, vWorldPos);
+
+ fragColor = vec4(lit, 1.0);
+}
diff --git a/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert
new file mode 100644
index 00000000..10fb57aa
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert
@@ -0,0 +1,197 @@
+#version 460 core
+#extension GL_ARB_bindless_texture : require
+
+#include "directional_shadow_common.glsl"
+
+// Phase N.5b: terrain shader on the modern bindless dispatcher.
+// Math identical to terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes
+// lighting). The only structural change is the version + bindless extension
+// — sampler access in the fragment stage is unchanged at the GLSL level.
+
+layout(location = 0) in vec3 aPos;
+layout(location = 1) in vec3 aNormal;
+layout(location = 2) in uvec4 aPacked0;
+layout(location = 3) in uvec4 aPacked1;
+layout(location = 4) in uvec4 aPacked2;
+layout(location = 5) in uvec4 aPacked3;
+
+// Campaign V slice V6f-1: uView/uProjection converged into the single
+// uViewProjection that GpuPushConstants already carries, so terrain can be
+// expressed in Vulkan GLSL at all — two loose mat4 uniforms are 128 bytes and
+// cannot both fit the pinned 96-byte push block, and Vulkan GLSL has no default
+// uniform block to hold them loose. The product is now formed on the CPU
+// (camera.View * camera.Projection) instead of per vertex here; the two are the
+// same transform, and System.Numerics' row-vector layout uploaded untransposed
+// reads in GLSL as the transpose, so (View*Proj)^T == Proj^T * View^T is exactly
+// the uProjection * uView this replaced.
+uniform mat4 uViewProjection;
+
+struct Light {
+ vec4 posAndKind;
+ vec4 dirAndRange;
+ vec4 colorAndIntensity;
+ vec4 coneAngleEtc;
+};
+layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
+ Light uLights[8];
+ vec4 uCellAmbient;
+ vec4 uFogParams;
+ vec4 uFogColor;
+ vec4 uCameraAndTime;
+};
+
+// === Phase U.3: terrain screen-space clip gate (OutsideView region) ===========
+// Terrain is a single global region (the OutsideView), so it needs one set of
+// clip planes, not a per-instance slot table like the mesh shader. A std140 UBO
+// at binding=2 carries it. The UBO binding namespace is distinct from the SSBO
+// binding namespace, so this does NOT collide with the mesh shader's SSBO
+// binding=2 — and within THIS shader binding=1 (SceneLighting) is the only other
+// UBO, leaving binding=2 free. uTerrainClipCount == 0 (the U.3 default) ungates
+// terrain entirely (the second loop sets all 8 distances to +1.0). Uploaded by
+// ClipFrame.UploadShared each frame; TerrainModernRenderer binds it before draw.
+//
+// Campaign V slice V6i-2: ACDREAM_UBO_SET is what puts this in set 1 under the
+// Vulkan dialect and expands to nothing under GL. Omitting it left the block at
+// set 0 binding 2, which the storage layout declares as a STORAGE buffer — see
+// plan §5.5.12 finding 2, measured on the committed SPIR-V rather than inferred.
+// sky.vert declares the SAME block correctly and is the precedent.
+layout(std140, ACDREAM_UBO_SET binding = 2) uniform TerrainClip {
+ int uTerrainClipCount;
+ vec4 uTerrainClipPlanes[8];
+};
+
+// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal.
+// Sized 8 to match GL_MAX_CLIP_DISTANCES >= 8. Host enables GL_CLIP_DISTANCE0..7
+// once at startup; unused planes are set to +1.0 below so they pass everything.
+out gl_PerVertex {
+ vec4 gl_Position;
+ float gl_ClipDistance[8];
+};
+
+out vec2 vBaseUV;
+out vec3 vWorldNormal;
+out vec3 vWorldPos;
+out vec3 vAmbientLocalLit;
+out vec3 vDirectionalLit;
+out vec4 vOverlay0;
+out vec4 vOverlay1;
+out vec4 vOverlay2;
+out vec4 vRoad0;
+out vec4 vRoad1;
+flat out float vBaseTexIdx;
+
+// Retail's N·L floor from FUN_00532440 lines 2119/2138/2157/2176 at
+// chunk_00530000.c (AdjustPlanes). The decompile reads:
+// if (fVar3 < DAT_00796344) fVar3 = DAT_00796344;
+// applied to the clamped Lambert result BEFORE it's multiplied into
+// dirColor. DAT_00796344's exact literal isn't pinned by the decompile
+// but every other "floor" use in retail clamps negatives to zero (the
+// physically-correct Lambert half-space). Our previous 0.08 was a
+// defensive guess from early acdream days that made back-lit terrain
+// visibly brighter than retail (user-observed 2026-04-24 "acdream
+// warmer / less blue than retail"). Reverting to 0.0 matches retail
+// per the decompile and lets ambient fill in the back side.
+// Cross-ref: docs/research/2026-04-24-lambert-brightness-split.md.
+const float MIN_FACTOR = 0.0;
+
+vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV) {
+ float texIdx = float(texIdxU);
+ float alphaIdx = float(alphaIdxU);
+ if (texIdx >= 254.0) texIdx = -1.0;
+ if (alphaIdx >= 254.0) alphaIdx = -1.0;
+
+ vec2 rotatedUV = baseUV;
+ if (rotIdx == 1u) rotatedUV = vec2(1.0 - baseUV.y, baseUV.x);
+ else if (rotIdx == 2u) rotatedUV = vec2(1.0 - baseUV.x, 1.0 - baseUV.y);
+ else if (rotIdx == 3u) rotatedUV = vec2( baseUV.y, 1.0 - baseUV.x);
+
+ return vec4(rotatedUV.x, rotatedUV.y, texIdx, alphaIdx);
+}
+
+void main() {
+ // Unpack rotation fields from aPacked3. Bit layout (data3):
+ // .x (byte 0): bits 0-1 rotBase (unused), 2-3 rotOvl0, 4-5 rotOvl1, 6-7 rotOvl2
+ // .y (byte 1): bits 0-1 rotRd0 (= data3 bit 8-9),
+ // bits 2-3 rotRd1 (= data3 bit 10-11),
+ // bit 4 splitDir (= data3 bit 12)
+ uint rotOvl0 = (aPacked3.x >> 2u) & 3u;
+ uint rotOvl1 = (aPacked3.x >> 4u) & 3u;
+ uint rotOvl2 = (aPacked3.x >> 6u) & 3u;
+ uint rotRd0 = aPacked3.y & 3u;
+ uint rotRd1 = (aPacked3.y >> 2u) & 3u;
+ uint splitDir= (aPacked3.y >> 4u) & 1u;
+
+ // Derive which of the 4 cell corners this vertex represents from
+ // gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a
+ // specific order for each split direction; the tables below must stay
+ // in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches.
+ // 2026-04-21 fix: geometry re-derived to match ACE's ConstructPolygons
+ // convention. SWtoNE (cut BL→TR, y=x diagonal) now maps to the {BL,BR,TR}
+ // + {BL,TR,TL} triangle pair; SEtoNW (cut BR→TL, x+y=1 diagonal) maps to
+ // {BL,BR,TL} + {BR,TR,TL}.
+ int vIdx = gl_VertexID % 6;
+ int corner = 0;
+ if (splitDir == 0u) {
+ // SWtoNE order: BL, BR, TR, BL, TR, TL → corners 0, 1, 2, 0, 2, 3
+ if (vIdx == 0) corner = 0;
+ else if (vIdx == 1) corner = 1;
+ else if (vIdx == 2) corner = 2;
+ else if (vIdx == 3) corner = 0;
+ else if (vIdx == 4) corner = 2;
+ else corner = 3;
+ } else {
+ // SEtoNW order: BL, BR, TL, BR, TR, TL → corners 0, 1, 3, 1, 2, 3
+ if (vIdx == 0) corner = 0;
+ else if (vIdx == 1) corner = 1;
+ else if (vIdx == 2) corner = 3;
+ else if (vIdx == 3) corner = 1;
+ else if (vIdx == 4) corner = 2;
+ else corner = 3;
+ }
+
+ vec2 baseUV;
+ if (corner == 0) baseUV = vec2(0.0, 1.0);
+ else if (corner == 1) baseUV = vec2(1.0, 1.0);
+ else if (corner == 2) baseUV = vec2(1.0, 0.0);
+ else baseUV = vec2(0.0, 0.0);
+
+ vBaseUV = baseUV;
+ vWorldPos = aPos;
+ vWorldNormal = normalize(aNormal);
+
+ // Retail AdjustPlanes bake (terrain.vert:124-134 — identical math).
+ vec3 surfaceToLight = normalize(uShadowLightDirectionAndSource.xyz);
+ vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
+ float L = max(dot(vWorldNormal, surfaceToLight), MIN_FACTOR);
+ // Preserve retail's authored lighting values, but keep the outdoor
+ // directional term separate so the receiver shadows no ambient/local light.
+ vAmbientLocalLit = uCellAmbient.xyz;
+ vDirectionalLit = sunCol * L;
+
+ float baseTex = float(aPacked0.x);
+ if (baseTex >= 254.0) baseTex = -1.0;
+ vBaseTexIdx = baseTex;
+
+ vOverlay0 = unpackOverlayLayer(aPacked0.z, aPacked0.w, rotOvl0, baseUV);
+ vOverlay1 = unpackOverlayLayer(aPacked1.x, aPacked1.y, rotOvl1, baseUV);
+ vOverlay2 = unpackOverlayLayer(aPacked1.z, aPacked1.w, rotOvl2, baseUV);
+ vRoad0 = unpackOverlayLayer(aPacked2.x, aPacked2.y, rotRd0, baseUV);
+ vRoad1 = unpackOverlayLayer(aPacked2.z, aPacked2.w, rotRd1, baseUV);
+
+ // Retail zFightTerrainAdjust (acclient_2013_pseudo_c.txt:1120769 = 0.00999999978,
+ // applied per terrain vertex inside ACRender::landPolysDraw at line 702254,
+ // address 006b6402). Render terrain 1 cm below its physical Z so coplanar
+ // building floors win the depth test. Physics path is unaffected — it reads
+ // the un-nudged heightmap via TerrainSurface.SampleZ.
+ // Closes issue #100; supersedes the hiddenTerrainCells cell-collapse hack.
+ vec3 terrainPos = vec3(aPos.xy, aPos.z - 0.01);
+ gl_Position = uViewProjection * vec4(terrainPos, 1.0);
+
+ // Phase U.3: terrain clip gate against the single OutsideView region. With
+ // uTerrainClipCount == 0 (U.3 default) the first loop is skipped and the
+ // second sets all 8 distances to +1.0 ⇒ no clipping ⇒ identical terrain.
+ for (int i = 0; i < uTerrainClipCount; ++i)
+ gl_ClipDistance[i] = dot(uTerrainClipPlanes[i], gl_Position);
+ for (int i = uTerrainClipCount; i < 8; ++i)
+ gl_ClipDistance[i] = 1.0;
+}
diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs
index 64a73db6..fcdc7ab6 100644
--- a/src/AcDream.App/Rendering/TerrainAtlas.cs
+++ b/src/AcDream.App/Rendering/TerrainAtlas.cs
@@ -28,6 +28,30 @@ namespace AcDream.App.Rendering;
///
public sealed class TerrainAtlas : IDisposable
{
+ ///
+ /// Retail's category-scoped detail surface, resolved from one
+ /// TexMerge.TerrainDesc entry. The texture-table slot samples a
+ /// one-layer 2-D array so it uses the same backend-neutral table contract
+ /// as every other world texture.
+ ///
+ internal readonly record struct RetailDetailTextureBinding(
+ GpuTextureSlot TextureSlot,
+ float Tiling,
+ uint SurfaceTextureId,
+ uint RenderSurfaceId,
+ int Width,
+ int Height)
+ {
+ public bool IsAvailable =>
+ TextureSlot.IsAssigned
+ && SurfaceTextureId != 0
+ && RenderSurfaceId != 0;
+ }
+
+ private sealed record DetailTextureResource(
+ IGpuTexture Texture,
+ RetailDetailTextureBinding Binding);
+
public IReadOnlyDictionary TerrainTypeToLayer { get; }
public int LayerCount { get; }
///
@@ -54,6 +78,18 @@ public sealed class TerrainAtlas : IDisposable
/// RCode for each RoadMap, parallel to .
public IReadOnlyList RoadAlphaRCodes { get; }
+ ///
+ /// Retail detail category 1. DrawBuilding is the only live object
+ /// path that consumes it; ordinary scenery and terrain do not.
+ ///
+ internal RetailDetailTextureBinding BuildingDetailTexture { get; }
+
+ ///
+ /// Retail detail category 2. DrawEnvCell consumes it for interior
+ /// cell shells.
+ ///
+ internal RetailDetailTextureBinding EnvironmentDetailTexture { get; }
+
///
/// Campaign V slice V6i-2: both arrays are s the
/// device created, and both slots were registered at build time, so
@@ -72,6 +108,8 @@ public sealed class TerrainAtlas : IDisposable
public IGpuTexture Alpha { get; } = alpha;
public IGpuSampler AlphaSampler { get; } = alphaSampler;
public IGpuSampler? TerrainSampler { get; set; }
+ public IGpuTexture? BuildingDetailTexture { get; set; }
+ public IGpuTexture? EnvironmentDetailTexture { get; set; }
public GpuTextureSlot TerrainSlot { get; set; } = GpuTextureSlot.Unassigned;
public GpuTextureSlot AlphaSlot { get; set; } = GpuTextureSlot.Unassigned;
}
@@ -106,7 +144,9 @@ public sealed class TerrainAtlas : IDisposable
IReadOnlyList roadLayers,
IReadOnlyList cornerTCodes,
IReadOnlyList sideTCodes,
- IReadOnlyList roadRCodes)
+ IReadOnlyList roadRCodes,
+ DetailTextureResource? buildingDetail,
+ DetailTextureResource? environmentDetail)
{
_rhi = new RhiArrays(device, terrain, alpha, alphaSampler);
TerrainTypeToLayer = map;
@@ -119,6 +159,10 @@ public sealed class TerrainAtlas : IDisposable
CornerAlphaTCodes = cornerTCodes;
SideAlphaTCodes = sideTCodes;
RoadAlphaRCodes = roadRCodes;
+ _rhi.BuildingDetailTexture = buildingDetail?.Texture;
+ _rhi.EnvironmentDetailTexture = environmentDetail?.Texture;
+ BuildingDetailTexture = buildingDetail?.Binding ?? default;
+ EnvironmentDetailTexture = environmentDetail?.Binding ?? default;
_rhi.AlphaSlot = device.RegisterTexture(alpha, alphaSampler);
ApplyAnisotropic(RetailMaxAnisotropy);
}
@@ -323,6 +367,9 @@ public sealed class TerrainAtlas : IDisposable
int mipLevels = Wb.RhiWorldTextureArray.MipLevelsFor(maxW, maxH);
IGpuTexture? terrainTexture = null;
IGpuTexture? alphaTexture = null;
+ IGpuSampler? detailSampler = null;
+ DetailTextureResource? buildingDetail = null;
+ DetailTextureResource? environmentDetail = null;
try
{
terrainTexture = device.CreateTexture(new GpuTextureDescription(
@@ -378,6 +425,31 @@ public sealed class TerrainAtlas : IDisposable
MipFilter = GpuMipFilter.None,
});
+ // Retail LScape::SetDetailTexturing category indices:
+ // 1 = building, 2 = environment/EnvCell.
+ // ChangeRegion and the only reachable SmartBox caller keep
+ // landscape (0) and object (3) disabled, so do not create or expose
+ // those categories here.
+ detailSampler = device.CreateSampler(GpuSamplerDescription.WorldRepeat);
+ if (terrainDesc is { Count: > 1 })
+ {
+ buildingDetail = TryCreateDetailTexture(
+ device,
+ dats,
+ detailSampler,
+ terrainDesc[1],
+ "building");
+ }
+ if (terrainDesc is { Count: > 2 })
+ {
+ environmentDetail = TryCreateDetailTexture(
+ device,
+ dats,
+ detailSampler,
+ terrainDesc[2],
+ "environment");
+ }
+
Console.WriteLine(
$"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} ({mipLevels} mip levels)");
Console.WriteLine(
@@ -399,16 +471,117 @@ public sealed class TerrainAtlas : IDisposable
alpha.RoadLayers,
alpha.CornerTCodes,
alpha.SideTCodes,
- alpha.RoadRCodes);
+ alpha.RoadRCodes,
+ buildingDetail,
+ environmentDetail);
}
catch
{
+ DisposeDetailTextureResource(device, environmentDetail);
+ DisposeDetailTextureResource(device, buildingDetail);
alphaTexture?.Dispose();
terrainTexture?.Dispose();
throw;
}
}
+ private static DetailTextureResource? TryCreateDetailTexture(
+ IGpuDevice device,
+ IDatReaderWriter dats,
+ IGpuSampler sampler,
+ DatReaderWriter.Types.TMTerrainDesc terrain,
+ string categoryName)
+ {
+ uint surfaceTextureId = (uint)terrain.TerrainTex.DetailTextureId;
+ if (surfaceTextureId == 0)
+ return null;
+
+ SurfaceTexture? surfaceTexture = dats.Get(surfaceTextureId);
+ if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
+ {
+ Console.WriteLine(
+ $"WARN: retail {categoryName} detail SurfaceTexture "
+ + $"0x{surfaceTextureId:X8} missing");
+ return null;
+ }
+
+ uint renderSurfaceId = (uint)surfaceTexture.Textures[0];
+ RenderSurface? renderSurface = dats.Get(renderSurfaceId);
+ if (renderSurface is null)
+ {
+ Console.WriteLine(
+ $"WARN: retail {categoryName} detail RenderSurface "
+ + $"0x{renderSurfaceId:X8} missing");
+ return null;
+ }
+
+ Palette? palette = renderSurface.DefaultPaletteId != 0
+ ? dats.Get(renderSurface.DefaultPaletteId)
+ : null;
+ DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface(
+ renderSurface,
+ palette);
+ if (ReferenceEquals(decoded, DecodedTexture.Magenta))
+ {
+ Console.WriteLine(
+ $"WARN: retail {categoryName} detail RenderSurface "
+ + $"0x{renderSurfaceId:X8} failed to decode");
+ return null;
+ }
+
+ int mipLevels = Wb.RhiWorldTextureArray.MipLevelsFor(
+ decoded.Width,
+ decoded.Height);
+ IGpuTexture? texture = null;
+ GpuTextureSlot slot = GpuTextureSlot.Unassigned;
+ try
+ {
+ texture = device.CreateTexture(new GpuTextureDescription(
+ $"retail-detail-{categoryName}",
+ GpuTextureKind.Texture2DArray,
+ GpuTextureFormat.Rgba8Unorm,
+ decoded.Width,
+ decoded.Height,
+ LayerCount: 1,
+ MipLevelCount: mipLevels));
+ texture.Upload(0, 0, decoded.Rgba8);
+ texture.GenerateMipChain();
+ slot = device.RegisterTexture(texture, sampler);
+ var binding = new RetailDetailTextureBinding(
+ slot,
+ terrain.TerrainTex.DetailTexTiling,
+ surfaceTextureId,
+ renderSurfaceId,
+ decoded.Width,
+ decoded.Height);
+ Console.WriteLine(
+ $"Retail detail {categoryName}: SurfaceTexture "
+ + $"0x{surfaceTextureId:X8} -> RenderSurface "
+ + $"0x{renderSurfaceId:X8}, {decoded.Width}x{decoded.Height}, "
+ + $"tiling={binding.Tiling}");
+ return new DetailTextureResource(texture, binding);
+ }
+ catch
+ {
+ if (slot.IsAssigned)
+ device.ReleaseTextureSlot(slot);
+ texture?.Dispose();
+ throw;
+ }
+ }
+
+ private static void DisposeDetailTextureResource(
+ IGpuDevice device,
+ DetailTextureResource? resource)
+ {
+ if (resource is null)
+ return;
+
+ if (resource.Binding.IsAvailable)
+ device.ReleaseTextureSlot(resource.Binding.TextureSlot);
+ resource.Texture.Dispose();
+ }
+
private static DecodedTexture WhitePixel() =>
new([0xFF, 0xFF, 0xFF, 0xFF], 1, 1);
@@ -495,13 +668,20 @@ public sealed class TerrainAtlas : IDisposable
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
}
+ private bool _disposed;
+
public void Dispose()
{
- // Slice V4t's teardown rule: the device dies with its callers, so the
- // table entries are not released here — deferring through a
- // possibly-disposed retirement queue would turn a clean shutdown into a
- // throw. The images themselves route through the device's retirement
- // queue, which is what IGpuTexture.Dispose does.
+ if (_disposed)
+ return;
+ _disposed = true;
+
+ // Slice V4t's teardown rule remains load-bearing: GameWindowLifetime
+ // disposes the device before this atlas, so normal teardown must not
+ // call back into its texture table. Build/registration failure paths
+ // above still release detail slots while the device is known alive.
+ _rhi.EnvironmentDetailTexture?.Dispose();
+ _rhi.BuildingDetailTexture?.Dispose();
_rhi.Alpha.Dispose();
_rhi.Terrain.Dispose();
}
diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadowReceivers.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadowReceivers.cs
new file mode 100644
index 00000000..8ec70656
--- /dev/null
+++ b/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadowReceivers.cs
@@ -0,0 +1,62 @@
+using AcDream.App.Rendering.Gpu;
+
+namespace AcDream.App.Rendering;
+
+public sealed partial class TerrainModernRenderer
+{
+ internal sealed class DirectionalShadowReceiverPipelineState(
+ IDirectionalShadowReceiverSource source,
+ IGpuPipeline pipeline) : IDisposable
+ {
+ internal IDirectionalShadowReceiverSource Source { get; } = source;
+
+ internal IGpuPipeline Pipeline { get; } = pipeline;
+
+ public void Dispose() => Pipeline.Dispose();
+ }
+
+ ///
+ /// Constructs the complete opt-in receiver state without publishing it.
+ /// The controller couples this candidate with the pack runtime and world
+ /// receiver set at one stable-boundary commit.
+ ///
+ internal DirectionalShadowReceiverPipelineState? PrepareDirectionalShadowReceiver(
+ IDirectionalShadowReceiverSource? source,
+ int sampleCount)
+ {
+ if (source is null)
+ return null;
+ IGpuDevice device = _device
+ ?? throw new InvalidOperationException("Directional receivers require the modern RHI device.");
+ if (_scope is null || sampleCount != _scope.SampleCount)
+ throw new InvalidOperationException("Receiver and world-pass sample counts must match.");
+
+ return new DirectionalShadowReceiverPipelineState(
+ source,
+ device.CreatePipeline(
+ new GpuPipelineDescription
+ {
+ Name = "terrain-atmospheric",
+ Shaders = source.PipelineShaders.TerrainReceiver,
+ VertexLayout = TerrainVertexLayout,
+ Topology = GpuPrimitiveTopology.TriangleList,
+ Blend = GpuBlendMode.None,
+ Depth = new GpuDepthState(true, true, GpuCompareOp.Less),
+ Cull = GpuCullMode.Back,
+ FrontFace = GpuFrontFace.CounterClockwise,
+ AlphaToCoverage = false,
+ ColorWrite = true,
+ UsesRenderPackShaderAbi = true,
+ SampleCount = sampleCount,
+ }));
+ }
+
+ /// Assignment-only publication; returned state retires after the coupled swap.
+ internal DirectionalShadowReceiverPipelineState? SwapDirectionalShadowReceiver(
+ DirectionalShadowReceiverPipelineState? candidate)
+ {
+ DirectionalShadowReceiverPipelineState? prior = _directionalShadowReceiver;
+ _directionalShadowReceiver = candidate;
+ return prior;
+ }
+}
diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadows.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadows.cs
new file mode 100644
index 00000000..d8c972ec
--- /dev/null
+++ b/src/AcDream.App/Rendering/TerrainModernRenderer.DirectionalShadows.cs
@@ -0,0 +1,146 @@
+using System.Runtime.CompilerServices;
+using AcDream.App.Rendering.Wb;
+using AcDream.App.Rendering.Gpu;
+
+namespace AcDream.App.Rendering;
+
+internal readonly record struct DirectionalShadowTerrainRange(
+ uint FirstIndex,
+ int IndexCount);
+
+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.
+///
+internal sealed class DirectionalShadowTerrainPreparedDraws
+{
+ private DrawElementsIndirectCommand[] _commands = [];
+ private int _count;
+ private bool _building;
+
+ public long SourceFrameSequence { get; private set; }
+
+ public ulong BuildSequence { get; private set; }
+
+ public ReadOnlySpan Commands =>
+ _commands.AsSpan(0, _count);
+
+ public long RetainedScratchBytes =>
+ checked((long)_commands.Length
+ * Unsafe.SizeOf());
+
+ public bool TryBegin(long frameSequence, int estimatedCommands)
+ {
+ if (_building)
+ throw new InvalidOperationException(
+ "A terrain shadow draw build is already active.");
+ if (frameSequence <= 0)
+ throw new ArgumentOutOfRangeException(nameof(frameSequence));
+ ArgumentOutOfRangeException.ThrowIfNegative(estimatedCommands);
+ if (SourceFrameSequence == frameSequence)
+ return false;
+
+ EnsureCapacity(estimatedCommands);
+ _count = 0;
+ _building = true;
+ return true;
+ }
+
+ public void Add(in DirectionalShadowTerrainRange range)
+ {
+ if (!_building)
+ throw new InvalidOperationException(
+ "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,
+ };
+ }
+
+ public void Complete(long frameSequence)
+ {
+ if (!_building)
+ throw new InvalidOperationException(
+ "No terrain shadow draw build is active.");
+ if (frameSequence <= 0)
+ throw new ArgumentOutOfRangeException(nameof(frameSequence));
+ SourceFrameSequence = frameSequence;
+ BuildSequence = checked(BuildSequence + 1);
+ _building = false;
+ }
+
+ public void Abort()
+ {
+ _count = 0;
+ _building = false;
+ }
+
+ private void EnsureCapacity(int required)
+ {
+ if (_commands.Length >= required)
+ return;
+ int capacity = _commands.Length == 0 ? 16 : _commands.Length;
+ while (capacity < required)
+ capacity = checked(capacity * 2);
+ Array.Resize(ref _commands, capacity);
+ }
+}
+
+public sealed partial class TerrainModernRenderer
+{
+ private readonly DirectionalShadowTerrainPreparedDraws
+ _directionalShadowTerrainDraws = new();
+ private long _directionalShadowFrameSequence;
+
+ 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.
+ ///
+ internal DirectionalShadowTerrainPreparedDraws
+ PrepareDirectionalShadowDraws()
+ {
+ if (!_directionalShadowTerrainDraws.TryBegin(
+ _directionalShadowFrameSequence,
+ _alloc.LoadedCount))
+ {
+ return _directionalShadowTerrainDraws;
+ }
+
+ try
+ {
+ for (int slot = 0; slot < _slots.Length; slot++)
+ {
+ SlotData? data = _slots[slot];
+ if (data is null)
+ continue;
+ var range = new DirectionalShadowTerrainRange(
+ data.FirstIndex,
+ data.IndexCount);
+ _directionalShadowTerrainDraws.Add(in range);
+ }
+ _directionalShadowTerrainDraws.Complete(
+ _directionalShadowFrameSequence);
+ return _directionalShadowTerrainDraws;
+ }
+ catch
+ {
+ _directionalShadowTerrainDraws.Abort();
+ throw;
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs
index 9840788f..b688cb4f 100644
--- a/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs
+++ b/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs
@@ -51,6 +51,7 @@ public sealed unsafe partial class TerrainModernRenderer
private readonly ICurrentGpuFrameSource? _frames;
private readonly IWorldPassScope? _scope;
private IGpuPipeline? _pipeline;
+ private DirectionalShadowReceiverPipelineState? _directionalShadowReceiver;
private IGpuBuffer? _vertexStore;
private IGpuBuffer? _indexStore;
private IGpuBuffer? _tilingBuffer;
@@ -232,13 +233,36 @@ public sealed unsafe partial class TerrainModernRenderer
ParamB = 0f,
};
- encoder.BindPipeline(_pipeline!);
+ IGpuPipeline pipeline = _pipeline!;
+ DirectionalShadowFrameBinding shadowBinding = default;
+ DirectionalShadowReceiverPipelineState? receiver =
+ _directionalShadowReceiver;
+ IDirectionalShadowReceiverSource? receiverSource = receiver?.Source;
+ bool bindingValid = receiverSource is not null
+ && receiverSource.TryGetCurrentFrameBinding(frame, out shadowBinding);
+ if (DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline(
+ encoder.Pass.Name,
+ receiverSource is not null,
+ bindingValid))
+ {
+ pipeline = receiver!.Pipeline;
+ }
+
+ encoder.BindPipeline(pipeline);
encoder.SetPushConstants(in pushConstants);
encoder.BindVertexBuffer(0, RequireVertexStore(), 0);
encoder.BindIndexBuffer(RequireIndexStore(), 0, GpuIndexType.UInt32);
BindTilingTable(encoder);
WorldFrameSectionBinding.BindSceneLighting(encoder, scope.Sections, frame);
WorldFrameSectionBinding.BindTerrainClip(encoder, scope.Sections, frame);
+ if (shadowBinding.Enabled && shadowBinding.Buffer is not null)
+ {
+ encoder.BindUniformBuffer(
+ GpuBindingModel.UniformDirectionalShadow,
+ shadowBinding.Buffer,
+ shadowBinding.OffsetBytes,
+ shadowBinding.SizeBytes);
+ }
GpuRingAllocation commands = frame.AllocateRing(
drawCount * sizeof(DrawElementsIndirectCommand),
@@ -315,6 +339,8 @@ public sealed unsafe partial class TerrainModernRenderer
private void DisposeRhi()
{
+ _directionalShadowReceiver?.Dispose();
+ _directionalShadowReceiver = null;
_pipeline?.Dispose();
_pipeline = null;
_tilingBuffer?.Dispose();
diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs
index a9a91cfe..1790655f 100644
--- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs
+++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs
@@ -90,6 +90,10 @@ public sealed partial class TerrainModernRenderer : IDisposable
public void BeginFrame(int frameSlot)
{
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
+ if (_directionalShadowFrameSequence == long.MaxValue)
+ throw new InvalidOperationException(
+ "Directional-shadow terrain frame identity was exhausted.");
+ _directionalShadowFrameSequence++;
_retirementLedger.RetryPendingPublications();
_dynamicFrameSlot = frameSlot;
_dynamicFrameStarted = true;
diff --git a/src/AcDream.App/Rendering/VolumetricShaftQuality.cs b/src/AcDream.App/Rendering/VolumetricShaftQuality.cs
new file mode 100644
index 00000000..717d59bd
--- /dev/null
+++ b/src/AcDream.App/Rendering/VolumetricShaftQuality.cs
@@ -0,0 +1,116 @@
+using System.Numerics;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering;
+
+internal readonly record struct VolumetricShaftQuality(
+ DirectionalShadowPreset Preset,
+ bool EnabledByDefault,
+ float ResolutionScale,
+ int RayMarchSteps,
+ double IncrementalGpuP50BudgetMilliseconds,
+ double IncrementalGpuP99BudgetMilliseconds)
+{
+ internal static VolumetricShaftQuality For(DirectionalShadowPreset preset) =>
+ preset switch
+ {
+ DirectionalShadowPreset.Low => new(
+ preset,
+ EnabledByDefault: false,
+ ResolutionScale: 0.25f,
+ RayMarchSteps: 24,
+ IncrementalGpuP50BudgetMilliseconds: 0.15,
+ IncrementalGpuP99BudgetMilliseconds: 0.30),
+ DirectionalShadowPreset.Medium => new(
+ preset,
+ EnabledByDefault: true,
+ ResolutionScale: 0.25f,
+ RayMarchSteps: 40,
+ IncrementalGpuP50BudgetMilliseconds: 0.25,
+ IncrementalGpuP99BudgetMilliseconds: 0.40),
+ DirectionalShadowPreset.High => new(
+ preset,
+ EnabledByDefault: true,
+ ResolutionScale: 0.50f,
+ RayMarchSteps: 56,
+ IncrementalGpuP50BudgetMilliseconds: 0.40,
+ IncrementalGpuP99BudgetMilliseconds: 0.65),
+ _ => throw new ArgumentOutOfRangeException(nameof(preset), preset, null),
+ };
+}
+
+internal readonly record struct VolumetricShaftFrameParameters(
+ bool Enabled,
+ float ResolutionScale,
+ int RayMarchSteps,
+ float Density,
+ float Strength,
+ Vector3 LinearSunColor)
+{
+ internal static VolumetricShaftFrameParameters Disabled =>
+ new(false, 0f, 0, 0f, 0f, Vector3.Zero);
+}
+
+///
+/// Maps authored AC sun/weather inputs and the already-gated directional map
+/// to pack-local volumetric parameters. It owns no clock or weather state and
+/// cannot enable shafts without the current frame's valid shadow map.
+///
+internal static class VolumetricShaftPolicy
+{
+ internal static VolumetricShaftFrameParameters Evaluate(
+ in VolumetricShaftQuality quality,
+ bool userEnabled,
+ in DirectionalShadowEnvironmentState shadow,
+ WeatherKind weather,
+ Vector3 authoredSunColor,
+ float authoredSunBrightness)
+ {
+ if (!userEnabled
+ || !shadow.ShouldRender
+ || shadow.SourceKind is not Packs.AuthoredCelestialShadowSourceKind.Sun
+ || !float.IsFinite(authoredSunBrightness)
+ || authoredSunBrightness <= 0f)
+ return VolumetricShaftFrameParameters.Disabled;
+
+ float weatherMultiplier = weather switch
+ {
+ WeatherKind.Clear => 1f,
+ WeatherKind.Overcast => 0.18f,
+ WeatherKind.Rain => 0.10f,
+ WeatherKind.Snow => 0.16f,
+ WeatherKind.Storm => 0.06f,
+ _ => 0f,
+ };
+ if (weatherMultiplier <= 0f)
+ return VolumetricShaftFrameParameters.Disabled;
+
+ // Peak at a raking but valid sun. The shadow gate already fades the
+ // first degrees above the horizon; this term then rolls shafts away
+ // toward noon without inventing a time-of-day schedule.
+ float elevation = Math.Clamp(shadow.LightElevationSin, 0f, 1f);
+ float noonRollOff = 1f - SmoothStep(0.18f, 0.82f, elevation);
+ float strength = Math.Clamp(
+ shadow.Strength * weatherMultiplier * noonRollOff,
+ 0f,
+ 1f);
+ if (strength <= 1e-4f)
+ return VolumetricShaftFrameParameters.Disabled;
+
+ Vector3 color = Vector3.Max(Vector3.Zero, authoredSunColor)
+ * Math.Clamp(authoredSunBrightness, 0f, 8f);
+ return new VolumetricShaftFrameParameters(
+ Enabled: true,
+ quality.ResolutionScale,
+ quality.RayMarchSteps,
+ Density: 0.035f * strength,
+ Strength: strength,
+ LinearSunColor: color);
+ }
+
+ private static float SmoothStep(float minimum, float maximum, float value)
+ {
+ float t = Math.Clamp((value - minimum) / (maximum - minimum), 0f, 1f);
+ return t * t * (3f - (2f * t));
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs
index bafea1a5..cb43449b 100644
--- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs
+++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs
@@ -16,8 +16,8 @@ namespace AcDream.App.Rendering.Wb;
/// Two structural differences from V4c. It records into the pass
/// VulkanWorldScenePhase opened rather than opening
/// "envcell-shells" of its own, because the frame's one backbuffer pass
-/// resolves. And there is no binding-9 texture table: V4t moved the slot onto
-/// the device, and on Vulkan that table is set 2, which the encoder binds.
+/// resolves. And the global texture table is set 2 rather than a storage
+/// buffer: storage binding 9 is now #226's per-instance detail category.
///
public sealed unsafe partial class EnvCellRenderer
{
@@ -27,10 +27,14 @@ public sealed unsafe partial class EnvCellRenderer
private IGpuPipeline? _opaquePipeline;
private IGpuPipeline? _alphaPipeline;
private IGpuPipeline? _additivePipeline;
+ private IGpuPipeline? _detailPipeline;
+ private IGpuPipeline? _transparentDetailPipeline;
+ private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail;
+ private readonly Func _buildingDetailEnabled;
///
/// The RHI arm's constructor. It also completes Initialize's job: the
- /// three pipelines ARE this renderer's program, so there is no second step
+ /// five pipelines ARE this renderer's program, so there is no second step
/// and no Shader to hand in.
///
internal EnvCellRenderer(
@@ -38,13 +42,17 @@ public sealed unsafe partial class EnvCellRenderer
ICurrentGpuFrameSource frames,
IWorldPassScope scope,
ObjectMeshManager meshManager,
- WbFrustum frustum)
+ WbFrustum frustum,
+ TerrainAtlas.RetailDetailTextureBinding environmentDetail = default,
+ Func? buildingDetailEnabled = null)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
_meshManager = meshManager ?? throw new ArgumentNullException(nameof(meshManager));
_frustum = frustum ?? throw new ArgumentNullException(nameof(frustum));
+ _environmentDetail = environmentDetail;
+ _buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures;
_opaquePipeline = CreateShellPipeline(
device, "envcell-opaque", GpuBlendMode.None, depthWrite: true, scope.SampleCount);
@@ -52,9 +60,29 @@ public sealed unsafe partial class EnvCellRenderer
device, "envcell-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, scope.SampleCount);
_additivePipeline = CreateShellPipeline(
device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount);
+ _detailPipeline = CreateShellPipeline(
+ device,
+ "envcell-retail-detail",
+ GpuBlendMode.RetailDetail,
+ depthWrite: true,
+ scope.SampleCount,
+ shaderName: "mesh_detail",
+ depthCompare: RetailDetailTextureContract.DetailDepthCompare(
+ transparent: false));
+ _transparentDetailPipeline = CreateShellPipeline(
+ device,
+ "envcell-retail-detail-alpha",
+ GpuBlendMode.RetailDetail,
+ depthWrite: false,
+ scope.SampleCount,
+ shaderName: "mesh_detail",
+ depthCompare: RetailDetailTextureContract.DetailDepthCompare(
+ transparent: true));
_initialized = true;
}
+ private static bool DisableDetailTextures() => false;
+
///
/// One pipeline per blend state the shell pass uses. Everything else is
/// shared: mesh_modern, the 32-byte world-mesh vertex, triangle lists,
@@ -70,15 +98,17 @@ public sealed unsafe partial class EnvCellRenderer
string name,
GpuBlendMode blend,
bool depthWrite,
- int sampleCount) =>
+ int sampleCount,
+ string shaderName = "mesh_modern",
+ GpuCompareOp depthCompare = GpuCompareOp.Less) =>
device.CreatePipeline(new GpuPipelineDescription
{
Name = name,
- Shaders = new GpuShaderSet("mesh_modern"),
+ Shaders = new GpuShaderSet(shaderName),
VertexLayout = GpuVertexLayout.WorldMesh,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = blend,
- Depth = new GpuDepthState(Test: true, Write: depthWrite, GpuCompareOp.Less),
+ Depth = new GpuDepthState(Test: true, Write: depthWrite, depthCompare),
Cull = GpuCullMode.Back,
FrontFace = GpuFrontFace.Clockwise,
AlphaToCoverage = false,
@@ -196,6 +226,7 @@ public sealed unsafe partial class EnvCellRenderer
BindRingSection(
encoder, frame, GpuBindingModel.StorageInstanceLightSets,
_lightSetData.AsSpan(0, uniqueInstanceCount * lightStride));
+ BindEnvironmentDetailCategory(encoder, frame);
// The frame-global sections, bound after this renderer's own binds
// because those binds are what select the descriptor scope.
@@ -210,6 +241,9 @@ public sealed unsafe partial class EnvCellRenderer
MemoryMarshal.AsBytes(_commands.AsSpan(0, totalDraws)).CopyTo(commands.Data);
IGpuBuffer commandBuffer = commands.Buffer;
uint commandBase = commands.OffsetBytes;
+ bool detailEnabled = RetailDetailTextureContract.ShouldRender(
+ _buildingDetailEnabled(),
+ _environmentDetail);
for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++)
{
@@ -222,13 +256,16 @@ public sealed unsafe partial class EnvCellRenderer
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
bool isAdditive = groupIndex >= 4;
+ IGpuPipeline rangeBasePipeline = isAdditive
+ ? _additivePipeline!
+ : _alphaPipeline!;
if (renderPass == WbRenderPass.Transparent)
{
// Blend state is the pipeline's; switching variants mid-pass has
// to re-establish the mesh, which is vertex-array state.
BindPipelineWithMesh(
encoder,
- isAdditive ? _additivePipeline! : _alphaPipeline!,
+ rangeBasePipeline,
mesh);
}
@@ -241,12 +278,85 @@ public sealed unsafe partial class EnvCellRenderer
: (int)renderPass;
pushConstants.DrawIdOffset = drawRange.FirstCommand;
encoder.SetPushConstants(in pushConstants);
+
+ // Retail DrawMesh's two-pass fallback redraws each transparent
+ // RenderMeshSubset immediately, before the next delayed-alpha
+ // subset. Preserve that base/detail adjacency so another shell or
+ // particle cannot be composited between the two contributions.
+ if (renderPass == WbRenderPass.Transparent && detailEnabled)
+ {
+ int rangeEnd = drawRange.FirstCommand + drawRange.CommandCount;
+ for (int command = drawRange.FirstCommand; command < rangeEnd; command++)
+ {
+ BindPipelineWithMesh(encoder, rangeBasePipeline, mesh);
+ SetCullMode(encoder, cullMode);
+ pushConstants.RenderPass = isAdditive
+ ? (int)renderPass | 0x100
+ : (int)renderPass;
+ pushConstants.DrawIdOffset = command;
+ pushConstants.TextureIndexA = 0;
+ pushConstants.ParamA = 0f;
+ pushConstants.ParamB = 0f;
+ encoder.SetPushConstants(in pushConstants);
+ encoder.MultiDrawIndexedIndirect(
+ commandBuffer,
+ commandBase + (uint)(command * sizeof(DrawElementsIndirectCommand)),
+ 1,
+ (uint)sizeof(DrawElementsIndirectCommand));
+
+ BindPipelineWithMesh(encoder, _transparentDetailPipeline!, mesh);
+ SetCullMode(encoder, cullMode);
+ pushConstants.DrawIdOffset = command;
+ pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index;
+ pushConstants.ParamA = _environmentDetail.Tiling;
+ pushConstants.ParamB = 0f;
+ encoder.SetPushConstants(in pushConstants);
+ encoder.MultiDrawIndexedIndirect(
+ commandBuffer,
+ commandBase + (uint)(command * sizeof(DrawElementsIndirectCommand)),
+ 1,
+ (uint)sizeof(DrawElementsIndirectCommand));
+ }
+ continue;
+ }
+
encoder.MultiDrawIndexedIndirect(
commandBuffer,
commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)),
(uint)drawRange.CommandCount,
(uint)sizeof(DrawElementsIndirectCommand));
}
+
+ // Retail DrawEnvCell category (2). Replay the already-filtered opaque
+ // shell commands, including ClipMap built-mesh subsets, and apply the
+ // 10-50 m positive-view-depth fade. The existing
+ // "Building Detail Textures" option gates both this and buildings,
+ // matching LScape::ChangeRegion.
+ if (renderPass == WbRenderPass.Opaque
+ && detailEnabled)
+ {
+ BindPipelineWithMesh(encoder, _detailPipeline!, mesh);
+ pushConstants.RenderPass = 0;
+ pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index;
+ pushConstants.ParamA = _environmentDetail.Tiling;
+ pushConstants.ParamB = 0f;
+
+ for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++)
+ {
+ MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex];
+ var cullMode = (CullMode)(drawRange.GroupIndex % 4);
+ if (cullMode == CullMode.Landblock)
+ cullMode = CullMode.None;
+ SetCullMode(encoder, cullMode);
+ pushConstants.DrawIdOffset = drawRange.FirstCommand;
+ encoder.SetPushConstants(in pushConstants);
+ encoder.MultiDrawIndexedIndirect(
+ commandBuffer,
+ commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)),
+ (uint)drawRange.CommandCount,
+ (uint)sizeof(DrawElementsIndirectCommand));
+ }
+ }
}
private void BindPipelineWithMesh(
@@ -315,6 +425,25 @@ public sealed unsafe partial class EnvCellRenderer
(uint)byteCount);
}
+ ///
+ /// Binds one category word for mesh_detail.vert's statically used
+ /// binding 9. EnvCell draws select their renderer-wide category via
+ /// uParamB=0, so the value is semantically unused, but Vulkan still
+ /// requires the declared descriptor to be valid.
+ ///
+ internal static void BindEnvironmentDetailCategory(
+ IGpuPassEncoder encoder,
+ IGpuFrame frame)
+ {
+ Span category = stackalloc uint[1];
+ category[0] = 1u;
+ BindRingSection(
+ encoder,
+ frame,
+ GpuBindingModel.StorageInstanceDetailCategory,
+ category);
+ }
+
private void DisposeRhiResources()
{
_opaquePipeline?.Dispose();
@@ -323,5 +452,9 @@ public sealed unsafe partial class EnvCellRenderer
_alphaPipeline = null;
_additivePipeline?.Dispose();
_additivePipeline = null;
+ _detailPipeline?.Dispose();
+ _detailPipeline = null;
+ _transparentDetailPipeline?.Dispose();
+ _transparentDetailPipeline = null;
}
}
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
index 982206c9..426768f4 100644
--- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
+++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
@@ -1290,6 +1290,11 @@ public sealed partial class EnvCellRenderer :
// mesh manager at upload rather than interned here.
TextureTableIndex = item.batch.TextureSlot.Index,
TextureIndex = (uint)item.batch.TextureIndex,
+ // #226: this renderer submits built EnvCell meshes.
+ // Retail DrawMesh forwards curr_detail_surface to
+ // RenderMeshSubset for every material subset, including
+ // ClipMap, transparent, additive and inverse alpha.
+ Flags = 1u,
};
_commands[cmdIndex] = new DrawElementsIndirectCommand
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs
new file mode 100644
index 00000000..d86570fa
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs
@@ -0,0 +1,130 @@
+using AcDream.App.Rendering.Gpu;
+
+namespace AcDream.App.Rendering.Wb;
+
+public sealed partial class WbDrawDispatcher
+{
+ internal sealed class DirectionalShadowReceiverPipelineState : IDisposable
+ {
+ private readonly MeshPipelineSet _backbuffer;
+ private readonly MeshPipelineSet _offscreen;
+
+ internal DirectionalShadowReceiverPipelineState(
+ IDirectionalShadowReceiverSource source,
+ MeshPipelineSet backbuffer,
+ MeshPipelineSet offscreen)
+ {
+ Source = source;
+ _backbuffer = backbuffer;
+ _offscreen = offscreen;
+ }
+
+ internal IDirectionalShadowReceiverSource Source { get; }
+
+ internal MeshPipelineSet ForSampleCount(int sampleCount) =>
+ sampleCount > 1 ? _backbuffer : _offscreen;
+
+ public void Dispose()
+ {
+ DisposeMeshPipelineSet(_backbuffer);
+ if (!ReferenceEquals(_offscreen, _backbuffer))
+ DisposeMeshPipelineSet(_offscreen);
+ }
+ }
+
+ private DirectionalShadowReceiverPipelineState? _directionalShadowReceiver;
+
+ ///
+ /// Builds both sample-count variants without publishing either. A different
+ /// pack always gets pipelines compiled from that candidate's shader blobs;
+ /// no prior pack pipeline is reused by shader name or nullable caching.
+ ///
+ internal DirectionalShadowReceiverPipelineState? PrepareDirectionalShadowReceiver(
+ IDirectionalShadowReceiverSource? source,
+ int sampleCount)
+ {
+ if (source is null)
+ return null;
+ IGpuDevice device = _device
+ ?? throw new InvalidOperationException("Directional receivers require the modern RHI device.");
+ if (_scope is null || sampleCount != _scope.SampleCount)
+ throw new InvalidOperationException("Receiver and world-pass sample counts must match.");
+
+ MeshPipelineSet? backbuffer = null;
+ MeshPipelineSet? offscreen = null;
+ try
+ {
+ backbuffer = CreateMeshPipelineSet(
+ device,
+ sampleCount,
+ baseShaders: source.PipelineShaders.WorldReceiver,
+ namePrefix: "wb-mesh-atmospheric",
+ usesRenderPackShaderAbi: true);
+ offscreen = sampleCount == 1
+ ? backbuffer
+ : CreateMeshPipelineSet(
+ device,
+ 1,
+ baseShaders: source.PipelineShaders.WorldReceiver,
+ namePrefix: "wb-mesh-atmospheric",
+ usesRenderPackShaderAbi: true);
+ return new DirectionalShadowReceiverPipelineState(source, backbuffer, offscreen);
+ }
+ catch
+ {
+ DisposeMeshPipelineSet(backbuffer);
+ if (!ReferenceEquals(offscreen, backbuffer))
+ DisposeMeshPipelineSet(offscreen);
+ throw;
+ }
+ }
+
+ /// Assignment-only publication; returned state retires after the coupled swap.
+ internal DirectionalShadowReceiverPipelineState? SwapDirectionalShadowReceiver(
+ DirectionalShadowReceiverPipelineState? candidate)
+ {
+ DirectionalShadowReceiverPipelineState? prior = _directionalShadowReceiver;
+ _directionalShadowReceiver = candidate;
+ return prior;
+ }
+
+ private MeshPipelineSet PipelinesFor(
+ IGpuPassEncoder encoder,
+ IGpuFrame frame,
+ out DirectionalShadowFrameBinding shadowBinding)
+ {
+ DirectionalShadowReceiverPipelineState? receiver = _directionalShadowReceiver;
+ IDirectionalShadowReceiverSource? source = receiver?.Source;
+ shadowBinding = DirectionalShadowFrameBinding.Disabled;
+ bool bindingValid = source is not null
+ && source.TryGetCurrentFrameBinding(frame, out shadowBinding);
+ if (DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline(
+ encoder.Pass.Name,
+ source is not null,
+ bindingValid))
+ {
+ return receiver!.ForSampleCount(encoder.Pass.SampleCount);
+ }
+ return PipelinesFor(encoder);
+ }
+
+ private static void BindDirectionalShadowReceiver(
+ IGpuPassEncoder encoder,
+ in DirectionalShadowFrameBinding binding)
+ {
+ if (!binding.Enabled || binding.Buffer is null)
+ return;
+ encoder.BindUniformBuffer(
+ GpuBindingModel.UniformDirectionalShadow,
+ binding.Buffer,
+ binding.OffsetBytes,
+ binding.SizeBytes);
+ }
+
+ private void DisposeDirectionalShadowReceiverPipelines()
+ {
+ DirectionalShadowReceiverPipelineState? state =
+ SwapDirectionalShadowReceiver(null);
+ state?.Dispose();
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs
new file mode 100644
index 00000000..db939f12
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs
@@ -0,0 +1,1186 @@
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Scene;
+using AcDream.Core.Meshing;
+using AcDream.Core.World;
+using DatReaderWriter.Enums;
+
+namespace AcDream.App.Rendering.Wb;
+
+internal enum DirectionalShadowCasterMaterial : byte
+{
+ Opaque,
+ AlphaCutout,
+}
+
+internal readonly record struct DirectionalShadowPreparedBatch(
+ GpuTextureSlot TextureSlot,
+ uint TextureLayer,
+ CullMode CullMode,
+ DirectionalShadowCasterMaterial Material);
+
+internal readonly record struct DirectionalShadowPreparedRun(
+ int StartCommand,
+ int CommandCount,
+ CullMode CullMode,
+ DirectionalShadowCasterMaterial Material);
+
+internal readonly record struct DirectionalShadowPreparationStats(
+ int SourceCasters,
+ int SourceMeshRefs,
+ int SourceParts,
+ int SourceBatches,
+ int PreparedInstances,
+ int PreparedOpaqueCommands,
+ int PreparedAlphaCutoutCommands,
+ int RejectedTransparentBatches,
+ int RejectedFadedParts,
+ int MissingMeshes,
+ int UnresolvedAlphaCutoutTextures);
+
+internal readonly record struct DirectionalShadowMeshGeometry(
+ IGpuBuffer VertexBuffer,
+ IGpuBuffer IndexBuffer);
+
+internal readonly record struct DirectionalShadowTransformSource(
+ bool Refreshable,
+ int CasterIndex,
+ int MeshIndex,
+ bool IsSetupPart,
+ Matrix4x4 SetupPartTransform)
+{
+ public static DirectionalShadowTransformSource Dynamic(
+ int casterIndex,
+ int meshIndex,
+ bool isSetupPart,
+ in Matrix4x4 setupPartTransform) =>
+ new(
+ true,
+ casterIndex,
+ meshIndex,
+ isSetupPart,
+ setupPartTransform);
+}
+
+///
+/// One reusable CPU product prepared before the first directional cascade.
+/// Every cascade replays these exact command, metadata, and transform spans;
+/// it never reclassifies materials or asks animation for another pose.
+///
+internal sealed class DirectionalShadowPreparedDraws
+{
+ private DirectionalShadowSourceDraw[] _source = [];
+ private Matrix4x4[] _transforms = [];
+ private DirectionalShadowTransformSource[] _transformSources = [];
+ private int[] _dynamicTransformSlots = [];
+ private int[] _allDynamicTransformSlots = [];
+ private int[] _firstDynamicTransformByCaster = [];
+ private int[] _nextDynamicTransform = [];
+ private int[] _denseChangedPoseByCaster = [];
+ private RenderProjectionId[] _mappedCasterIds = [];
+ private RenderProjectionClass[] _mappedCasterClasses = [];
+ private bool[] _mappedCasterIdentityPresent = [];
+ private DrawElementsIndirectCommand[] _commands = [];
+ private DirectionalShadowPreparedBatch[] _batches = [];
+ private DirectionalShadowPreparedRun[] _runs = [];
+ private int _sourceCount;
+ private int _commandCount;
+ private int _runCount;
+ private int _dynamicTransformSlotCount;
+ private int _allDynamicTransformSlotCount;
+ private int _mappedCasterCount;
+ private bool _building;
+ private bool _retryClassificationNextFrame;
+
+ public RenderSceneGeneration SourceGeneration { get; private set; }
+
+ public ulong SourceCasterBuildSequence { get; private set; }
+
+ public long SourceRenderDataAvailabilityVersion { get; private set; }
+
+ public ulong SourceTranslucencyFadeRevision { get; private set; }
+
+ public ulong BuildSequence { get; private set; }
+
+ public int LastDynamicTransformRefreshCount { get; private set; }
+
+ public bool LastDynamicTransformRefreshWasDense { get; private set; }
+
+ public int OpaqueCommandCount { get; private set; }
+
+ public int AlphaCutoutCommandCount => _commandCount - OpaqueCommandCount;
+
+ public int OpaqueRunCount { get; private set; }
+
+ public ReadOnlySpan Transforms =>
+ _transforms.AsSpan(0, _sourceCount);
+
+ ///
+ /// Sorted transform indices whose root and/or animated part values are
+ /// refreshed from already-published slim pose snapshots this frame.
+ /// Static indices are omitted so a retained GPU transform product can leave
+ /// their exact bits untouched until topology changes.
+ ///
+ public ReadOnlySpan DynamicTransformSlots =>
+ _dynamicTransformSlots.AsSpan(0, _dynamicTransformSlotCount);
+
+ ///
+ /// Every refreshable transform in the retained topology. The per-flight
+ /// GPU product uses this exact list only when its bounded replay journal
+ /// cannot cover all changes since a flight slot was last submitted.
+ ///
+ public ReadOnlySpan AllDynamicTransformSlots =>
+ _allDynamicTransformSlots.AsSpan(0, _allDynamicTransformSlotCount);
+
+ public ReadOnlySpan Commands =>
+ _commands.AsSpan(0, _commandCount);
+
+ public ReadOnlySpan OpaqueCommands =>
+ _commands.AsSpan(0, OpaqueCommandCount);
+
+ public ReadOnlySpan AlphaCutoutCommands =>
+ _commands.AsSpan(OpaqueCommandCount, AlphaCutoutCommandCount);
+
+ public ReadOnlySpan Batches =>
+ _batches.AsSpan(0, _commandCount);
+
+ public ReadOnlySpan OpaqueBatches =>
+ _batches.AsSpan(0, OpaqueCommandCount);
+
+ public ReadOnlySpan AlphaCutoutBatches =>
+ _batches.AsSpan(OpaqueCommandCount, AlphaCutoutCommandCount);
+
+ public ReadOnlySpan Runs =>
+ _runs.AsSpan(0, _runCount);
+
+ public ReadOnlySpan OpaqueRuns =>
+ _runs.AsSpan(0, OpaqueRunCount);
+
+ public ReadOnlySpan AlphaCutoutRuns =>
+ _runs.AsSpan(OpaqueRunCount, _runCount - OpaqueRunCount);
+
+ public DirectionalShadowPreparationStats Stats { get; private set; }
+
+ public long RetainedScratchBytes => checked(
+ (long)_source.Length * Unsafe.SizeOf()
+ + (long)_transforms.Length * Unsafe.SizeOf()
+ + (long)_transformSources.Length
+ * Unsafe.SizeOf()
+ + (long)_dynamicTransformSlots.Length * sizeof(int)
+ + (long)_allDynamicTransformSlots.Length * sizeof(int)
+ + (long)_firstDynamicTransformByCaster.Length * sizeof(int)
+ + (long)_nextDynamicTransform.Length * sizeof(int)
+ + (long)_denseChangedPoseByCaster.Length * sizeof(int)
+ + (long)_mappedCasterIds.Length * Unsafe.SizeOf()
+ + (long)_mappedCasterClasses.Length
+ * Unsafe.SizeOf()
+ + _mappedCasterIdentityPresent.Length
+ + (long)_commands.Length * Unsafe.SizeOf()
+ + (long)_batches.Length * Unsafe.SizeOf()
+ + (long)_runs.Length * Unsafe.SizeOf());
+
+ ///
+ /// Returns false when this exact resident-caster build was already
+ /// prepared. A controller may therefore ask once per cascade without
+ /// accidentally performing per-cascade CPU classification.
+ ///
+ public bool RequiresTopologyBuild(
+ RenderSceneGeneration generation,
+ ulong casterBuildSequence,
+ long renderDataAvailabilityVersion = 0,
+ ulong translucencyFadeRevision = 0) =>
+ SourceGeneration != generation
+ || SourceCasterBuildSequence != casterBuildSequence
+ || SourceRenderDataAvailabilityVersion
+ != renderDataAvailabilityVersion
+ || SourceTranslucencyFadeRevision != translucencyFadeRevision
+ || _retryClassificationNextFrame;
+
+ public bool TryBegin(
+ RenderSceneGeneration generation,
+ ulong casterBuildSequence,
+ int estimatedInstances,
+ long renderDataAvailabilityVersion = 0,
+ ulong translucencyFadeRevision = 0)
+ {
+ if (_building)
+ throw new InvalidOperationException(
+ "A directional-shadow draw build is already active.");
+ if (casterBuildSequence == 0)
+ throw new ArgumentOutOfRangeException(nameof(casterBuildSequence));
+ ArgumentOutOfRangeException.ThrowIfNegative(estimatedInstances);
+ if (!RequiresTopologyBuild(
+ generation,
+ casterBuildSequence,
+ renderDataAvailabilityVersion,
+ translucencyFadeRevision))
+ {
+ return false;
+ }
+
+ EnsureCapacity(ref _source, estimatedInstances);
+ _sourceCount = 0;
+ _commandCount = 0;
+ _runCount = 0;
+ _dynamicTransformSlotCount = 0;
+ _allDynamicTransformSlotCount = 0;
+ if (_mappedCasterCount != 0)
+ {
+ Array.Clear(
+ _mappedCasterIdentityPresent,
+ 0,
+ _mappedCasterCount);
+ }
+ _mappedCasterCount = 0;
+ OpaqueCommandCount = 0;
+ OpaqueRunCount = 0;
+ Stats = default;
+ LastDynamicTransformRefreshCount = 0;
+ LastDynamicTransformRefreshWasDense = false;
+ _building = true;
+ return true;
+ }
+
+ public void MapCasterIdentity(
+ int casterIndex,
+ RenderProjectionId id,
+ RenderProjectionClass projectionClass)
+ {
+ if (!_building)
+ {
+ throw new InvalidOperationException(
+ "Begin a directional-shadow draw build before mapping casters.");
+ }
+ ArgumentOutOfRangeException.ThrowIfNegative(casterIndex);
+ int required = checked(casterIndex + 1);
+ EnsureCapacity(ref _mappedCasterIds, required);
+ EnsureCapacity(ref _mappedCasterClasses, required);
+ EnsureCapacity(ref _mappedCasterIdentityPresent, required);
+ if (_mappedCasterIdentityPresent[casterIndex]
+ && (_mappedCasterIds[casterIndex] != id
+ || _mappedCasterClasses[casterIndex] != projectionClass))
+ {
+ throw new InvalidOperationException(
+ $"Directional-shadow caster slot {casterIndex} was mapped twice "
+ + "with different projection identities.");
+ }
+ _mappedCasterIds[casterIndex] = id;
+ _mappedCasterClasses[casterIndex] = projectionClass;
+ _mappedCasterIdentityPresent[casterIndex] = true;
+ _mappedCasterCount = Math.Max(_mappedCasterCount, required);
+ }
+
+ public void Add(
+ uint firstIndex,
+ int baseVertex,
+ int indexCount,
+ GpuTextureSlot textureSlot,
+ uint textureLayer,
+ CullMode cullMode,
+ DirectionalShadowCasterMaterial material,
+ in Matrix4x4 transform)
+ {
+ DirectionalShadowTransformSource source = default;
+ Add(
+ firstIndex,
+ baseVertex,
+ indexCount,
+ textureSlot,
+ textureLayer,
+ cullMode,
+ material,
+ in transform,
+ in source);
+ }
+
+ public void Add(
+ uint firstIndex,
+ int baseVertex,
+ int indexCount,
+ GpuTextureSlot textureSlot,
+ uint textureLayer,
+ CullMode cullMode,
+ DirectionalShadowCasterMaterial material,
+ in Matrix4x4 transform,
+ in DirectionalShadowTransformSource transformSource)
+ {
+ if (!_building)
+ throw new InvalidOperationException(
+ "Begin a directional-shadow draw build before adding batches.");
+ if (indexCount <= 0)
+ throw new ArgumentOutOfRangeException(nameof(indexCount));
+ if (material is DirectionalShadowCasterMaterial.AlphaCutout
+ && !textureSlot.IsAssigned)
+ {
+ throw new ArgumentException(
+ "An alpha-cutout caster requires an assigned texture slot.",
+ nameof(textureSlot));
+ }
+
+ EnsureCapacity(ref _source, checked(_sourceCount + 1));
+ _source[_sourceCount++] = new DirectionalShadowSourceDraw(
+ new DirectionalShadowDrawKey(
+ firstIndex,
+ baseVertex,
+ indexCount,
+ material is DirectionalShadowCasterMaterial.AlphaCutout
+ ? textureSlot
+ : GpuTextureSlot.Unassigned,
+ material is DirectionalShadowCasterMaterial.AlphaCutout
+ ? textureLayer
+ : 0u,
+ cullMode,
+ material),
+ transform,
+ transformSource);
+ }
+
+ public void Complete(
+ RenderSceneGeneration generation,
+ ulong casterBuildSequence,
+ in DirectionalShadowPreparationStats stats,
+ long renderDataAvailabilityVersion = 0,
+ ulong translucencyFadeRevision = 0)
+ {
+ if (!_building)
+ throw new InvalidOperationException(
+ "No directional-shadow draw build is active.");
+ if (casterBuildSequence == 0)
+ throw new ArgumentOutOfRangeException(nameof(casterBuildSequence));
+
+ Array.Sort(
+ _source,
+ 0,
+ _sourceCount,
+ DirectionalShadowSourceDrawComparer.Instance);
+ EnsureCapacity(ref _transforms, _sourceCount);
+ EnsureCapacity(ref _transformSources, _sourceCount);
+ EnsureCapacity(ref _dynamicTransformSlots, _sourceCount);
+ EnsureCapacity(ref _allDynamicTransformSlots, _sourceCount);
+ EnsureCapacity(ref _nextDynamicTransform, _sourceCount);
+ EnsureCapacity(ref _commands, _sourceCount);
+ EnsureCapacity(ref _batches, _sourceCount);
+ EnsureCapacity(ref _runs, _sourceCount);
+
+ int maxCasterIndex = -1;
+ for (int index = 0; index < _sourceCount; index++)
+ {
+ DirectionalShadowTransformSource transformSource =
+ _source[index].TransformSource;
+ if (transformSource.Refreshable)
+ maxCasterIndex = Math.Max(maxCasterIndex, transformSource.CasterIndex);
+ }
+ _mappedCasterCount = Math.Max(_mappedCasterCount, maxCasterIndex + 1);
+ EnsureCapacity(ref _firstDynamicTransformByCaster, _mappedCasterCount);
+ EnsureCapacity(ref _denseChangedPoseByCaster, _mappedCasterCount);
+ EnsureCapacity(ref _mappedCasterIds, _mappedCasterCount);
+ EnsureCapacity(ref _mappedCasterClasses, _mappedCasterCount);
+ EnsureCapacity(ref _mappedCasterIdentityPresent, _mappedCasterCount);
+ if (_mappedCasterCount != 0)
+ {
+ Array.Fill(
+ _firstDynamicTransformByCaster,
+ -1,
+ 0,
+ _mappedCasterCount);
+ }
+
+ int sourceIndex = 0;
+ int transformIndex = 0;
+ int commandIndex = 0;
+ int opaqueCommands = 0;
+ while (sourceIndex < _sourceCount)
+ {
+ DirectionalShadowDrawKey key = _source[sourceIndex].Key;
+ int groupStart = sourceIndex;
+ do
+ {
+ _transforms[transformIndex++] = _source[sourceIndex].Transform;
+ _transformSources[transformIndex - 1] =
+ _source[sourceIndex].TransformSource;
+ if (_source[sourceIndex].TransformSource.Refreshable)
+ {
+ int dynamicTransformIndex = transformIndex - 1;
+ DirectionalShadowTransformSource transformSource =
+ _source[sourceIndex].TransformSource;
+ if (transformSource.CasterIndex < 0)
+ {
+ throw new InvalidOperationException(
+ "A refreshable directional-shadow transform has a negative caster index.");
+ }
+ _allDynamicTransformSlots[_allDynamicTransformSlotCount++] =
+ dynamicTransformIndex;
+ _nextDynamicTransform[dynamicTransformIndex] =
+ _firstDynamicTransformByCaster[transformSource.CasterIndex];
+ _firstDynamicTransformByCaster[transformSource.CasterIndex] =
+ dynamicTransformIndex;
+ }
+ sourceIndex++;
+ }
+ while (sourceIndex < _sourceCount && _source[sourceIndex].Key == key);
+
+ int instanceCount = sourceIndex - groupStart;
+ _commands[commandIndex] = new DrawElementsIndirectCommand
+ {
+ Count = checked((uint)key.IndexCount),
+ InstanceCount = checked((uint)instanceCount),
+ FirstIndex = key.FirstIndex,
+ BaseVertex = key.BaseVertex,
+ BaseInstance = checked((uint)(transformIndex - instanceCount)),
+ };
+ _batches[commandIndex] = new DirectionalShadowPreparedBatch(
+ key.TextureSlot,
+ key.TextureLayer,
+ key.CullMode,
+ key.Material);
+ if (key.Material is DirectionalShadowCasterMaterial.Opaque)
+ opaqueCommands++;
+ commandIndex++;
+ }
+
+ _commandCount = commandIndex;
+ OpaqueCommandCount = opaqueCommands;
+ int runCursor = 0;
+ int runStart = 0;
+ while (runStart < commandIndex)
+ {
+ DirectionalShadowPreparedBatch first = _batches[runStart];
+ int runEnd = runStart + 1;
+ while (runEnd < commandIndex
+ && _batches[runEnd].CullMode == first.CullMode
+ && _batches[runEnd].Material == first.Material)
+ {
+ runEnd++;
+ }
+ _runs[runCursor++] = new DirectionalShadowPreparedRun(
+ runStart,
+ runEnd - runStart,
+ first.CullMode,
+ first.Material);
+ runStart = runEnd;
+ }
+ _runCount = runCursor;
+ while (OpaqueRunCount < runCursor
+ && _runs[OpaqueRunCount].Material is DirectionalShadowCasterMaterial.Opaque)
+ {
+ OpaqueRunCount++;
+ }
+ SourceGeneration = generation;
+ SourceCasterBuildSequence = casterBuildSequence;
+ SourceRenderDataAvailabilityVersion = renderDataAvailabilityVersion;
+ SourceTranslucencyFadeRevision = translucencyFadeRevision;
+ BuildSequence = checked(BuildSequence + 1);
+ LastDynamicTransformRefreshCount = 0;
+ LastDynamicTransformRefreshWasDense = false;
+ _dynamicTransformSlotCount = 0;
+ _retryClassificationNextFrame =
+ stats.UnresolvedAlphaCutoutTextures != 0;
+ Stats = stats with
+ {
+ PreparedInstances = _sourceCount,
+ PreparedOpaqueCommands = opaqueCommands,
+ PreparedAlphaCutoutCommands = commandIndex - opaqueCommands,
+ };
+ _building = false;
+ }
+
+ public void RefreshDynamicTransforms(
+ DirectionalShadowCasterFrame casters)
+ {
+ ArgumentNullException.ThrowIfNull(casters);
+ if (casters.Stats.TransformJournalFullRefresh)
+ {
+ RefreshAllDynamicTransforms(casters.Casters);
+ LastDynamicTransformRefreshWasDense = false;
+ return;
+ }
+ RefreshDynamicTransforms(
+ casters.ChangedCasterPoses,
+ casters.Stats.DensityBulkRefresh);
+ }
+
+ internal void RefreshDynamicTransforms(
+ ReadOnlySpan currentCasters)
+ {
+ RefreshAllDynamicTransforms(currentCasters);
+ LastDynamicTransformRefreshWasDense = false;
+ }
+
+ internal void RefreshDenseDynamicTransforms(
+ ReadOnlySpan currentCasters)
+ {
+ RefreshAllDynamicTransforms(currentCasters);
+ LastDynamicTransformRefreshWasDense = true;
+ }
+
+ private void RefreshAllDynamicTransforms(
+ ReadOnlySpan currentCasters)
+ {
+ _dynamicTransformSlotCount = _allDynamicTransformSlotCount;
+ _allDynamicTransformSlots.AsSpan(0, _allDynamicTransformSlotCount)
+ .CopyTo(_dynamicTransformSlots);
+ int refreshed = 0;
+ for (int dynamicIndex = 0;
+ dynamicIndex < _dynamicTransformSlotCount;
+ dynamicIndex++)
+ {
+ int transformIndex = _dynamicTransformSlots[dynamicIndex];
+ DirectionalShadowTransformSource source =
+ _transformSources[transformIndex];
+ if ((uint)source.CasterIndex >= (uint)currentCasters.Length)
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow transform source has a stale caster index.");
+ }
+
+ RenderProjectionRecord projection =
+ currentCasters[source.CasterIndex].Projection;
+ IReadOnlyList meshes = projection.EntityPayload.MeshRefs;
+ if ((uint)source.MeshIndex >= (uint)meshes.Count)
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow transform source has a stale mesh index.");
+ }
+
+ MeshRef mesh = meshes[source.MeshIndex];
+ _transforms[transformIndex] = source.IsSetupPart
+ ? WbDrawDispatcher.ComposePartWorldMatrix(
+ projection.Transform.LocalToWorld,
+ mesh.PartTransform,
+ source.SetupPartTransform)
+ : mesh.PartTransform * projection.Transform.LocalToWorld;
+ refreshed++;
+ }
+
+ LastDynamicTransformRefreshCount = refreshed;
+ }
+
+ internal void RefreshDynamicTransforms(
+ ReadOnlySpan currentCasters,
+ ReadOnlySpan changedCasterSlots)
+ {
+ _dynamicTransformSlotCount = 0;
+ for (int changedIndex = 0;
+ changedIndex < changedCasterSlots.Length;
+ changedIndex++)
+ {
+ int casterIndex = changedCasterSlots[changedIndex];
+ if ((uint)casterIndex >= (uint)currentCasters.Length)
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow changed-caster slot is stale.");
+ }
+ if ((uint)casterIndex >= (uint)_mappedCasterCount)
+ continue;
+
+ for (int transformIndex = _firstDynamicTransformByCaster[casterIndex];
+ transformIndex >= 0;
+ transformIndex = _nextDynamicTransform[transformIndex])
+ {
+ RefreshTransform(currentCasters, transformIndex);
+ _dynamicTransformSlots[_dynamicTransformSlotCount++] = transformIndex;
+ }
+ }
+ _dynamicTransformSlots.AsSpan(0, _dynamicTransformSlotCount).Sort();
+ LastDynamicTransformRefreshCount = _dynamicTransformSlotCount;
+ LastDynamicTransformRefreshWasDense = false;
+ }
+
+ internal void RefreshDynamicTransforms(
+ ReadOnlySpan changedPoses,
+ bool denseRefresh = false)
+ {
+ if (denseRefresh)
+ {
+ RefreshDenseDynamicTransforms(changedPoses);
+ return;
+ }
+
+ _dynamicTransformSlotCount = 0;
+ for (int changedIndex = 0;
+ changedIndex < changedPoses.Length;
+ changedIndex++)
+ {
+ ref readonly DirectionalShadowChangedPose changed =
+ ref changedPoses[changedIndex];
+ int casterIndex = changed.CasterIndex;
+ if ((uint)casterIndex >= (uint)_mappedCasterCount
+ || !_mappedCasterIdentityPresent[casterIndex])
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow changed pose has a stale or unmapped caster index.");
+ }
+
+ ref readonly DirectionalShadowTransformSnapshot pose =
+ ref changed.Snapshot;
+ if (pose.Id != _mappedCasterIds[casterIndex]
+ || pose.ProjectionClass != _mappedCasterClasses[casterIndex])
+ {
+ throw new InvalidOperationException(
+ $"Directional-shadow changed pose {pose.Id} does not match "
+ + $"retained caster {_mappedCasterIds[casterIndex]} at "
+ + $"slot {casterIndex}.");
+ }
+
+ for (int transformIndex = _firstDynamicTransformByCaster[casterIndex];
+ transformIndex >= 0;
+ transformIndex = _nextDynamicTransform[transformIndex])
+ {
+ RefreshTransform(
+ in pose,
+ casterIndex,
+ transformIndex);
+ _dynamicTransformSlots[_dynamicTransformSlotCount++] =
+ transformIndex;
+ }
+ }
+ _dynamicTransformSlots.AsSpan(0, _dynamicTransformSlotCount).Sort();
+ LastDynamicTransformRefreshCount = _dynamicTransformSlotCount;
+ LastDynamicTransformRefreshWasDense = denseRefresh;
+ }
+
+ ///
+ /// Dense animation refreshes already upload the complete retained dynamic
+ /// ranges. Mark the changed caster slots, then walk the topology's strictly
+ /// increasing transform slots once. This produces the same exact sorted
+ /// changed-slot product without sorting roughly two thousand integers on
+ /// every foliage-animation frame.
+ ///
+ private void RefreshDenseDynamicTransforms(
+ ReadOnlySpan changedPoses)
+ {
+ _dynamicTransformSlotCount = 0;
+ int marked = 0;
+ try
+ {
+ for (int changedIndex = 0;
+ changedIndex < changedPoses.Length;
+ changedIndex++)
+ {
+ ref readonly DirectionalShadowChangedPose changed =
+ ref changedPoses[changedIndex];
+ int casterIndex = changed.CasterIndex;
+ ValidateChangedPose(in changed, casterIndex);
+ if (_denseChangedPoseByCaster[casterIndex] != 0)
+ {
+ throw new InvalidOperationException(
+ "A dense directional-shadow refresh contains a duplicate caster slot.");
+ }
+
+ _denseChangedPoseByCaster[casterIndex] = changedIndex + 1;
+ marked++;
+ }
+
+ for (int dynamicIndex = 0;
+ dynamicIndex < _allDynamicTransformSlotCount;
+ dynamicIndex++)
+ {
+ int transformIndex = _allDynamicTransformSlots[dynamicIndex];
+ DirectionalShadowTransformSource source =
+ _transformSources[transformIndex];
+ int poseIndex = _denseChangedPoseByCaster[source.CasterIndex] - 1;
+ if (poseIndex < 0)
+ continue;
+
+ ref readonly DirectionalShadowChangedPose changed =
+ ref changedPoses[poseIndex];
+ RefreshTransform(
+ in changed.Snapshot,
+ changed.CasterIndex,
+ transformIndex);
+ _dynamicTransformSlots[_dynamicTransformSlotCount++] =
+ transformIndex;
+ }
+ }
+ finally
+ {
+ if (marked != 0)
+ {
+ for (int changedIndex = 0;
+ changedIndex < changedPoses.Length;
+ changedIndex++)
+ {
+ int casterIndex = changedPoses[changedIndex].CasterIndex;
+ if ((uint)casterIndex < (uint)_denseChangedPoseByCaster.Length)
+ _denseChangedPoseByCaster[casterIndex] = 0;
+ }
+ }
+ }
+
+ LastDynamicTransformRefreshCount = _dynamicTransformSlotCount;
+ LastDynamicTransformRefreshWasDense = true;
+ }
+
+ private void ValidateChangedPose(
+ in DirectionalShadowChangedPose changed,
+ int casterIndex)
+ {
+ if ((uint)casterIndex >= (uint)_mappedCasterCount
+ || !_mappedCasterIdentityPresent[casterIndex])
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow changed pose has a stale or unmapped caster index.");
+ }
+
+ ref readonly DirectionalShadowTransformSnapshot pose =
+ ref changed.Snapshot;
+ if (pose.Id != _mappedCasterIds[casterIndex]
+ || pose.ProjectionClass != _mappedCasterClasses[casterIndex])
+ {
+ throw new InvalidOperationException(
+ $"Directional-shadow changed pose {pose.Id} does not match "
+ + $"retained caster {_mappedCasterIds[casterIndex]} at "
+ + $"slot {casterIndex}.");
+ }
+ }
+
+ private void RefreshTransform(
+ in DirectionalShadowTransformSnapshot pose,
+ int casterIndex,
+ int transformIndex)
+ {
+ DirectionalShadowTransformSource source =
+ _transformSources[transformIndex];
+ if (source.CasterIndex != casterIndex)
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow transform source maps to a different caster.");
+ }
+
+ IReadOnlyList currentMeshes = pose.EntityPayload.MeshRefs;
+ if ((uint)source.MeshIndex >= (uint)currentMeshes.Count)
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow changed pose has a stale mesh index.");
+ }
+
+ MeshRef currentMesh = currentMeshes[source.MeshIndex];
+ _transforms[transformIndex] = source.IsSetupPart
+ ? WbDrawDispatcher.ComposePartWorldMatrix(
+ pose.Transform.LocalToWorld,
+ currentMesh.PartTransform,
+ source.SetupPartTransform)
+ : currentMesh.PartTransform * pose.Transform.LocalToWorld;
+ }
+
+ private void RefreshTransform(
+ ReadOnlySpan currentCasters,
+ int transformIndex)
+ {
+ DirectionalShadowTransformSource source =
+ _transformSources[transformIndex];
+ if ((uint)source.CasterIndex >= (uint)currentCasters.Length)
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow transform source has a stale caster index.");
+ }
+
+ RenderProjectionRecord projection =
+ currentCasters[source.CasterIndex].Projection;
+ IReadOnlyList meshes = projection.EntityPayload.MeshRefs;
+ if ((uint)source.MeshIndex >= (uint)meshes.Count)
+ {
+ throw new InvalidOperationException(
+ "Directional-shadow transform source has a stale mesh index.");
+ }
+
+ MeshRef mesh = meshes[source.MeshIndex];
+ _transforms[transformIndex] = source.IsSetupPart
+ ? WbDrawDispatcher.ComposePartWorldMatrix(
+ projection.Transform.LocalToWorld,
+ mesh.PartTransform,
+ source.SetupPartTransform)
+ : mesh.PartTransform * projection.Transform.LocalToWorld;
+ }
+
+ public void Abort()
+ {
+ _sourceCount = 0;
+ _commandCount = 0;
+ _runCount = 0;
+ _dynamicTransformSlotCount = 0;
+ _allDynamicTransformSlotCount = 0;
+ if (_mappedCasterCount != 0)
+ {
+ Array.Clear(
+ _mappedCasterIdentityPresent,
+ 0,
+ _mappedCasterCount);
+ }
+ _mappedCasterCount = 0;
+ OpaqueCommandCount = 0;
+ OpaqueRunCount = 0;
+ Stats = default;
+ SourceGeneration = default;
+ SourceCasterBuildSequence = 0;
+ SourceRenderDataAvailabilityVersion = 0;
+ SourceTranslucencyFadeRevision = 0;
+ LastDynamicTransformRefreshCount = 0;
+ LastDynamicTransformRefreshWasDense = false;
+ _retryClassificationNextFrame = false;
+ _building = false;
+ }
+
+ internal static bool TryClassifyMaterial(
+ TranslucencyKind translucency,
+ out DirectionalShadowCasterMaterial material)
+ {
+ switch (translucency)
+ {
+ case TranslucencyKind.Opaque:
+ material = DirectionalShadowCasterMaterial.Opaque;
+ return true;
+ case TranslucencyKind.ClipMap:
+ material = DirectionalShadowCasterMaterial.AlphaCutout;
+ return true;
+ default:
+ material = default;
+ return false;
+ }
+ }
+
+ internal static bool FadeExcludesCaster(float translucency) =>
+ !float.IsFinite(translucency) || translucency > 0f;
+
+ private static void EnsureCapacity(ref T[] values, int required)
+ {
+ if (values.Length >= required)
+ return;
+ int capacity = values.Length == 0 ? 16 : values.Length;
+ while (capacity < required)
+ capacity = checked(capacity * 2);
+ Array.Resize(ref values, capacity);
+ }
+
+ private readonly record struct DirectionalShadowDrawKey(
+ uint FirstIndex,
+ int BaseVertex,
+ int IndexCount,
+ GpuTextureSlot TextureSlot,
+ uint TextureLayer,
+ CullMode CullMode,
+ DirectionalShadowCasterMaterial Material);
+
+ private readonly record struct DirectionalShadowSourceDraw(
+ DirectionalShadowDrawKey Key,
+ Matrix4x4 Transform,
+ DirectionalShadowTransformSource TransformSource);
+
+ private sealed class DirectionalShadowSourceDrawComparer
+ : IComparer
+ {
+ public static DirectionalShadowSourceDrawComparer Instance { get; } = new();
+
+ public int Compare(
+ DirectionalShadowSourceDraw left,
+ DirectionalShadowSourceDraw right)
+ {
+ DirectionalShadowDrawKey x = left.Key;
+ DirectionalShadowDrawKey y = right.Key;
+ int order = x.Material.CompareTo(y.Material);
+ if (order != 0) return order;
+ order = x.CullMode.CompareTo(y.CullMode);
+ if (order != 0) return order;
+ order = x.FirstIndex.CompareTo(y.FirstIndex);
+ if (order != 0) return order;
+ order = x.BaseVertex.CompareTo(y.BaseVertex);
+ if (order != 0) return order;
+ order = x.IndexCount.CompareTo(y.IndexCount);
+ if (order != 0) return order;
+ order = x.TextureSlot.Index.CompareTo(y.TextureSlot.Index);
+ return order != 0
+ ? order
+ : x.TextureLayer.CompareTo(y.TextureLayer);
+ }
+ }
+}
+
+public sealed partial class WbDrawDispatcher
+{
+ private readonly DirectionalShadowPreparedDraws _directionalShadowDraws = new();
+
+ internal DirectionalShadowMeshGeometry GetDirectionalShadowGeometry()
+ {
+ GlobalMeshBuffer mesh = _meshAdapter.MeshManager?.GlobalBuffer
+ ?? throw new InvalidOperationException("The shared mesh arena is not published.");
+ return new DirectionalShadowMeshGeometry(
+ mesh.VertexStore ?? throw new InvalidOperationException(
+ "The shared mesh arena has no vertex store."),
+ mesh.IndexStore ?? throw new InvalidOperationException(
+ "The shared mesh arena has no index store."));
+ }
+
+ ///
+ /// Classifies the retained resident caster product exactly once. The
+ /// returned owner is renderer-retained and remains valid until the next
+ /// distinct caster build is prepared.
+ ///
+ internal DirectionalShadowPreparedDraws PrepareDirectionalShadowDraws(
+ DirectionalShadowCasterFrame casters)
+ {
+ ArgumentNullException.ThrowIfNull(casters);
+ ReadOnlySpan source = casters.Casters;
+ long renderDataAvailabilityVersion =
+ _meshAdapter.MeshManager?.RenderDataAvailabilityVersion ?? 0L;
+ ulong translucencyFadeRevision = _translucencyFades.Revision;
+ if (!_directionalShadowDraws.RequiresTopologyBuild(
+ casters.Generation,
+ casters.BuildSequence,
+ renderDataAvailabilityVersion,
+ translucencyFadeRevision))
+ {
+ _directionalShadowDraws.RefreshDynamicTransforms(casters);
+ return _directionalShadowDraws;
+ }
+
+ int estimatedInstances = 0;
+ for (int i = 0; i < source.Length; i++)
+ {
+ estimatedInstances = checked(
+ estimatedInstances
+ + source[i].Projection.EntityPayload.MeshRefs.Count);
+ }
+ if (!_directionalShadowDraws.TryBegin(
+ casters.Generation,
+ casters.BuildSequence,
+ estimatedInstances,
+ renderDataAvailabilityVersion,
+ translucencyFadeRevision))
+ {
+ return _directionalShadowDraws;
+ }
+
+ int meshRefs = 0;
+ int parts = 0;
+ int batches = 0;
+ int rejectedTransparent = 0;
+ int rejectedFaded = 0;
+ int missingMeshes = 0;
+ int unresolvedCutoutTextures = 0;
+ try
+ {
+ for (int casterIndex = 0; casterIndex < source.Length; casterIndex++)
+ {
+ RenderProjectionRecord projection = source[casterIndex].Projection;
+ _directionalShadowDraws.MapCasterIdentity(
+ casterIndex,
+ projection.Id,
+ projection.ProjectionClass);
+ IReadOnlyList projectionMeshes =
+ projection.EntityPayload.MeshRefs;
+ var frameCandidate = new RenderFrameEntityCandidate(
+ projection,
+ MeshPartOffset: 0,
+ MeshPartCount: projectionMeshes.Count,
+ Animated: source[casterIndex].UsesCurrentAnimatedTransforms);
+ RenderInstanceCandidate candidate =
+ RenderInstanceCandidate.FromFrame(
+ in frameCandidate,
+ projection.Residency.OwnerLandblockId);
+ PaletteCompositeIdentity paletteIdentity =
+ projection.EntityPayload.PaletteOverride is null
+ ? default
+ : TextureCache.GetPaletteIdentity(
+ projection.EntityPayload.PaletteOverride);
+
+ for (int meshIndex = 0;
+ meshIndex < projectionMeshes.Count;
+ meshIndex++)
+ {
+ meshRefs++;
+ MeshRef meshRef = projectionMeshes[meshIndex];
+ ObjectRenderData? renderData =
+ _meshAdapter.TryGetRenderData(meshRef.GfxObjId);
+ if (renderData is null)
+ {
+ missingMeshes++;
+ _meshAdapter.EnsureLoaded(meshRef.GfxObjId);
+ continue;
+ }
+
+ if (renderData.IsSetup && renderData.SetupParts.Count > 0)
+ {
+ for (int setupPartIndex = 0;
+ setupPartIndex < renderData.SetupParts.Count;
+ setupPartIndex++)
+ {
+ parts++;
+ if (PartFadeExcludesCaster(
+ projection.Source.LocalEntityId,
+ setupPartIndex))
+ {
+ rejectedFaded++;
+ continue;
+ }
+
+ (ulong partGfxObjId, Matrix4x4 partTransform) =
+ renderData.SetupParts[setupPartIndex];
+ ObjectRenderData? partData =
+ _meshAdapter.TryGetRenderData(partGfxObjId);
+ if (partData is null)
+ {
+ missingMeshes++;
+ _meshAdapter.EnsureLoaded(partGfxObjId);
+ continue;
+ }
+
+ Matrix4x4 model = ComposePartWorldMatrix(
+ projection.Transform.LocalToWorld,
+ meshRef.PartTransform,
+ partTransform);
+ DirectionalShadowTransformSource transformSource =
+ source[casterIndex].UsesCurrentAnimatedTransforms
+ ? DirectionalShadowTransformSource.Dynamic(
+ casterIndex,
+ meshIndex,
+ true,
+ in partTransform)
+ : default;
+ AddDirectionalShadowBatches(
+ partData,
+ in candidate,
+ meshRef,
+ paletteIdentity,
+ in model,
+ in transformSource,
+ ref batches,
+ ref rejectedTransparent,
+ ref unresolvedCutoutTextures);
+ }
+ }
+ else
+ {
+ parts++;
+ if (PartFadeExcludesCaster(
+ projection.Source.LocalEntityId,
+ meshIndex))
+ {
+ rejectedFaded++;
+ continue;
+ }
+
+ Matrix4x4 model = meshRef.PartTransform
+ * projection.Transform.LocalToWorld;
+ Matrix4x4 noSetupPart = default;
+ DirectionalShadowTransformSource transformSource =
+ source[casterIndex].UsesCurrentAnimatedTransforms
+ ? DirectionalShadowTransformSource.Dynamic(
+ casterIndex,
+ meshIndex,
+ false,
+ in noSetupPart)
+ : default;
+ AddDirectionalShadowBatches(
+ renderData,
+ in candidate,
+ meshRef,
+ paletteIdentity,
+ in model,
+ in transformSource,
+ ref batches,
+ ref rejectedTransparent,
+ ref unresolvedCutoutTextures);
+ }
+ }
+ }
+
+ var stats = new DirectionalShadowPreparationStats(
+ SourceCasters: source.Length,
+ SourceMeshRefs: meshRefs,
+ SourceParts: parts,
+ SourceBatches: batches,
+ PreparedInstances: 0,
+ PreparedOpaqueCommands: 0,
+ PreparedAlphaCutoutCommands: 0,
+ RejectedTransparentBatches: rejectedTransparent,
+ RejectedFadedParts: rejectedFaded,
+ MissingMeshes: missingMeshes,
+ UnresolvedAlphaCutoutTextures: unresolvedCutoutTextures);
+ _directionalShadowDraws.Complete(
+ casters.Generation,
+ casters.BuildSequence,
+ in stats,
+ renderDataAvailabilityVersion,
+ translucencyFadeRevision);
+ return _directionalShadowDraws;
+ }
+ catch
+ {
+ _directionalShadowDraws.Abort();
+ throw;
+ }
+ }
+
+ private bool PartFadeExcludesCaster(uint entityId, int partIndex) =>
+ _translucencyFades.TryGetCurrentValue(
+ entityId,
+ checked((uint)partIndex),
+ out float translucency)
+ && DirectionalShadowPreparedDraws.FadeExcludesCaster(translucency);
+
+ private void AddDirectionalShadowBatches(
+ ObjectRenderData renderData,
+ in RenderInstanceCandidate candidate,
+ MeshRef meshRef,
+ PaletteCompositeIdentity paletteIdentity,
+ in Matrix4x4 model,
+ in DirectionalShadowTransformSource transformSource,
+ ref int sourceBatches,
+ ref int rejectedTransparent,
+ ref int unresolvedCutoutTextures)
+ {
+ for (int batchIndex = 0;
+ batchIndex < renderData.Batches.Count;
+ batchIndex++)
+ {
+ ObjectRenderBatch batch = renderData.Batches[batchIndex];
+ sourceBatches++;
+ if (!DirectionalShadowPreparedDraws.TryClassifyMaterial(
+ batch.Translucency,
+ out DirectionalShadowCasterMaterial material))
+ {
+ rejectedTransparent++;
+ continue;
+ }
+
+ GpuTextureSlot textureSlot = GpuTextureSlot.Unassigned;
+ uint textureLayer = 0;
+ if (material is DirectionalShadowCasterMaterial.AlphaCutout)
+ {
+ // ObjectRenderBatch.SurfaceId is a legacy property that the
+ // modern mesh uploader does not populate. Key.SurfaceId is
+ // the same authoritative id ResolveTexture consumes.
+ if (batch.Key.SurfaceId is 0 or uint.MaxValue)
+ {
+ unresolvedCutoutTextures++;
+ continue;
+ }
+ ResolvedTexture texture = ResolveTexture(
+ in candidate,
+ meshRef,
+ batch,
+ paletteIdentity,
+ out bool compositePending);
+ if (compositePending || !texture.Slot.IsAssigned)
+ {
+ unresolvedCutoutTextures++;
+ continue;
+ }
+ textureSlot = texture.Slot;
+ textureLayer = texture.Layer;
+ }
+
+ _directionalShadowDraws.Add(
+ batch.FirstIndex,
+ checked((int)batch.BaseVertex),
+ batch.IndexCount,
+ textureSlot,
+ textureLayer,
+ batch.CullMode,
+ material,
+ in model,
+ in transformSource);
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs
index 6b4a4868..78a6b0e6 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs
@@ -636,6 +636,7 @@ public sealed unsafe partial class WbDrawDispatcher
slot,
lights,
indoor,
+ entity.IsBuildingShell,
opacity,
selectionLighting);
}
@@ -661,6 +662,7 @@ public sealed unsafe partial class WbDrawDispatcher
slot,
lights,
indoor,
+ entity.IsBuildingShell,
opacity: 1f,
selectionLighting);
}
@@ -684,19 +686,51 @@ public sealed unsafe partial class WbDrawDispatcher
uint slot,
InstanceLightSet lights,
bool indoor,
+ bool buildingDetail,
float opacity,
Vector2 selectionLighting)
{
InstanceGroup group =
GetOrCreatePackedGroup(classified.Key);
+ AppendPackedInstance(
+ group,
+ model,
+ classified.LocalSortCenter,
+ _nextPackedInstanceSubmissionOrder++,
+ slot,
+ lights,
+ indoor,
+ buildingDetail,
+ opacity,
+ selectionLighting);
+ }
+
+ ///
+ /// Appends one packed-route instance and every per-instance attribute in
+ /// lockstep. Keeping the writer in one testable seam prevents a newly
+ /// introduced storage binding from covering the legacy classifier while
+ /// leaving the production packed classifier with a shorter parallel list.
+ ///
+ internal static void AppendPackedInstance(
+ InstanceGroup group,
+ Matrix4x4 model,
+ Vector3 localSortCenter,
+ int submissionOrder,
+ uint slot,
+ InstanceLightSet lights,
+ bool indoor,
+ bool buildingDetail,
+ float opacity,
+ Vector2 selectionLighting)
+ {
+ ArgumentNullException.ThrowIfNull(group);
group.Matrices.Add(model);
- group.LocalSortCenters.Add(
- classified.LocalSortCenter);
- group.SubmissionOrders.Add(
- _nextPackedInstanceSubmissionOrder++);
+ group.LocalSortCenters.Add(localSortCenter);
+ group.SubmissionOrders.Add(submissionOrder);
group.Slots.Add(slot);
group.LightSets.Add(lights);
group.IndoorFlags.Add(indoor ? 1u : 0u);
+ group.DetailCategories.Add(buildingDetail ? 1u : 0u);
group.Opacities.Add(opacity);
group.SelectionLighting.Add(selectionLighting);
}
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
index ef2e3fa7..25d3dc17 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs
@@ -18,9 +18,9 @@ namespace AcDream.App.Rendering.Wb;
/// AMD's GL stack did not. Every GL statement in the sibling file is untouched;
/// everything here runs only when there is no GL context.
///
-/// Three differences from V4c, each because the tree moved under it. There
-/// is no binding-9 texture table — V4t put the slot on the device and Vulkan
-/// binds set 2. The pass is BORROWED from rather
+/// Three differences from V4c, each because the tree moved under it. The
+/// texture table moved to set 2; #226 now uses storage binding 9 for the detail
+/// category. The pass is BORROWED from rather
/// than opened, because the frame's one backbuffer pass resolves. And the
/// pipelines carry the device's sample count, because Vulkan requires a
/// pipeline's rasterizationSamples to match the pass and
@@ -33,7 +33,7 @@ public sealed unsafe partial class WbDrawDispatcher
private readonly IWorldPassScope? _scope;
///
- /// The five mesh pipelines at ONE sample count.
+ /// The seven mesh pipelines at ONE sample count.
///
/// Campaign V slice V6l: there are two of these. Vulkan requires a
/// pipeline's rasterizationSamples to equal the pass it draws in, and
@@ -46,19 +46,25 @@ public sealed unsafe partial class WbDrawDispatcher
/// the live pass actually is. Both are built at startup against the persisted
/// cache, so no frame ever compiles one.
///
- private sealed record MeshPipelineSet(
+ internal sealed record MeshPipelineSet(
int SampleCount,
IGpuPipeline Opaque,
IGpuPipeline OpaqueAlphaToCoverage,
IGpuPipeline AlphaBlend,
IGpuPipeline AlphaAdditive,
- IGpuPipeline AlphaInverse);
+ IGpuPipeline AlphaInverse,
+ IGpuPipeline RetailDetail,
+ IGpuPipeline RetailDetailTransparent);
private MeshPipelineSet? _backbufferPipelines;
private MeshPipelineSet? _offscreenPipelines;
private const string OpaqueTimerScope = "wb-entities-opaque";
private const string TransparentTimerScope = "wb-entities-transparent";
+ private const string DetailTimerScope = "wb-buildings-detail";
+
+ private readonly TerrainAtlas.RetailDetailTextureBinding _buildingDetail;
+ private readonly Func _buildingDetailEnabled;
///
/// A ring slice reduced to the three values a later bind needs. The prepared
@@ -70,6 +76,11 @@ public sealed unsafe partial class WbDrawDispatcher
uint OffsetBytes,
uint SizeBytes);
+ private readonly WorldTransformFrameArena _worldTransformFrames = new();
+ private long _ordinaryTransformDemandFrameSerial = -1;
+ private uint _ordinaryTransformDemandThisFrame;
+ private uint _ordinaryTransformDemandHighWater;
+
private RhiSection _alphaInstances;
private RhiSection _alphaBatches;
private RhiSection _alphaClipSlots;
@@ -78,12 +89,78 @@ public sealed unsafe partial class WbDrawDispatcher
private RhiSection _alphaIndoor;
private RhiSection _alphaOpacity;
private RhiSection _alphaSelectionLighting;
+ private RhiSection _alphaDetailCategory;
private RhiSection _alphaCommands;
+ private int _preparedAlphaInstanceCount;
+ private uint _alphaTransformBaseInstance;
+
+ ///
+ /// Starts the one authoritative transform address space for an enhanced
+ /// world frame. The compatibility overload writes the shadow prefix into a
+ /// frame ring; the retained overload activates the current flight slot's
+ /// already-published prefix. Ordinary N.5 submissions append later and use
+ /// absolute BaseInstance values into the same bound buffer/range.
+ ///
+ internal WorldTransformFrameSlice BeginDirectionalShadowTransformFrame(
+ IGpuFrame frame,
+ ReadOnlySpan transforms)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ return _worldTransformFrames.Begin(
+ frame,
+ transforms,
+ ResolveDirectionalShadowTransformBindingSize(transforms.Length));
+ }
+
+ ///
+ /// Resolves the one authoritative pose-buffer range before any shadow draw
+ /// records it. The retained owner and ordinary world appenders therefore
+ /// use the same demand-sized address space for the complete frame.
+ ///
+ internal uint ResolveDirectionalShadowTransformBindingSize(
+ int requiredPrefixInstances,
+ int ordinaryInstanceUpperBound = 0)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(requiredPrefixInstances);
+ ArgumentOutOfRangeException.ThrowIfNegative(ordinaryInstanceUpperBound);
+ uint maximum = _device?.Capabilities.MaxStorageBufferRangeBytes
+ ?? WorldTransformCapacityPolicy.VulkanGuaranteedMaxStorageBufferRangeBytes;
+ uint currentFrameDemand = checked(
+ (uint)requiredPrefixInstances + (uint)ordinaryInstanceUpperBound);
+ uint requiredCombinedInstances = checked(
+ (uint)requiredPrefixInstances + _ordinaryTransformDemandHighWater);
+ return WorldTransformCapacityPolicy.ResolveBindingSizeBytes(
+ Math.Max(currentFrameDemand, requiredCombinedInstances),
+ maximum);
+ }
+
+ internal WorldTransformFrameSlice BeginDirectionalShadowTransformFrame(
+ IGpuFrame frame,
+ in WorldTransformFrameSlice retainedShadowPrefix)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ return _worldTransformFrames.BeginRetained(
+ frame,
+ in retainedShadowPrefix);
+ }
+
+ internal void CancelDirectionalShadowTransformFrame(IGpuFrame frame)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ _worldTransformFrames.Cancel(frame);
+ }
+
+ internal bool HasDirectionalShadowTransformFrame(long frameSerial) =>
+ _worldTransformFrames.IsActiveFor(frameSerial);
+
+ internal uint DirectionalShadowTransformFrameUsedInstances =>
+ _worldTransformFrames.UsedInstances;
///
/// The RHI arm's constructor. No GL context, no Shader, no
- /// BindlessSupport: the five pipelines compile mesh_modern from
- /// the committed SPIR-V, and batch data already carries the device's own
+ /// BindlessSupport: five base pipelines compile mesh_modern and
+ /// the detail overlay compiles mesh_detail from committed SPIR-V;
+ /// batch data already carries the device's own
/// GpuTextureSlot (V4t) rather than a bindless handle.
///
internal WbDrawDispatcher(
@@ -97,7 +174,9 @@ public sealed unsafe partial class WbDrawDispatcher
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades,
IRetailSelectionRenderSink? selectionSink = null,
RetailAlphaQueue? alphaQueue = null,
- long? alphaScratchBudgetBytes = null)
+ long? alphaScratchBudgetBytes = null,
+ TerrainAtlas.RetailDetailTextureBinding buildingDetail = default,
+ Func? buildingDetailEnabled = null)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
@@ -114,6 +193,8 @@ public sealed unsafe partial class WbDrawDispatcher
_selectionLighting = selectionSink as IRetailSelectionLightingSource;
_alphaQueue = alphaQueue;
_alphaSource = new AlphaDrawSource(this);
+ _buildingDetail = buildingDetail;
+ _buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures;
long scratchBudget = alphaScratchBudgetBytes
?? AlphaScratchBudgetProfile.Create(
ResidencyBudgetOptions.Default.AlphaScratchBytes)
@@ -137,21 +218,82 @@ public sealed unsafe partial class WbDrawDispatcher
}
}
- private static MeshPipelineSet CreateMeshPipelineSet(IGpuDevice device, int samples)
+ private static bool DisableDetailTextures() => false;
+
+ private static MeshPipelineSet CreateMeshPipelineSet(
+ IGpuDevice device,
+ int samples,
+ string baseShaderName = "mesh_modern",
+ GpuShaderSet? baseShaders = null,
+ string namePrefix = "wb-mesh",
+ bool usesRenderPackShaderAbi = false)
{
string suffix = samples > 1 ? string.Empty : "-1x";
- return new MeshPipelineSet(
- samples,
- CreateMeshPipeline(
- device, $"wb-mesh-opaque{suffix}", GpuBlendMode.None, true, false, samples),
- CreateMeshPipeline(
- device, $"wb-mesh-opaque-a2c{suffix}", GpuBlendMode.None, true, true, samples),
- CreateMeshPipeline(
- device, $"wb-mesh-alpha{suffix}", GpuBlendMode.StraightAlpha, false, false, samples),
- CreateMeshPipeline(
- device, $"wb-mesh-additive{suffix}", GpuBlendMode.Additive, false, false, samples),
- CreateMeshPipeline(
- device, $"wb-mesh-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples));
+ var created = new List(7);
+ try
+ {
+ return new MeshPipelineSet(
+ samples,
+ Track(CreateMeshPipeline(
+ device, $"{namePrefix}-opaque{suffix}", GpuBlendMode.None, true, false, samples,
+ shaders: baseShaders,
+ shaderName: baseShaderName,
+ usesRenderPackShaderAbi: usesRenderPackShaderAbi)),
+ Track(CreateMeshPipeline(
+ device, $"{namePrefix}-opaque-a2c{suffix}", GpuBlendMode.None, true, true, samples,
+ shaders: baseShaders,
+ shaderName: baseShaderName,
+ usesRenderPackShaderAbi: usesRenderPackShaderAbi)),
+ Track(CreateMeshPipeline(
+ device, $"{namePrefix}-alpha{suffix}", GpuBlendMode.StraightAlpha, false, false, samples,
+ shaders: baseShaders,
+ shaderName: baseShaderName,
+ usesRenderPackShaderAbi: usesRenderPackShaderAbi)),
+ Track(CreateMeshPipeline(
+ device, $"{namePrefix}-additive{suffix}", GpuBlendMode.Additive, false, false, samples,
+ shaders: baseShaders,
+ shaderName: baseShaderName,
+ usesRenderPackShaderAbi: usesRenderPackShaderAbi)),
+ Track(CreateMeshPipeline(
+ device, $"{namePrefix}-inverse{suffix}", GpuBlendMode.InverseAlpha, false, false, samples,
+ shaders: baseShaders,
+ shaderName: baseShaderName,
+ usesRenderPackShaderAbi: usesRenderPackShaderAbi)),
+ Track(CreateMeshPipeline(
+ device,
+ $"wb-mesh-retail-detail{suffix}",
+ GpuBlendMode.RetailDetail,
+ true,
+ false,
+ samples,
+ shaderName: "mesh_detail",
+ depthCompare: RetailDetailTextureContract.DetailDepthCompare(
+ transparent: false),
+ usesRenderPackShaderAbi: usesRenderPackShaderAbi)),
+ Track(CreateMeshPipeline(
+ device,
+ $"wb-mesh-retail-detail-alpha{suffix}",
+ GpuBlendMode.RetailDetail,
+ false,
+ false,
+ samples,
+ shaderName: "mesh_detail",
+ depthCompare: RetailDetailTextureContract.DetailDepthCompare(
+ transparent: true),
+ usesRenderPackShaderAbi: usesRenderPackShaderAbi)));
+ }
+ catch
+ {
+ for (int i = created.Count - 1; i >= 0; i--)
+ created[i].Dispose();
+ throw;
+ }
+
+ IGpuPipeline Track(IGpuPipeline pipeline)
+ {
+ created.Add(pipeline);
+ return pipeline;
+ }
}
///
@@ -183,19 +325,24 @@ public sealed unsafe partial class WbDrawDispatcher
GpuBlendMode blend,
bool depthWrite,
bool alphaToCoverage,
- int sampleCount) =>
+ int sampleCount,
+ string shaderName = "mesh_modern",
+ GpuShaderSet? shaders = null,
+ GpuCompareOp depthCompare = GpuCompareOp.Less,
+ bool usesRenderPackShaderAbi = false) =>
device.CreatePipeline(new GpuPipelineDescription
{
Name = name,
- Shaders = new GpuShaderSet("mesh_modern"),
+ Shaders = shaders ?? new GpuShaderSet(shaderName),
VertexLayout = GpuVertexLayout.WorldMesh,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = blend,
- Depth = new GpuDepthState(Test: true, Write: depthWrite, GpuCompareOp.Less),
+ Depth = new GpuDepthState(Test: true, Write: depthWrite, depthCompare),
Cull = GpuCullMode.Back,
FrontFace = GpuFrontFace.Clockwise,
AlphaToCoverage = alphaToCoverage,
ColorWrite = true,
+ UsesRenderPackShaderAbi = usesRenderPackShaderAbi,
SampleCount = sampleCount,
});
@@ -230,19 +377,33 @@ public sealed unsafe partial class WbDrawDispatcher
ParamB = 0f,
};
+ RhiSection instanceTransforms = WriteWorldTransformSection(
+ frame,
+ _instanceData.AsSpan(0, immediateInstances * 16),
+ out uint transformBaseInstance);
+ // Pack receiver/detail shaders subtract this shared-arena prefix for
+ // every parallel per-instance array while retaining the absolute pose
+ // lookup. The acdream default path always receives zero here.
+ pushConstants.TextureIndexB = transformBaseInstance;
+
// Bind the opaque variant first so the ring binds land on a live program;
// the transparent bracket rebinds its own variant, and push constants
// survive that switch per the encoder contract.
- MeshPipelineSet pipelines = PipelinesFor(encoder);
+ MeshPipelineSet pipelines = PipelinesFor(
+ encoder,
+ frame,
+ out DirectionalShadowFrameBinding shadowBinding);
BindPipelineWithMesh(
encoder,
AlphaToCoverage ? pipelines.OpaqueAlphaToCoverage : pipelines.Opaque,
mesh);
encoder.SetPushConstants(in pushConstants);
+ BindDirectionalShadowReceiver(encoder, in shadowBinding);
- BindRingSection(
- encoder, frame, GpuBindingModel.StorageInstances,
- _instanceData.AsSpan(0, immediateInstances * 16));
+ BindSection(
+ encoder,
+ GpuBindingModel.StorageInstances,
+ instanceTransforms);
BindRingSection(
encoder, frame, GpuBindingModel.StorageBatches,
_batchData.AsSpan(0, totalDraws));
@@ -262,19 +423,25 @@ public sealed unsafe partial class WbDrawDispatcher
BindRingSection(
encoder, frame, GpuBindingModel.StorageInstanceSelectionLighting,
_selectionLightingData.AsSpan(0, immediateInstances));
+ BindRingSection(
+ encoder, frame, GpuBindingModel.StorageInstanceDetailCategory,
+ _detailCategoryData.AsSpan(0, immediateInstances));
AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions(
encoder, scope.Sections, frame);
AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting(
encoder, scope.Sections, frame);
- GpuRingAllocation commands = frame.AllocateRing(
- totalDraws * DrawCommandStride,
- GpuRingUsage.Indirect);
- MemoryMarshal.AsBytes(_indirectCommands.AsSpan(0, totalDraws))
- .CopyTo(commands.Data);
+ GpuRingAllocation commands = WriteIndirectCommands(
+ frame,
+ _indirectCommands.AsSpan(0, totalDraws),
+ transformBaseInstance);
IGpuBuffer commandBuffer = commands.Buffer;
uint commandBase = commands.OffsetBytes;
+ ReadOnlySpan usedCommands =
+ _indirectCommands.AsSpan(0, totalDraws);
+ ReadOnlySpan usedDetailCategories =
+ _detailCategoryData.AsSpan(0, immediateInstances);
// ── Phase 7: opaque pass ─────────────────────────────────────────────
if (_opaqueDrawCount > 0)
@@ -295,10 +462,64 @@ public sealed unsafe partial class WbDrawDispatcher
}
}
+ // Retail DrawBuilding detail category (1). Only consecutive command
+ // runs containing a building are replayed; mesh_detail filters ordinary
+ // instances inside a mixed command. This includes ClipMap built-mesh
+ // subsets: the named retail
+ // DrawMesh path forwards curr_detail_surface to RenderMeshSubset for
+ // every material kind. The setting is read at draw time so the existing
+ // Options checkbox changes the live scene immediately.
+ if (_opaqueDrawCount > 0
+ && RetailDetailTextureContract.ShouldRender(
+ _buildingDetailEnabled(),
+ _buildingDetail))
+ {
+ int searchStart = 0;
+ if (TryGetNextDetailCommandRun(
+ usedCommands,
+ usedDetailCategories,
+ searchStart,
+ _opaqueDrawCount,
+ out DetailCommandRun run))
+ {
+ BindPipelineWithMesh(encoder, pipelines.RetailDetail, mesh);
+ pushConstants.RenderPass = 0;
+ pushConstants.DrawIdOffset = 0;
+ pushConstants.TextureIndexA = _buildingDetail.TextureSlot.Index;
+ pushConstants.ParamA = _buildingDetail.Tiling;
+ pushConstants.ParamB = 1f;
+ encoder.SetPushConstants(in pushConstants);
+ using (BeginRhiTimer(encoder, diag, DetailTimerScope))
+ {
+ do
+ {
+ DrawIndirectRangeRhi(
+ encoder,
+ ref pushConstants,
+ commandBuffer,
+ commandBase,
+ run.FirstCommand,
+ run.CommandCount);
+ searchStart = run.FirstCommand + run.CommandCount;
+ }
+ while (TryGetNextDetailCommandRun(
+ usedCommands,
+ usedDetailCategories,
+ searchStart,
+ _opaqueDrawCount,
+ out run));
+ }
+ }
+
+ // Later base passes expect neutral spare push fields.
+ pushConstants.TextureIndexA = 0;
+ pushConstants.ParamA = 0f;
+ pushConstants.ParamB = 0f;
+ }
+
// ── Phase 8: transparent pass ────────────────────────────────────────
if (_transparentDrawCount > 0)
{
- BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh);
// Issue #52 again: the transparent section starts at _opaqueDrawCount.
// Without the offset each transparent draw reads the OPAQUE section
// and the lifestone crystal's texture flickers.
@@ -307,9 +528,14 @@ public sealed unsafe partial class WbDrawDispatcher
encoder.SetPushConstants(in pushConstants);
using (BeginRhiTimer(encoder, diag, TransparentTimerScope))
{
- DrawIndirectRangeRhi(
- encoder, ref pushConstants, commandBuffer, commandBase,
- _opaqueDrawCount, _transparentDrawCount);
+ DrawImmediateTransparentRhi(
+ encoder,
+ mesh,
+ pipelines,
+ ref pushConstants,
+ commandBuffer,
+ commandBase,
+ usedDetailCategories);
}
}
@@ -323,8 +549,13 @@ public sealed unsafe partial class WbDrawDispatcher
///
private void PrepareRhiAlphaSections(int count)
{
+ _preparedAlphaInstanceCount = count;
IGpuFrame frame = RequireRhiFrame();
- _alphaInstances = WriteRingSection(frame, _instanceData.AsSpan(0, count * 16));
+ _alphaInstances = WriteWorldTransformSection(
+ frame,
+ _instanceData.AsSpan(0, count * 16),
+ out uint transformBaseInstance);
+ _alphaTransformBaseInstance = transformBaseInstance;
_alphaBatches = WriteRingSection(frame, _batchData.AsSpan(0, count));
_alphaClipSlots = WriteRingSection(frame, _clipSlotData.AsSpan(0, count));
int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
@@ -340,10 +571,17 @@ public sealed unsafe partial class WbDrawDispatcher
_alphaSelectionLighting = WriteRingSection(
frame,
_selectionLightingData.AsSpan(0, count));
- _alphaCommands = WriteRingSection(
+ _alphaDetailCategory = WriteRingSection(
+ frame,
+ _detailCategoryData.AsSpan(0, count));
+ GpuRingAllocation commands = WriteIndirectCommands(
frame,
_indirectCommands.AsSpan(0, count),
- GpuRingUsage.Indirect);
+ transformBaseInstance);
+ _alphaCommands = new RhiSection(
+ commands.Buffer,
+ commands.OffsetBytes,
+ checked((uint)(count * DrawCommandStride)));
}
private void DrawPreparedAlphaBatchRhi(
@@ -366,14 +604,18 @@ public sealed unsafe partial class WbDrawDispatcher
RenderPass = 1,
LightDebug = RenderingDiagnostics.LightDebugMode,
TextureIndexA = 0,
- TextureIndexB = 0,
+ TextureIndexB = _alphaTransformBaseInstance,
ParamA = 0f,
ParamB = 0f,
};
- MeshPipelineSet pipelines = PipelinesFor(encoder);
+ MeshPipelineSet pipelines = PipelinesFor(
+ encoder,
+ frame,
+ out DirectionalShadowFrameBinding shadowBinding);
BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh);
encoder.SetPushConstants(in pushConstants);
+ BindDirectionalShadowReceiver(encoder, in shadowBinding);
BindSection(encoder, GpuBindingModel.StorageInstances, _alphaInstances);
BindSection(encoder, GpuBindingModel.StorageBatches, _alphaBatches);
BindSection(encoder, GpuBindingModel.StorageClipSlots, _alphaClipSlots);
@@ -385,18 +627,45 @@ public sealed unsafe partial class WbDrawDispatcher
encoder,
GpuBindingModel.StorageInstanceSelectionLighting,
_alphaSelectionLighting);
+ BindSection(
+ encoder,
+ GpuBindingModel.StorageInstanceDetailCategory,
+ _alphaDetailCategory);
AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions(
encoder, scope.Sections, frame);
AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting(
encoder, scope.Sections, frame);
+ bool detailEnabled = RetailDetailTextureContract.ShouldRender(
+ _buildingDetailEnabled(),
+ _buildingDetail);
+ if (firstPreparedDraw < 0
+ || drawCount < 0
+ || firstPreparedDraw > _preparedAlphaInstanceCount - drawCount)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(firstPreparedDraw),
+ "The prepared-alpha draw range exceeds its uploaded instance/category payload.");
+ }
+ ReadOnlySpan usedDetailCategories =
+ _detailCategoryData.AsSpan(0, _preparedAlphaInstanceCount);
int runStart = firstPreparedDraw;
int preparedEnd = firstPreparedDraw + drawCount;
while (runStart < preparedEnd)
{
TranslucencyKind blend = _deferredAlphaKinds[runStart];
+ bool hasDetail = detailEnabled
+ && CommandContainsDetailCategory(
+ _indirectCommands[runStart],
+ usedDetailCategories);
int runEnd = runStart + 1;
- while (runEnd < preparedEnd && _deferredAlphaKinds[runEnd] == blend)
+ while (runEnd < preparedEnd
+ && !hasDetail
+ && _deferredAlphaKinds[runEnd] == blend
+ && (!detailEnabled
+ || !CommandContainsDetailCategory(
+ _indirectCommands[runEnd],
+ usedDetailCategories)))
runEnd++;
// ApplyRetailBlend's three cases are three pipelines, including the
@@ -410,10 +679,112 @@ public sealed unsafe partial class WbDrawDispatcher
_alphaCommands.OffsetBytes,
runStart,
runEnd - runStart);
+
+ if (hasDetail)
+ {
+ DrawBuildingDetailRangeRhi(
+ encoder,
+ mesh,
+ pipelines.RetailDetailTransparent,
+ ref pushConstants,
+ _alphaCommands.Buffer!,
+ _alphaCommands.OffsetBytes,
+ runStart,
+ 1);
+ }
runStart = runEnd;
}
}
+ private void DrawImmediateTransparentRhi(
+ IGpuPassEncoder encoder,
+ GlobalMeshBuffer mesh,
+ MeshPipelineSet pipelines,
+ ref GpuPushConstants pushConstants,
+ IGpuBuffer commandBuffer,
+ uint commandBase,
+ ReadOnlySpan usedDetailCategories)
+ {
+ bool detailEnabled = RetailDetailTextureContract.ShouldRender(
+ _buildingDetailEnabled(),
+ _buildingDetail);
+ int command = _opaqueDrawCount;
+ int end = command + _transparentDrawCount;
+ while (command < end)
+ {
+ bool hasDetail = detailEnabled
+ && CommandContainsDetailCategory(
+ _indirectCommands[command],
+ usedDetailCategories);
+ int runEnd = command + 1;
+ while (runEnd < end
+ && !hasDetail
+ && (!detailEnabled
+ || !CommandContainsDetailCategory(
+ _indirectCommands[runEnd],
+ usedDetailCategories)))
+ {
+ runEnd++;
+ }
+
+ BindPipelineWithMesh(encoder, pipelines.AlphaBlend, mesh);
+ ClearDetailPushConstants(ref pushConstants);
+ DrawIndirectRangeRhi(
+ encoder,
+ ref pushConstants,
+ commandBuffer,
+ commandBase,
+ command,
+ runEnd - command);
+
+ if (hasDetail)
+ {
+ DrawBuildingDetailRangeRhi(
+ encoder,
+ mesh,
+ pipelines.RetailDetailTransparent,
+ ref pushConstants,
+ commandBuffer,
+ commandBase,
+ command,
+ 1);
+ }
+ command = runEnd;
+ }
+ }
+
+ private void DrawBuildingDetailRangeRhi(
+ IGpuPassEncoder encoder,
+ GlobalMeshBuffer mesh,
+ IGpuPipeline detailPipeline,
+ ref GpuPushConstants pushConstants,
+ IGpuBuffer commandBuffer,
+ uint commandBase,
+ int firstCommand,
+ int commandCount)
+ {
+ BindPipelineWithMesh(encoder, detailPipeline, mesh);
+ pushConstants.TextureIndexA = _buildingDetail.TextureSlot.Index;
+ pushConstants.ParamA = _buildingDetail.Tiling;
+ pushConstants.ParamB = 1f;
+ DrawIndirectRangeRhi(
+ encoder,
+ ref pushConstants,
+ commandBuffer,
+ commandBase,
+ firstCommand,
+ commandCount);
+ ClearDetailPushConstants(ref pushConstants);
+ }
+
+ private static void ClearDetailPushConstants(
+ ref GpuPushConstants pushConstants)
+ {
+ pushConstants.TextureIndexA = 0;
+ pushConstants.ParamA = 0f;
+ pushConstants.ParamB = 0f;
+ }
+
private static IGpuPipeline PipelineForBlend(MeshPipelineSet pipelines, TranslucencyKind blend) =>
blend switch
{
@@ -551,6 +922,95 @@ public sealed unsafe partial class WbDrawDispatcher
return new RhiSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount);
}
+ private RhiSection WriteWorldTransformSection(
+ IGpuFrame frame,
+ ReadOnlySpan matrixFloats,
+ out uint firstInstance)
+ {
+ ResetWorldTransformFrameIfStale(frame.Serial);
+ if ((matrixFloats.Length & 15) != 0)
+ {
+ throw new ArgumentException(
+ "World transforms must contain complete 16-float matrices.",
+ nameof(matrixFloats));
+ }
+ ObserveOrdinaryTransformDemand(
+ frame.Serial,
+ checked((uint)(matrixFloats.Length / 16)));
+ if (!_worldTransformFrames.IsActive)
+ {
+ firstInstance = 0;
+ return WriteRingSection(frame, matrixFloats);
+ }
+
+ WorldTransformFrameSlice appended = _worldTransformFrames.Append(
+ frame,
+ MemoryMarshal.Cast(matrixFloats));
+ firstInstance = appended.FirstInstance;
+ return new RhiSection(
+ appended.Buffer,
+ appended.BaseOffsetBytes,
+ appended.BindingSizeBytes);
+ }
+
+ private static GpuRingAllocation WriteIndirectCommands(
+ IGpuFrame frame,
+ Span commands,
+ uint baseInstance)
+ {
+ int byteCount = checked(commands.Length * DrawCommandStride);
+ GpuRingAllocation allocation = frame.AllocateRing(
+ byteCount,
+ GpuRingUsage.Indirect);
+ if (baseInstance == 0)
+ {
+ MemoryMarshal.AsBytes(commands).CopyTo(allocation.Data);
+ return allocation;
+ }
+
+ int adjusted = 0;
+ try
+ {
+ for (int i = 0; i < commands.Length; i++)
+ {
+ commands[i].BaseInstance = checked(
+ commands[i].BaseInstance + baseInstance);
+ adjusted++;
+ }
+ MemoryMarshal.AsBytes(commands).CopyTo(allocation.Data);
+ }
+ finally
+ {
+ for (int i = 0; i < adjusted; i++)
+ commands[i].BaseInstance -= baseInstance;
+ }
+ return allocation;
+ }
+
+ private void ResetWorldTransformFrameIfStale(long frameSerial)
+ {
+ _worldTransformFrames.ResetIfStale(frameSerial);
+ }
+
+ private void ObserveOrdinaryTransformDemand(long frameSerial, uint instances)
+ {
+ if (_ordinaryTransformDemandFrameSerial != frameSerial)
+ {
+ _ordinaryTransformDemandFrameSerial = frameSerial;
+ _ordinaryTransformDemandThisFrame = 0;
+ }
+ _ordinaryTransformDemandThisFrame = checked(
+ _ordinaryTransformDemandThisFrame + instances);
+ _ordinaryTransformDemandHighWater = Math.Max(
+ _ordinaryTransformDemandHighWater,
+ _ordinaryTransformDemandThisFrame);
+ }
+
+ private void ResetWorldTransformFrame()
+ {
+ _worldTransformFrames.Reset();
+ }
+
private IGpuFrame RequireRhiFrame()
{
// The same precondition ActivateNextDynamicBufferSet enforces on GL: a
@@ -593,6 +1053,11 @@ public sealed unsafe partial class WbDrawDispatcher
totalMs += transparentMs;
any = true;
}
+ if (_device.Timers.TryResolve(DetailTimerScope, out double detailMs))
+ {
+ totalMs += detailMs;
+ any = true;
+ }
if (!any)
return;
@@ -611,6 +1076,7 @@ public sealed unsafe partial class WbDrawDispatcher
// there is one set and disposing it twice would be a double free.
if (!ReferenceEquals(offscreen, backbuffer))
DisposeMeshPipelineSet(offscreen);
+ DisposeDirectionalShadowReceiverPipelines();
}
private static void DisposeMeshPipelineSet(MeshPipelineSet? pipelines)
@@ -622,6 +1088,8 @@ public sealed unsafe partial class WbDrawDispatcher
pipelines.AlphaBlend.Dispose();
pipelines.AlphaAdditive.Dispose();
pipelines.AlphaInverse.Dispose();
+ pipelines.RetailDetail.Dispose();
+ pipelines.RetailDetailTransparent.Dispose();
}
private sealed class NullRhiTimerScope : IDisposable
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index a540e0e3..6c36b5ea 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -505,6 +505,11 @@ public sealed partial class WbDrawDispatcher : IDisposable
// Mechanically a clone of _clipSlotData.
private uint[] _indoorData = new uint[256];
+ // #226: per-instance retail detail category (binding=9), parallel to the
+ // transform buffer. 1 = building shell; 0 = ordinary object. Landscape
+ // and generic objects are intentionally never enabled by the retail caller.
+ private uint[] _detailCategoryData = new uint[256];
+
// #188: per-instance opacity multiplier (binding=7), one float per
// instance, parallel to the instance data. 1.0 = unmodified (the dat's own
// material/texture alpha, untouched); < 1.0 multiplies the shader's
@@ -539,6 +544,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
// (ParentCellId is an EnvCell). Appended to InstanceGroup.IndoorFlags in
// AppendCurrentLightSet; uploaded as binding=6 instanceIndoor[].
private bool _currentEntityIndoor;
+ private bool _currentEntityBuildingDetail;
private Vector2 _currentEntitySelectionLighting = new(0f, 1f);
// Phase U.3: the SHARED per-cell clip-region SSBO (binding=2) id, owned by
@@ -593,13 +599,13 @@ public sealed partial class WbDrawDispatcher : IDisposable
// every existing CPU writer's offsets are unchanged (see
// GpuBindingModel.GpuBatchDataStrideBytes). TextureIndex used to be a
// 64-bit ulong TextureHandle (an ARB_bindless_texture handle, uvec2 in
- // GLSL); it is now a slot into the binding=9 handle table
+ // GLSL); it is now a slot into the device texture table
// (mesh_modern.vert's BatchData.textureIndex / ACDREAM_TEXTURE_HANDLE),
// which is why the struct only needs 4-byte (not 8-byte) packing now.
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private struct BatchData
{
- public uint TextureIndex; // slot into the binding=9 handle table
+ public uint TextureIndex; // slot into the device texture table
public uint Reserved; // pad — keeps TextureLayer/Flags at offsets 8/12
public uint TextureLayer;
public uint Flags;
@@ -611,6 +617,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
uint ClipSlot,
InstanceLightSet Lights,
uint Indoor,
+ uint DetailCategory,
float Opacity,
Vector2 SelectionLighting);
@@ -679,6 +686,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
+ (long)_clipSlotData.Length * sizeof(uint)
+ (long)_lightSetData.Length * sizeof(int)
+ (long)_indoorData.Length * sizeof(uint)
+ + (long)_detailCategoryData.Length * sizeof(uint)
+ (long)_alphaData.Length * sizeof(float)
+ (long)_selectionLightingData.Length * Unsafe.SizeOf()
+ (long)_batchData.Length * Unsafe.SizeOf()
@@ -795,6 +803,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
public void BeginFrame(int frameSlot)
{
_ = frameSlot;
+ ResetWorldTransformFrame();
if (_groupFrame == long.MaxValue)
throw new InvalidOperationException("Instance-group frame identity was exhausted.");
@@ -1619,6 +1628,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
// is constant across the entity's parts/tuples), by the entity's
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
ComputeEntityLightSet(entity);
+ _currentEntityBuildingDetail = entity.IsBuildingShell;
_currentEntitySelectionLighting =
_selectionLighting?.TryGetLighting(
entity.ServerGuid,
@@ -2107,6 +2117,9 @@ public sealed partial class WbDrawDispatcher : IDisposable
if (_indoorData.Length < immediateInstances)
_indoorData = new uint[immediateInstances + 256];
+ if (_detailCategoryData.Length < immediateInstances)
+ _detailCategoryData = new uint[immediateInstances + 256];
+
// #188: per-instance opacity buffer, one float per instance, parallel to
// _clipSlotData / _instanceData. Grown on demand like the others.
if (_alphaData.Length < immediateInstances)
@@ -2336,6 +2349,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
_lightSetData,
cursor * LightManager.MaxLightsPerObject);
_indoorData[cursor] = group.IndoorFlags[i];
+ _detailCategoryData[cursor] = group.DetailCategories[i];
_alphaData[cursor] = group.Opacities[i];
_selectionLightingData[cursor] = group.SelectionLighting[i];
cursor++;
@@ -2623,6 +2637,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
hash.Add(lights[lightIndex]);
}
hash.Add(group.IndoorFlags[index]);
+ hash.Add(group.DetailCategories[index]);
hash.Add(group.Opacities[index]);
hash.Add(group.SelectionLighting[index]);
}
@@ -2679,6 +2694,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
group.Slots[i],
group.LightSets[i],
group.IndoorFlags[i],
+ group.DetailCategories[i],
group.Opacities[i],
group.SelectionLighting[i]));
queue.Submit(
@@ -2721,6 +2737,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
WriteMatrix(_instanceData, i * 16, entry.Model);
_clipSlotData[i] = entry.ClipSlot;
_indoorData[i] = entry.Indoor;
+ _detailCategoryData[i] = entry.DetailCategory;
_alphaData[i] = entry.Opacity;
_selectionLightingData[i] = entry.SelectionLighting;
int lightOffset = i * LightManager.MaxLightsPerObject;
@@ -2732,7 +2749,11 @@ public sealed partial class WbDrawDispatcher : IDisposable
// Campaign V slice V2: table slot, not the raw handle.
TextureIndex = key.TextureSlot.Index,
TextureLayer = key.TextureLayer,
- Flags = 0,
+ // DrawMesh invokes RenderMeshSubset with detail enabled for
+ // every built-mesh material subset while curr_detail_surface
+ // is installed. The per-instance category still rejects
+ // ordinary objects in mesh_detail.
+ Flags = 1,
};
_indirectCommands[i] = new DrawElementsIndirectCommand
{
@@ -2781,6 +2802,8 @@ public sealed partial class WbDrawDispatcher : IDisposable
_clipSlotData = new uint[count + 256];
if (_indoorData.Length < count)
_indoorData = new uint[count + 256];
+ if (_detailCategoryData.Length < count)
+ _detailCategoryData = new uint[count + 256];
if (_alphaData.Length < count)
_alphaData = new float[count + 256];
if (_selectionLightingData.Length < count)
@@ -2820,6 +2843,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
int bytesPerUnit = checked(
16 * sizeof(float)
+ sizeof(uint)
+ + sizeof(uint)
+ LightManager.MaxLightsPerObject * sizeof(int)
+ sizeof(uint)
+ sizeof(float)
@@ -2844,6 +2868,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
_lightSetData = new int[
checked(targetCapacity * LightManager.MaxLightsPerObject)];
_indoorData = new uint[targetCapacity];
+ _detailCategoryData = new uint[targetCapacity];
_alphaData = new float[targetCapacity];
_selectionLightingData = new Vector2[targetCapacity];
_batchData = new BatchData[targetCapacity];
@@ -3271,6 +3296,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
{
grp.LightSets.Add(_currentEntityLightSet);
grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block
+ grp.DetailCategories.Add(_currentEntityBuildingDetail ? 1u : 0u); // #226
}
private bool ClassifyBatches(
@@ -3527,8 +3553,8 @@ public sealed partial class WbDrawDispatcher : IDisposable
///
/// Public view of the per-group inputs to — used in tests.
- /// Campaign V slice V2: TextureIndex is a slot into the binding=9
- /// handle table (was a raw 64-bit bindless TextureHandle).
+ /// Campaign V slice V2: TextureIndex is a slot into the device's
+ /// texture table (was a raw 64-bit bindless TextureHandle).
///
public readonly record struct IndirectGroupInput(
int IndexCount,
@@ -3602,7 +3628,10 @@ public sealed partial class WbDrawDispatcher : IDisposable
TextureIndex = g.TextureIndex,
Reserved = 0,
TextureLayer = g.TextureLayer,
- Flags = 0,
+ // #226: this is the built-mesh path. Retail DrawMesh passes
+ // curr_detail_surface through RenderMeshSubset for opaque,
+ // ClipMap, alpha, additive and inverse-alpha subsets alike.
+ Flags = 1u,
};
if (IsOpaque(g.Translucency))
@@ -3631,6 +3660,85 @@ public sealed partial class WbDrawDispatcher : IDisposable
///
public static bool IsOpaquePublic(TranslucencyKind t) => IsOpaque(t);
+ internal readonly record struct DetailCommandRun(
+ int FirstCommand,
+ int CommandCount);
+
+ ///
+ /// Finds the next consecutive run containing at least one building instance
+ /// per command. Commands with no building instances are never submitted to
+ /// the detail pipeline; a mixed command remains eligible and relies on the
+ /// shader's per-instance category filter.
+ ///
+ internal static bool TryGetNextDetailCommandRun(
+ ReadOnlySpan commands,
+ ReadOnlySpan detailCategories,
+ int searchStart,
+ int exclusiveEnd,
+ out DetailCommandRun run)
+ {
+ if (searchStart < 0
+ || exclusiveEnd < searchStart
+ || exclusiveEnd > commands.Length)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(searchStart),
+ "The requested detail-command search range is invalid.");
+ }
+
+ int first = searchStart;
+ while (first < exclusiveEnd
+ && !CommandContainsDetailCategory(
+ commands[first],
+ detailCategories))
+ {
+ first++;
+ }
+ if (first == exclusiveEnd)
+ {
+ run = default;
+ return false;
+ }
+
+ int end = first + 1;
+ while (end < exclusiveEnd
+ && CommandContainsDetailCategory(
+ commands[end],
+ detailCategories))
+ {
+ end++;
+ }
+ run = new DetailCommandRun(first, end - first);
+ return true;
+ }
+
+ ///
+ /// Returns whether an indirect command contains at least one building
+ /// instance. Retail's detail fallback redraws the exact built-mesh subset
+ /// immediately after its transparent base subset; this predicate lets the
+ /// Vulkan arm preserve that adjacency without replaying ordinary objects.
+ ///
+ internal static bool CommandContainsDetailCategory(
+ DrawElementsIndirectCommand command,
+ ReadOnlySpan detailCategories)
+ {
+ ulong first = command.BaseInstance;
+ ulong end = first + command.InstanceCount;
+ if (end > (ulong)detailCategories.Length)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(command),
+ "The indirect instance range exceeds the detail-category buffer.");
+ }
+
+ for (ulong index = first; index < end; index++)
+ {
+ if (detailCategories[(int)index] != 0u)
+ return true;
+ }
+ return false;
+ }
+
private static bool IsOpaque(TranslucencyKind t)
=> t == TranslucencyKind.Opaque || t == TranslucencyKind.ClipMap;
@@ -3724,6 +3832,10 @@ public sealed partial class WbDrawDispatcher : IDisposable
// cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0.
public readonly List IndoorFlags = new();
+ // #226: 1 for a building-shell instance, 0 otherwise. Parallel to
+ // Matrices and uploaded at storage binding 9 for the detail replay.
+ public readonly List DetailCategories = new();
+
// #188: per-instance opacity multiplier, parallel to Matrices.
// Opacities[i] is 1.0=unmodified, or <1.0 while a TransparentPartHook
// fade is in flight for the instance whose matrix is Matrices[i]. At
@@ -3753,6 +3865,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
Slots.Clear();
LightSets.Clear();
IndoorFlags.Clear();
+ DetailCategories.Clear();
Opacities.Clear();
SelectionLighting.Clear();
}
@@ -3766,6 +3879,7 @@ public sealed partial class WbDrawDispatcher : IDisposable
Slots.TrimExcess();
LightSets.TrimExcess();
IndoorFlags.TrimExcess();
+ DetailCategories.TrimExcess();
Opacities.TrimExcess();
SelectionLighting.TrimExcess();
}
diff --git a/src/AcDream.App/Rendering/Wb/WorldTransformFrameArena.cs b/src/AcDream.App/Rendering/Wb/WorldTransformFrameArena.cs
new file mode 100644
index 00000000..eae4fec3
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/WorldTransformFrameArena.cs
@@ -0,0 +1,264 @@
+using System.Numerics;
+using System.Runtime.InteropServices;
+using AcDream.App.Rendering.Gpu;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Stable non-ref view of the one pack-on world-transform allocation for a
+/// render frame. Shadow and ordinary world commands address different matrix
+/// ranges through BaseInstance, but bind this exact buffer, offset, and
+/// range. The matrices remain the already-published N.5 values; this type owns
+/// no animation or scene projection.
+///
+internal readonly record struct WorldTransformFrameSlice(
+ long FrameSerial,
+ IGpuBuffer Buffer,
+ uint BaseOffsetBytes,
+ uint BindingSizeBytes,
+ uint FirstInstance,
+ uint InstanceCount)
+{
+ internal bool IsValidFor(IGpuFrame frame) =>
+ FrameSerial == frame.Serial
+ && Buffer is not null
+ && BindingSizeBytes >= checked(
+ (FirstInstance + InstanceCount) * WorldTransformCapacityPolicy.MatrixBytes)
+ && BindingSizeBytes % WorldTransformCapacityPolicy.MatrixBytes == 0u
+ && BaseOffsetBytes % WorldTransformCapacityPolicy.MatrixBytes == 0u
+ && checked((long)BaseOffsetBytes + BindingSizeBytes) <= Buffer.SizeBytes;
+}
+
+///
+/// Demand-sized policy for the one enhanced-frame pose address space. The
+/// 68,395-matrix connected dense row is the bootstrap observation, not a hard
+/// ceiling: allocations grow in 64 KiB pages and stop only at the adapter's
+/// probed maxStorageBufferRange. Vulkan guarantees that limit is at least
+/// 128 MiB (2,097,152 matrices).
+///
+internal static class WorldTransformCapacityPolicy
+{
+ internal const uint MatrixBytes = 64u;
+ internal const uint ConnectedDenseBootstrapInstances = 68_395u;
+ internal const uint AllocationQuantumBytes = 64u * 1024u;
+ internal const uint InitialBindingSizeBytes =
+ ((ConnectedDenseBootstrapInstances * MatrixBytes
+ + AllocationQuantumBytes - 1u) / AllocationQuantumBytes)
+ * AllocationQuantumBytes;
+ internal const uint VulkanGuaranteedMaxStorageBufferRangeBytes =
+ 128u * 1024u * 1024u;
+
+ internal static uint ResolveBindingSizeBytes(
+ uint requiredInstances,
+ uint maxStorageBufferRangeBytes)
+ {
+ uint maximum = maxStorageBufferRangeBytes
+ - (maxStorageBufferRangeBytes % MatrixBytes);
+ ulong requiredBytes = (ulong)requiredInstances * MatrixBytes;
+ if (maximum < MatrixBytes || requiredBytes > maximum)
+ {
+ throw new NotSupportedException(
+ $"The enhanced frame needs {requiredInstances:N0} world matrices "
+ + $"({requiredBytes:N0} bytes), but this adapter exposes only "
+ + $"{maximum:N0} bytes through one storage-buffer binding. "
+ + "The pack will fail safe rather than split the authoritative pose buffer.");
+ }
+
+ ulong targetBytes = Math.Max(
+ requiredBytes,
+ (ulong)ConnectedDenseBootstrapInstances * MatrixBytes);
+ ulong growthBytes = checked(
+ ((targetBytes + AllocationQuantumBytes - 1u)
+ / AllocationQuantumBytes)
+ * AllocationQuantumBytes);
+
+ // An implementation is allowed to expose a non-page-aligned maximum.
+ // Use all of it when it is below the preferred growth page, provided
+ // the current demand still fits.
+ return (uint)Math.Min(growthBytes, maximum);
+ }
+
+ internal static void ValidateBindingSizeBytes(
+ uint bindingSizeBytes,
+ uint requiredInstances,
+ uint maxStorageBufferRangeBytes)
+ {
+ ulong requiredBytes = (ulong)requiredInstances * MatrixBytes;
+ if (bindingSizeBytes < requiredBytes
+ || bindingSizeBytes % MatrixBytes != 0u
+ || bindingSizeBytes > maxStorageBufferRangeBytes)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(bindingSizeBytes),
+ bindingSizeBytes,
+ $"A shared world-transform binding must be matrix-aligned, contain "
+ + $"all {requiredInstances:N0} matrices, and not exceed the adapter's "
+ + $"{maxStorageBufferRangeBytes:N0}-byte storage range.");
+ }
+ }
+}
+
+///
+/// Frame-local address allocator over the pack-on N.5 transform block. The
+/// backing buffer may be a frame-ring allocation or the active pack's retained
+/// flight-slot arena. It publishes the prepared shadow prefix once and then
+/// only appends already-built ordinary world matrices. It deliberately has no
+/// scene, animation, or transform-derivation dependency.
+///
+internal sealed class WorldTransformFrameArena
+{
+ private WorldTransformFrameSlice _allocation;
+ private uint _usedBytes;
+
+ internal bool IsActive => _allocation.Buffer is not null;
+
+ internal uint UsedInstances => _usedBytes / 64u;
+
+ internal WorldTransformFrameSlice Begin(
+ IGpuFrame frame,
+ ReadOnlySpan transforms) => Begin(
+ frame,
+ transforms,
+ WorldTransformCapacityPolicy.ResolveBindingSizeBytes(
+ checked((uint)transforms.Length),
+ WorldTransformCapacityPolicy.VulkanGuaranteedMaxStorageBufferRangeBytes));
+
+ internal WorldTransformFrameSlice Begin(
+ IGpuFrame frame,
+ ReadOnlySpan transforms,
+ uint bindingSizeBytes)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ ResetIfStale(frame.Serial);
+ if (IsActive)
+ {
+ throw new InvalidOperationException(
+ "The directional-shadow transform frame was already published.");
+ }
+
+ uint byteCount = checked((uint)(transforms.Length * WorldTransformCapacityPolicy.MatrixBytes));
+ if (bindingSizeBytes < byteCount
+ || bindingSizeBytes % WorldTransformCapacityPolicy.MatrixBytes != 0u)
+ throw new ArgumentOutOfRangeException(nameof(bindingSizeBytes));
+
+ GpuRingAllocation allocation = frame.AllocateRing(
+ checked((int)bindingSizeBytes),
+ GpuRingUsage.Storage);
+ if (!transforms.IsEmpty)
+ MemoryMarshal.AsBytes(transforms).CopyTo(allocation.Data);
+
+ _allocation = new WorldTransformFrameSlice(
+ frame.Serial,
+ allocation.Buffer,
+ allocation.OffsetBytes,
+ bindingSizeBytes,
+ FirstInstance: 0,
+ checked((uint)transforms.Length));
+ _usedBytes = byteCount;
+ return _allocation;
+ }
+
+ ///
+ /// Activates a pack-owned flight-slot arena whose shadow prefix was already
+ /// published. Ordinary N.5 matrices append after that prefix and bind this
+ /// exact buffer/range, preserving the one authoritative pose SSBO without
+ /// copying stable shadow matrices through the frame ring.
+ ///
+ internal WorldTransformFrameSlice BeginRetained(
+ IGpuFrame frame,
+ in WorldTransformFrameSlice shadowPrefix)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ ResetIfStale(frame.Serial);
+ if (IsActive)
+ {
+ throw new InvalidOperationException(
+ "The directional-shadow transform frame was already published.");
+ }
+ if (!shadowPrefix.IsValidFor(frame)
+ || shadowPrefix.FirstInstance != 0
+ || shadowPrefix.Buffer.Residency != GpuMemoryResidency.HostWritable
+ || !shadowPrefix.Buffer.HostWritesAreCoherent
+ || !shadowPrefix.Buffer.Usage.HasFlag(GpuBufferUsage.Storage))
+ {
+ throw new ArgumentException(
+ "The retained shadow prefix must be this frame's host-writable "
+ + "matrix-aligned storage arena at base instance zero.",
+ nameof(shadowPrefix));
+ }
+
+ uint byteCount = checked(
+ shadowPrefix.InstanceCount * WorldTransformCapacityPolicy.MatrixBytes);
+ if (byteCount > shadowPrefix.BindingSizeBytes)
+ {
+ throw new ArgumentException(
+ "The retained shadow prefix exceeds its storage binding.",
+ nameof(shadowPrefix));
+ }
+
+ _allocation = shadowPrefix;
+ _usedBytes = byteCount;
+ return _allocation;
+ }
+
+ internal WorldTransformFrameSlice Append(
+ IGpuFrame frame,
+ ReadOnlySpan transforms)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ ResetIfStale(frame.Serial);
+ if (!IsActive)
+ {
+ throw new InvalidOperationException(
+ "The shared world-transform frame has not been published.");
+ }
+
+ uint byteCount = checked(
+ (uint)(transforms.Length * WorldTransformCapacityPolicy.MatrixBytes));
+ uint start = _usedBytes;
+ uint end = checked(start + byteCount);
+ if (end > _allocation.BindingSizeBytes)
+ {
+ throw new InvalidOperationException(
+ $"The enhanced frame needs {end / WorldTransformCapacityPolicy.MatrixBytes:N0} world matrices; "
+ + $"this frame's shared transform binding contains "
+ + $"{_allocation.BindingSizeBytes / WorldTransformCapacityPolicy.MatrixBytes:N0}. "
+ + "The pack will fail safe rather than bind a second pose buffer.");
+ }
+
+ if (!transforms.IsEmpty)
+ {
+ _allocation.Buffer.Upload(
+ checked((long)_allocation.BaseOffsetBytes + start),
+ MemoryMarshal.AsBytes(transforms));
+ }
+ _usedBytes = end;
+ return _allocation with
+ {
+ FirstInstance = start / WorldTransformCapacityPolicy.MatrixBytes,
+ InstanceCount = checked((uint)transforms.Length),
+ };
+ }
+
+ internal bool IsActiveFor(long frameSerial) =>
+ IsActive && _allocation.FrameSerial == frameSerial;
+
+ internal void Cancel(IGpuFrame frame)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ if (IsActiveFor(frame.Serial))
+ Reset();
+ }
+
+ internal void ResetIfStale(long frameSerial)
+ {
+ if (IsActive && _allocation.FrameSerial != frameSerial)
+ Reset();
+ }
+
+ internal void Reset()
+ {
+ _allocation = default;
+ _usedBytes = 0;
+ }
+}
diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
index b5b6554e..9ab24c9e 100644
--- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
+++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
@@ -2,6 +2,7 @@ using System.Numerics;
using AcDream.App.Audio;
using AcDream.App.Input;
using AcDream.App.Rendering.Selection;
+using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
@@ -60,6 +61,21 @@ internal readonly record struct WorldRenderFrame(
HashSet AnimatedEntityIds)
{
public LoadedCell? ClipRoot => Roots.ViewerRoot ?? Buildings.OutdoorNode;
+
+ ///
+ /// Pack-on-only borrowed publication fact. Retail does not read it. The
+ /// world scene attaches it after the ordinary frame has resolved the exact
+ /// render centre, so shadow fitting cannot infer reach from a configured
+ /// radius or touch streaming ownership.
+ ///
+ public ResidentStreamingWindowFact ResidentStreamingWindow { get; init; }
+
+ ///
+ /// Pack-on-only authored celestial direction. The authoritative retail
+ /// lighting path ignores this fact; it exists solely for the opt-in
+ /// directional-shadow prepass.
+ ///
+ public AuthoredCelestialShadowSource CelestialShadowSource { get; init; }
}
internal interface IWorldRenderFrameBuilder
diff --git a/src/AcDream.App/Rendering/WorldSceneRenderer.cs b/src/AcDream.App/Rendering/WorldSceneRenderer.cs
index bb8b93d7..9d7ec0d1 100644
--- a/src/AcDream.App/Rendering/WorldSceneRenderer.cs
+++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs
@@ -1,5 +1,6 @@
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Selection;
+using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.Core.Rendering;
@@ -15,6 +16,28 @@ internal interface IWorldScenePViewRenderer
void AbortFrame();
}
+internal readonly record struct PreparedWorldSceneFrame(
+ bool ShouldRender,
+ RenderFrameFoundation Foundation,
+ WorldRenderFrame World,
+ int ActiveDayGroup);
+
+///
+/// Pack-on-only two-stage seam. Preparation resolves the canonical camera and
+/// borrowed world roots once; the shadow prepass may consume those exact facts
+/// before the normal world pass executes them without a second PView/build.
+///
+internal interface IPreparedWorldSceneFramePhase : IWorldSceneFramePhase
+{
+ PreparedWorldSceneFrame PrepareEnhanced(RenderFrameInput input);
+
+ WorldRenderFrameOutcome RenderPreparedEnhanced(
+ RenderFrameInput input,
+ in PreparedWorldSceneFrame prepared);
+
+ void CancelPreparedEnhanced(in PreparedWorldSceneFrame prepared);
+}
+
internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer
{
private readonly RetailPViewRenderer _renderer;
@@ -46,7 +69,7 @@ internal sealed class WorldScenePViewRenderer : IWorldScenePViewRenderer
/// decision: a resolved viewer root uses DrawInside; only an unresolved root
/// uses the flat safety path.
///
-internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
+internal sealed class WorldSceneRenderer : IPreparedWorldSceneFramePhase
{
private readonly IRenderFrameFoundationSource _foundation;
private readonly IRenderLoginStateSource _login;
@@ -62,7 +85,10 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
private readonly IWorldRenderRangeSource _renderRange;
private readonly IWorldSceneDiagnostics _diagnostics;
private readonly IWorldGenerationAvailability _availability;
+ private readonly IAtmosphericWorldFrameSink? _atmosphere;
private readonly RetailPViewFrameInput _pviewFrameInput = new();
+ private WorldRenderFrame _preparedEnhancedWorld;
+ private bool _hasPreparedEnhancedWorld;
public WorldSceneRenderer(
IRenderFrameFoundationSource foundation,
@@ -78,7 +104,8 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
IWorldScenePassExecutor passes,
IWorldRenderRangeSource renderRange,
IWorldSceneDiagnostics diagnostics,
- IWorldGenerationAvailability? availability = null)
+ IWorldGenerationAvailability? availability = null,
+ IAtmosphericWorldFrameSink? atmosphere = null)
{
_foundation = foundation ?? throw new ArgumentNullException(nameof(foundation));
_login = login ?? throw new ArgumentNullException(nameof(login));
@@ -95,6 +122,7 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
+ _atmosphere = atmosphere;
}
public WorldRenderFrameOutcome Render(RenderFrameInput input)
@@ -105,7 +133,15 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
bool pviewFrameStarted = false;
try
{
- _selection?.BeginFrame();
+ // Enhanced rendering builds the canonical camera before this world
+ // transaction so the shadow prepass can consume it. Carry that
+ // exact frustum across BeginFrame: clearing it here would make every
+ // subsequently drawn part fail RetailSelectionScene's visibility
+ // gate, leaving radar selection alive but disabling world picking.
+ FrustumPlanes? preparedSelectionFrustum = _hasPreparedEnhancedWorld
+ ? _preparedEnhancedWorld.Camera.Frustum
+ : null;
+ _selection?.BeginFrame(preparedSelectionFrustum);
if (!_availability.IsWorldAvailable)
{
_selection?.CompleteFrame();
@@ -125,10 +161,23 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
// the same abort path instead of making every later frame fail.
worldFrameStarted = true;
_alpha.BeginFrame();
- WorldRenderFrame world = _frames.Build(
+ WorldRenderFrame world;
+ if (_hasPreparedEnhancedWorld)
+ {
+ world = _preparedEnhancedWorld;
+ _hasPreparedEnhancedWorld = false;
+ }
+ else
+ {
+ world = _frames.Build(
+ in foundation,
+ _login.IsWaitingForLogin,
+ _sky.ActiveDayGroup);
+ }
+ _atmosphere?.Publish(
in foundation,
- _login.IsWaitingForLogin,
- _sky.ActiveDayGroup);
+ in world,
+ _sky.ActiveDayGroupIndex);
_passes.BeginFrame();
WorldCameraFrame camera = world.Camera;
@@ -311,6 +360,78 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
}
}
+ public PreparedWorldSceneFrame PrepareEnhanced(RenderFrameInput input)
+ {
+ _ = input;
+ if (_hasPreparedEnhancedWorld)
+ {
+ throw new InvalidOperationException(
+ "The prior enhanced world preparation was not consumed.");
+ }
+
+ RenderFrameFoundation foundation = _foundation.Foundation;
+ if (!_availability.IsWorldAvailable || foundation.PortalViewportVisible)
+ return new PreparedWorldSceneFrame(false, foundation, default, -1);
+
+ WorldRenderFrame world = _frames.Build(
+ in foundation,
+ _login.IsWaitingForLogin,
+ _sky.ActiveDayGroup);
+ WorldRootFrame roots = world.Roots;
+ world = world with
+ {
+ ResidentStreamingWindow =
+ _entities.CaptureResidentStreamingWindow(
+ roots.RenderCenterLandblockX,
+ roots.RenderCenterLandblockY),
+ CelestialShadowSource =
+ AuthoredCelestialShadowSourceResolver.Resolve(
+ _sky.ActiveDayGroup,
+ _sky.DayFraction,
+ foundation.Sky),
+ };
+ _preparedEnhancedWorld = world;
+ _hasPreparedEnhancedWorld = true;
+ return new PreparedWorldSceneFrame(
+ true,
+ foundation,
+ world,
+ _sky.ActiveDayGroupIndex);
+ }
+
+ public WorldRenderFrameOutcome RenderPreparedEnhanced(
+ RenderFrameInput input,
+ in PreparedWorldSceneFrame prepared)
+ {
+ if (prepared.ShouldRender != _hasPreparedEnhancedWorld)
+ {
+ throw new InvalidOperationException(
+ "The prepared enhanced-world token does not match the pending frame.");
+ }
+
+ try
+ {
+ return Render(input);
+ }
+ finally
+ {
+ // A gate may change between prepare and execute. Never let that
+ // exceptional edge leak borrowed builder scratch into another frame.
+ _hasPreparedEnhancedWorld = false;
+ _preparedEnhancedWorld = default;
+ }
+ }
+
+ public void CancelPreparedEnhanced(in PreparedWorldSceneFrame prepared)
+ {
+ if (!prepared.ShouldRender)
+ return;
+ if (!_hasPreparedEnhancedWorld)
+ return;
+ _hasPreparedEnhancedWorld = false;
+ _preparedEnhancedWorld = default;
+ }
+
private void CompleteWorldFrame()
{
_alpha.EndFrame();
diff --git a/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs
index e8615988..4e2c188b 100644
--- a/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs
+++ b/src/AcDream.App/Rendering/WorldSceneRuntimeSources.cs
@@ -10,6 +10,8 @@ internal interface IWorldSceneSkyStateSource
{
DayGroupData? ActiveDayGroup { get; }
+ int ActiveDayGroupIndex => 0;
+
float DayFraction { get; }
}
@@ -21,6 +23,10 @@ internal interface IWorldSceneEntitySource
{ get; }
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds { get; }
+
+ ResidentStreamingWindowFact CaptureResidentStreamingWindow(
+ int centerX,
+ int centerY) => ResidentStreamingWindowFact.Unavailable(revision: 0);
}
internal sealed class RuntimeWorldSceneEntitySource : IWorldSceneEntitySource
@@ -39,6 +45,11 @@ internal sealed class RuntimeWorldSceneEntitySource : IWorldSceneEntitySource
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
_world.LandblockBounds;
+
+ public ResidentStreamingWindowFact CaptureResidentStreamingWindow(
+ int centerX,
+ int centerY) =>
+ _world.CaptureResidentStreamingWindow(centerX, centerY);
}
internal interface IWorldScenePViewDiagnosticSource
diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs
index 1d69d3e9..635d513e 100644
--- a/src/AcDream.App/RuntimeOptions.cs
+++ b/src/AcDream.App/RuntimeOptions.cs
@@ -63,9 +63,24 @@ public sealed record RuntimeOptions(
bool UiProbeDump,
string? UiProbeScript,
string? AutomationArtifactDirectory,
+ /// Diagnostic-only request for an exact automation framebuffer.
+ /// The graphical host uses the persisted display resolution as the initial
+ /// size of a borderless window so the OS cannot clamp a decorated window to
+ /// the desktop work area. False for every ordinary launch.
+ bool ExactAutomationFramebuffer,
int? ForcedDayGroupIndex,
float? PinnedWorldDayFraction,
float? SkyAnimationPhaseSeconds,
+ /// Diagnostic initial distance for the offline orbit camera, in
+ /// metres. Null for every ordinary launch; used by deterministic renderer
+ /// acceptance captures that need receivers inside a finite shadow reach.
+ float? InitialOrbitDistanceMeters,
+ /// Diagnostic-only initial orbit heading in degrees. Null keeps
+ /// the normal camera default.
+ float? InitialOrbitYawDegrees,
+ /// Diagnostic-only initial orbit elevation in degrees. Null keeps
+ /// the normal camera default.
+ float? InitialOrbitPitchDegrees,
float FogStartMultiplier,
float FogEndMultiplier,
ResidencyBudgetOptions ResidencyBudgets,
@@ -159,6 +174,8 @@ public sealed record RuntimeOptions(
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")),
AutomationArtifactDirectory:
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")),
+ ExactAutomationFramebuffer:
+ IsExactlyOne(env("ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER")),
ForcedDayGroupIndex:
TryParseNonNegativeInt(env("ACDREAM_DAY_GROUP")),
// Campaign V slice V7 instrument determinism: pins the Dereth day
@@ -182,6 +199,14 @@ public sealed record RuntimeOptions(
// per axis, so any finite number is a valid phase.
SkyAnimationPhaseSeconds:
TryParseFloat(env("ACDREAM_SKY_PHASE_SECONDS")),
+ InitialOrbitDistanceMeters:
+ TryParsePositiveFiniteFloat(
+ env("ACDREAM_ORBIT_DISTANCE_METERS")),
+ InitialOrbitYawDegrees:
+ TryParseFiniteFloat(env("ACDREAM_ORBIT_YAW_DEGREES")),
+ InitialOrbitPitchDegrees:
+ TryParseOrbitPitchDegrees(
+ env("ACDREAM_ORBIT_PITCH_DEGREES")),
FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f,
ResidencyBudgets: ResidencyBudgetOptions.Parse(env),
@@ -348,4 +373,22 @@ public sealed record RuntimeOptions(
=> float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)
? value
: null;
+
+ private static float? TryParsePositiveFiniteFloat(string? s)
+ => TryParseFloat(s) is { } value
+ && float.IsFinite(value)
+ && value > 0f
+ ? value
+ : null;
+
+ private static float? TryParseFiniteFloat(string? s)
+ => TryParseFloat(s) is { } value && float.IsFinite(value)
+ ? value
+ : null;
+
+ private static float? TryParseOrbitPitchDegrees(string? s)
+ => TryParseFiniteFloat(s) is { } value
+ && value is >= -89f and <= 89f
+ ? value
+ : null;
}
diff --git a/src/AcDream.App/Settings/RuntimeSettingsController.cs b/src/AcDream.App/Settings/RuntimeSettingsController.cs
index d22dbb46..7838812d 100644
--- a/src/AcDream.App/Settings/RuntimeSettingsController.cs
+++ b/src/AcDream.App/Settings/RuntimeSettingsController.cs
@@ -76,14 +76,14 @@ internal sealed record RuntimeSettingsSnapshot(
internal interface IRuntimeSettingsStartupTarget
{
- void ApplyDisplay(DisplaySettings display);
+ RuntimeDisplayApplyResult ApplyDisplay(DisplaySettings display);
void ApplyAudio(AudioSettings audio);
}
internal interface IRuntimeSettingsTargets
{
- void ApplyDisplayWindowState(DisplaySettings display);
+ RuntimeDisplayApplyResult ApplyDisplayWindowState(DisplaySettings display);
///
/// Campaign OP slice OP6 (2026-08-11): pushes the CURRENT audio
@@ -195,6 +195,13 @@ internal sealed class RuntimeSettingsController :
public QualitySettings ResolvedQuality { get; private set; }
+ ///
+ /// Successful committed display changes. Render-pack selection listens to
+ /// this edge and defers the actual candidate swap to its next stable frame
+ /// boundary; failed persistence never changes the live selection.
+ ///
+ public event Action? DisplayChanged;
+
// OP9: the optional developer-tools draft-preview view model
// (SettingsVM) was retired — it had zero production construction
// sites (only tests ever called CreateViewModel). HasDraftPreview is
@@ -216,7 +223,16 @@ internal sealed class RuntimeSettingsController :
if (!_startupDisplayApplied)
{
- target.ApplyDisplay(Startup.Display);
+ RuntimeDisplayApplyResult result = target.ApplyDisplay(Startup.Display);
+ DisplaySettings applied = ReconcileDisplayResult(Startup.Display, result);
+ if (!ReferenceEquals(applied, Startup.Display))
+ {
+ _storage.SaveDisplay(applied);
+ Display = applied;
+ _log(
+ $"settings: startup fullscreen request reconciled to "
+ + $"native state {applied.Fullscreen}");
+ }
_startupDisplayApplied = true;
}
if (!_startupAudioApplied)
@@ -420,16 +436,43 @@ internal sealed class RuntimeSettingsController :
{
_storage.SaveDisplay(display);
_log($"settings: display saved to {_storage.Location}");
- _runtimeTargets?.ApplyDisplayWindowState(display);
- Display = display;
- ReapplyQualityPreset(display.Quality);
+ RuntimeDisplayApplyResult result = _runtimeTargets is null
+ ? new RuntimeDisplayApplyResult(display.Fullscreen)
+ : _runtimeTargets.ApplyDisplayWindowState(display);
+ DisplaySettings applied = ReconcileDisplayResult(display, result);
+ if (!ReferenceEquals(applied, display))
+ {
+ _storage.SaveDisplay(applied);
+ _log(
+ $"settings: fullscreen request reconciled to native state "
+ + $"{applied.Fullscreen}");
+ }
+ Display = applied;
+ ReapplyQualityPreset(applied.Quality);
}
catch (Exception ex)
{
_log($"settings: display save failed: {ex.Message}");
+ return;
+ }
+
+ try
+ {
+ DisplayChanged?.Invoke(Display);
+ }
+ catch (Exception ex)
+ {
+ _log($"settings: display observer failed: {ex.Message}");
}
}
+ private static DisplaySettings ReconcileDisplayResult(
+ DisplaySettings requested,
+ RuntimeDisplayApplyResult result) =>
+ requested.Fullscreen == result.Fullscreen
+ ? requested
+ : requested with { Fullscreen = result.Fullscreen };
+
/// Widened from private to public at Campaign OP slice OP6,
/// same reason as . Also now pushes the saved
/// snapshot into the live engine via
diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs
index 9f59ca23..53e80268 100644
--- a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs
+++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs
@@ -16,9 +16,16 @@ namespace AcDream.App.Settings;
internal interface IRuntimeDisplayWindowTarget
{
- void Apply(DisplaySettings display);
+ RuntimeDisplayApplyResult Apply(DisplaySettings display);
}
+///
+/// Native display state observed after a requested settings apply. The
+/// controller owns persistence and uses this result to keep the checkbox and
+/// settings file aligned with the GLFW post-condition (#392).
+///
+internal readonly record struct RuntimeDisplayApplyResult(bool Fullscreen);
+
internal interface IRuntimeQualityApplicationTarget
{
void SetAlphaToCoverage(bool enabled);
@@ -133,7 +140,7 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
/// gate crash). Every failure path logs and leaves the window in a
/// usable state instead of throwing.
///
- public void Apply(DisplaySettings display)
+ public RuntimeDisplayApplyResult Apply(DisplaySettings display)
{
ArgumentNullException.ThrowIfNull(display);
bool haveResolution =
@@ -147,7 +154,7 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
// "any refused/failed switch logs a line".
Console.WriteLine(
$"display: fullscreen refused — unparseable resolution '{display.Resolution}'");
- return;
+ return CurrentResult();
}
// Mechanism/blast M2: idempotence BEFORE any native work — every
// Display-backed Config row applies per change (sliders per drag
@@ -155,17 +162,17 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
// display-mode change per mouse sample.
if (_modeSwitcher.CurrentFullscreenMode is (int curW, int curH)
&& curW == width && curH == height)
- return;
+ return CurrentResult();
if (!_isOfferedMode.Invoke($"{width}x{height}"))
{
Console.WriteLine(
$"display: fullscreen {width}x{height} refused — not an offered mode");
- return;
+ return CurrentResult();
}
if (!_modeSwitcher.TryEnterFullscreen(width, height, out string? error))
Console.WriteLine(
- $"display: fullscreen {width}x{height} failed ({error}) — window state unchanged (#392 tracks the persisted-flag divergence)");
- return;
+ $"display: fullscreen {width}x{height} failed ({error}) — window state unchanged");
+ return CurrentResult();
}
// Windowed target: leave fullscreen first if needed (the native exit
@@ -180,7 +187,7 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
if (!_modeSwitcher.TryLeaveFullscreen(width, height, out string? error))
Console.WriteLine(
$"display: leaving fullscreen failed ({error})");
- return;
+ return CurrentResult();
}
if (haveResolution && (_window.Size.X != width || _window.Size.Y != height))
@@ -196,8 +203,12 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
$"(window was {_window.Size.X}x{_window.Size.Y})");
_window.Size = new Vector2D(width, height);
}
+ return CurrentResult();
}
+ private RuntimeDisplayApplyResult CurrentResult() =>
+ new(_modeSwitcher.IsFullscreen);
+
internal static bool TryParseResolution(
string spec,
out int width,
@@ -240,13 +251,14 @@ internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTar
_audio = audio;
}
- public void ApplyDisplay(DisplaySettings display)
+ public RuntimeDisplayApplyResult ApplyDisplay(DisplaySettings display)
{
ArgumentNullException.ThrowIfNull(display);
_pacing.RefreshActiveMonitor();
_pacing.ApplyPreference(display.VSync);
- _displayWindow.Apply(display);
+ RuntimeDisplayApplyResult result = _displayWindow.Apply(display);
ApplyFieldOfView(_cameras, display.FieldOfView);
+ return result;
}
public void ApplyAudio(AudioSettings audio) => ApplyAudio(_audio, audio);
@@ -470,9 +482,9 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
_cameras = cameras;
}
- public void ApplyDisplayWindowState(DisplaySettings display)
+ public RuntimeDisplayApplyResult ApplyDisplayWindowState(DisplaySettings display)
{
- _displayWindow.Apply(display);
+ RuntimeDisplayApplyResult result = _displayWindow.Apply(display);
// #389 blast MUST-FIX 2 (see the ctor's cameras doc): the Field of
// View applies live on Save, from the update-phase save handler —
// deliberately NOT from the render-phase preview seam
@@ -480,6 +492,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
// is the review's WATCH-3 cull-vs-raster landmine.
if (_cameras is not null)
RuntimeSettingsStartupTargets.ApplyFieldOfView(_cameras, display.FieldOfView);
+ return result;
}
/// Campaign OP slice OP6: reuses the SAME static helper the
diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index ce8cb764..5ad82a65 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -163,11 +163,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
private int _mutationDepth;
private long _visibilityCommitCount;
private ulong _flatViewGeneration;
+ private ulong _residentWindowRevision;
private bool _flatMembershipDirty;
public IReadOnlyList Entities => _flatEntities;
public ulong FlatViewGeneration => _flatViewGeneration;
public IReadOnlyCollection LoadedLandblockIds => _loaded.Keys;
+ internal ulong ResidentWindowRevision => _residentWindowRevision;
public event Action? LiveProjectionVisibilityChanged;
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
@@ -428,6 +430,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
throw new InvalidOperationException(
$"Landblock 0x{landblockId:X8} already owns a render traversal slot.");
}
+ _residentWindowRevision = checked(_residentWindowRevision + 1);
InvalidateLandblockRenderViews(entries: true, bounds: true);
}
@@ -449,10 +452,81 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_renderTraversalLandblockSlots[slot] = 0;
_freeRenderTraversalLandblockSlots.Push(slot);
+ _residentWindowRevision = checked(_residentWindowRevision + 1);
InvalidateLandblockRenderViews(entries: true, bounds: true);
return true;
}
+ ///
+ /// Captures the largest complete, actually-published square around the
+ /// current render centre. This is a presentation-only read: it neither
+ /// consults the desired nor requests missing
+ /// landblocks. Hysteresis-retained cells outside a gap cannot inflate the
+ /// result, which is what makes it safe during recenter and portal turnover.
+ ///
+ internal ResidentStreamingWindowFact CaptureResidentStreamingWindow(
+ int centerX,
+ int centerY)
+ {
+ if ((uint)centerX > byte.MaxValue || (uint)centerY > byte.MaxValue)
+ return ResidentStreamingWindowFact.Unavailable(_residentWindowRevision);
+
+ uint center = StreamingRegion.EncodeLandblockId(centerX, centerY);
+ if (!_loaded.ContainsKey(center))
+ return ResidentStreamingWindowFact.Unavailable(_residentWindowRevision);
+
+ int maximumCandidate = 0;
+ foreach (uint landblockId in _loaded.Keys)
+ {
+ int x = (int)((landblockId >> 24) & 0xFFu);
+ int y = (int)((landblockId >> 16) & 0xFFu);
+ maximumCandidate = Math.Max(
+ maximumCandidate,
+ Math.Max(Math.Abs(x - centerX), Math.Abs(y - centerY)));
+ }
+
+ int completeRadius = 0;
+ for (int radius = 1; radius <= maximumCandidate; radius++)
+ {
+ if (!ContainsCompleteRing(radius))
+ break;
+ completeRadius = radius;
+ }
+
+ return new ResidentStreamingWindowFact(
+ _residentWindowRevision,
+ centerX,
+ centerY,
+ completeRadius,
+ _loaded.Count,
+ HasPublishedCenter: true);
+
+ bool ContainsCompleteRing(int radius)
+ {
+ int minX = Math.Max(0, centerX - radius);
+ int maxX = Math.Min(byte.MaxValue, centerX + radius);
+ int minY = Math.Max(0, centerY - radius);
+ int maxY = Math.Min(byte.MaxValue, centerY + radius);
+ for (int x = minX; x <= maxX; x++)
+ {
+ if (!_loaded.ContainsKey(StreamingRegion.EncodeLandblockId(x, minY))
+ || !_loaded.ContainsKey(StreamingRegion.EncodeLandblockId(x, maxY)))
+ {
+ return false;
+ }
+ }
+ for (int y = minY + 1; y < maxY; y++)
+ {
+ if (!_loaded.ContainsKey(StreamingRegion.EncodeLandblockId(minX, y))
+ || !_loaded.ContainsKey(StreamingRegion.EncodeLandblockId(maxX, y)))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+
///
/// Total live entities currently parked in the pending bucket waiting
/// for their landblock to arrive. Useful diagnostic for verifying the
@@ -1482,6 +1556,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_loadedLiveByLandblock.Clear();
_liveProjectionByKey.Clear();
+ if (_loaded.Count != 0)
+ _residentWindowRevision = checked(_residentWindowRevision + 1);
_loaded.Clear();
_renderTraversalLandblockSlots.Clear();
_freeRenderTraversalLandblockSlots.Clear();
diff --git a/src/AcDream.App/Streaming/ResidentStreamingWindowFact.cs b/src/AcDream.App/Streaming/ResidentStreamingWindowFact.cs
new file mode 100644
index 00000000..540a874e
--- /dev/null
+++ b/src/AcDream.App/Streaming/ResidentStreamingWindowFact.cs
@@ -0,0 +1,31 @@
+namespace AcDream.App.Streaming;
+
+///
+/// Read-only presentation fact describing the complete portion of the live
+/// two-tier landblock window. A landblock is 192 metres wide. Radius zero means
+/// only the centre is published and therefore offers no safe camera-relative
+/// cascade reach beyond that landblock.
+///
+internal readonly record struct ResidentStreamingWindowFact(
+ ulong Revision,
+ int CenterX,
+ int CenterY,
+ int CompleteRadiusLandblocks,
+ int PublishedLandblockCount,
+ bool HasPublishedCenter)
+{
+ internal const float LandblockSizeMeters = 192f;
+
+ internal float MaximumReachMeters => HasPublishedCenter
+ ? CompleteRadiusLandblocks * LandblockSizeMeters
+ : 0f;
+
+ internal static ResidentStreamingWindowFact Unavailable(ulong revision) =>
+ new(
+ revision,
+ CenterX: 0,
+ CenterY: 0,
+ CompleteRadiusLandblocks: 0,
+ PublishedLandblockCount: 0,
+ HasPublishedCenter: false);
+}
diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs
index 07afa390..10d2f26f 100644
--- a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs
+++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs
@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.UI;
+using AcDream.Plugin.Abstractions.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.UI.Layout;
@@ -118,8 +120,9 @@ namespace AcDream.App.UI.Layout;
/// multiplier, a different unit system with its own live legacy-panel
/// consumer; no gamma-correction render pass exists for either); Automatic
/// Degrades/Graphics Performance/Degrade Distance/the four texture-detail-
-/// family menus/Building Detail Textures/Multi-Pass Alpha (the renderer is
-/// Vulkan + one aggregate QualityPreset, no per-feature knobs);
+/// family menus/Multi-Pass Alpha (the renderer is Vulkan + one aggregate
+/// QualityPreset, no per-feature knobs). Building Detail Textures is
+/// LIVE: #226 consumes it directly in the retail building/EnvCell detail pass;
/// Camera Stiffness/Adjustment Speed/Align To Slope/Mouse Look
/// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no
/// persistent mouse-turning camera mode exists for ANY of these six to
@@ -135,8 +138,8 @@ namespace AcDream.App.UI.Layout;
///
///
/// Caption dimming (AD-78, user-directed, 2026-08-11, gate 2). Every
-/// STORE-ONLY row above (the 21 rows named in the paragraph before this one
-/// -- AP-198's ten, AP-199's three, and TS-74's six Camera/Input rows plus
+/// STORE-ONLY row above (the 20 rows named in the paragraph before this one
+/// -- AP-198's nine, AP-199's three, and TS-74's six Camera/Input rows plus
/// AP-200's two Chat-font rows) renders its caption in
/// instead of the normal
/// white/DAT-authored color. The row stays fully interactive -- it still
@@ -343,7 +346,47 @@ public static class ConfigOptionsPageController
Func LoadCameraTurning,
Action SaveCameraTurning,
Func LoadChat,
- Action SaveChat);
+ Action SaveChat)
+ {
+ ///
+ /// Optional modern extension appended after retail's exact 39 authored
+ /// rows. Null preserves the byte-verified retail Config-page shape.
+ ///
+ public RenderPackBindings? RenderPacks { get; init; }
+ }
+
+ public sealed record RenderPackBindings(
+ Func> LoadChoices)
+ {
+ public Func? LoadRevision { get; init; }
+ public Func? LoadFailureNotice { get; init; }
+ }
+
+ public sealed record RenderPackChoice(
+ string Id,
+ string DisplayName,
+ string? Version,
+ bool Selectable,
+ string? UnavailableReason,
+ IReadOnlyList Presets)
+ {
+ public string FeatureSummary { get; init; } = string.Empty;
+ public IReadOnlyList Settings { get; init; } = [];
+ }
+
+ public sealed record RenderPackPresetChoice(
+ string Id,
+ string DisplayName,
+ bool Selectable,
+ string? UnavailableReason)
+ {
+ public IReadOnlyList SettingOverrides { get; init; } = [];
+ public long MaxResidentGpuBytes { get; init; }
+ public double MaxIncrementalGpuMillisecondsP50 { get; init; }
+ public double MaxIncrementalGpuMillisecondsP99 { get; init; }
+ public double MaxIncrementalCpuMillisecondsP50 { get; init; }
+ public double MaxIncrementalCpuMillisecondsP99 { get; init; }
+ }
///
/// Builds the six authored sections (Sound/Camera/Graphics/Rendering
@@ -455,9 +498,518 @@ public static class ConfigOptionsPageController
// built 38 — five interior separators, no trailing one).
BuildSeparatorRow(listBox);
+ if (bindings.RenderPacks is not null)
+ BindRenderPackSection(
+ listBox,
+ page,
+ bindings,
+ bindings.RenderPacks,
+ resolveSprite,
+ datFont,
+ debugFont);
+
return true;
}
+ private static void BindRenderPackSection(
+ UiTemplateListBox listBox,
+ OptionPage page,
+ Bindings bindings,
+ RenderPackBindings renderPacks,
+ Func? resolveSprite,
+ UiDatFont? datFont,
+ BitmapFont? debugFont)
+ {
+ List choices = LoadPackChoices(renderPacks);
+ long observedRevision = renderPacks.LoadRevision?.Invoke() ?? 0;
+ var menuChoices = new ExplicitMenuChoiceSource(
+ choices.Select(static value =>
+ new ExplicitMenuChoice(
+ value.Id,
+ value.DisplayName,
+ value.Selectable,
+ PackTooltip(value))).ToArray());
+
+ BuildExplicitHeaderRow(listBox, "Graphics Enhancements");
+ RenderPackTailOwner? tail = null;
+ StringOptionRow? packOption = null;
+ UiMenu? packMenu = BuildExplicitStringMenuRow(
+ listBox,
+ "Shader pack",
+ menuChoices.Choices,
+ page,
+ read: () => NormalizePackId(bindings.LoadDisplay().RenderPack, choices),
+ apply: selectedId =>
+ {
+ RenderPackChoice? selected = choices.FirstOrDefault(value =>
+ value.Selectable && string.Equals(
+ value.Id,
+ selectedId,
+ StringComparison.OrdinalIgnoreCase));
+ RenderPackPresetChoice? preset = selected?.Presets.FirstOrDefault(
+ static value => value.Selectable);
+ if (selected is null || preset is null)
+ return;
+ bindings.SaveDisplay(bindings.LoadDisplay() with
+ {
+ RenderPack = new RenderPackSelectionSettings(
+ selected.Id,
+ selected.Version,
+ preset.Id),
+ });
+ tail?.RebuildPackTail(selected);
+ },
+ defaultValue: RenderPackSelectionSettings.RetailPackId,
+ resolveSprite,
+ datFont,
+ debugFont,
+ menuChoices,
+ option => packOption = option);
+
+ string initialPackId = NormalizePackId(bindings.LoadDisplay().RenderPack, choices);
+ RenderPackChoice initialPack = choices.First(value =>
+ string.Equals(value.Id, initialPackId, StringComparison.OrdinalIgnoreCase));
+ tail = new RenderPackTailOwner(
+ listBox,
+ page,
+ bindings,
+ retainedItemCount: listBox.ItemCount,
+ retainedOptionCount: page.Rows.Count,
+ resolveSprite,
+ datFont,
+ debugFont);
+ tail.RebuildPackTail(initialPack);
+ if (packMenu is not null)
+ {
+ packMenu.BeforeOpen = () =>
+ {
+ if (renderPacks.LoadRevision is not { } loadRevision)
+ return;
+ long revision = loadRevision();
+ if (revision == observedRevision)
+ return;
+ observedRevision = revision;
+ choices = LoadPackChoices(renderPacks);
+ menuChoices.Choices = choices.Select(static value =>
+ new ExplicitMenuChoice(
+ value.Id,
+ value.DisplayName,
+ value.Selectable,
+ PackTooltip(value))).ToArray();
+ packMenu.Items = menuChoices.Choices
+ .Select(static choice => new UiMenu.MenuItem(
+ choice.Label,
+ choice.Id))
+ .ToArray();
+ string selectedId = NormalizePackId(
+ bindings.LoadDisplay().RenderPack,
+ choices);
+ packMenu.Selected = selectedId;
+ packMenu.TooltipText = menuChoices.Choices.FirstOrDefault(choice =>
+ string.Equals(
+ choice.Id,
+ selectedId,
+ StringComparison.OrdinalIgnoreCase))
+ .Tooltip;
+ RenderPackChoice selected = choices.First(value => string.Equals(
+ value.Id,
+ selectedId,
+ StringComparison.OrdinalIgnoreCase));
+ tail.RebuildPackTail(selected);
+ // Catalog withdrawal/update is an external state change, not
+ // an unsaved user edit. Rebase Reset to the now-valid live
+ // selection so it cannot resurrect a withdrawn pack id.
+ packOption?.SaveCurrentValue();
+ };
+ packMenu.TooltipTextProvider = () => CombineTooltip(
+ renderPacks.LoadFailureNotice?.Invoke(),
+ PackTooltip(choices.FirstOrDefault(value => string.Equals(
+ value.Id,
+ packMenu.Selected as string,
+ StringComparison.OrdinalIgnoreCase))));
+ }
+ }
+
+ private static List LoadPackChoices(
+ RenderPackBindings renderPacks)
+ {
+ IReadOnlyList discovered = renderPacks.LoadChoices();
+ var choices = new List(discovered.Count + 1)
+ {
+ new(
+ RenderPackSelectionSettings.RetailPackId,
+ "acdream default (retail-faithful)",
+ Version: null,
+ Selectable: true,
+ UnavailableReason: null,
+ [new RenderPackPresetChoice(
+ RenderPackSelectionSettings.RetailPresetId,
+ "Off",
+ Selectable: true,
+ UnavailableReason: null)]),
+ };
+ choices.AddRange(discovered.Where(static choice =>
+ !string.Equals(
+ choice.Id,
+ RenderPackSelectionSettings.RetailPackId,
+ StringComparison.OrdinalIgnoreCase)));
+ return choices;
+ }
+
+ private static string NormalizePackId(
+ RenderPackSelectionSettings selection,
+ IReadOnlyList choices) =>
+ choices.Any(value => string.Equals(
+ value.Id,
+ selection.PackId,
+ StringComparison.OrdinalIgnoreCase) && value.Selectable)
+ ? selection.PackId
+ : RenderPackSelectionSettings.RetailPackId;
+
+ private static string NormalizePresetId(
+ string presetId,
+ RenderPackChoice pack) =>
+ pack.Presets.Any(value => value.Selectable && string.Equals(
+ value.Id,
+ presetId,
+ StringComparison.OrdinalIgnoreCase))
+ ? presetId
+ : pack.Presets.FirstOrDefault(static value => value.Selectable)?.Id
+ ?? RenderPackSelectionSettings.RetailPresetId;
+
+ private static string PackTooltip(RenderPackChoice? pack)
+ {
+ if (pack is null)
+ return "acdream's default retail-faithful renderer remains authoritative unless an enhancement pack is explicitly selected.";
+ string summary = string.IsNullOrWhiteSpace(pack.FeatureSummary)
+ ? "acdream's default retail-faithful renderer remains authoritative unless an enhancement pack is explicitly selected."
+ : pack.FeatureSummary.Trim();
+ return CombineTooltip(pack.UnavailableReason, summary) ?? summary;
+ }
+
+ private static string PresetTooltip(RenderPackPresetChoice preset)
+ {
+ double residentMiB = preset.MaxResidentGpuBytes / (1024d * 1024d);
+ string estimate = string.Format(
+ CultureInfo.InvariantCulture,
+ "Estimated ceiling — GPU p50/p99 ≤ {0:0.###}/{1:0.###} ms; "
+ + "render CPU p50/p99 ≤ {2:0.###}/{3:0.###} ms; pack VRAM ≤ {4:0.##} MiB.",
+ preset.MaxIncrementalGpuMillisecondsP50,
+ preset.MaxIncrementalGpuMillisecondsP99,
+ preset.MaxIncrementalCpuMillisecondsP50,
+ preset.MaxIncrementalCpuMillisecondsP99,
+ residentMiB);
+ return CombineTooltip(preset.UnavailableReason, estimate) ?? estimate;
+ }
+
+ private static string? CombineTooltip(string? first, string? second)
+ {
+ bool hasFirst = !string.IsNullOrWhiteSpace(first);
+ bool hasSecond = !string.IsNullOrWhiteSpace(second);
+ if (!hasFirst)
+ return hasSecond ? second!.Trim() : null;
+ if (!hasSecond)
+ return first!.Trim();
+ return first!.Trim() + Environment.NewLine + second!.Trim();
+ }
+
+ private readonly record struct ExplicitMenuChoice(
+ string Id,
+ string Label,
+ bool Enabled,
+ string? Tooltip);
+
+ private sealed class ExplicitMenuChoiceSource(
+ IReadOnlyList choices)
+ {
+ internal IReadOnlyList Choices { get; set; } = choices;
+ }
+
+ ///
+ /// Owns only the modern extension suffix after the persistent pack picker.
+ /// Pack changes replace preset + settings; preset changes replace settings
+ /// only. Generation guards make externally-retained stale widgets inert,
+ /// while the ListBox and OptionPage tail APIs remove their visual and verb
+ /// ownership synchronously.
+ ///
+ private sealed class RenderPackTailOwner(
+ UiTemplateListBox listBox,
+ OptionPage page,
+ Bindings bindings,
+ int retainedItemCount,
+ int retainedOptionCount,
+ Func? resolveSprite,
+ UiDatFont? datFont,
+ BitmapFont? debugFont)
+ {
+ private readonly int _retainedItemCount = retainedItemCount;
+ private readonly int _retainedOptionCount = retainedOptionCount;
+ private int _packGeneration;
+ private int _settingsGeneration;
+ private int _settingsItemCount;
+ private int _settingsOptionCount;
+
+ internal void RebuildPackTail(RenderPackChoice pack)
+ {
+ int generation = ++_packGeneration;
+ ++_settingsGeneration;
+ listBox.RemoveTail(_retainedItemCount);
+ page.RemoveTail(_retainedOptionCount);
+
+ DisplaySettings display = bindings.LoadDisplay();
+ string presetId = NormalizePresetId(display.RenderPack.PresetId, pack);
+ RenderPackPresetChoice preset = pack.Presets.First(value =>
+ string.Equals(value.Id, presetId, StringComparison.OrdinalIgnoreCase));
+ BuildExplicitStringMenuRow(
+ listBox,
+ "Quality preset",
+ pack.Presets.Select(static value =>
+ new ExplicitMenuChoice(
+ value.Id,
+ value.DisplayName,
+ value.Selectable,
+ PresetTooltip(value))).ToArray(),
+ page,
+ read: () => NormalizePresetId(
+ bindings.LoadDisplay().RenderPack.PresetId,
+ pack),
+ apply: selectedPresetId =>
+ {
+ if (generation != _packGeneration)
+ return;
+ RenderPackPresetChoice? selected = pack.Presets.FirstOrDefault(value =>
+ value.Selectable && string.Equals(
+ value.Id,
+ selectedPresetId,
+ StringComparison.OrdinalIgnoreCase));
+ DisplaySettings current = bindings.LoadDisplay();
+ if (selected is null || !SamePack(current.RenderPack, pack))
+ return;
+ bindings.SaveDisplay(current with
+ {
+ RenderPack = current.RenderPack with
+ {
+ PresetId = selected.Id,
+ SettingOverrides = SanitizeOverrides(
+ pack,
+ current.RenderPack.SettingOverrides),
+ },
+ });
+ RebuildSettingsTail(pack, selected);
+ },
+ defaultValue: preset.Id,
+ resolveSprite,
+ datFont,
+ debugFont);
+
+ _settingsItemCount = listBox.ItemCount;
+ _settingsOptionCount = page.Rows.Count;
+ RebuildSettingsTail(pack, preset);
+ }
+
+ private void RebuildSettingsTail(
+ RenderPackChoice pack,
+ RenderPackPresetChoice preset)
+ {
+ int generation = ++_settingsGeneration;
+ listBox.RemoveTail(_settingsItemCount);
+ page.RemoveTail(_settingsOptionCount);
+
+ var ids = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (RenderSettingDeclaration setting in pack.Settings)
+ {
+ if (!ids.Add(setting.Id) || !CanBuildSetting(setting))
+ continue;
+ BuildSetting(pack, preset, setting, generation);
+ }
+ BuildSeparatorRow(listBox);
+ }
+
+ private void BuildSetting(
+ RenderPackChoice pack,
+ RenderPackPresetChoice preset,
+ RenderSettingDeclaration setting,
+ int generation)
+ {
+ bool IsCurrent() => generation == _settingsGeneration
+ && SamePack(bindings.LoadDisplay().RenderPack, pack)
+ && string.Equals(
+ bindings.LoadDisplay().RenderPack.PresetId,
+ preset.Id,
+ StringComparison.OrdinalIgnoreCase);
+ string Read() => ResolveSettingValue(
+ pack,
+ preset,
+ setting,
+ bindings.LoadDisplay().RenderPack.SettingOverrides);
+ string defaultValue = ResolveSettingValue(
+ pack,
+ preset,
+ setting,
+ RenderPackSettingOverrides.Empty);
+ void Apply(string value)
+ {
+ if (!IsCurrent()
+ || !RenderPackSettingValueCodec.TryEncode(setting, value, out _))
+ return;
+ DisplaySettings current = bindings.LoadDisplay();
+ RenderPackSettingOverrides clean = SanitizeOverrides(
+ pack,
+ current.RenderPack.SettingOverrides);
+ bindings.SaveDisplay(current with
+ {
+ RenderPack = current.RenderPack with
+ {
+ SettingOverrides = clean.Set(setting.Id, value),
+ },
+ });
+ }
+
+ switch (setting.Kind)
+ {
+ case RenderSettingKind.Boolean:
+ BuildExplicitToggleRow(
+ listBox,
+ setting.DisplayName,
+ bool.Parse(defaultValue),
+ page,
+ read: () => bool.Parse(Read()),
+ apply: value => Apply(value ? "true" : "false"),
+ IsCurrent);
+ break;
+
+ case RenderSettingKind.Float:
+ case RenderSettingKind.Integer:
+ double min = setting.Minimum!.Value;
+ double max = setting.Maximum!.Value;
+ double step = setting.Step
+ ?? (setting.Kind == RenderSettingKind.Integer ? 1d : 0d);
+ BuildExplicitNumericSliderRow(
+ listBox,
+ setting.DisplayName,
+ min,
+ max,
+ step,
+ setting.Kind == RenderSettingKind.Integer,
+ double.Parse(defaultValue, CultureInfo.InvariantCulture),
+ page,
+ read: () => double.Parse(Read(), CultureInfo.InvariantCulture),
+ apply: value => Apply(FormatNumeric(value, setting.Kind)),
+ IsCurrent);
+ break;
+
+ case RenderSettingKind.Choice:
+ BuildExplicitStringMenuRow(
+ listBox,
+ setting.DisplayName,
+ setting.Choices.Select(static value =>
+ new ExplicitMenuChoice(value, value, true, null)).ToArray(),
+ page,
+ read: Read,
+ apply: value =>
+ {
+ if (IsCurrent()) Apply(value);
+ },
+ defaultValue,
+ resolveSprite,
+ datFont,
+ debugFont);
+ break;
+ }
+ }
+
+ private static bool CanBuildSetting(RenderSettingDeclaration setting)
+ {
+ if (string.IsNullOrWhiteSpace(setting.Id)
+ || string.IsNullOrWhiteSpace(setting.DisplayName)
+ || !RenderPackSettingValueCodec.TryEncode(
+ setting,
+ setting.DefaultValue,
+ out _))
+ return false;
+ if (setting.Kind is RenderSettingKind.Float or RenderSettingKind.Integer)
+ {
+ return setting.Minimum is { } min
+ && setting.Maximum is { } max
+ && double.IsFinite(min)
+ && double.IsFinite(max)
+ && min >= -float.MaxValue
+ && max <= float.MaxValue
+ && max > min
+ && (setting.Step is null
+ || double.IsFinite(setting.Step.Value) && setting.Step.Value > 0);
+ }
+ return setting.Kind is RenderSettingKind.Boolean
+ || setting.Kind == RenderSettingKind.Choice && setting.Choices.Count > 0;
+ }
+
+ private static string ResolveSettingValue(
+ RenderPackChoice pack,
+ RenderPackPresetChoice preset,
+ RenderSettingDeclaration setting,
+ IReadOnlyDictionary userOverrides)
+ {
+ if (TryGet(userOverrides, setting.Id, out string? user)
+ && RenderPackSettingValueCodec.TryEncode(setting, user, out _))
+ return user;
+ RenderQualitySettingOverride? presetValue = preset.SettingOverrides
+ .FirstOrDefault(value => string.Equals(
+ value.SettingId,
+ setting.Id,
+ StringComparison.OrdinalIgnoreCase));
+ if (presetValue is not null
+ && RenderPackSettingValueCodec.TryEncode(setting, presetValue.Value, out _))
+ return presetValue.Value;
+ return setting.DefaultValue;
+ }
+
+ private static RenderPackSettingOverrides SanitizeOverrides(
+ RenderPackChoice pack,
+ IReadOnlyDictionary overrides)
+ {
+ var valid = new List>();
+ foreach ((string id, string value) in overrides)
+ {
+ RenderSettingDeclaration? setting = pack.Settings.FirstOrDefault(candidate =>
+ string.Equals(candidate.Id, id, StringComparison.OrdinalIgnoreCase));
+ if (setting is not null
+ && RenderPackSettingValueCodec.TryEncode(setting, value, out _))
+ valid.Add(new KeyValuePair(setting.Id, value));
+ }
+ return new RenderPackSettingOverrides(valid);
+ }
+
+ private static bool TryGet(
+ IReadOnlyDictionary values,
+ string id,
+ out string value)
+ {
+ if (values.TryGetValue(id, out value!))
+ return true;
+ foreach ((string key, string candidate) in values)
+ {
+ if (string.Equals(key, id, StringComparison.OrdinalIgnoreCase))
+ {
+ value = candidate;
+ return true;
+ }
+ }
+ value = string.Empty;
+ return false;
+ }
+
+ private static bool SamePack(
+ RenderPackSelectionSettings selection,
+ RenderPackChoice pack) =>
+ string.Equals(selection.PackId, pack.Id, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(selection.PackVersion, pack.Version, StringComparison.Ordinal);
+
+ private static string FormatNumeric(double value, RenderSettingKind kind) =>
+ kind == RenderSettingKind.Integer
+ ? checked((long)Math.Round(value)).ToString(CultureInfo.InvariantCulture)
+ : value.ToString("R", CultureInfo.InvariantCulture);
+ }
+
// ── Section 1: Sound Options ────────────────────────────────────────
private static void BindSoundSection(
@@ -729,9 +1281,9 @@ public static class ConfigOptionsPageController
{
BuildHeaderRow(listBox, "ID_Graphics_TextureSection", resolveString);
- // The whole section is store-only: the world renderer is
- // Vulkan + one aggregate QualityPreset, not per-feature knobs
- // (register row, OP6).
+ // Every row except Building Detail Textures is still store-only:
+ // #226 consumes that existing preference in the retail building and
+ // EnvCell detail replay. The other rows remain aggregate-preset gaps.
BuildMenuRow(
listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString,
read: () => bindings.LoadDisplay().LandscapeTextureDetail,
@@ -772,7 +1324,7 @@ public static class ConfigOptionsPageController
listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString,
read: () => bindings.LoadDisplay().BuildingDetailTextures,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { BuildingDetailTextures = value }),
- storeOnly: true); // AP-198
+ storeOnly: false); // LIVE — #226 retail building/EnvCell detail pass
BuildToggleRow(
listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString,
@@ -916,6 +1468,23 @@ public static class ConfigOptionsPageController
header.LinesProvider = () => new[] { new UiText.Line(label, header.DefaultColor) };
}
+ private static void BuildExplicitHeaderRow(
+ UiTemplateListBox listBox,
+ string label)
+ {
+ if (listBox.AddItemFromTemplateList(HeaderTemplateIndex) is not UiText header)
+ {
+ Console.WriteLine(
+ "[render-pack] Config header template did not build as UiText.");
+ return;
+ }
+
+ header.LinesProvider = () => new[]
+ {
+ new UiText.Line(label, header.DefaultColor),
+ };
+ }
+
private static void BuildSeparatorRow(UiTemplateListBox listBox)
{
if (listBox.AddItemFromTemplateList(SeparatorTemplateIndex) is null)
@@ -1314,6 +1883,231 @@ public static class ConfigOptionsPageController
};
}
+ private static UiMenu? BuildExplicitStringMenuRow(
+ UiTemplateListBox listBox,
+ string labelText,
+ IReadOnlyList choices,
+ OptionPage page,
+ Func read,
+ Action apply,
+ string defaultValue,
+ Func? resolveSprite,
+ UiDatFont? datFont,
+ BitmapFont? debugFont,
+ ExplicitMenuChoiceSource? dynamicChoices = null,
+ Action? captureOption = null)
+ {
+ UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex);
+ if (row is null)
+ {
+ Console.WriteLine(
+ $"[render-pack] Config menu template did not build for '{labelText}'.");
+ return null;
+ }
+
+ if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label)
+ {
+ label.LinesProvider = () => new[]
+ {
+ new UiText.Line(labelText, label.DefaultColor),
+ };
+ }
+
+ if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu)
+ {
+ Console.WriteLine(
+ $"[render-pack] No UiMenu leaf found for '{labelText}'.");
+ return null;
+ }
+
+ var choiceSource = dynamicChoices ?? new ExplicitMenuChoiceSource(choices);
+ ApplyMenuChrome(menu, resolveSprite, datFont, debugFont);
+ menu.Items = choiceSource.Choices
+ .Select(static choice => new UiMenu.MenuItem(choice.Label, choice.Id))
+ .ToArray();
+ menu.EnabledProvider = payload => choiceSource.Choices.Any(choice =>
+ choice.Enabled && Equals(choice.Id, payload));
+
+ string initial = read();
+ menu.Selected = initial;
+ menu.ButtonLabelProvider = () =>
+ {
+ string current = menu.Selected as string ?? initial;
+ return choiceSource.Choices.FirstOrDefault(choice => string.Equals(
+ choice.Id,
+ current,
+ StringComparison.OrdinalIgnoreCase))
+ .Label ?? current;
+ };
+ menu.TooltipText = choiceSource.Choices.FirstOrDefault(choice => string.Equals(
+ choice.Id,
+ initial,
+ StringComparison.OrdinalIgnoreCase))
+ .Tooltip;
+
+ var option = new StringOptionRow(
+ initial,
+ defaultValue,
+ apply: value =>
+ {
+ menu.Selected = value;
+ menu.TooltipText = choiceSource.Choices.FirstOrDefault(choice => string.Equals(
+ choice.Id,
+ value,
+ StringComparison.OrdinalIgnoreCase))
+ .Tooltip;
+ apply(value);
+ },
+ read,
+ refresh: value => menu.Selected = value);
+ page.Register(option);
+ captureOption?.Invoke(option);
+ menu.OnSelect = payload =>
+ {
+ if (payload is string value && choiceSource.Choices.Any(choice =>
+ choice.Enabled && string.Equals(
+ choice.Id,
+ value,
+ StringComparison.OrdinalIgnoreCase)))
+ option.SetCurrentValue(value);
+ };
+ return menu;
+ }
+
+ private static UiButton? BuildExplicitToggleRow(
+ UiTemplateListBox listBox,
+ string labelText,
+ bool defaultValue,
+ OptionPage page,
+ Func read,
+ Action apply,
+ Func isCurrent)
+ {
+ UiElement? row = listBox.AddItemFromTemplateList(ToggleTemplateIndex);
+ UiButton? checkbox = row is null ? null : FindCheckbox(row);
+ if (checkbox is null)
+ {
+ Console.WriteLine(
+ $"[render-pack] Toggle template did not build for '{labelText}'.");
+ return null;
+ }
+
+ checkbox.Label = labelText;
+ checkbox.LabelColor = Vector4.One;
+ bool initial = read();
+ checkbox.Selected = initial;
+ var option = new BoolOptionRow(
+ initial,
+ defaultValue,
+ apply: value =>
+ {
+ checkbox.Selected = value;
+ if (isCurrent()) apply(value);
+ },
+ read: () => isCurrent() ? read() : initial,
+ refresh: value => checkbox.Selected = value);
+ page.Register(option);
+ checkbox.OnClick = () =>
+ {
+ if (isCurrent()) option.SetCurrentValue(checkbox.Selected);
+ };
+ return checkbox;
+ }
+
+ private static UiScrollbar? BuildExplicitNumericSliderRow(
+ UiTemplateListBox listBox,
+ string labelText,
+ double min,
+ double max,
+ double step,
+ bool integer,
+ double defaultValue,
+ OptionPage page,
+ Func read,
+ Action apply,
+ Func isCurrent)
+ {
+ UiElement? row = listBox.AddItemFromTemplateList(RangedSliderTemplateIndex);
+ if (row is null)
+ {
+ Console.WriteLine(
+ $"[render-pack] Slider template did not build for '{labelText}'.");
+ return null;
+ }
+ if (UiElement.FindDescendant(row, SliderLabelElementId) is UiText label)
+ {
+ label.LinesProvider = () =>
+ [
+ new UiText.Line(labelText, label.DefaultColor),
+ ];
+ }
+ if (UiElement.FindDescendant(row, SliderRangeMinElementId) is UiText low)
+ {
+ string text = FormatExplicitNumber(min, integer);
+ low.LinesProvider = () => [new UiText.Line(text, low.DefaultColor)];
+ }
+ if (UiElement.FindDescendant(row, SliderRangeMaxElementId) is UiText high)
+ {
+ string text = FormatExplicitNumber(max, integer);
+ high.LinesProvider = () => [new UiText.Line(text, high.DefaultColor)];
+ }
+ if (UiElement.FindDescendant(row, SliderElementId) is not UiScrollbar slider)
+ {
+ Console.WriteLine(
+ $"[render-pack] No slider leaf found for '{labelText}'.");
+ return null;
+ }
+
+ double initialValue = SnapExplicitNumber(read(), min, max, step, integer);
+ float initial = (float)initialValue;
+ slider.SetScalarPosition((float)((initialValue - min) / (max - min)));
+ var option = new FloatOptionRow(
+ initial,
+ (float)SnapExplicitNumber(defaultValue, min, max, step, integer),
+ apply: value =>
+ {
+ double snapped = SnapExplicitNumber(value, min, max, step, integer);
+ slider.SetScalarPosition((float)((snapped - min) / (max - min)));
+ if (isCurrent()) apply(snapped);
+ },
+ read: () => isCurrent()
+ ? (float)SnapExplicitNumber(read(), min, max, step, integer)
+ : initial,
+ refresh: value =>
+ {
+ double snapped = SnapExplicitNumber(value, min, max, step, integer);
+ slider.SetScalarPosition((float)((snapped - min) / (max - min)));
+ });
+ page.Register(option);
+ slider.ScalarChanged = normalized =>
+ {
+ if (!isCurrent()) return;
+ double raw = min + normalized * (max - min);
+ option.SetCurrentValue((float)SnapExplicitNumber(raw, min, max, step, integer));
+ };
+ return slider;
+ }
+
+ private static double SnapExplicitNumber(
+ double value,
+ double min,
+ double max,
+ double step,
+ bool integer)
+ {
+ double clamped = Math.Clamp(value, min, max);
+ if (step > 0)
+ clamped = min + Math.Round((clamped - min) / step) * step;
+ if (integer)
+ clamped = Math.Round(clamped);
+ return Math.Clamp(clamped, min, max);
+ }
+
+ private static string FormatExplicitNumber(double value, bool integer) =>
+ integer
+ ? checked((long)Math.Round(value)).ToString(CultureInfo.InvariantCulture)
+ : value.ToString("R", CultureInfo.InvariantCulture);
+
private static void ApplyLabelAndTooltip(
UiButton checkbox, string labelKey, Func resolveString, bool storeOnly)
{
diff --git a/src/AcDream.App/UI/Layout/OptionPageModel.cs b/src/AcDream.App/UI/Layout/OptionPageModel.cs
index d1125e50..682a67b2 100644
--- a/src/AcDream.App/UI/Layout/OptionPageModel.cs
+++ b/src/AcDream.App/UI/Layout/OptionPageModel.cs
@@ -678,6 +678,24 @@ public sealed class OptionPage
_rows.Add(row);
}
+ ///
+ /// Detaches a dynamically-owned suffix while preserving the registered
+ /// prefix. Detached rows no longer participate in Apply/Reset/Defaults and
+ /// their page-notify callback is severed, so retained widgets removed from
+ /// the visual tree cannot keep this page alive or re-arm its buttons.
+ ///
+ public void RemoveTail(int retainedRowCount)
+ {
+ if (retainedRowCount < 0 || retainedRowCount > _rows.Count)
+ throw new ArgumentOutOfRangeException(nameof(retainedRowCount));
+ for (int i = _rows.Count - 1; i >= retainedRowCount; i--)
+ {
+ _rows[i].AttachPageNotify(static () => { });
+ _rows.RemoveAt(i);
+ }
+ OnOptionChanged?.Invoke();
+ }
+
/// OptionPage::Changed @0x004F2D60: true if ANY
/// registered row's own is true.
public bool Changed => _rows.Any(static row => row.Changed);
@@ -687,8 +705,9 @@ public sealed class OptionPage
/// , then re-evaluates .
public void Apply()
{
- foreach (IOptionRow row in _rows)
- row.SaveCurrentValue();
+ foreach (IOptionRow row in _rows.ToArray())
+ if (_rows.Contains(row))
+ row.SaveCurrentValue();
AfterApply?.Invoke();
OnOptionChanged?.Invoke();
}
@@ -702,7 +721,8 @@ public sealed class OptionPage
public void Reset()
{
foreach (IOptionRow row in _rows.Where(static row => row.Changed).ToArray())
- row.RestoreSavedValue();
+ if (_rows.Contains(row))
+ row.RestoreSavedValue();
OnOptionChanged?.Invoke();
}
@@ -711,8 +731,9 @@ public sealed class OptionPage
/// committing, then re-evaluates .
public void Defaults()
{
- foreach (IOptionRow row in _rows)
- row.RestoreDefaultValue();
+ foreach (IOptionRow row in _rows.ToArray())
+ if (_rows.Contains(row))
+ row.RestoreDefaultValue();
OnOptionChanged?.Invoke();
}
@@ -735,8 +756,9 @@ public sealed class OptionPage
///
public void ReloadFromLive()
{
- foreach (IOptionRow row in _rows)
- row.SaveCurrentValue();
+ foreach (IOptionRow row in _rows.ToArray())
+ if (_rows.Contains(row))
+ row.SaveCurrentValue();
OnOptionChanged?.Invoke();
}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 043aab3f..62b8c251 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -224,7 +224,11 @@ public sealed record OptionsRuntimeBindings(
Func LoadDisplay,
Action SaveDisplay,
Func LoadAudio,
- Action SaveAudio);
+ Action SaveAudio,
+ Func>?
+ LoadRenderPackChoices = null,
+ Func? LoadRenderPackCatalogRevision = null,
+ Func? LoadRenderPackFailureNotice = null);
///
/// Campaign FA slice FA3: the social panel's (Friends/Allegiance/
@@ -2884,7 +2888,18 @@ public sealed class RetailUiRuntime : IDisposable
// silently clobber CH6's filter/opacity edits with a
// stale snapshot the next time either surface saves.
LoadChat: () => _bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default,
- SaveChat: chat => _bindings.Chat.Store?.SaveChat(chat)),
+ SaveChat: chat => _bindings.Chat.Store?.SaveChat(chat))
+ {
+ RenderPacks = _bindings.Options.LoadRenderPackChoices is { } load
+ ? new Layout.ConfigOptionsPageController.RenderPackBindings(load)
+ {
+ LoadRevision =
+ _bindings.Options.LoadRenderPackCatalogRevision,
+ LoadFailureNotice =
+ _bindings.Options.LoadRenderPackFailureNotice,
+ }
+ : null,
+ },
// #378: the eight Config-tab dropdown menus need the SAME
// sprite/font resolvers every other retail-menu consumer
// (ChatWindowController's channel menu, VendorUiController's
diff --git a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
index ca5ab3a9..1f82b6f7 100644
--- a/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
+++ b/src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs
@@ -23,6 +23,29 @@ public interface IRetailUiAutomationCheckpoint
string? Error { get; }
}
+public enum RetailUiAutomationRenderPackState
+{
+ Retail,
+ CandidatePending,
+ Active,
+ FailedToRetail,
+}
+
+public readonly record struct RetailUiAutomationRenderPackStatus(
+ RetailUiAutomationRenderPackState State,
+ string PackId,
+ string PresetId,
+ long ActivationGeneration,
+ string? FailureReason)
+{
+ public static RetailUiAutomationRenderPackStatus Retail { get; } = new(
+ RetailUiAutomationRenderPackState.Retail,
+ "retail",
+ "off",
+ ActivationGeneration: 0,
+ FailureReason: null);
+}
+
///
/// Narrow bridge from retained-UI scripts to render/world lifecycle
/// diagnostics. Implementations run on the same update/render thread as the
@@ -33,6 +56,42 @@ public interface IRetailUiAutomationRuntime
bool IsWorldReady { get; }
bool IsWorldViewportVisible { get; }
int PortalMaterializationCount { get; }
+ int RenderPackPerformanceSampleCount => 0;
+ bool RenderPackFailedToRetail => false;
+ RetailUiAutomationRenderPackStatus RenderPackStatus =>
+ RetailUiAutomationRenderPackStatus.Retail;
+ int FramebufferWidth => 0;
+ int FramebufferHeight => 0;
+ bool TrySelectRenderPack(string presetId, out string error)
+ {
+ error = "render-pack selection automation is unavailable";
+ return false;
+ }
+ bool TryDisableRenderPack(out string error)
+ {
+ error = "render-pack selection automation is unavailable";
+ return false;
+ }
+ bool TryReenableRenderPack(out string error)
+ {
+ error = "render-pack selection automation is unavailable";
+ return false;
+ }
+ bool TryResizeFramebuffer(int width, int height, out string error)
+ {
+ error = "framebuffer resize automation is unavailable";
+ return false;
+ }
+ bool TryResetRenderPackPerformance(out string error)
+ {
+ error = "render-pack performance automation is unavailable";
+ return false;
+ }
+ bool TryRequestClientClose(out string error)
+ {
+ error = "client-close automation is unavailable";
+ return false;
+ }
bool TryRequestCheckpoint(
string name,
out IRetailUiAutomationCheckpoint? checkpoint,
@@ -205,7 +264,10 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
"command" => DoCommand(command),
"input" => DoInput(command),
"checkpoint" => DoCheckpoint(command),
+ "renderpack" => DoRenderPack(command),
+ "resize" => DoResize(command),
"screenshot" => DoScreenshot(command),
+ "close-client" => DoCloseClient(command),
_ => Stop(command, $"unknown command '{p[0]}'"),
};
}
@@ -333,7 +395,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
private bool DoWait(ScriptCommand command)
{
var p = command.Parts;
- if (p.Length < 2) return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ...");
+ if (p.Length < 2) return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized|render-pack|render-pack-samples|framebuffer ...");
string target = p[1].ToLowerInvariant();
if (target == "item")
@@ -376,7 +438,168 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
return WaitOrTimeout(command, TimeoutMs(p, 3, 60000), $"portal materialization {occurrence}");
}
- return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ...");
+ if (target == "render-pack-samples")
+ {
+ if (_runtime is null)
+ return Stop(command, "render-pack performance automation is unavailable");
+ if (p.Length < 3
+ || !TryParseInt(p[2], out int required)
+ || required <= 0)
+ {
+ return Stop(
+ command,
+ "usage: wait render-pack-samples [timeoutMs]");
+ }
+ if (_runtime.RenderPackPerformanceSampleCount >= required)
+ return true;
+ // An unavailable preset cannot ever fill an enhanced performance
+ // window. Finish this wait immediately so the following screenshot
+ // captures the controller's fully resource-free default fallback;
+ // the harness validates the exact failure class and zero-work
+ // metadata instead of turning every fallback into a five-minute
+ // timeout.
+ if (_runtime.RenderPackFailedToRetail)
+ return true;
+ return WaitOrTimeout(
+ command,
+ TimeoutMs(p, 3, 300000),
+ $"{required} render-pack performance samples");
+ }
+
+ if (target == "render-pack")
+ {
+ if (_runtime is null)
+ return Stop(command, "render-pack selection automation is unavailable");
+ if (p.Length < 3 || !TryNormalizeRenderPackPreset(p[2], out string preset))
+ {
+ return Stop(
+ command,
+ "usage: wait render-pack retail|low|medium|high|auto [timeoutMs]");
+ }
+
+ RetailUiAutomationRenderPackStatus status = _runtime.RenderPackStatus;
+ bool expectRetail = string.Equals(
+ preset,
+ "retail",
+ StringComparison.Ordinal);
+ if (expectRetail
+ && status.State == RetailUiAutomationRenderPackState.Retail
+ && string.Equals(status.PackId, "retail", StringComparison.OrdinalIgnoreCase)
+ && string.Equals(status.PresetId, "off", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ if (!expectRetail
+ && status.State == RetailUiAutomationRenderPackState.Active
+ && string.Equals(
+ status.PackId,
+ "acdream.atmospheric",
+ StringComparison.OrdinalIgnoreCase)
+ && string.Equals(status.PresetId, preset, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ if (status.State == RetailUiAutomationRenderPackState.FailedToRetail)
+ {
+ return Stop(
+ command,
+ $"render pack '{preset}' failed to retail: "
+ + (status.FailureReason ?? "no failure reason was published"));
+ }
+ return WaitOrTimeout(
+ command,
+ TimeoutMs(p, 3, 90000),
+ $"render pack '{preset}' activation");
+ }
+
+ if (target == "framebuffer")
+ {
+ if (_runtime is null)
+ return Stop(command, "framebuffer resize automation is unavailable");
+ if (p.Length < 4
+ || !TryParseInt(p[2], out int width)
+ || !TryParseInt(p[3], out int height)
+ || width <= 0
+ || height <= 0)
+ {
+ return Stop(
+ command,
+ "usage: wait framebuffer [timeoutMs]");
+ }
+ if (_runtime.FramebufferWidth == width
+ && _runtime.FramebufferHeight == height)
+ {
+ return true;
+ }
+ return WaitOrTimeout(
+ command,
+ TimeoutMs(p, 4, 30000),
+ $"framebuffer {width}x{height}");
+ }
+
+ return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized|render-pack|render-pack-samples|framebuffer ...");
+ }
+
+ private bool DoRenderPack(ScriptCommand command)
+ {
+ if (_runtime is null)
+ return Stop(command, "render-pack automation is unavailable");
+ var p = command.Parts;
+ if (p.Length == 2
+ && string.Equals(p[1], "reset-performance", StringComparison.OrdinalIgnoreCase))
+ {
+ return _runtime.TryResetRenderPackPerformance(out string error)
+ || Stop(command, error);
+ }
+ if (p.Length == 3
+ && string.Equals(p[1], "select", StringComparison.OrdinalIgnoreCase)
+ && TryNormalizeRenderPackPreset(p[2], out string preset))
+ {
+ return _runtime.TrySelectRenderPack(preset, out string error)
+ || Stop(command, error);
+ }
+ if (p.Length == 2
+ && string.Equals(p[1], "disable", StringComparison.OrdinalIgnoreCase))
+ {
+ return _runtime.TryDisableRenderPack(out string error)
+ || Stop(command, error);
+ }
+ if (p.Length == 2
+ && string.Equals(p[1], "reenable", StringComparison.OrdinalIgnoreCase))
+ {
+ return _runtime.TryReenableRenderPack(out string error)
+ || Stop(command, error);
+ }
+ return Stop(
+ command,
+ "usage: renderpack reset-performance | renderpack select retail|low|medium|high|auto | renderpack disable | renderpack reenable");
+ }
+
+ private bool DoResize(ScriptCommand command)
+ {
+ if (_runtime is null)
+ return Stop(command, "framebuffer resize automation is unavailable");
+ var p = command.Parts;
+ if (p.Length != 3
+ || !TryParseInt(p[1], out int width)
+ || !TryParseInt(p[2], out int height)
+ || width <= 0
+ || height <= 0)
+ {
+ return Stop(command, "usage: resize ");
+ }
+ return _runtime.TryResizeFramebuffer(width, height, out string error)
+ || Stop(command, error);
+ }
+
+ private static bool TryNormalizeRenderPackPreset(
+ string value,
+ out string preset)
+ {
+ preset = value.ToLowerInvariant();
+ if (preset == "off")
+ preset = "retail";
+ return preset is "retail" or "low" or "medium" or "high" or "auto";
}
private bool DoSleep(ScriptCommand command)
@@ -540,6 +763,16 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
return WaitOrTimeout(command, TimeoutMs(command.Parts, 2, 10000), $"screenshot '{name}'");
}
+ private bool DoCloseClient(ScriptCommand command)
+ {
+ if (command.Parts.Length != 1)
+ return Stop(command, "usage: close-client");
+ if (_runtime is null)
+ return Stop(command, "client-close automation is unavailable");
+ return _runtime.TryRequestClientClose(out string error)
+ || Stop(command, error);
+ }
+
private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label)
{
if (!HasExceeded(timeoutMs)) return false;
diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs
index 7b087934..5cff6a3c 100644
--- a/src/AcDream.App/UI/UiMenu.cs
+++ b/src/AcDream.App/UI/UiMenu.cs
@@ -39,6 +39,13 @@ public sealed class UiMenu : UiElement
///
public Action? OnOpen { get; set; }
+ ///
+ /// Optional interaction-boundary refresh invoked immediately before a
+ /// closed popup opens. Retained settings menus use it to consume an
+ /// event-driven catalog revision without polling during draw/update.
+ ///
+ public Action? BeforeOpen { get; set; }
+
/// Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled.
public Func
public string? TooltipText { get; set; }
+ /// Optional live tooltip source. Controllers use this when the
+ /// reason or cost behind a menu selection can change while the panel stays
+ /// open. A non-blank provider value takes precedence over
+ /// .
+ public Func? TooltipTextProvider { get; set; }
+
///
- public override string? GetTooltipText() =>
- string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
+ public override string? GetTooltipText()
+ {
+ string? live = TooltipTextProvider?.Invoke();
+ if (!string.IsNullOrWhiteSpace(live))
+ return live;
+ return string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
+ }
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template);
// ALSO the visible-row window height when Scrollable
@@ -271,7 +289,11 @@ public sealed class UiMenu : UiElement
if (_open == value) return;
// Before _open flips, so a handler that replaces Items is reflected in
// the very first measure/draw of this opening.
- if (value) OnOpen?.Invoke();
+ if (value)
+ {
+ BeforeOpen?.Invoke();
+ OnOpen?.Invoke();
+ }
_open = value;
if (FindRoot() is not { } root) return;
if (value) root.SetActivePopup(this, () => SetOpen(false));
diff --git a/src/AcDream.App/UI/UiTemplateListBox.cs b/src/AcDream.App/UI/UiTemplateListBox.cs
index f10241ff..f1f16b25 100644
--- a/src/AcDream.App/UI/UiTemplateListBox.cs
+++ b/src/AcDream.App/UI/UiTemplateListBox.cs
@@ -272,6 +272,29 @@ public sealed class UiTemplateListBox : UiDatElement
return row;
}
+ ///
+ /// Removes the dynamically-owned suffix after
+ /// without disturbing the retained prefix. This is the structural seam used by
+ /// optional Config-page extensions whose schema can change at runtime: retail's
+ /// authored prefix remains mounted, while only extension rows are detached.
+ /// Existing scroll is preserved and clamped by .
+ ///
+ public void RemoveTail(int retainedItemCount)
+ {
+ int count = _viewport?.Children.Count ?? 0;
+ if (retainedItemCount < 0 || retainedItemCount > count)
+ throw new ArgumentOutOfRangeException(nameof(retainedItemCount));
+ if (_viewport is null || retainedItemCount == count)
+ return;
+
+ for (int i = count - 1; i >= retainedItemCount; i--)
+ _viewport.RemoveChild(_viewport.Children[i]);
+ }
+
+ /// Current number of materialized row widgets. Reading this does
+ /// not wake a dormant list box.
+ public int ItemCount => _viewport?.Children.Count ?? 0;
+
///
/// Campaign FA slice FA3: removes every row previously added via
/// /, resetting
diff --git a/src/AcDream.App/World/WorldEnvironmentController.cs b/src/AcDream.App/World/WorldEnvironmentController.cs
index c605d733..ffec43eb 100644
--- a/src/AcDream.App/World/WorldEnvironmentController.cs
+++ b/src/AcDream.App/World/WorldEnvironmentController.cs
@@ -1,4 +1,5 @@
using AcDream.Core.Audio;
+using AcDream.Core.Content;
using AcDream.App.Rendering;
using AcDream.Core.World;
using AcDream.Runtime.World;
@@ -60,13 +61,15 @@ internal sealed class WorldEnvironmentController : IWorldSceneSkyStateSource
}
}
+ public int ActiveDayGroupIndex => Runtime.ActiveDayGroupIndex;
+
public float DayFraction => (float)WorldTime.DayFraction;
- public void Initialize(Region region)
+ public void Initialize(Region region, IDatObjectSource? dats = null)
{
ArgumentNullException.ThrowIfNull(region);
Initialize(
- SkyDescLoader.LoadFromRegion(region),
+ SkyDescLoader.LoadFromRegion(region, dats),
region.GameTime?.ZeroTimeOfYear);
}
diff --git a/src/AcDream.Core/Plugins/LoadedPlugin.cs b/src/AcDream.Core/Plugins/LoadedPlugin.cs
index 9f1f534a..b73190e9 100644
--- a/src/AcDream.Core/Plugins/LoadedPlugin.cs
+++ b/src/AcDream.Core/Plugins/LoadedPlugin.cs
@@ -1,14 +1,16 @@
using System.Runtime.Loader;
using AcDream.Plugin.Abstractions;
+using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Plugins;
///
/// Outcome of a plugin load attempt.
-/// On success, is the instantiated plugin,
-/// owns its assembly, and is null.
+/// On success, at least one of or
+/// is instantiated,
+/// owns the assembly, and is null.
/// On failure, describes what went wrong. A partial
-/// and/or may still be present;
+/// entry-point instances and/or may still be present;
/// the caller owns their cleanup. The loader never requests collectible unload
/// itself because the session must first roll back host registrations.
///
@@ -16,8 +18,11 @@ public sealed record LoadedPlugin(
PluginManifest Manifest,
IAcDreamPlugin? Plugin,
AssemblyLoadContext? LoadContext,
- Exception? Error)
+ Exception? Error,
+ IRenderPackPlugin? RenderPackPlugin = null)
{
public bool Success =>
- Plugin is not null && LoadContext is not null && Error is null;
+ (Plugin is not null || RenderPackPlugin is not null)
+ && LoadContext is not null
+ && Error is null;
}
diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs
index 7683f705..7e37ef60 100644
--- a/src/AcDream.Core/Plugins/PluginLoader.cs
+++ b/src/AcDream.Core/Plugins/PluginLoader.cs
@@ -1,5 +1,6 @@
using System.Reflection;
using AcDream.Plugin.Abstractions;
+using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Plugins;
@@ -7,14 +8,19 @@ public static class PluginLoader
{
///
/// Load a plugin DLL from into a collectible
- /// , find the first type
- /// implementing , instantiate it, and call its
- /// with the supplied host. Any failure
- /// is returned as a failed rather than thrown.
+ /// , resolve the gameplay
+ /// and/or render-pack entry points declared by its manifest, and invoke only
+ /// the facilities this host supplied. Any failure is returned as a failed
+ /// rather than thrown.
/// A returned partial plugin/context remains caller-owned; this method never
- /// requests unload because the caller must close host registrations first.
+ /// requests unload because the caller must close host and render-pack
+ /// registrations first.
///
- public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host)
+ public static LoadedPlugin Load(
+ string pluginDirectory,
+ PluginManifest manifest,
+ IPluginHost host,
+ IRenderPackRegistry? renderPacks = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
ArgumentNullException.ThrowIfNull(manifest);
@@ -45,6 +51,7 @@ public static class PluginLoader
PluginAssemblyLoadContext? alc = null;
IAcDreamPlugin? instance = null;
+ IRenderPackPlugin? renderPackInstance = null;
try
{
alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
@@ -60,10 +67,51 @@ public static class PluginLoader
types = rtle.Types.OfType();
}
- var pluginType = types
- .FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t));
+ Type[] concreteTypes = types
+ .Where(static type => !type.IsAbstract && !type.IsInterface)
+ .ToArray();
+ Type? pluginType = manifest.Declares(PluginKind.Gameplay)
+ ? concreteTypes.FirstOrDefault(
+ static type => typeof(IAcDreamPlugin).IsAssignableFrom(type))
+ : null;
+ bool registerRenderPack =
+ manifest.Declares(PluginKind.RenderPack) && renderPacks is not null;
+ CountingRenderPackRegistry? countedRenderPacks = registerRenderPack
+ ? new CountingRenderPackRegistry(renderPacks!)
+ : null;
+ Type? renderPackType = null;
+ if (registerRenderPack)
+ {
+ Type[] renderPackTypes = concreteTypes
+ .Where(static type => typeof(IRenderPackPlugin).IsAssignableFrom(type))
+ .ToArray();
+ if (renderPackTypes.Length != 1)
+ {
+ return new LoadedPlugin(
+ manifest,
+ Plugin: null,
+ LoadContext: alc,
+ Error: new InvalidOperationException(
+ $"render-pack entry DLL '{manifest.EntryDll}' must contain exactly "
+ + "one IRenderPackPlugin implementation; found "
+ + renderPackTypes.Length));
+ }
- if (pluginType is null)
+ renderPackType = renderPackTypes[0];
+ if (!renderPackType.IsVisible
+ || renderPackType.GetConstructor(Type.EmptyTypes) is null)
+ {
+ return new LoadedPlugin(
+ manifest,
+ Plugin: null,
+ LoadContext: alc,
+ Error: new InvalidOperationException(
+ $"render-pack entry type '{renderPackType.FullName}' must be public, "
+ + "non-abstract, and expose a public parameterless constructor"));
+ }
+ }
+
+ if (manifest.Declares(PluginKind.Gameplay) && pluginType is null)
{
return new LoadedPlugin(
manifest,
@@ -73,9 +121,58 @@ public static class PluginLoader
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"));
}
- instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
- instance.Initialize(host);
- return new LoadedPlugin(manifest, instance, alc, Error: null);
+ if (registerRenderPack && renderPackType is null)
+ {
+ return new LoadedPlugin(
+ manifest,
+ Plugin: null,
+ LoadContext: alc,
+ Error: new InvalidOperationException(
+ $"no IRenderPackPlugin implementation found in {manifest.EntryDll}"));
+ }
+
+ if (pluginType is null && renderPackType is null)
+ {
+ return new LoadedPlugin(
+ manifest,
+ Plugin: null,
+ LoadContext: alc,
+ Error: new InvalidOperationException(
+ "the host did not supply any facility declared by this plugin"));
+ }
+
+ object? sharedInstance = null;
+ if (pluginType is not null)
+ {
+ sharedInstance = Activator.CreateInstance(pluginType);
+ instance = (IAcDreamPlugin?)sharedInstance
+ ?? throw new InvalidOperationException(
+ $"could not construct IAcDreamPlugin {pluginType.FullName}");
+ instance.Initialize(host);
+ }
+
+ if (renderPackType is not null)
+ {
+ object renderObject = ReferenceEquals(renderPackType, pluginType)
+ ? sharedInstance!
+ : Activator.CreateInstance(renderPackType)
+ ?? throw new InvalidOperationException(
+ $"could not construct IRenderPackPlugin {renderPackType.FullName}");
+ renderPackInstance = (IRenderPackPlugin)renderObject;
+ renderPackInstance.Register(countedRenderPacks!);
+ if (countedRenderPacks!.RegistrationCount == 0)
+ {
+ throw new InvalidOperationException(
+ $"render-pack entry point '{renderPackType.FullName}' registered no packs");
+ }
+ }
+
+ return new LoadedPlugin(
+ manifest,
+ instance,
+ alc,
+ Error: null,
+ renderPackInstance);
}
catch (Exception ex)
{
@@ -87,7 +184,45 @@ public static class PluginLoader
manifest,
Plugin: instance,
LoadContext: alc,
- Error: ex);
+ Error: ex,
+ RenderPackPlugin: renderPackInstance);
+ }
+ }
+
+ private sealed class CountingRenderPackRegistry(IRenderPackRegistry inner) :
+ IRenderPackRegistry
+ {
+ private int _registrationCount;
+
+ internal int RegistrationCount => Volatile.Read(ref _registrationCount);
+
+ public IDisposable Register(
+ RenderPackDescriptor descriptor,
+ IRenderPackAssets assets)
+ {
+ IDisposable registration = inner.Register(descriptor, assets)
+ ?? throw new InvalidOperationException(
+ "The render-pack registry returned a null registration handle.");
+ Interlocked.Increment(ref _registrationCount);
+ return new CountedRegistration(this, registration);
+ }
+
+ private sealed class CountedRegistration(
+ CountingRenderPackRegistry owner,
+ IDisposable inner) : IDisposable
+ {
+ private CountingRenderPackRegistry? _owner = owner;
+ private IDisposable? _inner = inner;
+
+ public void Dispose()
+ {
+ CountingRenderPackRegistry? activeOwner =
+ Interlocked.Exchange(ref _owner, null);
+ if (activeOwner is null)
+ return;
+ Interlocked.Decrement(ref activeOwner._registrationCount);
+ Interlocked.Exchange(ref _inner, null)?.Dispose();
+ }
}
}
}
diff --git a/src/AcDream.Core/Plugins/PluginManifest.cs b/src/AcDream.Core/Plugins/PluginManifest.cs
index a6cfbd97..8bf2a708 100644
--- a/src/AcDream.Core/Plugins/PluginManifest.cs
+++ b/src/AcDream.Core/Plugins/PluginManifest.cs
@@ -2,6 +2,13 @@ using System.Text.Json;
namespace AcDream.Core.Plugins;
+/// Host facility an entry assembly declares in plugin.json.
+public enum PluginKind
+{
+ Gameplay,
+ RenderPack,
+}
+
public sealed record PluginManifest(
string Id,
string DisplayName,
@@ -10,6 +17,32 @@ public sealed record PluginManifest(
int ApiVersion,
IReadOnlyList Dependencies)
{
+ ///
+ /// Declared entry-point kinds. Old manifests omit kinds and remain
+ /// gameplay plugins, preserving the pre-render-pack loading contract.
+ ///
+ public IReadOnlyList Kinds { get; init; } = [PluginKind.Gameplay];
+
+ public PluginManifest(
+ string Id,
+ string DisplayName,
+ string Version,
+ string EntryDll,
+ int ApiVersion,
+ IReadOnlyList Dependencies,
+ IReadOnlyList Kinds)
+ : this(Id, DisplayName, Version, EntryDll, ApiVersion, Dependencies)
+ {
+ ArgumentNullException.ThrowIfNull(Kinds);
+ if (Kinds.Count == 0)
+ throw new ArgumentException("At least one plugin kind is required.", nameof(Kinds));
+ this.Kinds = Kinds
+ .Distinct()
+ .ToArray();
+ }
+
+ public bool Declares(PluginKind kind) => Kinds.Contains(kind);
+
public static PluginManifest Parse(string json)
{
PluginManifestDto? dto;
@@ -32,13 +65,40 @@ public sealed record PluginManifest(
if (dto.ApiVersion <= 0)
throw new PluginManifestException("apiVersion must be >= 1");
+ IReadOnlyList kinds = ParseKinds(dto.Kinds);
+
return new PluginManifest(
dto.Id!,
dto.DisplayName!,
dto.Version!,
dto.EntryDll!,
dto.ApiVersion,
- dto.Dependencies ?? Array.Empty());
+ dto.Dependencies ?? Array.Empty(),
+ kinds);
+ }
+
+ private static IReadOnlyList ParseKinds(IReadOnlyList? values)
+ {
+ if (values is null)
+ return [PluginKind.Gameplay];
+ if (values.Count == 0)
+ throw new PluginManifestException("kinds must contain at least one entry");
+
+ var kinds = new List(values.Count);
+ foreach (string? value in values)
+ {
+ if (string.IsNullOrWhiteSpace(value)
+ || !Enum.TryParse(value, ignoreCase: true, out PluginKind kind)
+ || !Enum.IsDefined(kind))
+ {
+ throw new PluginManifestException(
+ $"unknown plugin kind: {value ?? ""}");
+ }
+
+ if (!kinds.Contains(kind))
+ kinds.Add(kind);
+ }
+ return kinds;
}
private static void Require(string? value, string jsonFieldName)
@@ -61,6 +121,7 @@ public sealed record PluginManifest(
public string? EntryDll { get; set; }
public int ApiVersion { get; set; }
public IReadOnlyList? Dependencies { get; set; }
+ public IReadOnlyList? Kinds { get; set; }
}
}
diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs
index 1436fb6b..82d1a2d3 100644
--- a/src/AcDream.Core/Plugins/PluginSession.cs
+++ b/src/AcDream.Core/Plugins/PluginSession.cs
@@ -1,4 +1,5 @@
using AcDream.Plugin.Abstractions;
+using AcDream.Plugin.Abstractions.Rendering;
namespace AcDream.Core.Plugins;
@@ -17,16 +18,25 @@ public readonly record struct PluginSessionStatus(
PluginSessionStatusKind Kind,
string? Error = null);
+/// A configured plugin declares no entry point usable by this host.
+public sealed class PluginHostKindException : Exception
+{
+ public PluginHostKindException(string message) : base(message) { }
+}
+
///
/// One host/session-scoped plugin lifetime. Discovery, allow-listing,
-/// initialize/enable, failure isolation, reverse-order disable, and collectible
-/// load-context release are shared by graphical and no-window hosts so their
-/// configured plugin-set semantics cannot drift.
+/// initialize/enable, declarative render-pack registration, failure isolation,
+/// reverse-order disable, and collectible load-context release are shared by
+/// graphical and no-window hosts so their configured plugin-set semantics
+/// cannot drift. Unsupported kinds are filtered before assembly loading.
///
public sealed class PluginSession : IDisposable
{
private readonly IPluginHost _host;
private readonly Action? _report;
+ private readonly IRenderPackRegistry? _renderPacks;
+ private readonly HashSet _supportedKinds;
private readonly List _loaded = [];
private readonly List _releasedContexts = [];
private bool _started;
@@ -34,10 +44,28 @@ public sealed class PluginSession : IDisposable
public PluginSession(
IPluginHost host,
- Action? report = null)
+ Action? report = null,
+ IRenderPackRegistry? renderPacks = null,
+ IEnumerable? supportedKinds = null)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_report = report;
+ _renderPacks = renderPacks;
+ _supportedKinds = new HashSet(
+ supportedKinds
+ ?? (renderPacks is null
+ ? [PluginKind.Gameplay]
+ : [PluginKind.Gameplay, PluginKind.RenderPack]));
+ if (_supportedKinds.Count == 0)
+ throw new ArgumentException(
+ "At least one supported plugin kind is required.",
+ nameof(supportedKinds));
+ if (_supportedKinds.Contains(PluginKind.RenderPack) && renderPacks is null)
+ {
+ throw new ArgumentException(
+ "A host that supports render-pack plugins must supply a render-pack registry.",
+ nameof(renderPacks));
+ }
}
public int LoadedCount => _loaded.Count;
@@ -122,6 +150,25 @@ public sealed class PluginSession : IDisposable
string id = result.Manifest!.Id;
if (requestedSet is not null && !requestedSet.Contains(id))
continue;
+ if (!result.Manifest.Kinds.Any(_supportedKinds.Contains))
+ {
+ // A shared plugin root may contain graphical-only packs.
+ // An unfiltered/headless scan omits those silently. An
+ // explicitly requested id gets one precise host-kind
+ // failure, still without loading its assembly.
+ if (requestedSet is not null)
+ {
+ AddOrdered(discoveredOrder, id);
+ AddError(
+ errors,
+ id,
+ new PluginHostKindException(
+ $"plugin '{id}' declares only "
+ + $"{string.Join(", ", result.Manifest.Kinds)} entry points, "
+ + "which this host does not support."));
+ }
+ continue;
+ }
AddOrdered(discoveredOrder, id);
if (!candidates.TryGetValue(id, out List? list))
{
@@ -160,23 +207,27 @@ public sealed class PluginSession : IDisposable
{
ActivePlugin active = _loaded[index];
LoadedPlugin loaded = active.Loaded;
- try
+ if (loaded.Plugin is not null)
{
- loaded.Plugin!.Disable();
- }
- catch (Exception error)
- {
- SafeLog(
- static (log, message, exception) =>
- log.Error(message, exception),
- $"plugin disable failed: {loaded.Manifest.Id}",
- error);
+ try
+ {
+ loaded.Plugin.Disable();
+ }
+ catch (Exception error)
+ {
+ SafeLog(
+ static (log, message, exception) =>
+ log.Error(message, exception),
+ $"plugin disable failed: {loaded.Manifest.Id}",
+ error);
+ }
}
// Host-owned registrations are released even when Disable throws.
// This must precede ALC unload so no UI binding or event delegate
// can keep the plugin assembly reachable.
active.Scope.Dispose();
+ ReleaseRenderScope(active.RenderPackScope, loaded.Manifest.Id);
try
{
@@ -208,16 +259,23 @@ public sealed class PluginSession : IDisposable
foreach (PluginDiscoveryResult candidate in available)
{
var scope = new ScopedPluginHost(_host);
+ ScopedRenderPackRegistry? renderPackScope =
+ candidate.Manifest!.Declares(PluginKind.RenderPack)
+ && _renderPacks is not null
+ ? new ScopedRenderPackRegistry(_renderPacks)
+ : null;
LoadedPlugin loaded = PluginLoader.Load(
candidate.PluginDirectory,
- candidate.Manifest!,
- scope);
+ candidate.Manifest,
+ scope,
+ renderPackScope);
if (!loaded.Success)
{
// Initialize can register callbacks before it fails. The
// registration transaction closes before plugin cleanup
// and, critically, before any ALC Unloading notification.
scope.Dispose();
+ ReleaseRenderScope(renderPackScope, candidate.Manifest.Id);
ReleaseFailedLoad(loaded);
AddError(
errors,
@@ -229,8 +287,8 @@ public sealed class PluginSession : IDisposable
try
{
- loaded.Plugin!.Enable();
- _loaded.Add(new ActivePlugin(loaded, scope));
+ loaded.Plugin?.Enable();
+ _loaded.Add(new ActivePlugin(loaded, scope, renderPackScope));
SafeLog(
static (log, message, _) => log.Info(message),
$"plugin loaded: {loaded.Manifest.Id} "
@@ -244,7 +302,7 @@ public sealed class PluginSession : IDisposable
catch (Exception error)
{
AddError(errors, id, error);
- ReleaseFailedEnable(loaded, scope);
+ ReleaseFailedEnable(loaded, scope, renderPackScope);
}
}
}
@@ -274,22 +332,27 @@ public sealed class PluginSession : IDisposable
private void ReleaseFailedEnable(
LoadedPlugin loaded,
- ScopedPluginHost scope)
+ ScopedPluginHost scope,
+ ScopedRenderPackRegistry? renderPackScope)
{
- try
+ if (loaded.Plugin is not null)
{
- loaded.Plugin!.Disable();
- }
- catch (Exception error)
- {
- SafeLog(
- static (log, message, exception) =>
- log.Error(message, exception),
- $"plugin cleanup after enable failure failed: {loaded.Manifest.Id}",
- error);
+ try
+ {
+ loaded.Plugin.Disable();
+ }
+ catch (Exception error)
+ {
+ SafeLog(
+ static (log, message, exception) =>
+ log.Error(message, exception),
+ $"plugin cleanup after enable failure failed: {loaded.Manifest.Id}",
+ error);
+ }
}
scope.Dispose();
+ ReleaseRenderScope(renderPackScope, loaded.Manifest.Id);
_releasedContexts.Add(new WeakReference(loaded.LoadContext!));
try
@@ -342,6 +405,26 @@ public sealed class PluginSession : IDisposable
}
}
+ private void ReleaseRenderScope(
+ ScopedRenderPackRegistry? scope,
+ string pluginId)
+ {
+ if (scope is null)
+ return;
+ try
+ {
+ scope.Dispose();
+ }
+ catch (Exception error)
+ {
+ SafeLog(
+ static (log, message, exception) =>
+ log.Error(message, exception),
+ $"render-pack registration cleanup failed: {pluginId}",
+ error);
+ }
+ }
+
private void Report(PluginSessionStatus status)
{
if (_report is null)
@@ -417,5 +500,6 @@ public sealed class PluginSession : IDisposable
private sealed record ActivePlugin(
LoadedPlugin Loaded,
- ScopedPluginHost Scope);
+ ScopedPluginHost Scope,
+ ScopedRenderPackRegistry? RenderPackScope);
}
diff --git a/src/AcDream.Core/Plugins/ScopedRenderPackRegistry.cs b/src/AcDream.Core/Plugins/ScopedRenderPackRegistry.cs
new file mode 100644
index 00000000..6479fd4e
--- /dev/null
+++ b/src/AcDream.Core/Plugins/ScopedRenderPackRegistry.cs
@@ -0,0 +1,96 @@
+using AcDream.Plugin.Abstractions.Rendering;
+
+namespace AcDream.Core.Plugins;
+
+///
+/// Per-plugin render-pack registration transaction. Every successful catalog
+/// registration is withdrawn before the plugin's collectible load context is
+/// released, including partial Register failures.
+///
+internal sealed class ScopedRenderPackRegistry : IRenderPackRegistry, IDisposable
+{
+ private readonly IRenderPackRegistry _inner;
+ private readonly object _gate = new();
+ private readonly List _registrations = [];
+ private bool _disposed;
+
+ internal ScopedRenderPackRegistry(IRenderPackRegistry inner) =>
+ _inner = inner ?? throw new ArgumentNullException(nameof(inner));
+
+ public IDisposable Register(
+ RenderPackDescriptor descriptor,
+ IRenderPackAssets assets)
+ {
+ ArgumentNullException.ThrowIfNull(descriptor);
+ ArgumentNullException.ThrowIfNull(assets);
+
+ lock (_gate)
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ IDisposable innerRegistration = _inner.Register(descriptor, assets)
+ ?? throw new InvalidOperationException(
+ "The render-pack registry returned a null registration handle.");
+ var registration = new RegistrationHandle(this, innerRegistration);
+ lock (_gate)
+ {
+ if (!_disposed)
+ {
+ _registrations.Add(registration);
+ return registration;
+ }
+ }
+
+ registration.Dispose();
+ throw new ObjectDisposedException(nameof(ScopedRenderPackRegistry));
+ }
+
+ public void Dispose()
+ {
+ RegistrationHandle[] registrations;
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ registrations = _registrations.ToArray();
+ _registrations.Clear();
+ }
+
+ List? failures = null;
+ for (int index = registrations.Length - 1; index >= 0; index--)
+ {
+ try { registrations[index].Dispose(); }
+ catch (Exception error) { (failures ??= []).Add(error); }
+ }
+
+ if (failures is not null)
+ {
+ throw new AggregateException(
+ "One or more render-pack registrations could not be withdrawn.",
+ failures);
+ }
+ }
+
+ private void Release(RegistrationHandle registration)
+ {
+ lock (_gate)
+ _registrations.Remove(registration);
+ registration.DisposeInner();
+ }
+
+ private sealed class RegistrationHandle(
+ ScopedRenderPackRegistry owner,
+ IDisposable inner) : IDisposable
+ {
+ private ScopedRenderPackRegistry? _owner = owner;
+ private IDisposable? _inner = inner;
+
+ public void Dispose()
+ {
+ Interlocked.Exchange(ref _owner, null)?.Release(this);
+ }
+
+ internal void DisposeInner() =>
+ Interlocked.Exchange(ref _inner, null)?.Dispose();
+ }
+}
diff --git a/src/AcDream.Core/Rendering/TranslucencyFadeManager.cs b/src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
index 58be5c04..4e9633a9 100644
--- a/src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
+++ b/src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
@@ -53,6 +53,15 @@ public sealed class TranslucencyFadeManager
// frames keep reading the settled value.
private readonly Dictionary> _committed = new();
+ private ulong _revision = 1;
+
+ ///
+ /// Advances whenever the committed per-part opacity topology changes.
+ /// Render products may use this to retain classification while no fade
+ /// state changes, and to rebuild exact caster membership when it does.
+ ///
+ public ulong Revision => _revision;
+
///
/// Start (or replace) a translucency ramp for one Setup part of one
/// entity. Mirrors CPhysicsObj::SetPartTranslucency: a
@@ -151,8 +160,10 @@ public sealed class TranslucencyFadeManager
/// Drop all fade state for an entity (despawn / unload).
public void ClearEntity(uint entityId)
{
- _activeFades.Remove(entityId);
- _committed.Remove(entityId);
+ bool changed = _activeFades.Remove(entityId);
+ changed |= _committed.Remove(entityId);
+ if (changed)
+ AdvanceRevision();
}
private void Commit(uint entityId, uint partIndex, float value)
@@ -162,6 +173,26 @@ public sealed class TranslucencyFadeManager
parts = new Dictionary();
_committed[entityId] = parts;
}
+
+ if (parts.TryGetValue(partIndex, out float prior)
+ && BitConverter.SingleToInt32Bits(prior)
+ == BitConverter.SingleToInt32Bits(value))
+ {
+ return;
+ }
+
parts[partIndex] = value;
+ AdvanceRevision();
+ }
+
+ private void AdvanceRevision()
+ {
+ if (_revision == ulong.MaxValue)
+ {
+ throw new InvalidOperationException(
+ "Translucency fade revision space was exhausted.");
+ }
+
+ _revision++;
}
}
diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs
index 81e67249..e3a09fca 100644
--- a/src/AcDream.Core/Terrain/LandblockMesh.cs
+++ b/src/AcDream.Core/Terrain/LandblockMesh.cs
@@ -56,27 +56,21 @@ public static class LandblockMesh
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
// Pre-sample all 81 heights into a 2D array (x-major indexing). This
- // doubles as the source for per-vertex normals via central differences
- // (Phase 3b lighting, preserved through the per-cell refactor).
+ // is also the source for retail's topology-aware vertex normals.
var heights = new float[HeightmapSide, HeightmapSide];
for (int x = 0; x < HeightmapSide; x++)
for (int y = 0; y < HeightmapSide; y++)
heights[x, y] = heightTable[block.Height[x * HeightmapSide + y]];
- // Pre-compute all 81 vertex normals so the inner cell loop is a pure
- // lookup. Central differences on the heightmap → smooth normal field.
- var normals = new Vector3[HeightmapSide, HeightmapSide];
- for (int x = 0; x < HeightmapSide; x++)
- for (int y = 0; y < HeightmapSide; y++)
- {
- int xL = Math.Max(x - 1, 0);
- int xR = Math.Min(x + 1, HeightmapSide - 1);
- int yD = Math.Max(y - 1, 0);
- int yU = Math.Min(y + 1, HeightmapSide - 1);
- float dx = (heights[xR, y] - heights[xL, y]) / ((xR - xL) * CellSize);
- float dy = (heights[x, yU] - heights[x, yD]) / ((yU - yD) * CellSize);
- normals[x, y] = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
- }
+ // Retail CLandBlockStruct::calc_lighting accumulates the normalized
+ // plane normal of every incident terrain polygon at each of the 81
+ // shared height-sample vertices, then normalizes the sum. Use the same
+ // split hash and triangle topology as the emitted mesh; this changes
+ // lighting only, never positions, indices, or the collision surface.
+ var normals = BuildRetailVertexNormals(
+ heights,
+ landblockX,
+ landblockY);
var vertices = new TerrainVertex[VerticesPerLandblock];
var indices = new uint[VerticesPerLandblock]; // 1 index per vertex (no deduplication)
@@ -173,6 +167,85 @@ public static class LandblockMesh
return new LandblockMeshData(vertices, indices);
}
+ private static Vector3[,] BuildRetailVertexNormals(
+ float[,] heights,
+ uint landblockX,
+ uint landblockY)
+ {
+ var normalSums = new Vector3[HeightmapSide, HeightmapSide];
+
+ for (int cy = 0; cy < CellsPerSide; cy++)
+ {
+ for (int cx = 0; cx < CellsPerSide; cx++)
+ {
+ var posBL = new Vector3( cx * CellSize, cy * CellSize, heights[cx, cy ]);
+ var posBR = new Vector3((cx + 1) * CellSize, cy * CellSize, heights[cx + 1, cy ]);
+ var posTR = new Vector3((cx + 1) * CellSize, (cy + 1) * CellSize, heights[cx + 1, cy + 1]);
+ var posTL = new Vector3( cx * CellSize, (cy + 1) * CellSize, heights[cx, cy + 1]);
+
+ var split = TerrainBlending.CalculateSplitDirection(
+ landblockX, (uint)cx, landblockY, (uint)cy);
+
+ if (split == CellSplitDirection.SWtoNE)
+ {
+ AccumulateFaceNormal(
+ normalSums,
+ posBL, cx, cy,
+ posBR, cx + 1, cy,
+ posTR, cx + 1, cy + 1);
+ AccumulateFaceNormal(
+ normalSums,
+ posBL, cx, cy,
+ posTR, cx + 1, cy + 1,
+ posTL, cx, cy + 1);
+ }
+ else
+ {
+ AccumulateFaceNormal(
+ normalSums,
+ posBL, cx, cy,
+ posBR, cx + 1, cy,
+ posTL, cx, cy + 1);
+ AccumulateFaceNormal(
+ normalSums,
+ posBR, cx + 1, cy,
+ posTR, cx + 1, cy + 1,
+ posTL, cx, cy + 1);
+ }
+ }
+ }
+
+ var normals = new Vector3[HeightmapSide, HeightmapSide];
+ for (int x = 0; x < HeightmapSide; x++)
+ {
+ for (int y = 0; y < HeightmapSide; y++)
+ {
+ Vector3 sum = normalSums[x, y];
+ normals[x, y] = sum.LengthSquared() > 0f
+ ? Vector3.Normalize(sum)
+ : Vector3.UnitZ;
+ }
+ }
+
+ return normals;
+ }
+
+ private static void AccumulateFaceNormal(
+ Vector3[,] normalSums,
+ Vector3 p0, int x0, int y0,
+ Vector3 p1, int x1, int y1,
+ Vector3 p2, int x2, int y2)
+ {
+ Vector3 cross = Vector3.Cross(p1 - p0, p2 - p0);
+ if (cross.LengthSquared() <= 0f)
+ return;
+
+ Vector3 faceNormal = Vector3.Normalize(cross);
+ normalSums[x0, y0] += faceNormal;
+ normalSums[x1, y1] += faceNormal;
+ normalSums[x2, y2] += faceNormal;
+ }
+
private static void WriteCell(
TerrainVertex[] verts, ref int vi,
uint d0, uint d1, uint d2, uint d3,
diff --git a/src/AcDream.Core/Terrain/TerrainVertex.cs b/src/AcDream.Core/Terrain/TerrainVertex.cs
index a031e837..69775c23 100644
--- a/src/AcDream.Core/Terrain/TerrainVertex.cs
+++ b/src/AcDream.Core/Terrain/TerrainVertex.cs
@@ -11,11 +11,11 @@ namespace AcDream.Core.Terrain;
/// which of the 4 cell corners a given vertex represents from
/// gl_VertexID % 6 plus the split direction bit.
///
-/// Normal is stored per vertex via Phase 3b's central-difference scheme on
-/// the 9×9 heightmap — this lets the fragment shader interpolate a smooth
-/// normal across triangles (softer than WorldBuilder's dFdx/dFdy
-/// flat-shaded approach). UVs are derived from the corner index in the
-/// vertex shader — not stored here.
+/// Normal is stored per vertex using retail's terrain-lighting rule: each
+/// shared height-sample vertex receives the normalized plane normals of its
+/// incident, split-aware terrain triangles and normalizes their sum. The
+/// fragment shader interpolates that smooth result across triangles. UVs are
+/// derived from the corner index in the vertex shader — not stored here.
///
/// Size: 12 (position) + 12 (normal) + 4*4 (Data0..3) = 40 bytes.
///
diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs
index 3311cef4..fdfb2faf 100644
--- a/src/AcDream.Core/World/SkyDescLoader.cs
+++ b/src/AcDream.Core/World/SkyDescLoader.cs
@@ -38,6 +38,14 @@ public sealed class SkyObjectData
public uint PesObjectId;
public uint Properties;
+ ///
+ /// Source GfxObj sort centre. Celestial billboards are authored at their
+ /// apparent direction from the camera, so transforming and normalizing
+ /// this point yields the exact direction rendered by the sky pass. Zero
+ /// means the optional DAT lookup was unavailable.
+ ///
+ public Vector3 AuthoredSortCenter;
+
///
/// True when this SkyObject is gated on the weather system (Properties
/// bit 0x04). Per the named retail decomp,
@@ -140,6 +148,9 @@ public sealed class SkyObjectReplaceData
public float Transparent;
public float Luminosity;
public float MaxBright;
+
+ /// Sort centre for a replacement .
+ public Vector3 AuthoredSortCenter;
}
///
@@ -334,7 +345,7 @@ public static class SkyDescLoader
ArgumentNullException.ThrowIfNull(dats);
var region = dats.Get(RegionDatId);
if (region is null) return null;
- return LoadFromRegion(region);
+ return LoadFromRegion(region, dats);
}
///
@@ -352,7 +363,9 @@ public static class SkyDescLoader
/// GfxObjReplace swap pattern.
///
///
- public static LoadedSkyDesc? LoadFromRegion(Region region)
+ public static LoadedSkyDesc? LoadFromRegion(
+ Region region,
+ IDatObjectSource? dats = null)
{
ArgumentNullException.ThrowIfNull(region);
if (!region.PartsMask.HasFlag(PartsMask.HasSkyInfo) || region.SkyInfo is null)
@@ -367,8 +380,12 @@ public static class SkyDescLoader
foreach (var dg in sky.DayGroups)
{
- var objs = dg.SkyObjects.Select(ConvertSkyObject).ToList();
- var times = dg.SkyTime.Select(ConvertTimeOfDay).ToList();
+ var objs = dg.SkyObjects
+ .Select(value => ConvertSkyObject(value, dats))
+ .ToList();
+ var times = dg.SkyTime
+ .Select(value => ConvertTimeOfDay(value, dats))
+ .ToList();
dayGroups.Add(new DayGroupData
{
@@ -495,7 +512,9 @@ public static class SkyDescLoader
Console.WriteLine("[sky-dump] ======== END SkyDesc dump ========");
}
- private static SkyObjectData ConvertSkyObject(SkyObject s) => new()
+ private static SkyObjectData ConvertSkyObject(
+ SkyObject s,
+ IDatObjectSource? dats) => new()
{
BeginTime = s.BeginTime,
EndTime = s.EndTime,
@@ -506,9 +525,14 @@ public static class SkyDescLoader
GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u,
PesObjectId = s.DefaultPesObjectId?.DataId ?? 0u,
Properties = s.Properties,
+ AuthoredSortCenter = ResolveSortCenter(
+ s.DefaultGfxObjectId?.DataId ?? 0u,
+ dats),
};
- private static DatSkyKeyframeData ConvertTimeOfDay(SkyTimeOfDay s)
+ private static DatSkyKeyframeData ConvertTimeOfDay(
+ SkyTimeOfDay s,
+ IDatObjectSource? dats)
{
// Transparent / Luminosity / MaxBright are stored in the retail
// Region dat as PERCENTAGES (0..100), not fractions (0..1). Our
@@ -537,6 +561,9 @@ public static class SkyDescLoader
Transparent = r.Transparent / 100f,
Luminosity = r.Luminosity / 100f,
MaxBright = r.MaxBright / 100f,
+ AuthoredSortCenter = ResolveSortCenter(
+ r.GfxObjId?.DataId ?? 0u,
+ dats),
}).ToList();
var fogMode = s.WorldFog switch
@@ -577,6 +604,26 @@ public static class SkyDescLoader
};
}
+ private static Vector3 ResolveSortCenter(
+ uint gfxObjId,
+ IDatObjectSource? dats)
+ {
+ if (dats is null || (gfxObjId & 0xFF000000u) != 0x01000000u)
+ return Vector3.Zero;
+
+ try
+ {
+ return dats.TryGet(gfxObjId, out var gfx) && gfx is not null
+ ? gfx.SortCenter
+ : Vector3.Zero;
+ }
+ catch
+ {
+ // Enhancement metadata cannot make authoritative sky loading fail.
+ return Vector3.Zero;
+ }
+ }
+
///
/// stores bytes as B,G,R,A — but the logical
/// channel mapping is just "R/G/B in 0..255". Convert to linear
diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
index d199752b..6791670f 100644
--- a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
+++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs
@@ -60,7 +60,9 @@ internal sealed class HeadlessPluginSession : IDisposable
() => runtime.Generation.Value));
var plugins = new PluginSession(
host,
- status => Report(statusWriter, sessionId, status));
+ status => Report(statusWriter, sessionId, status),
+ renderPacks: null,
+ supportedKinds: [PluginKind.Gameplay]);
return new HeadlessPluginSession(
host,
plugins,
diff --git a/src/AcDream.Platform/ApplicationPathSet.cs b/src/AcDream.Platform/ApplicationPathSet.cs
index a6743ad1..76e3a1f5 100644
--- a/src/AcDream.Platform/ApplicationPathSet.cs
+++ b/src/AcDream.Platform/ApplicationPathSet.cs
@@ -72,6 +72,16 @@ public sealed record ApplicationPathSet(
{
platform ??= ApplicationPathEnvironment.Instance;
+ // Explicit method/CLI arguments remain authoritative. The environment
+ // seam lets graphical automation and portable installations isolate
+ // all mutable user state without rewriting the real user's settings.
+ configDirectory ??= NonEmpty(
+ platform.GetEnvironmentVariable("ACDREAM_CONFIG_DIR"));
+ dataDirectory ??= NonEmpty(
+ platform.GetEnvironmentVariable("ACDREAM_DATA_DIR"));
+ cacheDirectory ??= NonEmpty(
+ platform.GetEnvironmentVariable("ACDREAM_CACHE_DIR"));
+
string config;
string data;
string cache;
@@ -140,6 +150,9 @@ public sealed record ApplicationPathSet(
return Path.Combine(root, leaf);
}
+ private static string? NonEmpty(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? null : value;
+
private static string RequireFolder(
IApplicationPathEnvironment platform,
Environment.SpecialFolder folder)
diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackContracts.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackContracts.cs
new file mode 100644
index 00000000..03ea1d65
--- /dev/null
+++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackContracts.cs
@@ -0,0 +1,50 @@
+namespace AcDream.Plugin.Abstractions.Rendering;
+
+/// The public render-pack contract version.
+public static class RenderPackApi
+{
+ /// The newest contract this build implements.
+ public const int Current = 1;
+
+ /// The oldest contract this build can still consume.
+ public const int MinimumSupported = 1;
+
+ /// Whether a descriptor's contract version can be consumed.
+ public static bool IsSupported(int apiVersion) =>
+ apiVersion >= MinimumSupported && apiVersion <= Current;
+}
+
+///
+/// Optional plugin entry point for declarative graphics enhancements. The host
+/// invokes this only in a graphical process; no-window hosts never expose a
+/// registry or ask a render-pack entry point to register.
+///
+public interface IRenderPackPlugin
+{
+ /// Register every pack supplied by this plugin.
+ void Register(IRenderPackRegistry registry);
+}
+
+///
+/// Host-owned render-pack catalog. Registration is descriptive only: it must
+/// not open assets or allocate GPU objects.
+///
+public interface IRenderPackRegistry
+{
+ ///
+ /// Register one immutable descriptor and its lazy asset source. Disposing
+ /// the returned handle withdraws the pack and every host reference to the
+ /// asset source.
+ ///
+ IDisposable Register(RenderPackDescriptor descriptor, IRenderPackAssets assets);
+}
+
+///
+/// Lazy, key-addressed assets for one pack. The renderer opens assets only
+/// while validating an explicitly selected candidate.
+///
+public interface IRenderPackAssets
+{
+ /// Open a new readable stream for a descriptor-declared key.
+ Stream OpenRead(string assetKey);
+}
diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs
new file mode 100644
index 00000000..d93fb470
--- /dev/null
+++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackDeclarations.cs
@@ -0,0 +1,432 @@
+namespace AcDream.Plugin.Abstractions.Rendering;
+
+/// Prerequisite tier reached by a pack.
+public enum RenderPackTier
+{
+ Tier1 = 1,
+ Tier2 = 2,
+ Tier2Plus = 3,
+}
+
+///
+/// Renderer-owned facilities a pack may require or use opportunistically.
+/// These are semantic capabilities, not Vulkan extension or feature names.
+///
+public enum RenderCapability
+{
+ MainWorldColorIntermediate,
+ FullscreenPasses,
+ SceneDepthSampling,
+ SceneNormalSampling,
+ AuthoredSunDirection,
+ AuthoredSunScreenPosition,
+ AuthoredWeather,
+ DirectionalShadowMaps,
+ OutdoorDirectionalShadowCasterReplay,
+ AnimatedCasterTransforms,
+ AlphaCutoutShadowCasters,
+ GpuTimestampQueries,
+ /// One layered directional-depth pass may address multiple cascade views.
+ MultiviewDirectionalShadowCascades,
+ /// One renderer-selected authored sun-or-moon shadow direction.
+ AuthoredCelestialDirectionalLight,
+}
+
+/// Fixed renderer-owned positions at which a declared pass may run.
+public enum RenderPassHook
+{
+ ShadowDepthBeforeWorld,
+ AtmosphereBeforeToneMap,
+ ToneMap,
+ AfterToneMapBeforePrivateViewports,
+}
+
+/// Immutable frame facts the renderer may bind for a pack.
+public enum RenderSemanticInput
+{
+ WorldColor,
+ SceneDepth,
+ SceneNormals,
+ SunDirection,
+ SunScreenPosition,
+ ActiveDayGroup,
+ Weather,
+ CameraMatrices,
+ ShadowCasterTransforms,
+ DirectionalShadowMaps,
+ FrameTime,
+ /// Selected surface-to-sun-or-moon direction for shadow work.
+ SelectedCelestialDirectionalLight,
+}
+
+/// Renderer-owned replay operations available to a declaration.
+public enum RenderSceneReplaySemantic
+{
+ OutdoorDirectionalShadowCasters,
+}
+
+/// Existing retained-scene classes eligible for a scene replay.
+[Flags]
+public enum RenderCasterClass
+{
+ None = 0,
+ Terrain = 1 << 0,
+ OpaqueWorld = 1 << 1,
+ AlphaCutoutWorld = 1 << 2,
+ AnimatedOpaque = 1 << 3,
+ AnimatedAlphaCutout = 1 << 4,
+}
+
+/// Base renderer pipeline a pack may specialize.
+public enum RenderPipelineBaseSemantic
+{
+ Terrain,
+ WorldMesh,
+ EnvCell,
+}
+
+/// Material classifications accepted by a pipeline variant.
+[Flags]
+public enum RenderMaterialClass
+{
+ None = 0,
+ Opaque = 1 << 0,
+ AlphaCutout = 1 << 1,
+ AnimatedOpaque = 1 << 2,
+ AnimatedAlphaCutout = 1 << 3,
+}
+
+/// Kind of renderer-owned intermediate resource.
+public enum RenderResourceKind
+{
+ Image2D,
+ Image2DArray,
+ Buffer,
+}
+
+/// Portable format families resolved by the renderer.
+public enum RenderFormatClass
+{
+ LdrColor,
+ HdrColor,
+ SingleChannel,
+ DirectionalDepth,
+ StructuredData,
+}
+
+/// How declared image dimensions are interpreted.
+public enum RenderExtentMode
+{
+ AbsolutePixels,
+ RelativeToMainWorld,
+ RelativeToOutput,
+}
+
+/// Permitted uses of a declared resource.
+[Flags]
+public enum RenderResourceUsage
+{
+ None = 0,
+ Sampled = 1 << 0,
+ ColorAttachment = 1 << 1,
+ DepthAttachment = 1 << 2,
+ Storage = 1 << 3,
+ TransferSource = 1 << 4,
+ TransferDestination = 1 << 5,
+}
+
+/// Lifetime class used by the renderer's frame-flight allocator.
+public enum RenderResourceLifetime
+{
+ TransientPass,
+ FrameFlight,
+ ActivePack,
+}
+
+///
+/// Renderer-owned meaning of a declared resource. is
+/// available to ordinary declarative fullscreen graphs; the remaining values
+/// let a pack request host executors without relying on magic resource IDs.
+///
+public enum RenderResourceSemantic
+{
+ Custom,
+ MainWorldHdr,
+ BloomPing,
+ BloomPong,
+ SunOcclusionMask,
+ SunRays,
+ DirectionalShadowDepth,
+ VolumetricShafts,
+}
+
+/// Shape of one image declaration.
+/// Absolute pixels or a scale relative to a renderer surface.
+/// Pixel width for absolute mode; horizontal scale otherwise.
+/// Pixel height for absolute mode; vertical scale otherwise.
+/// Array layers; one for an ordinary 2-D image.
+public sealed record RenderExtentDeclaration(
+ RenderExtentMode Mode,
+ double Width,
+ double Height,
+ int Layers = 1);
+
+/// One renderer-owned intermediate image or buffer.
+public sealed record RenderResourceDeclaration(
+ string Id,
+ RenderResourceKind Kind,
+ RenderFormatClass Format,
+ RenderExtentDeclaration? Extent,
+ long SizeBytes,
+ RenderResourceUsage Usage,
+ RenderResourceLifetime Lifetime,
+ long EstimatedResidentBytes)
+{
+ public RenderResourceSemantic Semantic { get; init; } = RenderResourceSemantic.Custom;
+}
+
+///
+/// Renderer-owned execution meaning of a pass. IDs remain pack-owned stable
+/// identifiers; semantic execution never depends on a particular ID string.
+///
+public enum RenderPassSemantic
+{
+ CustomFullscreen,
+ DirectionalShadowDepth,
+ BloomDownsample,
+ BloomBlurHorizontal,
+ BloomBlurVertical,
+ SunOcclusion,
+ SunRays,
+ VolumetricShafts,
+ FilmicComposite,
+}
+
+/// One declarative full-screen, atmosphere, or tone-map pass.
+public sealed record RenderPassDeclaration(
+ string Id,
+ RenderPassHook Hook,
+ string VertexShaderAsset,
+ string FragmentShaderAsset,
+ IReadOnlyList SemanticInputs,
+ IReadOnlyList ResourceReads,
+ IReadOnlyList ResourceWrites)
+{
+ public RenderPassSemantic Semantic { get; init; } = RenderPassSemantic.CustomFullscreen;
+}
+
+/// A renderer-owned replay of retained scene geometry.
+public sealed record SceneReplayDeclaration(
+ string Id,
+ RenderSceneReplaySemantic Semantic,
+ RenderCasterClass CasterClasses,
+ int ViewCount);
+
+/// A shader specialization of an existing renderer pipeline.
+public sealed record PipelineVariantDeclaration(
+ string Id,
+ RenderPipelineBaseSemantic BaseSemantic,
+ string VertexShaderAsset,
+ string FragmentShaderAsset,
+ RenderMaterialClass CompatibleMaterials,
+ IReadOnlyList SemanticInputs)
+{
+ public RenderPipelineVariantSemantic Semantic { get; init; } =
+ RenderPipelineVariantSemantic.Custom;
+}
+
+/// Renderer-owned role of a fixed retained-scene pipeline variant.
+public enum RenderPipelineVariantSemantic
+{
+ Custom,
+ TerrainDirectionalShadowCaster,
+ WorldOpaqueDirectionalShadowCaster,
+ WorldAlphaCutoutDirectionalShadowCaster,
+ TerrainDirectionalShadowReceiver,
+ WorldDirectionalShadowReceiver,
+ TerrainMultiviewDirectionalShadowCaster,
+ WorldOpaqueMultiviewDirectionalShadowCaster,
+ WorldAlphaCutoutMultiviewDirectionalShadowCaster,
+}
+
+/// Per-preset replacement for one resource's size.
+public sealed record RenderQualityResourceOverride(
+ string ResourceId,
+ RenderExtentDeclaration? Extent,
+ long SizeBytes,
+ long EstimatedResidentBytes);
+
+/// Per-preset value for a declared user setting.
+public sealed record RenderQualitySettingOverride(
+ string SettingId,
+ string Value);
+
+/// One user-selectable, independently capability-gated preset.
+public sealed record RenderQualityPreset(
+ string Id,
+ string DisplayName,
+ IReadOnlyList RequiredCapabilities,
+ IReadOnlyList ResourceOverrides,
+ IReadOnlyList SettingOverrides,
+ long MaxResidentGpuBytes,
+ double MaxIncrementalGpuMillisecondsP50,
+ double MaxIncrementalGpuMillisecondsP99,
+ double MaxIncrementalCpuMillisecondsP50,
+ double MaxIncrementalCpuMillisecondsP99,
+ bool AutoEligible = true)
+{
+ public RenderQualitySemantic Semantic { get; init; } = RenderQualitySemantic.Custom;
+
+ ///
+ /// Optional renderer-owned execution optimizations whose shader ABI the
+ /// pack explicitly implements. The host never infers these from a pack ID.
+ ///
+ public RenderQualityExecutionHints ExecutionHints { get; init; } =
+ RenderQualityExecutionHints.None;
+}
+
+///
+/// Opt-in execution forms for renderer-owned atmospheric work. These hints
+/// may fuse passes or compatible submissions; they do not remove declared
+/// effects or caster classes from the final image.
+///
+[Flags]
+public enum RenderQualityExecutionHints
+{
+ None = 0,
+
+ ///
+ /// The sun-rays shader accepts scene depth directly and filmic composite
+ /// evaluates the declared bloom extraction/filter while composing the
+ /// image. See the standard PackPass ABI flags.
+ ///
+ FusedAtmosphericPostProcess = 1 << 0,
+
+ ///
+ /// The three multiview caster variants select the exact cascade matrix with
+ /// the renderer-owned view index and render every declared Low cascade in
+ /// one layered depth pass.
+ ///
+ MultiviewDirectionalShadowCascades = 1 << 1,
+}
+
+///
+/// Optional host quality role. Pack-owned IDs remain persisted; this semantic
+/// is used only when a pack opts into the host's automatic-quality controller.
+///
+public enum RenderQualitySemantic
+{
+ Custom,
+ Low,
+ Medium,
+ High,
+ Automatic,
+}
+
+/// Storage and presentation kind for a pack-defined setting.
+public enum RenderSettingKind
+{
+ Boolean,
+ Integer,
+ Float,
+ Choice,
+}
+
+/// A bounded, user-visible pack setting.
+public sealed record RenderSettingDeclaration(
+ string Id,
+ string DisplayName,
+ RenderSettingKind Kind,
+ string DefaultValue,
+ double? Minimum,
+ double? Maximum,
+ double? Step,
+ IReadOnlyList Choices)
+{
+ public RenderSettingSemantic Semantic { get; init; } = RenderSettingSemantic.Custom;
+}
+
+///
+/// Optional host meaning for settings consumed by a renderer-owned atmospheric
+/// executor. Ordinary pack settings use .
+///
+public enum RenderSettingSemantic
+{
+ Custom,
+ BloomStrength,
+ FilmicStrength,
+ Exposure,
+ GradeSaturation,
+ GradeContrast,
+ VignetteStrength,
+ SunRayStrength,
+ DirectionalShadowStrength,
+ DirectionalShadowReachMetres,
+ DirectionalShadowPcfTaps,
+ VolumetricStrength,
+ VolumetricRayMarchSteps,
+ AutomaticQuality,
+}
+
+/// One point on a declared sun-elevation response curve.
+public sealed record SunElevationResponsePoint(
+ double ElevationDegrees,
+ double Multiplier);
+
+/// Explicit mapping from an authored AC day group to an effect multiplier.
+public sealed record ActiveDayGroupMultiplier(
+ int ActiveDayGroup,
+ double Multiplier);
+
+/// Visible authored-atmosphere interpretation owned by the pack.
+public sealed record AtmospherePolicyDeclaration(
+ IReadOnlyList SunElevationResponse,
+ IReadOnlyList ActiveDayGroupMultipliers)
+{
+ ///
+ /// Optional selected-light elevation curve for directional shadows. The host
+ /// linearly interpolates adjacent points in sine-of-elevation space and
+ /// clamps beyond the endpoints.
+ /// The resolved multiplier must be zero at and below the authored
+ /// 0-degree horizon: every non-positive control point must be zero and,
+ /// when no exact 0-degree point is declared, the first positive control
+ /// point must also be zero.
+ /// A pack using the directional-shadow semantic must declare this curve.
+ ///
+ public IReadOnlyList DirectionalShadowLightElevationResponse
+ { get; init; } = [];
+
+ ///
+ /// Optional moving-sun strength curve for volumetric shafts. The host
+ /// smoothstep-interpolates adjacent points in elevation-degree space and
+ /// clamps beyond the endpoints.
+ /// A pack using the volumetric-shaft semantic must declare this curve.
+ ///
+ public IReadOnlyList VolumetricShaftSunElevationResponse
+ { get; init; } = [];
+}
+
+///
+/// Complete immutable declaration for one render pack. Packs describe what
+/// they need; the renderer validates and owns every concrete resource, pass,
+/// pipeline, barrier, and scene replay.
+///
+public sealed record RenderPackDescriptor(
+ string Id,
+ string DisplayName,
+ Version PackVersion,
+ int PackApiVersion,
+ RenderPackTier HighestTier,
+ IReadOnlyList RequiredCapabilities,
+ IReadOnlyList OptionalCapabilities,
+ IReadOnlyList Resources,
+ IReadOnlyList Passes,
+ IReadOnlyList SceneReplays,
+ IReadOnlyList PipelineVariants,
+ IReadOnlyList QualityPresets,
+ IReadOnlyList Settings,
+ AtmospherePolicyDeclaration? AtmospherePolicy)
+{
+ /// Short user-facing description shown beside compatibility and cost.
+ public string FeatureSummary { get; init; } = string.Empty;
+}
diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSettingValueCodec.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSettingValueCodec.cs
new file mode 100644
index 00000000..e6261f9f
--- /dev/null
+++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSettingValueCodec.cs
@@ -0,0 +1,113 @@
+using System.Globalization;
+
+namespace AcDream.Plugin.Abstractions.Rendering;
+
+///
+/// API-v1 grammar and scalar encoding for a declared render-pack setting.
+/// Both authoring tools and the graphical host use this codec so a value
+/// cannot validate one way and bind another way.
+///
+public static class RenderPackSettingValueCodec
+{
+ private const long ExactFloatIntegerLimit = 16_777_216L;
+
+ ///
+ /// Validate and encode one string value for set-1/binding-8. The encoded
+ /// value is zero on failure, matching the host block's fail-safe fill.
+ ///
+ public static bool TryEncode(
+ RenderSettingDeclaration setting,
+ string? value,
+ out float encoded)
+ {
+ ArgumentNullException.ThrowIfNull(setting);
+ encoded = 0f;
+ if (value is null)
+ return false;
+
+ switch (setting.Kind)
+ {
+ case RenderSettingKind.Boolean:
+ if (!bool.TryParse(value, out bool boolean))
+ return false;
+ encoded = boolean ? 1f : 0f;
+ return true;
+
+ case RenderSettingKind.Integer:
+ if (!long.TryParse(
+ value,
+ NumberStyles.Integer,
+ CultureInfo.InvariantCulture,
+ out long integer)
+ || integer is < -ExactFloatIntegerLimit or > ExactFloatIntegerLimit
+ || !WithinBounds(integer, setting)
+ || !AlignedToStep(integer, setting))
+ {
+ return false;
+ }
+ encoded = integer;
+ return true;
+
+ case RenderSettingKind.Float:
+ if (!double.TryParse(
+ value,
+ NumberStyles.Float,
+ CultureInfo.InvariantCulture,
+ out double floating)
+ || !double.IsFinite(floating)
+ || floating < -float.MaxValue
+ || floating > float.MaxValue
+ || !WithinBounds(floating, setting)
+ || !AlignedToStep(floating, setting))
+ {
+ return false;
+ }
+ encoded = (float)floating;
+ return float.IsFinite(encoded);
+
+ case RenderSettingKind.Choice:
+ int choice = IndexOf(setting.Choices, value);
+ if (choice < 0)
+ return false;
+ encoded = choice;
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ private static bool WithinBounds(
+ double value,
+ RenderSettingDeclaration setting) =>
+ (setting.Minimum is null || value >= setting.Minimum.Value)
+ && (setting.Maximum is null || value <= setting.Maximum.Value);
+
+ private static bool AlignedToStep(
+ double value,
+ RenderSettingDeclaration setting)
+ {
+ if (setting.Step is not { } step)
+ return true;
+ if (!double.IsFinite(step) || step <= 0)
+ return false;
+ double origin = setting.Minimum ?? 0d;
+ double quotient = (value - origin) / step;
+ if (!double.IsFinite(quotient))
+ return false;
+ double tolerance = Math.Max(1e-7, Math.Abs(quotient) * 1e-7);
+ return Math.Abs(quotient - Math.Round(quotient)) <= tolerance;
+ }
+
+ private static int IndexOf(IReadOnlyList? choices, string value)
+ {
+ if (choices is null)
+ return -1;
+ for (int i = 0; i < choices.Count; i++)
+ {
+ if (string.Equals(choices[i], value, StringComparison.Ordinal))
+ return i;
+ }
+ return -1;
+ }
+}
diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackShaderAbi.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackShaderAbi.cs
new file mode 100644
index 00000000..71221a30
--- /dev/null
+++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackShaderAbi.cs
@@ -0,0 +1,28 @@
+namespace AcDream.Plugin.Abstractions.Rendering;
+
+///
+/// Numeric SPIR-V interface contract for v1.
+/// Descriptor sets and resources remain host-owned; these constants expose
+/// layout numbers only and are not Vulkan handles.
+///
+public static class RenderPackShaderAbi
+{
+ public const int UniformDescriptorSet = 3;
+ public const int AtmosphericFrameBinding = 5;
+ public const int AtmosphericFrameSizeBytes = 160;
+ public const int DirectionalShadowBinding = 6;
+ public const int DirectionalShadowSizeBytes = 336;
+ public const int PackPassBinding = 7;
+ public const int PackPassSizeBytes = 64;
+ public const int PackSettingsBinding = 8;
+ public const int PackSettingsSizeBytes = 256;
+ public const int PackSettingScalarCapacity = 64;
+
+ public const int SampledTextureDescriptorSet = 2;
+ public const int SampledTextureBinding = 0;
+ public const int SampledPassInputCapacity = 4;
+
+ public const int PushConstantSizeBytes = 96;
+
+ public const int MaximumShaderAssetBytes = 16 * 1024 * 1024;
+}
diff --git a/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs
new file mode 100644
index 00000000..0d371633
--- /dev/null
+++ b/src/AcDream.Plugin.Abstractions/Rendering/RenderPackSpirvValidator.cs
@@ -0,0 +1,611 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Plugin.Abstractions.Rendering;
+
+/// The shader stage a render-pack declaration assigns to one SPIR-V asset.
+public enum RenderPackShaderStage
+{
+ Vertex,
+ Fragment,
+}
+
+/// Hardware-independent validation result for one declared SPIR-V module.
+public readonly record struct RenderPackSpirvValidationResult(bool Success, string? Reason)
+{
+ public static RenderPackSpirvValidationResult Valid() => new(true, null);
+
+ public static RenderPackSpirvValidationResult Invalid(string reason) => new(false, reason);
+}
+
+///
+/// BCL-only SPIR-V interface validator for render-pack API v1. This validates
+/// the binary handed to Vulkan, not filenames or GLSL source. Descriptor and
+/// push-constant handles remain host-owned.
+///
+public static class RenderPackSpirvValidator
+{
+ private const uint SpirvMagic = 0x0723_0203;
+ private const uint VertexExecutionModel = 0;
+ private const uint FragmentExecutionModel = 4;
+
+ public static RenderPackSpirvValidationResult ValidatePassShader(
+ ReadOnlySpan spirv,
+ RenderPackShaderStage stage,
+ RenderPassDeclaration pass)
+ {
+ ArgumentNullException.ThrowIfNull(pass);
+ bool declaresSampledInput = pass.ResourceReads.Count != 0
+ || pass.SemanticInputs.Any(static semantic => semantic is
+ RenderSemanticInput.WorldColor
+ or RenderSemanticInput.SceneDepth
+ or RenderSemanticInput.SceneNormals
+ or RenderSemanticInput.DirectionalShadowMaps);
+ bool directionalDepth = pass.Semantic == RenderPassSemantic.DirectionalShadowDepth;
+ var access = new AllowedInterface(
+ StorageBindings: directionalDepth ? [0u] : [],
+ UniformBindings: [],
+ AllowSampledTable: declaresSampledInput,
+ AllowAtmosphericFrame: directionalDepth || UsesAtmosphericFrame(pass.SemanticInputs),
+ AllowDirectionalShadow: directionalDepth
+ || pass.SemanticInputs.Contains(RenderSemanticInput.DirectionalShadowMaps)
+ || pass.SemanticInputs.Contains(
+ RenderSemanticInput.SelectedCelestialDirectionalLight),
+ AllowPackPass: !directionalDepth,
+ AllowPackSettings: !directionalDepth);
+ return Validate(spirv, stage, access);
+ }
+
+ public static RenderPackSpirvValidationResult ValidatePipelineVariantShader(
+ ReadOnlySpan spirv,
+ RenderPackShaderStage stage,
+ PipelineVariantDeclaration variant)
+ {
+ ArgumentNullException.ThrowIfNull(variant);
+ AllowedInterface access = variant.Semantic switch
+ {
+ RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster =>
+ new([], [], false, false, true, false, false),
+ RenderPipelineVariantSemantic.TerrainMultiviewDirectionalShadowCaster =>
+ new([], [], false, false, true, false, false),
+ RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster =>
+ new([0u], [], false, false, true, false, false),
+ RenderPipelineVariantSemantic.WorldOpaqueMultiviewDirectionalShadowCaster =>
+ new([0u], [], false, false, true, false, false),
+ RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster =>
+ new([0u, 1u], [], true, false, true, false, false),
+ RenderPipelineVariantSemantic.WorldAlphaCutoutMultiviewDirectionalShadowCaster =>
+ new([0u, 1u], [], true, false, true, false, false),
+ RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver =>
+ new([], [1u, 2u, 3u], true, false, true, false, true),
+ RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver =>
+ new([0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u], [1u],
+ true, false, true, false, true),
+ _ => new(
+ [],
+ [],
+ variant.SemanticInputs.Any(static semantic => semantic is
+ RenderSemanticInput.WorldColor
+ or RenderSemanticInput.SceneDepth
+ or RenderSemanticInput.SceneNormals
+ or RenderSemanticInput.DirectionalShadowMaps),
+ UsesAtmosphericFrame(variant.SemanticInputs),
+ variant.SemanticInputs.Contains(RenderSemanticInput.DirectionalShadowMaps)
+ || variant.SemanticInputs.Contains(
+ RenderSemanticInput.SelectedCelestialDirectionalLight),
+ false,
+ true),
+ };
+ return Validate(spirv, stage, access);
+ }
+
+ private static bool UsesAtmosphericFrame(IReadOnlyList inputs) =>
+ inputs.Any(static semantic => semantic is
+ RenderSemanticInput.SunDirection
+ or RenderSemanticInput.SunScreenPosition
+ or RenderSemanticInput.ActiveDayGroup
+ or RenderSemanticInput.Weather
+ or RenderSemanticInput.CameraMatrices
+ or RenderSemanticInput.FrameTime);
+
+ private static RenderPackSpirvValidationResult Validate(
+ ReadOnlySpan bytes,
+ RenderPackShaderStage stage,
+ AllowedInterface access)
+ {
+ if (bytes.Length < 20 || (bytes.Length & 3) != 0)
+ return Invalid("the module is not a word-aligned SPIR-V binary");
+ uint[] words = new uint[bytes.Length / 4];
+ for (int i = 0; i < words.Length; i++)
+ words[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(i * 4, 4));
+ if (words[0] != SpirvMagic)
+ return Invalid("the module does not have the SPIR-V magic word");
+
+ Module module;
+ try
+ {
+ module = Module.Parse(words);
+ }
+ catch (InvalidDataException error)
+ {
+ return Invalid(error.Message);
+ }
+
+ uint expectedModel = stage == RenderPackShaderStage.Vertex
+ ? VertexExecutionModel
+ : FragmentExecutionModel;
+ if (module.EntryPoints.Count != 1
+ || module.EntryPoints[0].ExecutionModel != expectedModel
+ || !string.Equals(module.EntryPoints[0].Name, "main", StringComparison.Ordinal))
+ {
+ return Invalid(
+ $"the declared {stage.ToString().ToLowerInvariant()} asset must expose exactly "
+ + "entry point 'main' for that stage");
+ }
+
+ var descriptors = new HashSet<(uint Set, uint Binding)>();
+ int pushBlockCount = 0;
+ foreach (Variable variable in module.Variables)
+ {
+ if (variable.StorageClass == StorageClass.PushConstant)
+ {
+ if (++pushBlockCount > 1)
+ return Invalid("the module declares more than one push-constant block");
+ string? pushFailure = ValidatePushBlock(module, variable);
+ if (pushFailure is not null)
+ return Invalid(pushFailure);
+ continue;
+ }
+ if (variable.StorageClass is not StorageClass.UniformConstant
+ and not StorageClass.Uniform
+ and not StorageClass.StorageBuffer)
+ continue;
+
+ if (!module.DescriptorSets.TryGetValue(variable.Id, out uint set)
+ || !module.Bindings.TryGetValue(variable.Id, out uint binding))
+ return Invalid($"descriptor %{variable.Id} does not declare both set and binding");
+ if (!descriptors.Add((set, binding)))
+ return Invalid($"descriptor set {set} binding {binding} is declared more than once");
+
+ if (set == RenderPackShaderAbi.SampledTextureDescriptorSet
+ && binding == RenderPackShaderAbi.SampledTextureBinding)
+ {
+ if (!access.AllowSampledTable)
+ return Invalid("set 2 binding 0 is sampled without a declared semantic/resource input");
+ if (!module.IsGlobalSampledTextureTable(variable))
+ return Invalid("set 2 binding 0 must be one runtime array of combined 2-D-array samplers");
+ continue;
+ }
+
+ if (set == RenderPackShaderAbi.UniformDescriptorSet)
+ {
+ if (variable.StorageClass != StorageClass.Uniform)
+ return Invalid($"set 3 binding {binding} must be a uniform buffer");
+ bool allowed = binding switch
+ {
+ RenderPackShaderAbi.AtmosphericFrameBinding => access.AllowAtmosphericFrame,
+ RenderPackShaderAbi.DirectionalShadowBinding => access.AllowDirectionalShadow,
+ RenderPackShaderAbi.PackPassBinding => access.AllowPackPass,
+ RenderPackShaderAbi.PackSettingsBinding => access.AllowPackSettings,
+ _ => false,
+ };
+ if (!allowed)
+ return Invalid($"set 3 binding {binding} is not declared for this shader role");
+ string? blockFailure = ValidatePackBlock(module, variable, binding);
+ if (blockFailure is not null)
+ return Invalid(blockFailure);
+ continue;
+ }
+
+ if (set == 0 && variable.StorageClass == StorageClass.StorageBuffer)
+ {
+ if (!access.StorageBindings.Contains(binding))
+ return Invalid($"set 0 binding {binding} storage access is not allowed for this shader role");
+ if (!module.IsReadOnlyStorage(variable))
+ return Invalid($"set 0 binding {binding} is writable; render-pack storage writes are forbidden");
+ if (!module.IsSingleBlockDescriptor(variable))
+ return Invalid($"set 0 binding {binding} must be one storage-buffer descriptor");
+ continue;
+ }
+
+ if (set == 1 && variable.StorageClass == StorageClass.Uniform)
+ {
+ if (!access.UniformBindings.Contains(binding))
+ return Invalid($"set 1 binding {binding} aliases renderer state not allowed for this shader role");
+ if (!module.IsSingleBlockDescriptor(variable))
+ return Invalid($"set 1 binding {binding} must be one uniform-buffer descriptor");
+ continue;
+ }
+
+ return Invalid(
+ $"descriptor set {set} binding {binding} has no render-pack API v1 binding");
+ }
+
+ if (module.ContainsImageWrite)
+ return Invalid("storage image writes are forbidden by render-pack API v1");
+ return RenderPackSpirvValidationResult.Valid();
+ }
+
+ private static string? ValidatePackBlock(Module module, Variable variable, uint binding)
+ {
+ if (!module.TryPointeeStruct(variable, out uint structId, out uint[] members)
+ || !module.Blocks.Contains(structId))
+ return $"set 3 binding {binding} must point to one std140 Block struct";
+
+ return binding switch
+ {
+ RenderPackShaderAbi.AtmosphericFrameBinding =>
+ ValidateAtmosphericFrame(module, structId, members),
+ RenderPackShaderAbi.DirectionalShadowBinding =>
+ ValidateDirectionalShadow(module, structId, members),
+ RenderPackShaderAbi.PackPassBinding =>
+ ValidateVec4Block(module, structId, members, 4, "PackPass"),
+ RenderPackShaderAbi.PackSettingsBinding =>
+ ValidatePackSettings(module, structId, members),
+ _ => $"set 3 binding {binding} is reserved",
+ };
+ }
+
+ private static string? ValidateAtmosphericFrame(Module module, uint id, uint[] members)
+ {
+ if (members.Length != 7)
+ return "AtmosphericFrame must contain exactly seven members and occupy 160 bytes";
+ for (int i = 0; i < 6; i++)
+ {
+ if (!module.IsFloatVector(members[i], 4) || module.MemberOffset(id, i) != (uint)(i * 16))
+ return "AtmosphericFrame member types/offsets do not match ABI v1";
+ }
+ if (!module.IsFloatMatrix(members[6], 4, 4)
+ || module.MemberOffset(id, 6) != 96
+ || module.MemberDecoration(id, 6, Decoration.ColMajor) is null
+ || module.MemberDecoration(id, 6, Decoration.MatrixStride) != 16)
+ return "AtmosphericFrame inverse-view-projection layout does not match ABI v1";
+ return null;
+ }
+
+ private static string? ValidateDirectionalShadow(Module module, uint id, uint[] members)
+ {
+ if (members.Length != 6
+ || !module.IsArray(members[0], 4, 64, static (m, t) => m.IsFloatMatrix(t, 4, 4))
+ || module.MemberOffset(id, 0) != 0
+ || module.MemberDecoration(id, 0, Decoration.ColMajor) is null
+ || module.MemberDecoration(id, 0, Decoration.MatrixStride) != 16)
+ return "DirectionalShadow matrix array does not match the 336-byte ABI v1 layout";
+ for (int i = 1; i <= 3; i++)
+ {
+ if (!module.IsFloatVector(members[i], 4)
+ || module.MemberOffset(id, i) != (uint)(240 + i * 16))
+ return "DirectionalShadow vec4 member types/offsets do not match ABI v1";
+ }
+ if (!module.IsUIntVector(members[4], 4) || module.MemberOffset(id, 4) != 304)
+ return "DirectionalShadow flags member does not match ABI v1";
+ if (!module.IsFloatVector(members[5], 4) || module.MemberOffset(id, 5) != 320)
+ {
+ return "DirectionalShadow selected-light direction/source member does not "
+ + "match the 336-byte ABI v1 layout";
+ }
+ return null;
+ }
+
+ private static string? ValidateVec4Block(
+ Module module,
+ uint id,
+ uint[] members,
+ int count,
+ string name)
+ {
+ if (members.Length != count)
+ return $"{name} must contain {count} vec4 members";
+ for (int i = 0; i < count; i++)
+ {
+ if (!module.IsFloatVector(members[i], 4) || module.MemberOffset(id, i) != (uint)(i * 16))
+ return $"{name} member types/offsets do not match ABI v1";
+ }
+ return null;
+ }
+
+ private static string? ValidatePackSettings(Module module, uint id, uint[] members)
+ {
+ if (members.Length != 1
+ || module.MemberOffset(id, 0) != 0
+ || !module.IsArray(members[0], 16, 16, static (m, t) => m.IsFloatVector(t, 4)))
+ return "PackSettings must be one std140 vec4[16] block occupying 256 bytes";
+ return null;
+ }
+
+ private static string? ValidatePushBlock(Module module, Variable variable)
+ {
+ if (!module.TryPointeeStruct(variable, out uint id, out uint[] members)
+ || !module.Blocks.Contains(id)
+ || members.Length != 9)
+ return "the push-constant block must match the exact 96-byte retail layout";
+ uint[] offsets = [0, 64, 68, 72, 76, 80, 84, 88, 92];
+ for (int i = 0; i < offsets.Length; i++)
+ {
+ if (module.MemberOffset(id, i) != offsets[i])
+ return "the push-constant member offsets do not match the exact 96-byte retail layout";
+ }
+ if (!module.IsFloatMatrix(members[0], 4, 4)
+ || module.MemberDecoration(id, 0, Decoration.ColMajor) is null
+ || module.MemberDecoration(id, 0, Decoration.MatrixStride) != 16
+ || !module.IsInt(members[1], signed: true)
+ || !module.IsInt(members[2], signed: true)
+ || !module.IsInt(members[3], signed: true)
+ || !module.IsInt(members[4], signed: true)
+ || !module.IsInt(members[5], signed: false)
+ || !module.IsInt(members[6], signed: false)
+ || !module.IsFloat(members[7])
+ || !module.IsFloat(members[8]))
+ return "the push-constant member types do not match the exact 96-byte retail layout";
+ return null;
+ }
+
+ private static RenderPackSpirvValidationResult Invalid(string reason) =>
+ RenderPackSpirvValidationResult.Invalid(reason);
+
+ private sealed record AllowedInterface(
+ IReadOnlyList StorageBindings,
+ IReadOnlyList UniformBindings,
+ bool AllowSampledTable,
+ bool AllowAtmosphericFrame,
+ bool AllowDirectionalShadow,
+ bool AllowPackPass,
+ bool AllowPackSettings);
+
+ private enum StorageClass : uint
+ {
+ UniformConstant = 0,
+ Uniform = 2,
+ PushConstant = 9,
+ StorageBuffer = 12,
+ }
+
+ private enum Decoration : uint
+ {
+ Block = 2,
+ ColMajor = 5,
+ ArrayStride = 6,
+ MatrixStride = 7,
+ NonWritable = 24,
+ Binding = 33,
+ DescriptorSet = 34,
+ Offset = 35,
+ }
+
+ private readonly record struct EntryPoint(uint ExecutionModel, string Name);
+ private readonly record struct Variable(uint ResultType, uint Id, StorageClass StorageClass);
+ private sealed record TypeInstruction(uint Opcode, uint[] Operands);
+
+ private sealed class Module
+ {
+ private const uint OpEntryPoint = 15;
+ private const uint OpTypeInt = 21;
+ private const uint OpTypeFloat = 22;
+ private const uint OpTypeVector = 23;
+ private const uint OpTypeMatrix = 24;
+ private const uint OpTypeImage = 25;
+ private const uint OpTypeSampledImage = 27;
+ private const uint OpTypeArray = 28;
+ private const uint OpTypeRuntimeArray = 29;
+ private const uint OpTypeStruct = 30;
+ private const uint OpTypePointer = 32;
+ private const uint OpConstant = 43;
+ private const uint OpVariable = 59;
+ private const uint OpDecorate = 71;
+ private const uint OpMemberDecorate = 72;
+ private const uint OpImageWrite = 99;
+
+ internal List EntryPoints { get; } = [];
+ internal List Variables { get; } = [];
+ internal Dictionary DescriptorSets { get; } = [];
+ internal Dictionary Bindings { get; } = [];
+ internal HashSet NonWritable { get; } = [];
+ internal HashSet Blocks { get; } = [];
+ internal bool ContainsImageWrite { get; private set; }
+
+ private Dictionary Types { get; } = [];
+ private Dictionary Constants { get; } = [];
+ private Dictionary<(uint Id, Decoration Decoration), uint> Decorations { get; } = [];
+ private Dictionary<(uint Id, int Member, Decoration Decoration), uint> MemberDecorations { get; } = [];
+
+ internal static Module Parse(uint[] words)
+ {
+ var module = new Module();
+ int index = 5;
+ while (index < words.Length)
+ {
+ uint header = words[index];
+ int count = (int)(header >> 16);
+ uint opcode = header & 0xffff;
+ if (count <= 0 || index + count > words.Length)
+ throw new InvalidDataException($"malformed SPIR-V instruction at word {index}");
+ ReadOnlySpan instruction = words.AsSpan(index, count);
+ module.ReadInstruction(opcode, instruction);
+ index += count;
+ }
+ return module;
+ }
+
+ private void ReadInstruction(uint opcode, ReadOnlySpan words)
+ {
+ if (opcode == OpEntryPoint)
+ {
+ if (words.Length < 4)
+ throw new InvalidDataException("malformed SPIR-V OpEntryPoint");
+ EntryPoints.Add(new EntryPoint(words[1], ReadString(words[3..])));
+ }
+ else if (opcode is >= OpTypeInt and <= OpTypePointer)
+ {
+ if (words.Length < 2)
+ throw new InvalidDataException("malformed SPIR-V type instruction");
+ Types[words[1]] = new TypeInstruction(opcode, words[1..].ToArray());
+ }
+ else if (opcode == OpConstant && words.Length >= 4)
+ {
+ Constants[words[2]] = words[3];
+ }
+ else if (opcode == OpVariable)
+ {
+ if (words.Length < 4)
+ throw new InvalidDataException("malformed SPIR-V OpVariable");
+ Variables.Add(new Variable(words[1], words[2], (StorageClass)words[3]));
+ }
+ else if (opcode == OpDecorate)
+ {
+ if (words.Length < 3)
+ throw new InvalidDataException("malformed SPIR-V OpDecorate");
+ Decoration decoration = (Decoration)words[2];
+ uint value = words.Length >= 4 ? words[3] : 1;
+ Decorations[(words[1], decoration)] = value;
+ if (decoration == Decoration.DescriptorSet) DescriptorSets[words[1]] = value;
+ if (decoration == Decoration.Binding) Bindings[words[1]] = value;
+ if (decoration == Decoration.NonWritable) NonWritable.Add(words[1]);
+ if (decoration == Decoration.Block) Blocks.Add(words[1]);
+ }
+ else if (opcode == OpMemberDecorate)
+ {
+ if (words.Length < 4)
+ throw new InvalidDataException("malformed SPIR-V OpMemberDecorate");
+ Decoration decoration = (Decoration)words[3];
+ MemberDecorations[(words[1], checked((int)words[2]), decoration)] =
+ words.Length >= 5 ? words[4] : 1;
+ }
+ else if (opcode == OpImageWrite)
+ {
+ ContainsImageWrite = true;
+ }
+ }
+
+ private static string ReadString(ReadOnlySpan words)
+ {
+ var bytes = new List(words.Length * 4);
+ foreach (uint word in words)
+ {
+ for (int shift = 0; shift < 32; shift += 8)
+ {
+ byte value = (byte)(word >> shift);
+ if (value == 0)
+ return System.Text.Encoding.UTF8.GetString([.. bytes]);
+ bytes.Add(value);
+ }
+ }
+ throw new InvalidDataException("unterminated SPIR-V string");
+ }
+
+ internal bool IsSingleBlockDescriptor(Variable variable) =>
+ TryPointeeStruct(variable, out uint id, out _) && Blocks.Contains(id);
+
+ internal bool IsReadOnlyStorage(Variable variable)
+ {
+ if (NonWritable.Contains(variable.Id))
+ return true;
+ return TryPointeeStruct(variable, out uint id, out uint[] members)
+ && members.Length != 0
+ && Enumerable.Range(0, members.Length).All(member =>
+ MemberDecoration(id, member, Decoration.NonWritable) is not null);
+ }
+
+ internal bool TryPointeeStruct(Variable variable, out uint id, out uint[] members)
+ {
+ id = 0;
+ members = [];
+ if (!Types.TryGetValue(variable.ResultType, out TypeInstruction? pointer)
+ || pointer.Opcode != OpTypePointer
+ || pointer.Operands.Length < 3)
+ return false;
+ id = pointer.Operands[2];
+ if (!Types.TryGetValue(id, out TypeInstruction? structure)
+ || structure.Opcode != OpTypeStruct)
+ return false;
+ members = structure.Operands[1..];
+ return true;
+ }
+
+ internal bool IsGlobalSampledTextureTable(Variable variable)
+ {
+ if (!TryPointee(variable.ResultType, out uint arrayId)
+ || !Types.TryGetValue(arrayId, out TypeInstruction? array)
+ || array.Opcode != OpTypeRuntimeArray
+ || array.Operands.Length != 2
+ || !Types.TryGetValue(array.Operands[1], out TypeInstruction? sampled)
+ || sampled.Opcode != OpTypeSampledImage
+ || sampled.Operands.Length != 2
+ || !Types.TryGetValue(sampled.Operands[1], out TypeInstruction? image)
+ || image.Opcode != OpTypeImage
+ || image.Operands.Length < 8)
+ return false;
+ // Dim=2D (1), Arrayed=true, Sampled=image used with a sampler (1).
+ return image.Operands[2] == 1 && image.Operands[4] == 1 && image.Operands[6] == 1;
+ }
+
+ private bool TryPointee(uint pointerId, out uint pointee)
+ {
+ pointee = 0;
+ if (!Types.TryGetValue(pointerId, out TypeInstruction? pointer)
+ || pointer.Opcode != OpTypePointer
+ || pointer.Operands.Length < 3)
+ return false;
+ pointee = pointer.Operands[2];
+ return true;
+ }
+
+ internal uint? MemberOffset(uint id, int member) =>
+ MemberDecoration(id, member, Decoration.Offset);
+
+ internal uint? MemberDecoration(uint id, int member, Decoration decoration) =>
+ MemberDecorations.TryGetValue((id, member, decoration), out uint value) ? value : null;
+
+ internal bool IsFloat(uint id) => IsScalar(id, OpTypeFloat, 32, signed: null);
+ internal bool IsInt(uint id, bool signed) => IsScalar(id, OpTypeInt, 32, signed);
+
+ private bool IsScalar(uint id, uint opcode, uint width, bool? signed)
+ {
+ if (!Types.TryGetValue(id, out TypeInstruction? type)
+ || type.Opcode != opcode
+ || type.Operands.Length < 2
+ || type.Operands[1] != width)
+ return false;
+ return signed is null
+ || type.Operands.Length >= 3 && type.Operands[2] == (signed.Value ? 1u : 0u);
+ }
+
+ internal bool IsFloatVector(uint id, uint count) =>
+ IsVector(id, count, static (m, t) => m.IsFloat(t));
+
+ internal bool IsUIntVector(uint id, uint count) =>
+ IsVector(id, count, static (m, t) => m.IsInt(t, signed: false));
+
+ private bool IsVector(uint id, uint count, Func element)
+ {
+ return Types.TryGetValue(id, out TypeInstruction? vector)
+ && vector.Opcode == OpTypeVector
+ && vector.Operands.Length == 3
+ && vector.Operands[2] == count
+ && element(this, vector.Operands[1]);
+ }
+
+ internal bool IsFloatMatrix(uint id, uint rows, uint columns)
+ {
+ return Types.TryGetValue(id, out TypeInstruction? matrix)
+ && matrix.Opcode == OpTypeMatrix
+ && matrix.Operands.Length == 3
+ && matrix.Operands[2] == columns
+ && IsFloatVector(matrix.Operands[1], rows);
+ }
+
+ internal bool IsArray(
+ uint id,
+ uint length,
+ uint stride,
+ Func element)
+ {
+ return Types.TryGetValue(id, out TypeInstruction? array)
+ && array.Opcode == OpTypeArray
+ && array.Operands.Length == 3
+ && Constants.TryGetValue(array.Operands[2], out uint actualLength)
+ && actualLength == length
+ && Decorations.TryGetValue((id, Decoration.ArrayStride), out uint actualStride)
+ && actualStride == stride
+ && element(this, array.Operands[1]);
+ }
+ }
+}
diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs
index 30b39259..db09934e 100644
--- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs
+++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs
@@ -20,6 +20,13 @@ internal sealed class MossTankPanel
///
private const double StallTimeoutSeconds = 30.0;
+ ///
+ /// The retained markup host reads label bindings while drawing. Coverage
+ /// walks the complete known-buff table, so it belongs on the update side
+ /// and only needs to refresh at human-readable cadence.
+ ///
+ private const double CoverageRefreshIntervalSeconds = 1.0;
+
private readonly IPluginHost _host;
private readonly BuffSettings _buffSettings = new();
private readonly VitalSettings _vitalSettings = new();
@@ -34,6 +41,18 @@ internal sealed class MossTankPanel
private double _sinceProgress;
private int _castThisPass;
private string _status = "Idle.";
+ private string _vitals = string.Empty;
+ private string _coverage = string.Empty;
+ private bool _vitalsInitialized;
+ private uint _currentHealth;
+ private uint _maxHealth;
+ private uint _currentStamina;
+ private uint _maxStamina;
+ private uint _currentMana;
+ private uint _maxMana;
+ private IReadOnlyList? _coverageSpellSnapshot;
+ private int _coverageBuffLineCount;
+ private double _coverageRefreshRemaining;
///
/// What the player had selected before the pass, so targeting yourself for
@@ -59,42 +78,10 @@ internal sealed class MossTankPanel
public string Status => _status;
/// Vitals line, using the same numbers the character panel shows.
- public string Vitals
- {
- get
- {
- ICharacterInfo character = _host.Automation.Character;
- if (!_host.Automation.IsAvailable)
- return string.Empty;
- return $"Health {character.CurrentHealth}/{character.MaxHealth}"
- + $" Stam {character.CurrentStamina}/{character.MaxStamina}"
- + $" Mana {character.CurrentMana}/{character.MaxMana}";
- }
- }
+ public string Vitals => _vitals;
/// What a buff pass would cover, named from the retail tables.
- public string Coverage
- {
- get
- {
- IAutomationSurface automation = _host.Automation;
- if (!automation.IsAvailable)
- return string.Empty;
-
- int trained = 0;
- foreach (PluginSkillInfo skill in automation.Character.Skills)
- {
- if (skill.Training is PluginSkillTraining.Trained
- or PluginSkillTraining.Specialized)
- {
- trained++;
- }
- }
- return $"{automation.Character.Attributes.Count} attributes, "
- + $"{trained} trained skills, "
- + $"{BuffProfile.Build(automation.Spells.KnownSelfBuffs).Count} buff lines";
- }
- }
+ public string Coverage => _coverage;
// ── settings bindings ─────────────────────────────────────────────────
// Adjuster buttons rather than typed entry: buttons are a proven primitive
@@ -251,6 +238,8 @@ internal sealed class MossTankPanel
/// Driven by on the host update thread.
public void OnTick(double elapsedSeconds)
{
+ RefreshDisplayBindings(elapsedSeconds);
+
if (!_running)
return;
@@ -295,6 +284,74 @@ internal sealed class MossTankPanel
_queueIndex++;
}
+ private void RefreshDisplayBindings(double elapsedSeconds)
+ {
+ IAutomationSurface automation = _host.Automation;
+ if (!automation.IsAvailable)
+ {
+ _vitals = string.Empty;
+ _coverage = string.Empty;
+ _vitalsInitialized = false;
+ _coverageSpellSnapshot = null;
+ _coverageBuffLineCount = 0;
+ _coverageRefreshRemaining = 0.0;
+ return;
+ }
+
+ ICharacterInfo character = automation.Character;
+ uint currentHealth = character.CurrentHealth;
+ uint maxHealth = character.MaxHealth;
+ uint currentStamina = character.CurrentStamina;
+ uint maxStamina = character.MaxStamina;
+ uint currentMana = character.CurrentMana;
+ uint maxMana = character.MaxMana;
+ if (!_vitalsInitialized
+ || currentHealth != _currentHealth
+ || maxHealth != _maxHealth
+ || currentStamina != _currentStamina
+ || maxStamina != _maxStamina
+ || currentMana != _currentMana
+ || maxMana != _maxMana)
+ {
+ _currentHealth = currentHealth;
+ _maxHealth = maxHealth;
+ _currentStamina = currentStamina;
+ _maxStamina = maxStamina;
+ _currentMana = currentMana;
+ _maxMana = maxMana;
+ _vitals = $"Health {currentHealth}/{maxHealth}"
+ + $" Stam {currentStamina}/{maxStamina}"
+ + $" Mana {currentMana}/{maxMana}";
+ _vitalsInitialized = true;
+ }
+
+ IReadOnlyList spells = automation.Spells.KnownSelfBuffs;
+ bool spellbookChanged = !ReferenceEquals(spells, _coverageSpellSnapshot);
+ _coverageRefreshRemaining -= Math.Max(0.0, elapsedSeconds);
+ if (!spellbookChanged && _coverageRefreshRemaining > 0.0)
+ return;
+
+ if (spellbookChanged)
+ {
+ _coverageSpellSnapshot = spells;
+ _coverageBuffLineCount = BuffProfile.Build(spells).Count;
+ }
+
+ int trained = 0;
+ foreach (PluginSkillInfo skill in character.Skills)
+ {
+ if (skill.Training is PluginSkillTraining.Trained
+ or PluginSkillTraining.Specialized)
+ {
+ trained++;
+ }
+ }
+ _coverage = $"{character.Attributes.Count} attributes, "
+ + $"{trained} trained skills, "
+ + $"{_coverageBuffLineCount} buff lines";
+ _coverageRefreshRemaining = CoverageRefreshIntervalSeconds;
+ }
+
private bool TryVitalUpkeep(IAutomationSurface automation)
{
VitalAction action = VitalPlan.Decide(automation.Character, _vitalSettings);
diff --git a/src/AcDream.Plugins.MossTank/packages.linux-x64.lock.json b/src/AcDream.Plugins.MossTank/packages.linux-x64.lock.json
new file mode 100644
index 00000000..fe625889
--- /dev/null
+++ b/src/AcDream.Plugins.MossTank/packages.linux-x64.lock.json
@@ -0,0 +1,11 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "acdream.plugin.abstractions": {
+ "type": "Project"
+ }
+ },
+ "net10.0/linux-x64": {}
+ }
+}
\ No newline at end of file
diff --git a/src/AcDream.Plugins.MossTank/packages.win-x64.lock.json b/src/AcDream.Plugins.MossTank/packages.win-x64.lock.json
new file mode 100644
index 00000000..b12a77e4
--- /dev/null
+++ b/src/AcDream.Plugins.MossTank/packages.win-x64.lock.json
@@ -0,0 +1,11 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "acdream.plugin.abstractions": {
+ "type": "Project"
+ }
+ },
+ "net10.0/win-x64": {}
+ }
+}
\ No newline at end of file
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
index 93a27cad..33e48982 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
@@ -14,6 +14,117 @@ public enum ParticleRange
Extended = 1,
}
+///
+/// Immutable, value-equal user overrides for one selected render pack. Keys
+/// are stable setting IDs and compare case-insensitively; values remain the
+/// declaration's invariant string representation until descriptor validation.
+///
+public sealed class RenderPackSettingOverrides :
+ IReadOnlyDictionary,
+ IEquatable
+{
+ private readonly SortedDictionary _values;
+
+ public static RenderPackSettingOverrides Empty { get; } = new([]);
+
+ public RenderPackSettingOverrides(
+ IEnumerable> values)
+ {
+ ArgumentNullException.ThrowIfNull(values);
+ _values = new SortedDictionary(StringComparer.OrdinalIgnoreCase);
+ foreach ((string key, string value) in values)
+ {
+ ArgumentNullException.ThrowIfNull(key);
+ ArgumentNullException.ThrowIfNull(value);
+ _values[key] = value;
+ }
+ }
+
+ public int Count => _values.Count;
+
+ public IEnumerable Keys => _values.Keys;
+
+ public IEnumerable Values => _values.Values;
+
+ public string this[string key] => _values[key];
+
+ public bool ContainsKey(string key) => _values.ContainsKey(key);
+
+ public bool TryGetValue(string key, out string value) =>
+ _values.TryGetValue(key, out value!);
+
+ public IEnumerator> GetEnumerator() =>
+ _values.GetEnumerator();
+
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() =>
+ GetEnumerator();
+
+ public RenderPackSettingOverrides Set(string settingId, string value)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(settingId);
+ ArgumentNullException.ThrowIfNull(value);
+ var next = new SortedDictionary(
+ _values,
+ StringComparer.OrdinalIgnoreCase)
+ {
+ [settingId] = value,
+ };
+ return new RenderPackSettingOverrides(next);
+ }
+
+ public bool Equals(RenderPackSettingOverrides? other) =>
+ other is not null
+ && _values.Count == other._values.Count
+ && _values.All(pair => other._values.TryGetValue(pair.Key, out string? value)
+ && string.Equals(pair.Value, value, StringComparison.Ordinal));
+
+ public override bool Equals(object? obj) =>
+ obj is RenderPackSettingOverrides other && Equals(other);
+
+ public override int GetHashCode()
+ {
+ var hash = new HashCode();
+ foreach ((string key, string value) in _values)
+ {
+ hash.Add(key, StringComparer.OrdinalIgnoreCase);
+ hash.Add(value, StringComparer.Ordinal);
+ }
+ return hash.ToHashCode();
+ }
+}
+
+///
+/// Stable, user-authored selection of one optional render pack. Logical ids
+/// are persisted instead of menu indexes so discovery order can never select a
+/// different pack or preset after an install/update. The renderer normalizes a
+/// missing, malformed, unavailable, or incompatible selection back to
+/// and retains the precise reason for diagnostics.
+///
+public sealed record RenderPackSelectionSettings(
+ string PackId,
+ string? PackVersion,
+ string PresetId)
+{
+ public const string RetailPackId = "retail";
+ public const string RetailPresetId = "off";
+
+ public static RenderPackSelectionSettings Retail { get; } = new(
+ RetailPackId,
+ PackVersion: null,
+ RetailPresetId);
+
+ ///
+ /// User-authored values keyed by the selected pack's stable setting IDs.
+ /// Empty by default so pre-render-pack settings files upgrade without a
+ /// migration write.
+ ///
+ public RenderPackSettingOverrides SettingOverrides { get; init; } =
+ RenderPackSettingOverrides.Empty;
+
+ public bool IsRetail =>
+ string.Equals(PackId, RetailPackId, StringComparison.OrdinalIgnoreCase);
+}
+
///
/// Display-related preferences persisted to settings.json.
/// Originally documented as "no retail equivalent for FOV / vsync etc" —
@@ -77,6 +188,16 @@ public sealed record DisplaySettings(
bool BuildingDetailTextures = true,
bool MultiPassAlpha = false)
{
+ ///
+ /// Opt-in graphics enhancement selection. This is deliberately separate
+ /// from , which remains the authoritative retail
+ /// renderer/streaming quality preset. Keeping the default here means an
+ /// upgraded settings file that predates shader packs deserializes to the
+ /// exact retail path without a migration write.
+ ///
+ public RenderPackSelectionSettings RenderPack { get; init; } =
+ RenderPackSelectionSettings.Retail;
+
/// Values used on first launch / when settings.json is absent.
/// Geometry defaults preserve the pre-L.0 runtime state: Resolution
/// matches the WindowOptions startup size (1280×720). FieldOfView is
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
index acaa247d..a2cdebd7 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
@@ -97,7 +97,10 @@ public sealed class SettingsStore
TextureFiltering: ReadInt (disp, "textureFiltering", d.TextureFiltering),
LandscapeDrawDistance: ReadInt (disp, "landscapeDrawDistance", d.LandscapeDrawDistance),
BuildingDetailTextures: ReadBool (disp, "buildingDetailTextures", d.BuildingDetailTextures),
- MultiPassAlpha: ReadBool (disp, "multiPassAlpha", d.MultiPassAlpha));
+ MultiPassAlpha: ReadBool (disp, "multiPassAlpha", d.MultiPassAlpha))
+ {
+ RenderPack = ReadRenderPackSelection(disp, d.RenderPack),
+ };
}
catch (Exception ex)
{
@@ -686,6 +689,7 @@ public sealed class SettingsStore
["multiPassAlpha"] = d.MultiPassAlpha,
["particleRange"] = d.ParticleRange.ToString(),
["quality"] = d.Quality.ToString(),
+ ["renderPack"] = BuildRenderPackObject(d.RenderPack),
["resolution"] = d.Resolution,
["screenBrightness"] = d.ScreenBrightness,
["showFps"] = d.ShowFps,
@@ -693,6 +697,21 @@ public sealed class SettingsStore
["vsync"] = d.VSync,
};
+ private static SortedDictionary BuildRenderPackObject(
+ RenderPackSelectionSettings selection)
+ {
+ var overrides = new SortedDictionary(StringComparer.Ordinal);
+ foreach ((string key, string value) in selection.SettingOverrides)
+ overrides[key] = value;
+ return new SortedDictionary(StringComparer.Ordinal)
+ {
+ ["packId"] = selection.PackId,
+ ["packVersion"] = selection.PackVersion,
+ ["presetId"] = selection.PresetId,
+ ["settingOverrides"] = overrides,
+ };
+ }
+
private static SortedDictionary BuildMiscObject(MiscSettings m)
=> new(StringComparer.Ordinal)
{
@@ -777,6 +796,49 @@ public sealed class SettingsStore
File.WriteAllText(_path, sb.ToString());
}
+ private static RenderPackSelectionSettings ReadRenderPackSelection(
+ JsonElement display,
+ RenderPackSelectionSettings fallback)
+ {
+ if (!display.TryGetProperty("renderPack", out JsonElement value)
+ || value.ValueKind != JsonValueKind.Object)
+ {
+ return fallback;
+ }
+
+ string packId = ReadString(value, "packId", string.Empty);
+ string presetId = ReadString(value, "presetId", string.Empty);
+ if (string.IsNullOrWhiteSpace(packId) || string.IsNullOrWhiteSpace(presetId))
+ return fallback;
+ string? version = null;
+ if (value.TryGetProperty("packVersion", out JsonElement versionElement)
+ && versionElement.ValueKind == JsonValueKind.String)
+ {
+ version = versionElement.GetString();
+ }
+
+ var overrides = new List>();
+ if (value.TryGetProperty("settingOverrides", out JsonElement overrideElement))
+ {
+ if (overrideElement.ValueKind != JsonValueKind.Object)
+ return fallback;
+ foreach (JsonProperty property in overrideElement.EnumerateObject())
+ {
+ if (property.Value.ValueKind != JsonValueKind.String
+ || property.Value.GetString() is not { } settingValue)
+ {
+ return fallback;
+ }
+ overrides.Add(new KeyValuePair(property.Name, settingValue));
+ }
+ }
+
+ return new RenderPackSelectionSettings(packId, version, presetId)
+ {
+ SettingOverrides = new RenderPackSettingOverrides(overrides),
+ };
+ }
+
private static string ReadString(JsonElement obj, string name, string fallback)
=> obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String
? (el.GetString() ?? fallback) : fallback;
diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj
index 2a7a6d82..e31fed1b 100644
--- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj
+++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj
@@ -32,6 +32,14 @@
false
true
+
+ false
+ true
+
+
+ false
+ true
+
diff --git a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs
index ea1fb2a9..6bd44921 100644
--- a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs
@@ -35,6 +35,32 @@ public sealed class HostInputCameraCompositionTests
Assert.Equal((1280, 720), fixture.Factory.Viewport.Size);
}
+ [Fact]
+ public void DiagnosticOrbitOverridesReachOnlyTheInitialOrbitCamera()
+ {
+ using var fixture = new Fixture();
+
+ HostInputCameraResult result = fixture
+ .Phase(180f, -135f, 7.5f)
+ .Compose(fixture.Platform);
+
+ Assert.Equal(180f, result.CameraController.Orbit.Distance);
+ Assert.Equal(-135f * MathF.PI / 180f, result.CameraController.Orbit.Yaw);
+ Assert.Equal(7.5f * MathF.PI / 180f, result.CameraController.Orbit.Pitch);
+ }
+
+ [Fact]
+ public void VulkanFactoryConvertsDiagnosticOrbitAnglesFromDegrees()
+ {
+ var factory = new VulkanHostInputCameraCompositionFactory();
+
+ CameraController camera = factory.CreateCameraController(200f, 180f, -12f);
+
+ Assert.Equal(200f, camera.Orbit.Distance);
+ Assert.Equal(MathF.PI, camera.Orbit.Yaw, precision: 6);
+ Assert.Equal(-12f * MathF.PI / 180f, camera.Orbit.Pitch, precision: 6);
+ }
+
[Theory]
[MemberData(nameof(FaultPointValues))]
public void FailureAtEveryProductionBoundaryStopsTheExactSuffix(
@@ -138,7 +164,10 @@ public sealed class HostInputCameraCompositionTests
public Factory Factory { get; }
public Publication Publication { get; }
- public HostInputCameraCompositionPhase Phase() => new(
+ public HostInputCameraCompositionPhase Phase(
+ float? initialOrbitDistanceMeters = null,
+ float? initialOrbitYawDegrees = null,
+ float? initialOrbitPitchDegrees = null) => new(
new HostInputCameraDependencies(
Framebuffer,
new Vector2D(1280, 720),
@@ -150,7 +179,10 @@ public sealed class HostInputCameraCompositionTests
PlayerMode,
Chase,
Pointer,
- new DiagnosticLog()),
+ new DiagnosticLog(),
+ initialOrbitDistanceMeters,
+ initialOrbitYawDegrees,
+ initialOrbitPitchDegrees),
Publication,
Factory,
point =>
@@ -318,8 +350,20 @@ public sealed class HostInputCameraCompositionTests
KeyBindings bindings) =>
InputDispatcher.CreateDetached(keyboard, mouse, bindings);
- public CameraController CreateCameraController() =>
- new(new OrbitCamera(), new FlyCamera());
+ public CameraController CreateCameraController(
+ float? initialOrbitDistanceMeters,
+ float? initialOrbitYawDegrees,
+ float? initialOrbitPitchDegrees)
+ {
+ var orbit = new OrbitCamera();
+ if (initialOrbitDistanceMeters is { } distance)
+ orbit.Distance = distance;
+ if (initialOrbitYawDegrees is { } yaw)
+ orbit.Yaw = yaw * (MathF.PI / 180f);
+ if (initialOrbitPitchDegrees is { } pitch)
+ orbit.Pitch = pitch * (MathF.PI / 180f);
+ return new CameraController(orbit, new FlyCamera());
+ }
public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) =>
new CameraTarget(camera);
diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
index 8017787d..fe53d729 100644
--- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
+++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs
@@ -181,9 +181,25 @@ public sealed class InteractionUiRuntimeSourcesTests
Assert.True(source.IsWorldReady);
Assert.True(source.TryRequestCheckpoint("ready", out _, out _));
Assert.Equal("ready", target.LastCheckpoint);
+ Assert.Equal(target.RenderPackStatus, source.RenderPackStatus);
+ Assert.Equal(1280, source.FramebufferWidth);
+ Assert.Equal(720, source.FramebufferHeight);
+ Assert.True(source.TrySelectRenderPack("high", out _));
+ Assert.Equal("high", target.LastRenderPackPreset);
+ Assert.True(source.TryDisableRenderPack(out _));
+ Assert.True(target.RenderPackDisabled);
+ Assert.True(source.TryReenableRenderPack(out _));
+ Assert.True(target.RenderPackReenabled);
+ Assert.True(source.TryResizeFramebuffer(1024, 768, out _));
+ Assert.Equal((1024, 768), target.LastFramebufferSize);
binding.Dispose();
Assert.False(source.IsWorldReady);
+ Assert.Equal(
+ AcDream.App.UI.Testing.RetailUiAutomationRenderPackStatus.Retail,
+ source.RenderPackStatus);
+ Assert.False(source.TrySelectRenderPack("high", out string unboundError));
+ Assert.Contains("not bound", unboundError);
source.Deactivate();
Assert.Throws(() => source.Bind(target));
}
@@ -454,7 +470,48 @@ public sealed class InteractionUiRuntimeSourcesTests
public bool IsWorldReady => true;
public bool IsWorldViewportVisible => true;
public int PortalMaterializationCount => 2;
+ public AcDream.App.UI.Testing.RetailUiAutomationRenderPackStatus
+ RenderPackStatus { get; } = new(
+ AcDream.App.UI.Testing.RetailUiAutomationRenderPackState.Active,
+ "acdream.atmospheric",
+ "high",
+ 7,
+ null);
+ public int FramebufferWidth => 1280;
+ public int FramebufferHeight => 720;
public string? LastCheckpoint { get; private set; }
+ public string? LastRenderPackPreset { get; private set; }
+ public bool RenderPackDisabled { get; private set; }
+ public bool RenderPackReenabled { get; private set; }
+ public (int Width, int Height)? LastFramebufferSize { get; private set; }
+
+ public bool TrySelectRenderPack(string presetId, out string error)
+ {
+ LastRenderPackPreset = presetId;
+ error = string.Empty;
+ return true;
+ }
+
+ public bool TryDisableRenderPack(out string error)
+ {
+ RenderPackDisabled = true;
+ error = string.Empty;
+ return true;
+ }
+
+ public bool TryReenableRenderPack(out string error)
+ {
+ RenderPackReenabled = true;
+ error = string.Empty;
+ return true;
+ }
+
+ public bool TryResizeFramebuffer(int width, int height, out string error)
+ {
+ LastFramebufferSize = (width, height);
+ error = string.Empty;
+ return true;
+ }
public bool TryRequestCheckpoint(
string name,
diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
index 364b218a..9c4316e9 100644
--- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
@@ -223,7 +223,8 @@ public sealed class WorldRenderCompositionTests
public void InitializeEnvironment(
WorldEnvironmentController environment,
- Region region) { }
+ Region region,
+ IDatReaderWriter dats) { }
///
/// Campaign V slice V6i-2: the arm a backend with no GL context takes.
diff --git a/tests/AcDream.App.Tests/Diagnostics/AtmosphericPerformanceMatrixContractTests.cs b/tests/AcDream.App.Tests/Diagnostics/AtmosphericPerformanceMatrixContractTests.cs
new file mode 100644
index 00000000..1b6afd0c
--- /dev/null
+++ b/tests/AcDream.App.Tests/Diagnostics/AtmosphericPerformanceMatrixContractTests.cs
@@ -0,0 +1,567 @@
+using System.Diagnostics;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace AcDream.App.Tests.Diagnostics;
+
+public sealed class AtmosphericPerformanceMatrixContractTests
+{
+ [Fact]
+ public void ScriptsParseWithoutLaunchingTheMatrix()
+ {
+ foreach (string script in new[]
+ {
+ ScriptPath(),
+ Path.Combine(FindRepoRoot(), "tools", "run-offline-pixel-gate.ps1"),
+ Path.Combine(FindRepoRoot(), "tools", "atmospheric-performance-matrix-common.ps1"),
+ })
+ AssertPowerShellParses(script);
+ }
+
+ private static void AssertPowerShellParses(string script)
+ {
+ var start = new ProcessStartInfo
+ {
+ FileName = OperatingSystem.IsWindows() ? "pwsh.exe" : "pwsh",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ start.ArgumentList.Add("-NoProfile");
+ start.ArgumentList.Add("-NonInteractive");
+ start.ArgumentList.Add("-Command");
+ string quotedScript = "'" + script.Replace("'", "''", StringComparison.Ordinal) + "'";
+ start.ArgumentList.Add(
+ $"[scriptblock]::Create((Get-Content -Raw -LiteralPath {quotedScript})) | Out-Null");
+
+ using Process process = Process.Start(start)
+ ?? throw new InvalidOperationException("Could not start pwsh parser process.");
+ string stdout = process.StandardOutput.ReadToEnd();
+ string stderr = process.StandardError.ReadToEnd();
+ Assert.True(process.WaitForExit(30_000), "PowerShell parser did not exit.");
+ Assert.True(
+ process.ExitCode == 0,
+ $"PowerShell parser failed for {script} with exit code {process.ExitCode}.\n{stdout}\n{stderr}");
+ }
+
+ [Fact]
+ public void MatrixPinsAllRequiredRowsAndExplicitFramePacingModes()
+ {
+ string source = ReadScript();
+
+ Assert.Contains("[string]$FramePacing = 'both'", source, StringComparison.Ordinal);
+ Assert.Contains(
+ "[ValidateSet('capped', 'uncapped', 'both')]",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "[string[]]$PresetSet = @('retail', 'low', 'medium', 'high', 'auto')",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "[string[]]$ResolutionSet = @('1920x1080', '2560x1440', '3840x2160')",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "$presets = @($PresetSet | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique)",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "$resolutions = @($ResolutionSet | Select-Object -Unique)",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains("default { @('capped', 'uncapped') }", source, StringComparison.Ordinal);
+ Assert.Contains("if ($pacing -eq 'uncapped')", source, StringComparison.Ordinal);
+ Assert.Contains("$arguments += '-Uncapped'", source, StringComparison.Ordinal);
+ Assert.Contains(
+ "'-RequiredRenderPackSamples', '2048'",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "'-RenderPackSampleTimeoutMs', '300000'",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "ExplicitPresetPerformanceWindowResetAfterWarmup = $true",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "AutomaticPerformanceWindowPolicy",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains("'-AllowSafeRenderPackFallback'", source, StringComparison.Ordinal);
+
+ AssertAppearsInOrder(
+ source,
+ "$rowDirectory = Assert-MatrixContainedPath $outputRoot (Join-Path $outputRoot $rowId)",
+ "'-Out', $rowDirectory",
+ "'-WarmupMs', \"$WarmupMs\"",
+ "'-DayGroup', \"$DayGroup\"",
+ "'-WorldDayFraction'",
+ "'-SkyPhaseSeconds'",
+ "'-MsaaSamples', '0'",
+ "'-RenderPackPreset', $preset",
+ "'-Resolution', $resolution",
+ "'-OrbitDistanceMeters'",
+ "'-SkipBuild'");
+
+ string pixelGate = File.ReadAllText(
+ Path.Combine(FindRepoRoot(), "tools", "run-offline-pixel-gate.ps1"));
+ Assert.Contains(
+ "$probeCommands.Add('sleep 2000')",
+ pixelGate,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "last completed swapchain image",
+ pixelGate,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void DeclaredCeilingsAreExactAndNewIncrementalMetricsAreRequired()
+ {
+ string source = ReadScript();
+
+ AssertPresetBudget(source, "low", "0.15", "0.50", "2.00", "3.00", "64L");
+ AssertPresetBudget(source, "medium", "0.25", "0.75", "3.25", "4.50", "128L");
+ AssertPresetBudget(source, "high", "0.35", "1.00", "4.50", "6.00", "256L");
+
+ foreach (string field in new[]
+ {
+ "IncrementalCpuMillisecondsP50",
+ "IncrementalCpuMillisecondsP95",
+ "IncrementalCpuMillisecondsP99",
+ "AbsoluteReceiverCpuMillisecondsP50",
+ "AbsoluteReceiverCpuMillisecondsP95",
+ "AbsoluteReceiverCpuMillisecondsP99",
+ "InclusiveGpuMillisecondsP50",
+ "InclusiveGpuMillisecondsP95",
+ "InclusiveGpuMillisecondsP99",
+ "ResidentGpuBytes",
+ })
+ {
+ Assert.Contains($"'{field}'", source, StringComparison.Ordinal);
+ }
+
+ Assert.DoesNotContain("'CpuMillisecondsP50'", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("'CpuMillisecondsP99'", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("'GpuMillisecondsP50'", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("'GpuMillisecondsP99'", source, StringComparison.Ordinal);
+
+ Assert.Contains(
+ "$gpuBudgetApplies = $availability -eq 'Active' -and",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains("$budget = $budgets[$effectiveQuality]", source, StringComparison.Ordinal);
+ Assert.Contains(
+ "$cpuP50 -gt $budget.IncrementalCpuMillisecondsP50",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "$cpuP99 -gt $budget.IncrementalCpuMillisecondsP99",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "$residentGpuBytes -gt $budget.ResidentGpuBytes",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "$gpuP50 -gt $budget.InclusiveGpuMillisecondsP50At1080p",
+ source,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "$gpuP99 -gt $budget.InclusiveGpuMillisecondsP99At1080p",
+ source,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void MetadataAndSummariesCarryTheRequiredEvidenceWithoutSecrets()
+ {
+ string source = ReadScript();
+
+ Assert.Contains(
+ "$screenshotLeaf = 'world-offline'",
+ source,
+ StringComparison.Ordinal);
+ foreach (string evidence in new[]
+ {
+ "CpuSampleCount",
+ "GpuSampleCount",
+ "ShadowCasterCount",
+ "CascadeDrawCount",
+ "DrawCalls",
+ "DispatchCalls",
+ })
+ {
+ Assert.Contains($"'{evidence}'", source, StringComparison.Ordinal);
+ }
+
+ Assert.Contains("atmospheric-performance-matrix.json", source, StringComparison.Ordinal);
+ Assert.Contains("atmospheric-performance-matrix.md", source, StringComparison.Ordinal);
+ Assert.Contains("DeclaredBudgets", source, StringComparison.Ordinal);
+ Assert.Contains("Rows = @($rows)", source, StringComparison.Ordinal);
+ Assert.Contains("Failures = @($matrixFailures)", source, StringComparison.Ordinal);
+
+ Assert.DoesNotContain("ACDREAM_TEST_USER", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("ACDREAM_TEST_PASS", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("Get-ChildItem Env:", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ExecutableMetadataOracleAcceptsCompleteShapeAndRejectsAdversarialEvidence()
+ {
+ string directory = Path.Combine(Path.GetTempPath(), $"acdream-matrix-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(directory);
+ try
+ {
+ string metadataPath = Path.Combine(directory, "capture.metadata.json");
+ foreach (string preset in new[] { "low", "medium", "high" })
+ {
+ File.WriteAllText(metadataPath, CreateMetadata(preset));
+ JsonElement valid = RunMetadataOracle(metadataPath, preset);
+ Assert.True(valid.GetProperty("Passed").GetBoolean());
+ Assert.Equal(9498, valid.GetProperty("ShadowCasterCount").GetInt32());
+ }
+
+ JsonNode automatic = JsonNode.Parse(CreateMetadata("high"))!;
+ automatic["RenderPack"]!["PresetId"] = "auto";
+ automatic["RenderPack"]!["ActivationGeneration"] = 4;
+ File.WriteAllText(metadataPath, automatic.ToJsonString());
+ JsonElement validAutomatic = RunMetadataOracle(metadataPath, "auto");
+ Assert.True(validAutomatic.GetProperty("Passed").GetBoolean());
+ Assert.Equal(
+ "high",
+ validAutomatic.GetProperty("EffectiveQuality").GetString());
+
+ File.WriteAllText(metadataPath, CreateMetadata("high"));
+ JsonNode invalid = JsonNode.Parse(File.ReadAllText(metadataPath))!;
+ JsonNode pack = invalid["RenderPack"]!;
+ pack["ShadowCasterCount"] = 0;
+ pack["CascadeDrawCount"] = 3;
+ pack["CpuClassificationCalls"] = 1;
+ pack["RetainedGpuBytes"] = 99;
+ pack["Performance"]!["CpuSampleCount"] = 2047;
+ pack["Performance"]!["IncrementalCpuMillisecondsP95"] = -1.0;
+ pack["Passes"]![1]!["DrawCalls"] = 4;
+ File.WriteAllText(metadataPath, invalid.ToJsonString());
+
+ JsonElement rejected = RunMetadataOracle(metadataPath, "high");
+ Assert.False(rejected.GetProperty("Passed").GetBoolean());
+ string failures = rejected.GetProperty("Failures").ToString();
+ Assert.Contains("at least one shadow caster", failures, StringComparison.Ordinal);
+ Assert.Contains("exactly 4 cascades", failures, StringComparison.Ordinal);
+ Assert.Contains("zero CPU classifications", failures, StringComparison.Ordinal);
+ Assert.Contains("GPU bytes disagree", failures, StringComparison.Ordinal);
+ Assert.Contains("complete 2048-sample window", failures, StringComparison.Ordinal);
+ Assert.Contains("finite and non-negative", failures, StringComparison.Ordinal);
+ Assert.Contains("exactly 5 draws and zero dispatches", failures, StringComparison.Ordinal);
+ }
+ finally { Directory.Delete(directory, recursive: true); }
+ }
+
+ [Fact]
+ public void ExecutableMetadataOracleReportsOnlyStrictZeroWorkUnavailability()
+ {
+ string directory = Path.Combine(Path.GetTempPath(), $"acdream-matrix-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(directory);
+ try
+ {
+ string metadataPath = Path.Combine(directory, "capture.metadata.json");
+ const string resourceReason =
+ "Directional shadow rendering failed: Render pack preset 'low' needs 67465216 resident GPU bytes after materializing its scene-dependent shadow command buffers; the active pack budget is 67108864 bytes.";
+ File.WriteAllText(metadataPath, CreateFallbackMetadata(resourceReason));
+
+ JsonElement resourceUnavailable = RunMetadataOracle(
+ metadataPath,
+ "low",
+ allowSafeFallback: true);
+ Assert.True(resourceUnavailable.GetProperty("Passed").GetBoolean());
+ Assert.Equal("Unavailable", resourceUnavailable.GetProperty("Outcome").GetString());
+ Assert.Equal(
+ "ResourceUnavailable",
+ resourceUnavailable.GetProperty("UnavailableClassification").GetString());
+ Assert.Equal(resourceReason, resourceUnavailable.GetProperty("FailureReason").GetString());
+
+ JsonElement automaticUnavailable = RunMetadataOracle(
+ metadataPath,
+ "auto",
+ allowSafeFallback: true);
+ Assert.True(automaticUnavailable.GetProperty("Passed").GetBoolean());
+ Assert.Equal(
+ "ResourceUnavailable",
+ automaticUnavailable.GetProperty("UnavailableClassification").GetString());
+
+ JsonElement notOptedIn = RunMetadataOracle(metadataPath, "low");
+ Assert.False(notOptedIn.GetProperty("Passed").GetBoolean());
+ Assert.Contains(
+ "not explicitly allowed",
+ notOptedIn.GetProperty("Failures").ToString(),
+ StringComparison.Ordinal);
+
+ const string capabilityReason =
+ "Preset 'low' requires unsupported capability 'MultiviewDirectionalShadowCascades'.";
+ File.WriteAllText(metadataPath, CreateFallbackMetadata(capabilityReason));
+ JsonElement capabilityUnavailable = RunMetadataOracle(
+ metadataPath,
+ "low",
+ allowSafeFallback: true);
+ Assert.True(capabilityUnavailable.GetProperty("Passed").GetBoolean());
+ Assert.Equal(
+ "CapabilityUnavailable",
+ capabilityUnavailable.GetProperty("UnavailableClassification").GetString());
+
+ const string performanceReason =
+ "Automatic quality disabled render pack 'acdream.atmospheric' because Low remained over its declared performance budget for 180 stable samples: GPU p99 24.234 ms (budget 3.000 ms), CPU p99 0.630 ms (budget 0.500 ms), resident GPU bytes 41648404 (budget 67108864).";
+ File.WriteAllText(metadataPath, CreateFallbackMetadata(performanceReason));
+ JsonElement performanceUnavailable = RunMetadataOracle(
+ metadataPath,
+ "auto",
+ allowSafeFallback: true);
+ Assert.True(performanceUnavailable.GetProperty("Passed").GetBoolean());
+ Assert.Equal(
+ "PerformanceUnavailable",
+ performanceUnavailable.GetProperty("UnavailableClassification").GetString());
+
+ JsonElement explicitLowCannotUseAutoPerformanceFallback = RunMetadataOracle(
+ metadataPath,
+ "low",
+ allowSafeFallback: true);
+ Assert.False(
+ explicitLowCannotUseAutoPerformanceFallback.GetProperty("Passed").GetBoolean());
+
+ const string forgedWithinBudget =
+ "Automatic quality disabled render pack 'acdream.atmospheric' because Low remained over its declared performance budget for 180 stable samples: GPU p99 2.000 ms (budget 3.000 ms), CPU p99 0.400 ms (budget 0.500 ms), resident GPU bytes 41648404 (budget 67108864).";
+ File.WriteAllText(metadataPath, CreateFallbackMetadata(forgedWithinBudget));
+ JsonElement forged = RunMetadataOracle(
+ metadataPath,
+ "auto",
+ allowSafeFallback: true);
+ Assert.False(forged.GetProperty("Passed").GetBoolean());
+
+ const string arbitraryFailure =
+ "Render pack 'acdream.atmospheric' could not be prepared: shader validation failed: unsupported binding.";
+ File.WriteAllText(metadataPath, CreateFallbackMetadata(arbitraryFailure));
+ JsonElement unexpected = RunMetadataOracle(metadataPath, "low", allowSafeFallback: true);
+ Assert.False(unexpected.GetProperty("Passed").GetBoolean());
+ Assert.Equal(
+ "UnexpectedFailure",
+ unexpected.GetProperty("UnavailableClassification").GetString());
+ Assert.Contains(
+ "not a strict resource/capability/Auto-performance unavailability",
+ unexpected.GetProperty("Failures").ToString(),
+ StringComparison.Ordinal);
+
+ JsonNode unsafeFallback = JsonNode.Parse(CreateFallbackMetadata(resourceReason))!;
+ unsafeFallback["RenderPack"]!["ShadowCasterCount"] = 1;
+ unsafeFallback["RenderPack"]!["Performance"]!["GpuSampleCount"] = 1;
+ File.WriteAllText(metadataPath, unsafeFallback.ToJsonString());
+ JsonElement rejectedWork = RunMetadataOracle(metadataPath, "low", allowSafeFallback: true);
+ Assert.False(rejectedWork.GetProperty("Passed").GetBoolean());
+ string failures = rejectedWork.GetProperty("Failures").ToString();
+ Assert.Contains("zero pack work and resources", failures, StringComparison.Ordinal);
+ Assert.Contains("zero GpuSampleCount", failures, StringComparison.Ordinal);
+ }
+ finally { Directory.Delete(directory, recursive: true); }
+ }
+
+ private static string CreateMetadata(string preset)
+ {
+ int cascades = preset switch { "low" => 2, "medium" => 3, _ => 4 };
+ const int shadowDraws = 5;
+ var passIds = new List { "atmospheric-world-receiver" };
+ if (preset == "low")
+ passIds.Add("directional-shadow-multiview");
+ else for (int cascade = 0; cascade < cascades; cascade++)
+ passIds.Add($"directional-shadow-cascade-{cascade}");
+ passIds.AddRange([
+ "atmospheric-sun-occlusion", "atmospheric-sun-rays",
+ "atmospheric-volumetric-shafts", "atmospheric-bloom-downsample",
+ "atmospheric-bloom-blur-horizontal", "atmospheric-bloom-blur-vertical",
+ "atmospheric-filmic"]);
+ object[] passes = passIds.Select((id, index) => new
+ {
+ PassId = id,
+ GpuMilliseconds = 0.1,
+ DrawCalls = id.StartsWith("directional-", StringComparison.Ordinal) ? shadowDraws :
+ id is "atmospheric-world-receiver" or "atmospheric-volumetric-shafts" ? 0 : 1,
+ DispatchCalls = 0,
+ }).Cast