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

@ -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();