fix(rendering): port retail shared alpha list

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

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

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

View file

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