acdream/docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md
Erik 2cbf34a668 perf(rendering): preserve alpha order with bindless particles
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>
2026-07-18 08:32:21 +02:00

7 KiB

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:

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

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

Viewer-distance key

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

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

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

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.