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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue