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)) {