Carry each billboard particle's resident texture handle in the instance ABI so stable retail alpha ordering can batch mixed textures without rebinding and redrawing each short texture run. Keep DAT blend transitions as the only billboard draw boundary. The connected 0xC95B stress scene improved from 6 FPS / 171 ms to about 153 FPS / 6.6 ms at the same roughly 21,000 visible entities. Release build succeeds and all 5,916 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
194 lines
7 KiB
Markdown
194 lines
7 KiB
Markdown
# 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. The modern billboard vertex-instance
|
|
record carries a bindless texture handle, so adjacent particles with
|
|
different textures are still one compatible ordered draw; blend mode is
|
|
the only billboard state boundary.
|
|
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.
|