diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index b8fdfcb9..9c4966b4 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -44,6 +44,45 @@ Copy this block when adding a new issue:
---
+## #225 — Scene particles overpaint translucent world objects
+
+**Status:** IN-PROGRESS — implementation complete; connected visual gate pending
+**Severity:** MEDIUM
+**Filed:** 2026-07-18
+**Component:** rendering / world translucency / particles
+
+**Description:** Smoke, candle flames, and other scene particles behind a
+translucent lifestone crystal remained fully bright and visible through it.
+The crystal itself had the correct DAT translucency, but particles composited
+as though no transparent object were in front of them.
+
+**Root cause / status:** `WbDrawDispatcher` finished its transparent object
+pass before `ParticleRenderer` began its independent scene-particle pass.
+Both paths depth-tested but disabled depth writes, so a later particle behind
+the crystal still passed the opaque depth buffer and overpainted the crystal.
+Retail makes each emitted particle a `CPhysicsPart`, orders it with ordinary
+parts by the transformed GfxObj `sort_center`, and appends delayed surfaces to
+one alpha list. acdream now has one frame-scoped `RetailAlphaQueue` shared by
+world Wb entities and scene particles, with retail's landscape and final-world
+flush boundaries. It preserves DAT blend/cull/lighting state and batches only
+adjacent compatible submissions so renderer grouping cannot change the
+compositing order.
+
+**Files:** `src/AcDream.App/Rendering/RetailAlphaQueue.cs`;
+`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`;
+`src/AcDream.App/Rendering/ParticleRenderer.cs`;
+`src/AcDream.App/Rendering/RetailPViewRenderer.cs`.
+
+**Research:**
+`docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`.
+
+**Acceptance:** At a translucent lifestone, smoke or flame behind the crystal
+is attenuated by it while an effect in front remains bright. The lifestone's
+own transparency, nearby candle/portal particles, indoor/outdoor PView
+transitions, paperdoll rendering, and portal-space rendering do not regress.
+
+---
+
## #224 — Gameplay indicator bar only implemented effect icons
**Status:** IN-PROGRESS
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index bd802be8..4b3f2667 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -220,6 +220,8 @@ src/
TerrainModernRenderer.cs -> mandatory bindless+MDI terrain path
Wb/WbDrawDispatcher.cs -> ordinary live/static entity draw dispatch
Wb/EnvCellRenderer.cs -> indoor cell-shell draw path
+ ParticleRenderer.cs -> DAT particle billboard/full-mesh dispatch
+ RetailAlphaQueue.cs -> shared delayed world alpha ordering/flush owner
TextureCache.cs -> done
ChaseCamera.cs -> done
FlyCamera.cs -> done
@@ -512,6 +514,10 @@ matches retail's `CPhysicsObj::DoObjDescChangesFromDefault` behavior.
└── Read current entity mesh refs, draw
TerrainModernRenderer + WbDrawDispatcher + EnvCellRenderer
(frustum cull, translucency pass, portal visibility, etc.)
+ World Wb translucents + Scene particles submit to RetailAlphaQueue;
+ RetailPViewRenderer flushes the landscape scope before the conditional
+ depth clear and GameWindow flushes the final scope before private
+ viewports/UI. Sky/private viewport particles remain immediate.
Projectiles remain ordinary live-entity draws; there is no global or
projectile-specific render pass.
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 6310bf92..6ce6e706 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -129,7 +129,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~AP-18~~ | **RETIRED 2026-07-10 — faithful retail radar port.** Exact `RGBAColor_Radar*` floats were recovered from named static data and `gmRadarUI::GetBlipColor` was re-ported with `_blipColor` overrides plus portal/vendor/attackable-creature/admin/PK/PKLite/free-PK/fellowship precedence. The old implementation was not merely hue-tuned: it also had wrong portal/vendor colors and an incomplete dispatch matrix. | `src/AcDream.Core/Ui/RadarBlipColors.cs` + radar classification tests | — | — | `gmRadarUI::GetBlipColor` 0x004d76f0; static RGBA initializers at named decomp pc:1089736-1089804 |
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
-| AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1−SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive → D3DBLEND_ONE per-surface routing |
+| AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive → D3DBLEND_ONE |
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 |
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Rendering/GameWindow.cs` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
@@ -141,7 +141,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
-| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
+| AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewRenderer.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 |
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression, called from `GameWindow.OnUpdate` ~:7401) + `GameWindow:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 |
| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md
index a7da8937..b96a7f5d 100644
--- a/docs/architecture/worldbuilder-inventory.md
+++ b/docs/architecture/worldbuilder-inventory.md
@@ -130,6 +130,25 @@ anchors: `CPhysicsPart::GetMaxDegradeDistance` `0x0050D510`,
`CPhysicsObj::ShouldDrawParticles` `0x0050FE60`, and
`ParticleEmitter::UpdateParticles` `0x0051D180`.
+**Retail shared world-alpha seam (2026-07-18).** WorldBuilder's editor
+renderers classify translucent mesh batches correctly, but they have no live
+retail `CPartCell`/`CShadowPart` list and render particles in a separate
+batcher. `src/AcDream.App/Rendering/RetailAlphaQueue.cs` is therefore an
+acdream-owned runtime seam above the extracted mesh pipeline. During the main
+world frame, `WbDrawDispatcher` and `ParticleRenderer` submit transparent
+GfxObj subsets and scene particles into one stable far-to-near stream keyed by
+the transformed DAT `SortCenter`; only adjacent compatible entries may batch.
+`RetailPViewRenderer` drains the landscape scope before the optional depth
+clear, and `GameWindow` drains the final scope before private viewports/UI.
+Sky and sealed off-screen render targets remain independent. No DAT reader,
+mesh decoder, or second scene graph was introduced. Retail anchors:
+`CPhysicsPart::UpdateViewerDistance` `0x0050E030`,
+`RenderDeviceD3D::DrawObjCellForDummies` `0x005A0760`,
+`CShadowPart::insertion_sort` `0x006B5130`,
+`D3DPolyRender::AddMeshToAlphaList` `0x0059C230`, and
+`D3DPolyRender::FlushAlphaList` `0x0059D2E0`. The modern per-cell-order and
+EnvCell-shell residual is tracked explicitly as AP-34.
+
**Retail portal-space viewport adapter (2026-07-15).**
`src/AcDream.App/Rendering/PortalTunnelPresentation.cs` uses the extracted
Setup/GfxObj mesh pipeline and mandatory `WbDrawDispatcher` for retail's
diff --git a/docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md b/docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md
new file mode 100644
index 00000000..ce3f3f1f
--- /dev/null
+++ b/docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md
@@ -0,0 +1,191 @@
+# Retail shared alpha-list and particle ordering
+
+## Scope
+
+This note records the Sept. 2013 retail mechanism that orders translucent
+physics-object parts and particle parts. It is the oracle for replacing
+acdream's independent `WbDrawDispatcher` and `ParticleRenderer` transparent
+passes.
+
+The motivating failure is a translucent lifestone crystal drawn before a
+later scene-particle pass. Both passes depth-test and disable depth writes, so
+a smoke or flame particle behind the crystal passes the opaque depth buffer
+and composites at full strength over the already-drawn crystal.
+
+## Named-retail sources
+
+- `ParticleEmitter::EmitParticle` at `0x0051D010`
+- `CPhysicsObj::AddPartToShadowCells` call at `0x0051D126`
+- `CPhysicsPart::UpdateViewerDistance` at `0x0050E030`
+- `CPartCell::add_part` at `0x0052E740`
+- `RenderDeviceD3D::DrawObjCellForDummies` at `0x005A0760`
+- `CShadowPart::insertion_sort` at `0x006B5130`
+- `CShadowPart::draw` at `0x006B50D0`
+- `CPhysicsPart::Draw` at `0x0050D7A0`
+- `D3DPolyRender::DrawMesh` at `0x0059D4A0`
+- `D3DPolyRender::AddMeshToAlphaList` at `0x0059C230`
+- `D3DPolyRender::FlushAlphaList` at `0x0059D2E0`
+- `PView::DrawCells` at `0x005A4840`
+- `SmartBox::RenderNormalMode` at `0x00453AA0`
+- `D3DPolyRender::s_AlphaDelayMask = 0xE` at `0x00820D88`
+
+The matching PDB header defines:
+
+```text
+CShadowPart {
+ uint num_planes;
+ ClipPlaneList** planes;
+ Frame* frame;
+ CPhysicsPart* part;
+}
+
+CPhysicsPart {
+ float CYpt;
+ Vector3 viewer_heading;
+ ...
+}
+```
+
+`CYpt` is the distance from the viewer to the transformed GfxObj
+`sort_center`, not merely the object's origin.
+
+## Retail pseudocode
+
+### Particle materialization
+
+```text
+EmitParticle(emitter):
+ particlePart = initialize one particle as a CPhysicsPart
+ parentPhysicsObject.AddPartToShadowCells(particlePart)
+```
+
+A particle is therefore not a special post-process overlay. It participates
+in the same cell-owned part list as an object's ordinary `CPhysicsPart`s.
+
+### Viewer-distance key
+
+```text
+UpdateViewerDistance(part):
+ localCenter = part.gfxobj_scale * part.gfxobj.sort_center
+ viewerOffset = viewer.position offset to (part.position + localCenter)
+ part.CYpt = length(viewerOffset)
+ part.viewer_heading = normalize(viewerOffset), or +Z when almost zero
+ update degradation from CYpt
+ calculate final draw frame
+```
+
+### Cell part ordering
+
+```text
+DrawObjCellForDummies(cell):
+ update all object/part viewer distances in the cell
+ if cell.shadow_parts.count > 1:
+ CShadowPart.insertion_sort(cell.shadow_parts)
+ DrawObjCell(cell)
+
+CShadowPart.insertion_sort(cell.parts):
+ stable insertion sort using part.CYpt
+ arrange that cell's parts in descending CYpt order
+
+DrawPartCell(cell):
+ for shadowPart in cell.shadow_parts:
+ shadowPart.draw()
+
+CShadowPart.draw(shadowPart):
+ shadowPart.part.Draw(force = false)
+```
+
+The sort covers ordinary object parts and particle parts together **within one
+`CPartCell`**. Equal distance retains the established list order. The later
+alpha-list append also preserves the order in which retail traverses cells;
+it does not globally re-sort entries from different cells.
+
+### Deferred translucent subsets
+
+```text
+CPhysicsPart.Draw(part):
+ install material, surface array, object scale, and draw frame
+ DrawMesh(part.gfxobj)
+
+DrawMesh(mesh):
+ for each surface subset in authored order:
+ if subset matches AlphaDelayMask (retail default 0xE):
+ AddMeshToAlphaList(mesh, subset, surface,
+ captureCurrentWorldAndMaterialState)
+ else:
+ RenderMeshSubset immediately
+
+AddMeshToAlphaList(...):
+ append one entry to the selected fixed alpha list
+ preserve append order
+ optionally snapshot world matrix and material state
+
+FlushAlphaList():
+ render every queued clip-list entry in append order
+ clear clip list
+ render every queued alpha-list entry in append order
+ clear alpha list
+```
+
+`FlushAlphaList` does not invent a second distance sort. It preserves the
+order established by the cell's `CShadowPart` list and the authored part /
+surface traversal.
+
+### Flush scopes
+
+```text
+PView.DrawCells():
+ if outside view exists:
+ LScape.draw() // queues delayed alpha
+ FlushAlphaList()
+ conditionally clear depth
+ draw indoor cell shells and object lists
+
+SmartBox.RenderNormalMode():
+ DrawInside(viewerCell) // PView path above
+ FlushAlphaList() // final world-object alpha scope
+```
+
+The landscape flush must occur before retail's outside-to-inside depth clear.
+The final flush drains delayed alpha produced after that boundary.
+
+## Cross-reference findings
+
+- acdream's extracted WorldBuilder mesh path correctly preserves DAT surface
+ translucency metadata and a GfxObj `SortCenter`, but WorldBuilder renders
+ its editor content in renderer-local passes. It has no live `CPartCell` /
+ `CShadowPart` ownership and therefore cannot supply retail's mixed
+ object-particle queue.
+- ACViewer is useful for DAT geometry and particle asset interpretation but is
+ a viewer rather than the retail cell renderer. It does not supersede the
+ named-retail ordering above.
+- ACE is authoritative for particle/gameplay events but is server-side and has
+ no client alpha compositor. It corroborates asset and object identity, not
+ draw ordering.
+
+The named retail client remains the behavioral oracle.
+
+## Port contract
+
+1. Scene particles and translucent live/static GfxObj batches submit into one
+ stage-scoped queue.
+2. Each submission carries the retail viewer-distance key derived from the
+ transformed GfxObj sort center (particle billboards use their live center).
+3. In the modern renderer's stage-scoped reconstruction, sort far-to-near with
+ stable submission sequence as the equal-distance tiebreak. This recreates
+ mixed ordinary/particle part ordering, but it is scope-global rather than
+ retaining retail's missing per-`CPartCell` lists. AP-34 records that narrow
+ architectural residual explicitly.
+4. The queue itself does not reorder by renderer or material. Only adjacent
+ compatible submissions may batch.
+5. Every flush keeps depth testing enabled and depth writes disabled.
+6. Each submission preserves its DAT blend mode, cull mode, transform,
+ texture, lighting, opacity, and selection-lighting state.
+7. Flush before the outside-to-inside depth clear, then flush the final world
+ scope before world-space overlays and UI.
+8. Sky-pre-scene and sky-post-scene particles remain in their dedicated sky
+ passes; they do not enter the world alpha queue.
+9. A particle behind translucent crystal draws first and is then attenuated by
+ the crystal. A particle in front draws after the crystal and remains bright.
+10. No lifestone-specific branch, transparent depth prepass, or particle
+ depth-test suppression is permitted.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 65eaa37e..f17dcf99 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -375,6 +375,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
private double _physicsScriptGameTime;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
+ private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new();
// Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but
// never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime.
// Keep the experimental path available for DAT archaeology only.
@@ -2627,7 +2628,8 @@ public sealed class GameWindow : IDisposable
_classificationCache, _translucencyFades,
_retailSelectionScene ??= new AcDream.App.Rendering.Selection.RetailSelectionScene(
new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache(
- _dats!, _datLock)));
+ _dats!, _datLock)),
+ _retailAlphaQueue);
// A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
@@ -2686,7 +2688,8 @@ public sealed class GameWindow : IDisposable
_particleSystem,
_textureCache,
_dats,
- _wbMeshAdapter);
+ _wbMeshAdapter,
+ _retailAlphaQueue);
// A.5 T22.5: apply radii from the already-resolved _resolvedQuality.
// _resolvedQuality was set by the quality block immediately after
@@ -9632,6 +9635,7 @@ public sealed class GameWindow : IDisposable
_retailSelectionScene?.BeginFrame();
if (_cameraController is not null && !portalViewportVisible)
{
+ _retailAlphaQueue.BeginFrame();
var activeCamera = _cameraController.Active;
var camera = _teleportViewPlane.ApplyTo(activeCamera);
var worldProjection = camera.Projection;
@@ -10212,6 +10216,7 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Vfx.ParticleRenderPass.Scene,
emitter => emitter.AttachedObjectId == 0);
},
+ FlushLandscapeAlpha = _retailAlphaQueue.Flush,
DrawCellParticles = sliceCtx =>
DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
DrawDynamicsParticles = survivors =>
@@ -10543,6 +10548,7 @@ public sealed class GameWindow : IDisposable
// pre-login screen shows a live, correctly-tinted sky and
// nothing else.
SkipWorldGeometry:
+ _retailAlphaQueue.EndFrame();
if (_terrain is not null)
_particleVisibility.MarkVisibleCells(_terrain.VisibleCellIds);
_particleVisibility.CompleteFrame();
diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs
index f54c409f..eca9e05b 100644
--- a/src/AcDream.App/Rendering/ParticleRenderer.cs
+++ b/src/AcDream.App/Rendering/ParticleRenderer.cs
@@ -13,7 +13,10 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
///
-/// Instanced renderer for retail particle emitters.
+/// Instanced renderer for retail particle emitters. Scene particles submit to
+/// while a world frame is active so their
+/// compositing order is shared with ordinary translucent GfxObj parts. Sky and
+/// sealed off-screen passes retain their independent immediate path.
///
public sealed unsafe class ParticleRenderer : IDisposable
{
@@ -24,6 +27,11 @@ public sealed unsafe class ParticleRenderer : IDisposable
MeshBatchKey Key,
ObjectRenderBatch Batch,
MeshParticleInstance Instance);
+ private readonly record struct DeferredParticleDraw(
+ ParticleSubmissionKind Kind,
+ ParticleDraw Billboard,
+ MeshParticleDraw Mesh,
+ Matrix4x4 ViewProjection);
private readonly struct ParticleInstance
{
@@ -64,6 +72,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
private readonly DatCollection? _dats;
private readonly WbMeshAdapter? _meshAdapter;
private readonly ParticleSystem _particles;
+ private readonly RetailAlphaQueue? _alphaQueue;
+ private readonly AlphaDrawSource _alphaSource;
private readonly Dictionary _particleGfxInfoByGfxObj = new();
private readonly Dictionary _geometryKindByGfxObj = new();
private readonly Dictionary _meshBlendBySurface = new();
@@ -97,20 +107,33 @@ public sealed unsafe class ParticleRenderer : IDisposable
private readonly List _meshDrawListScratch = new(64);
private readonly List _meshRunScratch = new(64);
private readonly List _submissionScratch = new(128);
+ private readonly List _deferredAlpha = new(128);
- public ParticleRenderer(
+ private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource
+ {
+ public void DrawAlphaBatch(ReadOnlySpan tokens)
+ => owner.DrawDeferredAlphaBatch(tokens);
+
+ public void ResetAlphaSubmissions()
+ => owner._deferredAlpha.Clear();
+ }
+
+ internal ParticleRenderer(
GL gl,
string shadersDir,
ParticleSystem particles,
TextureCache? textures = null,
DatCollection? dats = null,
- WbMeshAdapter? meshAdapter = null)
+ WbMeshAdapter? meshAdapter = null,
+ RetailAlphaQueue? alphaQueue = null)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_textures = textures;
_dats = dats;
_meshAdapter = meshAdapter;
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
+ _alphaQueue = alphaQueue;
+ _alphaSource = new AlphaDrawSource(this);
if (_meshAdapter is not null)
{
_meshReferences = new ParticleMeshReferenceTracker(
@@ -221,10 +244,43 @@ public sealed unsafe class ParticleRenderer : IDisposable
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
BuildDrawLists(cameraWorldPos, renderPass, cameraRight, cameraUp, emitterFilter);
- if (_submissionScratch.Count > 0)
+ if (_submissionScratch.Count == 0)
+ return;
+
+ if (renderPass == ParticleRenderPass.Scene && _alphaQueue?.IsCollecting == true)
+ DeferToRetailAlphaQueue(camera);
+ else
DrawOrdered(camera);
}
+ private void DeferToRetailAlphaQueue(ICamera camera)
+ {
+ RetailAlphaQueue queue = _alphaQueue!;
+ Matrix4x4 viewProjection = camera.View * camera.Projection;
+ for (int i = 0; i < _submissionScratch.Count; i++)
+ {
+ ParticleSubmission submission = _submissionScratch[i];
+ DeferredParticleDraw deferred = submission.Kind == ParticleSubmissionKind.Billboard
+ ? new DeferredParticleDraw(
+ submission.Kind,
+ _drawListScratch[submission.DrawIndex],
+ default,
+ viewProjection)
+ : new DeferredParticleDraw(
+ submission.Kind,
+ default,
+ _meshDrawListScratch[submission.DrawIndex],
+ viewProjection);
+
+ int token = _deferredAlpha.Count;
+ _deferredAlpha.Add(deferred);
+ queue.Submit(
+ _alphaSource,
+ token,
+ MathF.Sqrt(MathF.Max(0f, submission.DistanceSq)));
+ }
+ }
+
private void DrawOrdered(ICamera camera)
{
ParticleSubmissionOrdering.Sort(_submissionScratch);
@@ -245,7 +301,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
BatchKey key = draw.Key;
if (activeKind != ParticleSubmissionKind.Billboard)
{
- PrepareBillboardPipeline(camera);
+ PrepareBillboardPipeline(camera.View * camera.Projection);
activeKind = ParticleSubmissionKind.Billboard;
}
@@ -281,7 +337,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
ObjectRenderBatch batch = meshDraw.Batch;
if (activeKind != ParticleSubmissionKind.Mesh)
{
- PrepareMeshPipeline(camera, global);
+ PrepareMeshPipeline(camera.View * camera.Projection, global);
activeKind = ParticleSubmissionKind.Mesh;
}
@@ -334,19 +390,137 @@ public sealed unsafe class ParticleRenderer : IDisposable
_gl.Disable(EnableCap.CullFace);
}
- private void PrepareBillboardPipeline(ICamera camera)
+ private void DrawDeferredAlphaBatch(ReadOnlySpan tokens)
+ {
+ if (tokens.Length == 0)
+ return;
+
+ GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer;
+ _gl.Enable(EnableCap.DepthTest);
+ _gl.Enable(EnableCap.Blend);
+ _gl.DepthMask(false);
+ _gl.ActiveTexture(TextureUnit.Texture0);
+
+ ParticleSubmissionKind? activeKind = null;
+ Matrix4x4 activeViewProjection = default;
+ int i = 0;
+ while (i < tokens.Length)
+ {
+ DeferredParticleDraw deferred = _deferredAlpha[tokens[i]];
+ if (deferred.Kind == ParticleSubmissionKind.Billboard)
+ {
+ ParticleDraw draw = deferred.Billboard;
+ BatchKey key = draw.Key;
+ if (activeKind != ParticleSubmissionKind.Billboard
+ || activeViewProjection != deferred.ViewProjection)
+ {
+ PrepareBillboardPipeline(deferred.ViewProjection);
+ activeKind = ParticleSubmissionKind.Billboard;
+ activeViewProjection = deferred.ViewProjection;
+ }
+
+ _runScratch.Clear();
+ do
+ {
+ _runScratch.Add(deferred.Billboard.Instance);
+ i++;
+ if (i >= tokens.Length)
+ break;
+ deferred = _deferredAlpha[tokens[i]];
+ }
+ while (deferred.Kind == ParticleSubmissionKind.Billboard
+ && deferred.Billboard.Key == key
+ && deferred.ViewProjection == activeViewProjection);
+
+ _gl.BlendFunc(
+ BlendingFactor.SrcAlpha,
+ key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha);
+ _shader.SetInt("uUseTexture", key.UseTexture ? 1 : 0);
+ _gl.BindTexture(TextureTarget.Texture2D, key.UseTexture ? key.TextureHandle : 0);
+ DrawInstances(_runScratch);
+ continue;
+ }
+
+ if (_meshShader is null || global is null)
+ {
+ i++;
+ continue;
+ }
+
+ MeshParticleDraw meshDraw = deferred.Mesh;
+ MeshBatchKey meshKey = meshDraw.Key;
+ ObjectRenderBatch batch = meshDraw.Batch;
+ if (activeKind != ParticleSubmissionKind.Mesh
+ || activeViewProjection != deferred.ViewProjection)
+ {
+ PrepareMeshPipeline(deferred.ViewProjection, global);
+ activeKind = ParticleSubmissionKind.Mesh;
+ activeViewProjection = deferred.ViewProjection;
+ }
+
+ _meshRunScratch.Clear();
+ do
+ {
+ _meshRunScratch.Add(deferred.Mesh.Instance);
+ i++;
+ if (i >= tokens.Length)
+ break;
+ deferred = _deferredAlpha[tokens[i]];
+ }
+ while (deferred.Kind == ParticleSubmissionKind.Mesh
+ && deferred.Mesh.Key == meshKey
+ && deferred.ViewProjection == activeViewProjection);
+
+ ApplyMeshCullMode(batch.CullMode);
+ TranslucencyKind blend = ResolveMeshBlend(batch);
+ _gl.BlendFunc(
+ blend == TranslucencyKind.InvAlpha
+ ? BlendingFactor.OneMinusSrcAlpha
+ : BlendingFactor.SrcAlpha,
+ blend switch
+ {
+ TranslucencyKind.Additive => BlendingFactor.One,
+ TranslucencyKind.InvAlpha => BlendingFactor.SrcAlpha,
+ _ => BlendingFactor.OneMinusSrcAlpha,
+ });
+
+ _gl.ProgramUniform2(
+ _meshShader.Program,
+ _meshTextureHandleLoc,
+ (uint)(batch.BindlessTextureHandle & 0xFFFFFFFFu),
+ (uint)(batch.BindlessTextureHandle >> 32));
+ _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex);
+
+ UploadMeshInstances(_meshRunScratch);
+ _gl.DrawElementsInstancedBaseVertex(
+ PrimitiveType.Triangles,
+ (uint)batch.IndexCount,
+ DrawElementsType.UnsignedShort,
+ (void*)(batch.FirstIndex * sizeof(ushort)),
+ (uint)_meshRunScratch.Count,
+ (int)batch.BaseVertex);
+ }
+
+ _gl.BindTexture(TextureTarget.Texture2D, 0);
+ _gl.BindVertexArray(0);
+ _gl.DepthMask(true);
+ _gl.Disable(EnableCap.Blend);
+ _gl.Disable(EnableCap.CullFace);
+ }
+
+ private void PrepareBillboardPipeline(Matrix4x4 viewProjection)
{
_shader.Use();
- _shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
+ _shader.SetMatrix4("uViewProjection", viewProjection);
_shader.SetInt("uParticleTexture", 0);
_gl.Disable(EnableCap.CullFace);
_gl.BindVertexArray(_quadVao);
}
- private void PrepareMeshPipeline(ICamera camera, GlobalMeshBuffer global)
+ private void PrepareMeshPipeline(Matrix4x4 viewProjection, GlobalMeshBuffer global)
{
_meshShader!.Use();
- _meshShader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
+ _meshShader.SetMatrix4("uViewProjection", viewProjection);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.BindVertexArray(_meshVao);
@@ -415,11 +589,10 @@ public sealed unsafe class ParticleRenderer : IDisposable
// mirrors retail's live-parent-frame read at
// ParticleEmitter::UpdateParticles 0x0051d2d4 for is_parent_local=1.
Vector3 pos = p.Position;
- float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId;
if (gfxObjId != 0
&& ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh
- && TryAppendMeshDraws(em, p, gfxObjId, distSq, ref sequence))
+ && TryAppendMeshDraws(em, p, gfxObjId, cameraWorldPos, ref sequence))
{
continue;
}
@@ -447,6 +620,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size);
}
+ float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
+
int drawIndex = draws.Count;
draws.Add(new ParticleDraw(key, new ParticleInstance(pos, axisX, axisY, p.ColorArgb, distSq)));
_submissionScratch.Add(new ParticleSubmission(
@@ -462,7 +637,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
AcDream.Core.Vfx.ParticleEmitter emitter,
Particle particle,
uint gfxObjId,
- float distanceSq,
+ Vector3 cameraWorldPosition,
ref int sequence)
{
if (_meshAdapter is null || _meshShader is null)
@@ -481,6 +656,11 @@ public sealed unsafe class ParticleRenderer : IDisposable
Matrix4x4 model = Matrix4x4.CreateScale(particle.Size)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(particle.Position);
+ float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
+ renderData.SortCenter,
+ model,
+ cameraWorldPosition);
+ float distanceSq = viewerDistance * viewerDistance;
var instance = new MeshParticleInstance(model, particle.ColorArgb, distanceSq);
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
diff --git a/src/AcDream.App/Rendering/RetailAlphaQueue.cs b/src/AcDream.App/Rendering/RetailAlphaQueue.cs
new file mode 100644
index 00000000..01369b6d
--- /dev/null
+++ b/src/AcDream.App/Rendering/RetailAlphaQueue.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.App.Rendering;
+
+internal static class RetailAlphaOrdering
+{
+ ///
+ /// Retail CPhysicsPart::UpdateViewerDistance 0x0050E030:
+ /// transform the GfxObj's authored sort center through the part draw frame,
+ /// then store its Euclidean distance from the viewer as CYpt.
+ ///
+ public static float ComputeViewerDistance(
+ Vector3 localSortCenter,
+ Matrix4x4 model,
+ Vector3 cameraWorldPosition)
+ => Vector3.Distance(Vector3.Transform(localSortCenter, model), cameraWorldPosition);
+}
+
+///
+/// Renderer-owned half of retail's delayed alpha list. A source keeps the
+/// immutable payload addressed by each token until the queue flushes, then
+/// releases the payload when is called.
+///
+internal interface IRetailAlphaDrawSource
+{
+ void DrawAlphaBatch(ReadOnlySpan tokens);
+ void ResetAlphaSubmissions();
+}
+
+internal readonly record struct RetailAlphaSubmission(
+ IRetailAlphaDrawSource Source,
+ int Token,
+ float ViewerDistance,
+ long Sequence);
+
+///
+/// Frame-scoped port of retail's shared D3DPolyRender alpha list.
+/// Scene-particle parts and ordinary GfxObj parts enter one stable,
+/// far-to-near stream instead of being composited in renderer-local passes.
+///
+/// Retail oracle:
+///
+/// - CShadowPart::insertion_sort 0x006B5130 orders all cell parts by
+/// CPhysicsPart::CYpt.
+/// - D3DPolyRender::AddMeshToAlphaList 0x0059C230 appends delayed
+/// surface subsets in that established order.
+/// - D3DPolyRender::FlushAlphaList 0x0059D2E0 drains without another
+/// material/renderer sort.
+///
+///
+internal sealed class RetailAlphaQueue
+{
+ private readonly List _submissions = new(256);
+ private readonly List _sources = new(4);
+ private int[] _tokenScratch = new int[256];
+ private long _nextSequence;
+
+ public bool IsCollecting { get; private set; }
+ internal int PendingCount => _submissions.Count;
+
+ public void BeginFrame()
+ {
+ if (IsCollecting)
+ throw new InvalidOperationException("Retail alpha frame is already active.");
+ if (_submissions.Count != 0 || _sources.Count != 0)
+ throw new InvalidOperationException("Retail alpha queue retained payload outside a frame.");
+
+ _nextSequence = 0;
+ IsCollecting = true;
+ }
+
+ public void Submit(IRetailAlphaDrawSource source, int token, float viewerDistance)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+ if (!IsCollecting)
+ throw new InvalidOperationException("Retail alpha submission requires an active frame.");
+ if (token < 0)
+ throw new ArgumentOutOfRangeException(nameof(token));
+
+ _submissions.Add(new RetailAlphaSubmission(
+ source,
+ token,
+ NormalizeDistance(viewerDistance),
+ _nextSequence++));
+
+ for (int i = 0; i < _sources.Count; i++)
+ if (ReferenceEquals(_sources[i], source))
+ return;
+ _sources.Add(source);
+ }
+
+ ///
+ /// Drains the current retail alpha scope and keeps the frame open so the
+ /// caller can collect the post-depth-clear scope. Only adjacent entries
+ /// from one renderer are handed over as a batch; the queue never groups by
+ /// renderer across another entry and therefore cannot alter compositing.
+ ///
+ public void Flush()
+ {
+ if (!IsCollecting)
+ throw new InvalidOperationException("Retail alpha flush requires an active frame.");
+
+ try
+ {
+ if (_submissions.Count == 0)
+ return;
+
+ _submissions.Sort(CompareRetailOrder);
+ EnsureTokenCapacity(_submissions.Count);
+
+ int start = 0;
+ while (start < _submissions.Count)
+ {
+ IRetailAlphaDrawSource source = _submissions[start].Source;
+ int end = start + 1;
+ while (end < _submissions.Count
+ && ReferenceEquals(_submissions[end].Source, source))
+ end++;
+
+ int count = end - start;
+ for (int i = 0; i < count; i++)
+ _tokenScratch[i] = _submissions[start + i].Token;
+ source.DrawAlphaBatch(_tokenScratch.AsSpan(0, count));
+ start = end;
+ }
+ }
+ finally
+ {
+ for (int i = 0; i < _sources.Count; i++)
+ _sources[i].ResetAlphaSubmissions();
+ _sources.Clear();
+ _submissions.Clear();
+ }
+ }
+
+ public void EndFrame()
+ {
+ if (!IsCollecting)
+ throw new InvalidOperationException("Retail alpha frame is not active.");
+
+ try
+ {
+ Flush();
+ }
+ finally
+ {
+ IsCollecting = false;
+ }
+ }
+
+ private static int CompareRetailOrder(RetailAlphaSubmission left, RetailAlphaSubmission right)
+ {
+ // CShadowPart::insertion_sort 0x006B5130 produces descending CYpt
+ // (far-to-near). Sequence makes the List.Sort result stable for equal
+ // distances, preserving part/surface append order like retail.
+ int distance = right.ViewerDistance.CompareTo(left.ViewerDistance);
+ return distance != 0 ? distance : left.Sequence.CompareTo(right.Sequence);
+ }
+
+ private static float NormalizeDistance(float value)
+ => float.IsFinite(value) && value >= 0f ? value : 0f;
+
+ private void EnsureTokenCapacity(int count)
+ {
+ if (_tokenScratch.Length >= count)
+ return;
+ Array.Resize(ref _tokenScratch, count + 256);
+ }
+}
diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
index 959c104a..33d5e464 100644
--- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs
+++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs
@@ -539,6 +539,11 @@ public sealed class RetailPViewRenderer
if (!ctx.RootCell.IsOutdoorNode)
ctx.DrawUnattachedSceneParticles?.Invoke();
+ // Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
+ // immediately after LScape::draw and before the optional depth clear.
+ // The queue remains active for the post-clear/final-world scope.
+ ctx.FlushLandscapeAlpha?.Invoke();
+
// T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
@@ -1105,6 +1110,10 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
/// end of the landscape stage (pre-clear). Outdoor roots draw them via
/// GameWindow's dedicated post-frame pass instead.
public Action? DrawUnattachedSceneParticles { get; init; }
+ /// Drains retail's delayed landscape alpha list before the
+ /// optional outside-to-inside depth clear. The same frame-scoped queue
+ /// remains open for the final world alpha scope.
+ public Action? FlushLandscapeAlpha { get; init; }
public Action>? DrawDynamicsParticles { get; init; }
public Action? EmitDiagnostics { get; init; }
}
diff --git a/src/AcDream.App/Rendering/Wb/CachedBatch.cs b/src/AcDream.App/Rendering/Wb/CachedBatch.cs
index d1bccb76..e270e19c 100644
--- a/src/AcDream.App/Rendering/Wb/CachedBatch.cs
+++ b/src/AcDream.App/Rendering/Wb/CachedBatch.cs
@@ -8,6 +8,8 @@ namespace AcDream.App.Rendering.Wb;
/// subPart contributes its own entries, with
/// already containing the
/// subPart.PartTransform * meshRef.PartTransform product.
+/// preserves the authored GfxObj key used by
+/// retail's delayed-alpha viewer-distance ordering on cache hits.
///
/// Accessibility: internal because is
/// internal and shows up in this struct's constructor / Deconstruct
@@ -18,7 +20,8 @@ namespace AcDream.App.Rendering.Wb;
internal readonly record struct CachedBatch(
GroupKey Key,
ulong BindlessTextureHandle,
- Matrix4x4 RestPose);
+ Matrix4x4 RestPose,
+ Vector3 LocalSortCenter = default);
///
/// One entity's cached classification. is flat across
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index 316c461a..5c2c7a6e 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -43,8 +43,10 @@ namespace AcDream.App.Rendering.Wb;
/// each group becomes one DrawElementsIndirectCommand. Three GPU buffers
/// are uploaded per frame: instance matrices (SSBO binding 0), per-group batch
/// metadata/texture handles (SSBO binding 1), and the indirect draw commands.
-/// Two glMultiDrawElementsIndirect calls cover the opaque and transparent
-/// passes respectively — one GL call per pass regardless of group count.
+/// Opaque world groups remain MDI-batched. Transparent world instances enter
+/// so ordinary GfxObj parts and particles share
+/// retail's stable far-to-near stream; sealed off-screen consumers retain the
+/// immediate transparent MDI path.
///
///
///
@@ -88,6 +90,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly EntitySpawnAdapter _entitySpawnAdapter;
private readonly IRetailSelectionRenderSink? _selectionSink;
private readonly IRetailSelectionLightingSource? _selectionLighting;
+ private readonly RetailAlphaQueue? _alphaQueue;
+ private readonly AlphaDrawSource _alphaSource;
private readonly BindlessSupport _bindless;
@@ -240,10 +244,43 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
public uint Flags;
}
+ private readonly record struct DeferredAlphaInstance(
+ GroupKey Key,
+ Matrix4x4 Model,
+ uint ClipSlot,
+ AlphaLightSet Lights,
+ uint Indoor,
+ float Opacity,
+ Vector2 SelectionLighting);
+
+ private readonly record struct AlphaLightSet(
+ int L0, int L1, int L2, int L3,
+ int L4, int L5, int L6, int L7)
+ {
+ public int this[int index] => index switch
+ {
+ 0 => L0, 1 => L1, 2 => L2, 3 => L3,
+ 4 => L4, 5 => L5, 6 => L6, 7 => L7,
+ _ => throw new ArgumentOutOfRangeException(nameof(index)),
+ };
+ }
+
+ private sealed class AlphaDrawSource(WbDrawDispatcher owner) : IRetailAlphaDrawSource
+ {
+ public void DrawAlphaBatch(ReadOnlySpan tokens)
+ => owner.DrawDeferredAlphaBatch(tokens);
+
+ public void ResetAlphaSubmissions()
+ => owner._deferredAlpha.Clear();
+ }
+
// Per-frame scratch — reused across frames to avoid per-frame allocation.
private readonly Dictionary _groups = new();
private readonly List _opaqueDraws = new();
private readonly List _translucentDraws = new();
+ private readonly List _deferredAlpha = new(128);
+ private TranslucencyKind[] _deferredAlphaKinds = new TranslucencyKind[128];
+ private Matrix4x4 _deferredAlphaViewProjection;
// A.5 T26 follow-up (Bug B): WalkEntities populates this scratch list
// instead of allocating a fresh List<(WorldEntity, int)> per frame. At
// ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes = ~480 KB / frame
@@ -363,7 +400,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
BindlessSupport bindless,
EntityClassificationCache classificationCache,
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades,
- IRetailSelectionRenderSink? selectionSink = null)
+ IRetailSelectionRenderSink? selectionSink = null,
+ RetailAlphaQueue? alphaQueue = null)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(shader);
@@ -382,6 +420,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_translucencyFades = translucencyFades;
_selectionSink = selectionSink;
_selectionLighting = selectionSink as IRetailSelectionLightingSource;
+ _alphaQueue = alphaQueue;
+ _alphaSource = new AlphaDrawSource(this);
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
_instanceSsbo = _gl.GenBuffer();
@@ -1579,12 +1619,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// stable 1k-draw live scene into hundreds of tiny MDI runs after a
// landblock transition, which shows up as a GPU-command bottleneck
// without a triangle-count spike.
+ // Retail particles and ordinary object parts share CPartCell's
+ // CShadowPart list before delayed alpha is flushed. During the world
+ // alpha frame, preserve each transparent instance as an independent
+ // submission so the shared queue can interleave it with particles.
+ // Immediate mode remains for sealed off-screen consumers such as the
+ // paperdoll and UI Studio render stack.
+ bool deferTransparent = _alphaQueue?.IsCollecting == true;
_opaqueDraws.Sort(CompareOpaqueSubmissionOrder);
- _translucentDraws.Sort(CompareTransparentSubmissionOrder);
+ if (deferTransparent)
+ DeferTransparentGroups(camPos, vp);
+ else
+ _translucentDraws.Sort(CompareTransparentSubmissionOrder);
// ── Phase 4: build IndirectGroupInput list (opaque sorted, then translucent),
// fill via BuildIndirectArrays ──────────────────────────────────
- int totalDraws = _opaqueDraws.Count + _translucentDraws.Count;
+ int immediateTransparentCount = deferTransparent ? 0 : _translucentDraws.Count;
+ int totalDraws = _opaqueDraws.Count + immediateTransparentCount;
if (_batchData.Length < totalDraws)
_batchData = new BatchData[totalDraws + 64];
if (_indirectCommands.Length < totalDraws)
@@ -1594,7 +1645,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
var groupInputs = new List(totalDraws);
foreach (var g in _opaqueDraws) groupInputs.Add(ToInput(g));
- foreach (var g in _translucentDraws) groupInputs.Add(ToInput(g));
+ if (!deferTransparent)
+ foreach (var g in _translucentDraws) groupInputs.Add(ToInput(g));
// Cast _batchData (private BatchData) to public-mirror BatchDataPublic for BuildIndirectArrays.
// Layout is asserted at test time (BatchDataPublic_LayoutMatchesPrivateBatchData test).
@@ -1907,6 +1959,200 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
Translucency: g.Translucency,
CullMode: g.CullMode);
+ private static GroupKey ToKey(InstanceGroup g) => new(
+ g.Ibo,
+ g.FirstIndex,
+ g.BaseVertex,
+ g.IndexCount,
+ g.BindlessTextureHandle,
+ g.TextureLayer,
+ g.Translucency,
+ g.CullMode);
+
+ private void DeferTransparentGroups(Vector3 cameraWorldPosition, Matrix4x4 viewProjection)
+ {
+ RetailAlphaQueue queue = _alphaQueue!;
+ if (_deferredAlpha.Count == 0)
+ _deferredAlphaViewProjection = viewProjection;
+ else if (_deferredAlphaViewProjection != viewProjection)
+ throw new InvalidOperationException(
+ "One retail alpha scope cannot combine different view-projection matrices.");
+
+ foreach (InstanceGroup group in _translucentDraws)
+ {
+ GroupKey key = ToKey(group);
+ for (int i = 0; i < group.Matrices.Count; i++)
+ {
+ Matrix4x4 model = group.Matrices[i];
+ Vector3 localSortCenter = group.LocalSortCenters[i];
+ float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
+ localSortCenter,
+ model,
+ cameraWorldPosition);
+ int lightOffset = i * LightManager.MaxLightsPerObject;
+ var lights = new AlphaLightSet(
+ group.LightSets[lightOffset + 0], group.LightSets[lightOffset + 1],
+ group.LightSets[lightOffset + 2], group.LightSets[lightOffset + 3],
+ group.LightSets[lightOffset + 4], group.LightSets[lightOffset + 5],
+ group.LightSets[lightOffset + 6], group.LightSets[lightOffset + 7]);
+
+ int token = _deferredAlpha.Count;
+ _deferredAlpha.Add(new DeferredAlphaInstance(
+ key,
+ model,
+ group.Slots[i],
+ lights,
+ group.IndoorFlags[i],
+ group.Opacities[i],
+ group.SelectionLighting[i]));
+ queue.Submit(_alphaSource, token, viewerDistance);
+ }
+ }
+ }
+
+ private void DrawDeferredAlphaBatch(ReadOnlySpan tokens)
+ {
+ if (tokens.Length == 0)
+ return;
+
+ GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer;
+ if (global is null || global.VAO == 0)
+ return;
+
+ int count = tokens.Length;
+ EnsureDeferredAlphaCapacity(count);
+ for (int i = 0; i < count; i++)
+ {
+ DeferredAlphaInstance entry = _deferredAlpha[tokens[i]];
+ WriteMatrix(_instanceData, i * 16, entry.Model);
+ _clipSlotData[i] = entry.ClipSlot;
+ _indoorData[i] = entry.Indoor;
+ _alphaData[i] = entry.Opacity;
+ _selectionLightingData[i] = entry.SelectionLighting;
+ int lightOffset = i * LightManager.MaxLightsPerObject;
+ for (int light = 0; light < LightManager.MaxLightsPerObject; light++)
+ _lightSetData[lightOffset + light] = entry.Lights[light];
+
+ GroupKey key = entry.Key;
+ _batchData[i] = new BatchData
+ {
+ TextureHandle = key.BindlessTextureHandle,
+ TextureLayer = key.TextureLayer,
+ Flags = 0,
+ };
+ _indirectCommands[i] = new DrawElementsIndirectCommand
+ {
+ Count = (uint)key.IndexCount,
+ InstanceCount = 1,
+ FirstIndex = key.FirstIndex,
+ BaseVertex = key.BaseVertex,
+ BaseInstance = (uint)i,
+ };
+ _drawCullModes[i] = key.CullMode;
+ _deferredAlphaKinds[i] = key.Translucency;
+ }
+
+ UploadDeferredAlphaBuffers(count);
+
+ _shader.Use();
+ _shader.SetMatrix4("uViewProjection", _deferredAlphaViewProjection);
+ _shader.SetInt("uLightingMode", 0);
+ _shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode);
+ _shader.SetInt("uRenderPass", 1);
+ BindClipRegionBinding2();
+ _gl.BindVertexArray(global.VAO);
+ _gl.Enable(EnableCap.DepthTest);
+ _gl.Enable(EnableCap.Blend);
+ _gl.DepthMask(false);
+ _gl.FrontFace(FrontFaceDirection.CW);
+
+ int runStart = 0;
+ while (runStart < count)
+ {
+ TranslucencyKind blend = _deferredAlphaKinds[runStart];
+ int runEnd = runStart + 1;
+ while (runEnd < count && _deferredAlphaKinds[runEnd] == blend)
+ runEnd++;
+
+ ApplyRetailBlend(blend);
+ DrawIndirectRange(runStart, runEnd - runStart);
+ runStart = runEnd;
+ }
+
+ _gl.DepthMask(true);
+ _gl.Disable(EnableCap.Blend);
+ _gl.Disable(EnableCap.CullFace);
+ _gl.BindVertexArray(0);
+ }
+
+ private void EnsureDeferredAlphaCapacity(int count)
+ {
+ int neededMatrixFloats = count * 16;
+ if (_instanceData.Length < neededMatrixFloats)
+ _instanceData = new float[neededMatrixFloats + 256 * 16];
+ if (_clipSlotData.Length < count)
+ _clipSlotData = new uint[count + 256];
+ if (_indoorData.Length < count)
+ _indoorData = new uint[count + 256];
+ if (_alphaData.Length < count)
+ _alphaData = new float[count + 256];
+ if (_selectionLightingData.Length < count)
+ _selectionLightingData = new Vector2[count + 256];
+ if (_lightSetData.Length < count * LightManager.MaxLightsPerObject)
+ _lightSetData = new int[(count + 256) * LightManager.MaxLightsPerObject];
+ if (_batchData.Length < count)
+ _batchData = new BatchData[count + 64];
+ if (_indirectCommands.Length < count)
+ _indirectCommands = new DrawElementsIndirectCommand[count + 64];
+ if (_drawCullModes.Length < count)
+ _drawCullModes = new CullMode[count + 64];
+ if (_deferredAlphaKinds.Length < count)
+ _deferredAlphaKinds = new TranslucencyKind[count + 64];
+ }
+
+ private void UploadDeferredAlphaBuffers(int count)
+ {
+ fixed (float* p = _instanceData)
+ UploadSsbo(_instanceSsbo, 0, p, count * 16 * sizeof(float));
+ fixed (BatchData* p = _batchData)
+ UploadSsbo(_batchSsbo, 1, p, count * sizeof(BatchData));
+ fixed (uint* p = _clipSlotData)
+ UploadSsbo(_clipSlotSsbo, 3, p, count * sizeof(uint));
+ fixed (int* p = _lightSetData)
+ UploadSsbo(_instLightSetSsbo, 5, p, count * LightManager.MaxLightsPerObject * sizeof(int));
+ fixed (uint* p = _indoorData)
+ UploadSsbo(_instIndoorSsbo, 6, p, count * sizeof(uint));
+ fixed (float* p = _alphaData)
+ UploadSsbo(_instAlphaSsbo, 7, p, count * sizeof(float));
+ fixed (Vector2* p = _selectionLightingData)
+ UploadSsbo(_instSelectionLightingSsbo, 8, p, count * sizeof(float) * 2);
+ UploadGlobalLights();
+
+ fixed (DrawElementsIndirectCommand* p = _indirectCommands)
+ {
+ _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
+ _gl.BufferData(
+ BufferTargetARB.DrawIndirectBuffer,
+ (nuint)(count * sizeof(DrawElementsIndirectCommand)),
+ p,
+ BufferUsageARB.DynamicDraw);
+ }
+ }
+
+ private void ApplyRetailBlend(TranslucencyKind blend)
+ {
+ _gl.BlendFunc(
+ blend == TranslucencyKind.InvAlpha
+ ? BlendingFactor.OneMinusSrcAlpha
+ : BlendingFactor.SrcAlpha,
+ blend switch
+ {
+ TranslucencyKind.Additive => BlendingFactor.One,
+ TranslucencyKind.InvAlpha => BlendingFactor.SrcAlpha,
+ _ => BlendingFactor.OneMinusSrcAlpha,
+ });
+ }
+
private static int CompareOpaqueSubmissionOrder(InstanceGroup a, InstanceGroup b)
{
int cull = a.CullMode.CompareTo(b.CullMode);
@@ -2117,11 +2363,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
internal static void ApplyCacheHit(
EntityCacheEntry entry,
Matrix4x4 entityWorld,
- Action appendInstance)
+ Action appendInstance)
{
foreach (var cached in entry.Batches)
{
- appendInstance(cached.Key, cached.RestPose * entityWorld);
+ appendInstance(cached.Key, cached.RestPose * entityWorld, cached.LocalSortCenter);
}
}
@@ -2188,7 +2434,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// creates an for the given key in
/// _groups and appends the per-instance world matrix.
///
- private void AppendInstanceToGroup(GroupKey key, Matrix4x4 model)
+ private void AppendInstanceToGroup(GroupKey key, Matrix4x4 model, Vector3 localSortCenter)
{
if (!_groups.TryGetValue(key, out var grp))
{
@@ -2206,6 +2452,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_groups[key] = grp;
}
grp.Matrices.Add(model);
+ grp.LocalSortCenters.Add(localSortCenter);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
// #188: cache-hit entities are always non-animated (the Tier-1 cache
@@ -2392,11 +2639,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_groups[key] = grp;
}
grp.Matrices.Add(model);
+ grp.LocalSortCenters.Add(renderData.SortCenter);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices
grp.SelectionLighting.Add(_currentEntitySelectionLighting);
- collector?.Add(new CachedBatch(key, texHandle, restPose));
+ collector?.Add(new CachedBatch(key, texHandle, restPose, renderData.SortCenter));
}
}
@@ -2659,6 +2907,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
public float SortDistance; // squared distance from camera to first instance, for opaque sort
public readonly List Matrices = new();
+ // Retail CPhysicsPart::CYpt uses the transformed GfxObj sort center,
+ // not the entity origin. Parallel to Matrices so delayed-alpha
+ // submissions retain the exact per-part key after material grouping.
+ public readonly List LocalSortCenters = new();
+
// Phase U.4: per-instance clip-slot index, parallel to Matrices (Slots[i]
// is the binding=2 CellClip slot for the instance whose matrix is
// Matrices[i]). At layout time the dispatcher writes Slots[i] into
@@ -2704,6 +2957,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
public void ClearPerInstanceData()
{
Matrices.Clear();
+ LocalSortCenters.Clear();
Slots.Clear();
LightSets.Clear();
IndoorFlags.Clear();
diff --git a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs
new file mode 100644
index 00000000..9811be6e
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs
@@ -0,0 +1,128 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class RetailAlphaQueueTests
+{
+ [Fact]
+ public void Flush_InterleavesRenderersInDescendingCyptOrder()
+ {
+ var log = new List();
+ var objects = new RecordingSource("object", log);
+ var particles = new RecordingSource("particle", log);
+ var queue = new RetailAlphaQueue();
+
+ queue.BeginFrame();
+ queue.Submit(objects, token: 0, viewerDistance: 10f); // near crystal
+ queue.Submit(particles, token: 0, viewerDistance: 30f); // far smoke
+ queue.Submit(objects, token: 1, viewerDistance: 25f);
+ queue.Submit(particles, token: 1, viewerDistance: 20f);
+ queue.Submit(objects, token: 2, viewerDistance: 5f);
+ queue.EndFrame();
+
+ Assert.Equal(
+ new[]
+ {
+ "particle:0",
+ "object:1",
+ "particle:1",
+ "object:0",
+ "object:2",
+ },
+ log);
+ Assert.Equal(1, objects.ResetCount);
+ Assert.Equal(1, particles.ResetCount);
+ }
+
+ [Fact]
+ public void Flush_EqualDistancePreservesSubmissionOrderAcrossRenderers()
+ {
+ var log = new List();
+ var objects = new RecordingSource("object", log);
+ var particles = new RecordingSource("particle", log);
+ var queue = new RetailAlphaQueue();
+
+ queue.BeginFrame();
+ queue.Submit(objects, 7, 12f);
+ queue.Submit(particles, 4, 12f);
+ queue.Submit(objects, 8, 12f);
+ queue.EndFrame();
+
+ Assert.Equal(new[] { "object:7", "particle:4", "object:8" }, log);
+ }
+
+ [Fact]
+ public void Flush_KeepsFrameOpenForPostDepthClearScope()
+ {
+ var log = new List();
+ var source = new RecordingSource("alpha", log);
+ var queue = new RetailAlphaQueue();
+
+ queue.BeginFrame();
+ queue.Submit(source, 1, 8f);
+ queue.Flush();
+
+ Assert.True(queue.IsCollecting);
+ Assert.Equal(0, queue.PendingCount);
+ queue.Submit(source, 2, 3f);
+ queue.EndFrame();
+
+ Assert.False(queue.IsCollecting);
+ Assert.Equal(new[] { "alpha:1", "alpha:2" }, log);
+ Assert.Equal(2, source.ResetCount);
+ }
+
+ [Fact]
+ public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer()
+ {
+ var log = new List();
+ var objects = new RecordingSource("object", log);
+ var particles = new RecordingSource("particle", log);
+ var queue = new RetailAlphaQueue();
+
+ queue.BeginFrame();
+ queue.Submit(objects, 0, 40f);
+ queue.Submit(objects, 1, 35f);
+ queue.Submit(particles, 0, 30f);
+ queue.Submit(objects, 2, 25f);
+ queue.EndFrame();
+
+ Assert.Equal(new[] { 2, 1 }, objects.BatchSizes);
+ Assert.Equal(new[] { 1 }, particles.BatchSizes);
+ }
+
+ [Fact]
+ public void ComputeViewerDistance_UsesTransformedGfxSortCenter()
+ {
+ // Retail CPhysicsPart::UpdateViewerDistance 0x0050E030 applies scale,
+ // orientation, and translation to gfxobj.sort_center before CYpt.
+ var model = Matrix4x4.CreateScale(2f)
+ * Matrix4x4.CreateRotationZ(MathF.PI / 2f)
+ * Matrix4x4.CreateTranslation(10f, 20f, 30f);
+ Vector3 localSortCenter = new(1f, 0f, 0f);
+ Vector3 camera = new(10f, 20f, 30f);
+
+ float cypt = RetailAlphaOrdering.ComputeViewerDistance(
+ localSortCenter,
+ model,
+ camera);
+
+ Assert.Equal(2f, cypt, precision: 5);
+ }
+
+ private sealed class RecordingSource(string name, List log) : IRetailAlphaDrawSource
+ {
+ public List BatchSizes { get; } = new();
+ public int ResetCount { get; private set; }
+
+ public void DrawAlphaBatch(ReadOnlySpan tokens)
+ {
+ BatchSizes.Add(tokens.Length);
+ foreach (int token in tokens)
+ log.Add($"{name}:{token}");
+ }
+
+ public void ResetAlphaSubmissions() => ResetCount++;
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs
index b15ca057..31946a75 100644
--- a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs
@@ -7,8 +7,9 @@ namespace AcDream.App.Tests.Rendering.Wb;
public class InstanceGroupClearTests
{
// #193 (regression from #188, 2026-07-09): WbDrawDispatcher's InstanceGroup holds
- // six per-instance parallel lists — Matrices, Slots, LightSets, IndoorFlags,
- // Opacities, SelectionLighting — appended in lockstep (one entry per drawn instance) every frame. The
+ // seven per-instance parallel lists — Matrices, LocalSortCenters, Slots,
+ // LightSets, IndoorFlags, Opacities, SelectionLighting — appended in lockstep
+ // (one entry per drawn instance) every frame. The
// per-frame reset must clear ALL of them. #188 added Opacities but left it out of
// the inline clear loop, so it grew one float per instance per frame forever; as
// List's backing array doubled it produced ~128 MB / ~512 MB LOH float[]
@@ -19,6 +20,7 @@ public class InstanceGroupClearTests
{
var grp = new WbDrawDispatcher.InstanceGroup();
grp.Matrices.Add(Matrix4x4.Identity);
+ grp.LocalSortCenters.Add(Vector3.Zero);
grp.Slots.Add(1u);
grp.LightSets.Add(-1);
grp.IndoorFlags.Add(0u);
@@ -28,6 +30,7 @@ public class InstanceGroupClearTests
grp.ClearPerInstanceData();
Assert.Empty(grp.Matrices);
+ Assert.Empty(grp.LocalSortCenters);
Assert.Empty(grp.Slots);
Assert.Empty(grp.LightSets);
Assert.Empty(grp.IndoorFlags);
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
index 208b5893..1ad2a125 100644
--- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
@@ -413,7 +413,12 @@ public sealed class WbDrawDispatcherBucketingTests
/// produces under the collector pattern.
///
private static CachedBatch MakeCachedBatch(
- uint ibo, uint firstIndex, int indexCount, ulong texHandle, Matrix4x4? restPose = null)
+ uint ibo,
+ uint firstIndex,
+ int indexCount,
+ ulong texHandle,
+ Matrix4x4? restPose = null,
+ Vector3? localSortCenter = null)
{
var key = new GroupKey(
Ibo: ibo,
@@ -423,7 +428,11 @@ public sealed class WbDrawDispatcherBucketingTests
BindlessTextureHandle: texHandle,
TextureLayer: 0,
Translucency: TranslucencyKind.Opaque);
- return new CachedBatch(key, texHandle, restPose ?? Matrix4x4.Identity);
+ return new CachedBatch(
+ key,
+ texHandle,
+ restPose ?? Matrix4x4.Identity,
+ localSortCenter ?? Vector3.Zero);
}
[Fact]
@@ -479,7 +488,8 @@ public sealed class WbDrawDispatcherBucketingTests
// Production code: this is the !isAnimated && _cache.TryGet branch
// at the top of the per-entity loop body in Draw.
var groups = new Dictionary>();
- void AppendInstance(GroupKey k, Matrix4x4 m)
+ var sortCenters = new List();
+ void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter)
{
if (!groups.TryGetValue(k, out var list))
{
@@ -487,6 +497,7 @@ public sealed class WbDrawDispatcherBucketingTests
groups[k] = list;
}
list.Add(m);
+ sortCenters.Add(localSortCenter);
}
Assert.True(cache.TryGet(EntityId, LandblockId, out var entryHit));
@@ -508,6 +519,35 @@ public sealed class WbDrawDispatcherBucketingTests
// appended matrix must equal entityWorld.
foreach (var (_, list) in groups)
Assert.Equal(entityWorld, list[0]);
+ Assert.All(sortCenters, center => Assert.Equal(Vector3.Zero, center));
+ }
+
+ [Fact]
+ public void ApplyCacheHit_PreservesAuthoredSortCenter()
+ {
+ Vector3 authoredCenter = new(1.25f, -2.5f, 7.75f);
+ var entry = new EntityCacheEntry
+ {
+ EntityId = 100,
+ LandblockHint = 0xA9B40000u,
+ Batches =
+ [
+ MakeCachedBatch(
+ ibo: 1,
+ firstIndex: 0,
+ indexCount: 6,
+ texHandle: 0xAA,
+ localSortCenter: authoredCenter),
+ ],
+ };
+ Vector3 observedCenter = default;
+
+ WbDrawDispatcher.ApplyCacheHit(
+ entry,
+ Matrix4x4.Identity,
+ (_, _, center) => observedCenter = center);
+
+ Assert.Equal(authoredCenter, observedCenter);
}
[Fact]
@@ -748,7 +788,7 @@ public sealed class WbDrawDispatcherBucketingTests
const uint EntityId = 100;
const int MeshRefCount = 3;
- void AppendInstance(GroupKey k, Matrix4x4 m)
+ void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter)
{
if (!groups.TryGetValue(k, out var list))
{