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:
parent
ec1bb19609
commit
6b0472ee32
14 changed files with 1083 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
191
docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md
Normal file
191
docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md
Normal 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.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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++)
|
||||
|
|
|
|||
171
src/AcDream.App/Rendering/RetailAlphaQueue.cs
Normal file
171
src/AcDream.App/Rendering/RetailAlphaQueue.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
128
tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs
Normal file
128
tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs
Normal 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++;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue