fix(rendering): port retail shared alpha list

Queue translucent world GfxObj batches and scene particles in one stable far-to-near stream using transformed DAT sort centers, then drain it at retail's landscape and final-world boundaries. Preserve authored blend, cull, lighting, opacity, and adjacent-only batching so particles behind lifestones are composited through the crystal instead of overpainting it.

Release build succeeds and all 5,914 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 08:14:05 +02:00
parent ec1bb19609
commit 6b0472ee32
14 changed files with 1083 additions and 34 deletions

View file

@ -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

View file

@ -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.

View file

@ -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 12 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, 1SrcAlpha) — 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 |

View file

@ -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

View file

@ -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.

View file

@ -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();

View file

@ -13,7 +13,10 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Instanced renderer for retail particle emitters.
/// Instanced renderer for retail particle emitters. Scene particles submit to
/// <see cref="RetailAlphaQueue"/> 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.
/// </summary>
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<uint, ParticleGfxInfo> _particleGfxInfoByGfxObj = new();
private readonly Dictionary<uint, RetailParticleGeometryKind> _geometryKindByGfxObj = new();
private readonly Dictionary<uint, TranslucencyKind> _meshBlendBySurface = new();
@ -97,20 +107,33 @@ public sealed unsafe class ParticleRenderer : IDisposable
private readonly List<MeshParticleDraw> _meshDrawListScratch = new(64);
private readonly List<MeshParticleInstance> _meshRunScratch = new(64);
private readonly List<ParticleSubmission> _submissionScratch = new(128);
private readonly List<DeferredParticleDraw> _deferredAlpha = new(128);
public ParticleRenderer(
private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource
{
public void DrawAlphaBatch(ReadOnlySpan<int> 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<int> 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++)

View file

@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
internal static class RetailAlphaOrdering
{
/// <summary>
/// Retail <c>CPhysicsPart::UpdateViewerDistance</c> 0x0050E030:
/// transform the GfxObj's authored sort center through the part draw frame,
/// then store its Euclidean distance from the viewer as <c>CYpt</c>.
/// </summary>
public static float ComputeViewerDistance(
Vector3 localSortCenter,
Matrix4x4 model,
Vector3 cameraWorldPosition)
=> Vector3.Distance(Vector3.Transform(localSortCenter, model), cameraWorldPosition);
}
/// <summary>
/// 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 <see cref="ResetAlphaSubmissions"/> is called.
/// </summary>
internal interface IRetailAlphaDrawSource
{
void DrawAlphaBatch(ReadOnlySpan<int> tokens);
void ResetAlphaSubmissions();
}
internal readonly record struct RetailAlphaSubmission(
IRetailAlphaDrawSource Source,
int Token,
float ViewerDistance,
long Sequence);
/// <summary>
/// Frame-scoped port of retail's shared <c>D3DPolyRender</c> 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:
/// <list type="bullet">
/// <item><c>CShadowPart::insertion_sort</c> 0x006B5130 orders all cell parts by
/// <c>CPhysicsPart::CYpt</c>.</item>
/// <item><c>D3DPolyRender::AddMeshToAlphaList</c> 0x0059C230 appends delayed
/// surface subsets in that established order.</item>
/// <item><c>D3DPolyRender::FlushAlphaList</c> 0x0059D2E0 drains without another
/// material/renderer sort.</item>
/// </list>
/// </summary>
internal sealed class RetailAlphaQueue
{
private readonly List<RetailAlphaSubmission> _submissions = new(256);
private readonly List<IRetailAlphaDrawSource> _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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -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.</summary>
public Action? DrawUnattachedSceneParticles { get; init; }
/// <summary>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.</summary>
public Action? FlushLandscapeAlpha { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}

View file

@ -8,6 +8,8 @@ namespace AcDream.App.Rendering.Wb;
/// subPart contributes its own <see cref="CachedBatch"/> entries, with
/// <see cref="RestPose"/> already containing the
/// <c>subPart.PartTransform * meshRef.PartTransform</c> product.
/// <see cref="LocalSortCenter"/> preserves the authored GfxObj key used by
/// retail's delayed-alpha viewer-distance ordering on cache hits.
///
/// Accessibility: <c>internal</c> because <see cref="GroupKey"/> is
/// <c>internal</c> and shows up in this struct's constructor / <c>Deconstruct</c>
@ -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);
/// <summary>
/// One entity's cached classification. <see cref="Batches"/> is flat across

View file

@ -43,8 +43,10 @@ namespace AcDream.App.Rendering.Wb;
/// each group becomes one <c>DrawElementsIndirectCommand</c>. 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 <c>glMultiDrawElementsIndirect</c> 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
/// <see cref="RetailAlphaQueue"/> so ordinary GfxObj parts and particles share
/// retail's stable far-to-near stream; sealed off-screen consumers retain the
/// immediate transparent MDI path.
/// </para>
///
/// <para>
@ -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<int> tokens)
=> owner.DrawDeferredAlphaBatch(tokens);
public void ResetAlphaSubmissions()
=> owner._deferredAlpha.Clear();
}
// Per-frame scratch — reused across frames to avoid per-frame allocation.
private readonly Dictionary<GroupKey, InstanceGroup> _groups = new();
private readonly List<InstanceGroup> _opaqueDraws = new();
private readonly List<InstanceGroup> _translucentDraws = new();
private readonly List<DeferredAlphaInstance> _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<IndirectGroupInput>(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<int> 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<GroupKey, Matrix4x4> appendInstance)
Action<GroupKey, Matrix4x4, Vector3> 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 <see cref="InstanceGroup"/> for the given key in
/// <c>_groups</c> and appends the per-instance world matrix.
/// </summary>
private void AppendInstanceToGroup(GroupKey key, Matrix4x4 model)
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<Matrix4x4> 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<Vector3> 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();

View file

@ -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<string>();
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<string>();
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<string>();
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<string>();
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<string> log) : IRetailAlphaDrawSource
{
public List<int> BatchSizes { get; } = new();
public int ResetCount { get; private set; }
public void DrawAlphaBatch(ReadOnlySpan<int> tokens)
{
BatchSizes.Add(tokens.Length);
foreach (int token in tokens)
log.Add($"{name}:{token}");
}
public void ResetAlphaSubmissions() => ResetCount++;
}
}

View file

@ -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<float>'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);

View file

@ -413,7 +413,12 @@ public sealed class WbDrawDispatcherBucketingTests
/// produces under the collector pattern.
/// </summary>
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<GroupKey, List<Matrix4x4>>();
void AppendInstance(GroupKey k, Matrix4x4 m)
var sortCenters = new List<Vector3>();
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))
{