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

@ -375,6 +375,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
private double _physicsScriptGameTime;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new();
// Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but
// never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime.
// Keep the experimental path available for DAT archaeology only.
@ -2627,7 +2628,8 @@ public sealed class GameWindow : IDisposable
_classificationCache, _translucencyFades,
_retailSelectionScene ??= new AcDream.App.Rendering.Selection.RetailSelectionScene(
new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache(
_dats!, _datLock)));
_dats!, _datLock)),
_retailAlphaQueue);
// A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
@ -2686,7 +2688,8 @@ public sealed class GameWindow : IDisposable
_particleSystem,
_textureCache,
_dats,
_wbMeshAdapter);
_wbMeshAdapter,
_retailAlphaQueue);
// A.5 T22.5: apply radii from the already-resolved _resolvedQuality.
// _resolvedQuality was set by the quality block immediately after
@ -9632,6 +9635,7 @@ public sealed class GameWindow : IDisposable
_retailSelectionScene?.BeginFrame();
if (_cameraController is not null && !portalViewportVisible)
{
_retailAlphaQueue.BeginFrame();
var activeCamera = _cameraController.Active;
var camera = _teleportViewPlane.ApplyTo(activeCamera);
var worldProjection = camera.Projection;
@ -10212,6 +10216,7 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Vfx.ParticleRenderPass.Scene,
emitter => emitter.AttachedObjectId == 0);
},
FlushLandscapeAlpha = _retailAlphaQueue.Flush,
DrawCellParticles = sliceCtx =>
DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
DrawDynamicsParticles = survivors =>
@ -10543,6 +10548,7 @@ public sealed class GameWindow : IDisposable
// pre-login screen shows a live, correctly-tinted sky and
// nothing else.
SkipWorldGeometry:
_retailAlphaQueue.EndFrame();
if (_terrain is not null)
_particleVisibility.MarkVisibleCells(_terrain.VisibleCellIds);
_particleVisibility.CompleteFrame();

View file

@ -13,7 +13,10 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Instanced renderer for retail particle emitters.
/// Instanced renderer for retail particle emitters. Scene particles submit to
/// <see cref="RetailAlphaQueue"/> while a world frame is active so their
/// compositing order is shared with ordinary translucent GfxObj parts. Sky and
/// sealed off-screen passes retain their independent immediate path.
/// </summary>
public sealed unsafe class ParticleRenderer : IDisposable
{
@ -24,6 +27,11 @@ public sealed unsafe class ParticleRenderer : IDisposable
MeshBatchKey Key,
ObjectRenderBatch Batch,
MeshParticleInstance Instance);
private readonly record struct DeferredParticleDraw(
ParticleSubmissionKind Kind,
ParticleDraw Billboard,
MeshParticleDraw Mesh,
Matrix4x4 ViewProjection);
private readonly struct ParticleInstance
{
@ -64,6 +72,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
private readonly DatCollection? _dats;
private readonly WbMeshAdapter? _meshAdapter;
private readonly ParticleSystem _particles;
private readonly RetailAlphaQueue? _alphaQueue;
private readonly AlphaDrawSource _alphaSource;
private readonly Dictionary<uint, ParticleGfxInfo> _particleGfxInfoByGfxObj = new();
private readonly Dictionary<uint, RetailParticleGeometryKind> _geometryKindByGfxObj = new();
private readonly Dictionary<uint, TranslucencyKind> _meshBlendBySurface = new();
@ -97,20 +107,33 @@ public sealed unsafe class ParticleRenderer : IDisposable
private readonly List<MeshParticleDraw> _meshDrawListScratch = new(64);
private readonly List<MeshParticleInstance> _meshRunScratch = new(64);
private readonly List<ParticleSubmission> _submissionScratch = new(128);
private readonly List<DeferredParticleDraw> _deferredAlpha = new(128);
public ParticleRenderer(
private sealed class AlphaDrawSource(ParticleRenderer owner) : IRetailAlphaDrawSource
{
public void DrawAlphaBatch(ReadOnlySpan<int> tokens)
=> owner.DrawDeferredAlphaBatch(tokens);
public void ResetAlphaSubmissions()
=> owner._deferredAlpha.Clear();
}
internal ParticleRenderer(
GL gl,
string shadersDir,
ParticleSystem particles,
TextureCache? textures = null,
DatCollection? dats = null,
WbMeshAdapter? meshAdapter = null)
WbMeshAdapter? meshAdapter = null,
RetailAlphaQueue? alphaQueue = null)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_textures = textures;
_dats = dats;
_meshAdapter = meshAdapter;
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
_alphaQueue = alphaQueue;
_alphaSource = new AlphaDrawSource(this);
if (_meshAdapter is not null)
{
_meshReferences = new ParticleMeshReferenceTracker(
@ -221,10 +244,43 @@ public sealed unsafe class ParticleRenderer : IDisposable
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
BuildDrawLists(cameraWorldPos, renderPass, cameraRight, cameraUp, emitterFilter);
if (_submissionScratch.Count > 0)
if (_submissionScratch.Count == 0)
return;
if (renderPass == ParticleRenderPass.Scene && _alphaQueue?.IsCollecting == true)
DeferToRetailAlphaQueue(camera);
else
DrawOrdered(camera);
}
private void DeferToRetailAlphaQueue(ICamera camera)
{
RetailAlphaQueue queue = _alphaQueue!;
Matrix4x4 viewProjection = camera.View * camera.Projection;
for (int i = 0; i < _submissionScratch.Count; i++)
{
ParticleSubmission submission = _submissionScratch[i];
DeferredParticleDraw deferred = submission.Kind == ParticleSubmissionKind.Billboard
? new DeferredParticleDraw(
submission.Kind,
_drawListScratch[submission.DrawIndex],
default,
viewProjection)
: new DeferredParticleDraw(
submission.Kind,
default,
_meshDrawListScratch[submission.DrawIndex],
viewProjection);
int token = _deferredAlpha.Count;
_deferredAlpha.Add(deferred);
queue.Submit(
_alphaSource,
token,
MathF.Sqrt(MathF.Max(0f, submission.DistanceSq)));
}
}
private void DrawOrdered(ICamera camera)
{
ParticleSubmissionOrdering.Sort(_submissionScratch);
@ -245,7 +301,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
BatchKey key = draw.Key;
if (activeKind != ParticleSubmissionKind.Billboard)
{
PrepareBillboardPipeline(camera);
PrepareBillboardPipeline(camera.View * camera.Projection);
activeKind = ParticleSubmissionKind.Billboard;
}
@ -281,7 +337,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
ObjectRenderBatch batch = meshDraw.Batch;
if (activeKind != ParticleSubmissionKind.Mesh)
{
PrepareMeshPipeline(camera, global);
PrepareMeshPipeline(camera.View * camera.Projection, global);
activeKind = ParticleSubmissionKind.Mesh;
}
@ -334,19 +390,137 @@ public sealed unsafe class ParticleRenderer : IDisposable
_gl.Disable(EnableCap.CullFace);
}
private void PrepareBillboardPipeline(ICamera camera)
private void DrawDeferredAlphaBatch(ReadOnlySpan<int> tokens)
{
if (tokens.Length == 0)
return;
GlobalMeshBuffer? global = _meshAdapter?.MeshManager?.GlobalBuffer;
_gl.Enable(EnableCap.DepthTest);
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
_gl.ActiveTexture(TextureUnit.Texture0);
ParticleSubmissionKind? activeKind = null;
Matrix4x4 activeViewProjection = default;
int i = 0;
while (i < tokens.Length)
{
DeferredParticleDraw deferred = _deferredAlpha[tokens[i]];
if (deferred.Kind == ParticleSubmissionKind.Billboard)
{
ParticleDraw draw = deferred.Billboard;
BatchKey key = draw.Key;
if (activeKind != ParticleSubmissionKind.Billboard
|| activeViewProjection != deferred.ViewProjection)
{
PrepareBillboardPipeline(deferred.ViewProjection);
activeKind = ParticleSubmissionKind.Billboard;
activeViewProjection = deferred.ViewProjection;
}
_runScratch.Clear();
do
{
_runScratch.Add(deferred.Billboard.Instance);
i++;
if (i >= tokens.Length)
break;
deferred = _deferredAlpha[tokens[i]];
}
while (deferred.Kind == ParticleSubmissionKind.Billboard
&& deferred.Billboard.Key == key
&& deferred.ViewProjection == activeViewProjection);
_gl.BlendFunc(
BlendingFactor.SrcAlpha,
key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha);
_shader.SetInt("uUseTexture", key.UseTexture ? 1 : 0);
_gl.BindTexture(TextureTarget.Texture2D, key.UseTexture ? key.TextureHandle : 0);
DrawInstances(_runScratch);
continue;
}
if (_meshShader is null || global is null)
{
i++;
continue;
}
MeshParticleDraw meshDraw = deferred.Mesh;
MeshBatchKey meshKey = meshDraw.Key;
ObjectRenderBatch batch = meshDraw.Batch;
if (activeKind != ParticleSubmissionKind.Mesh
|| activeViewProjection != deferred.ViewProjection)
{
PrepareMeshPipeline(deferred.ViewProjection, global);
activeKind = ParticleSubmissionKind.Mesh;
activeViewProjection = deferred.ViewProjection;
}
_meshRunScratch.Clear();
do
{
_meshRunScratch.Add(deferred.Mesh.Instance);
i++;
if (i >= tokens.Length)
break;
deferred = _deferredAlpha[tokens[i]];
}
while (deferred.Kind == ParticleSubmissionKind.Mesh
&& deferred.Mesh.Key == meshKey
&& deferred.ViewProjection == activeViewProjection);
ApplyMeshCullMode(batch.CullMode);
TranslucencyKind blend = ResolveMeshBlend(batch);
_gl.BlendFunc(
blend == TranslucencyKind.InvAlpha
? BlendingFactor.OneMinusSrcAlpha
: BlendingFactor.SrcAlpha,
blend switch
{
TranslucencyKind.Additive => BlendingFactor.One,
TranslucencyKind.InvAlpha => BlendingFactor.SrcAlpha,
_ => BlendingFactor.OneMinusSrcAlpha,
});
_gl.ProgramUniform2(
_meshShader.Program,
_meshTextureHandleLoc,
(uint)(batch.BindlessTextureHandle & 0xFFFFFFFFu),
(uint)(batch.BindlessTextureHandle >> 32));
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex);
UploadMeshInstances(_meshRunScratch);
_gl.DrawElementsInstancedBaseVertex(
PrimitiveType.Triangles,
(uint)batch.IndexCount,
DrawElementsType.UnsignedShort,
(void*)(batch.FirstIndex * sizeof(ushort)),
(uint)_meshRunScratch.Count,
(int)batch.BaseVertex);
}
_gl.BindTexture(TextureTarget.Texture2D, 0);
_gl.BindVertexArray(0);
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
_gl.Disable(EnableCap.CullFace);
}
private void PrepareBillboardPipeline(Matrix4x4 viewProjection)
{
_shader.Use();
_shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
_shader.SetMatrix4("uViewProjection", viewProjection);
_shader.SetInt("uParticleTexture", 0);
_gl.Disable(EnableCap.CullFace);
_gl.BindVertexArray(_quadVao);
}
private void PrepareMeshPipeline(ICamera camera, GlobalMeshBuffer global)
private void PrepareMeshPipeline(Matrix4x4 viewProjection, GlobalMeshBuffer global)
{
_meshShader!.Use();
_meshShader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
_meshShader.SetMatrix4("uViewProjection", viewProjection);
_gl.FrontFace(FrontFaceDirection.CW);
_gl.BindVertexArray(_meshVao);
@ -415,11 +589,10 @@ public sealed unsafe class ParticleRenderer : IDisposable
// mirrors retail's live-parent-frame read at
// ParticleEmitter::UpdateParticles 0x0051d2d4 for is_parent_local=1.
Vector3 pos = p.Position;
float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId;
if (gfxObjId != 0
&& ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh
&& TryAppendMeshDraws(em, p, gfxObjId, distSq, ref sequence))
&& TryAppendMeshDraws(em, p, gfxObjId, cameraWorldPos, ref sequence))
{
continue;
}
@ -447,6 +620,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size);
}
float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
int drawIndex = draws.Count;
draws.Add(new ParticleDraw(key, new ParticleInstance(pos, axisX, axisY, p.ColorArgb, distSq)));
_submissionScratch.Add(new ParticleSubmission(
@ -462,7 +637,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
AcDream.Core.Vfx.ParticleEmitter emitter,
Particle particle,
uint gfxObjId,
float distanceSq,
Vector3 cameraWorldPosition,
ref int sequence)
{
if (_meshAdapter is null || _meshShader is null)
@ -481,6 +656,11 @@ public sealed unsafe class ParticleRenderer : IDisposable
Matrix4x4 model = Matrix4x4.CreateScale(particle.Size)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(particle.Position);
float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
renderData.SortCenter,
model,
cameraWorldPosition);
float distanceSq = viewerDistance * viewerDistance;
var instance = new MeshParticleInstance(model, particle.ColorArgb, distanceSq);
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)

View file

@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
internal static class RetailAlphaOrdering
{
/// <summary>
/// Retail <c>CPhysicsPart::UpdateViewerDistance</c> 0x0050E030:
/// transform the GfxObj's authored sort center through the part draw frame,
/// then store its Euclidean distance from the viewer as <c>CYpt</c>.
/// </summary>
public static float ComputeViewerDistance(
Vector3 localSortCenter,
Matrix4x4 model,
Vector3 cameraWorldPosition)
=> Vector3.Distance(Vector3.Transform(localSortCenter, model), cameraWorldPosition);
}
/// <summary>
/// Renderer-owned half of retail's delayed alpha list. A source keeps the
/// immutable payload addressed by each token until the queue flushes, then
/// releases the payload when <see cref="ResetAlphaSubmissions"/> is called.
/// </summary>
internal interface IRetailAlphaDrawSource
{
void DrawAlphaBatch(ReadOnlySpan<int> tokens);
void ResetAlphaSubmissions();
}
internal readonly record struct RetailAlphaSubmission(
IRetailAlphaDrawSource Source,
int Token,
float ViewerDistance,
long Sequence);
/// <summary>
/// Frame-scoped port of retail's shared <c>D3DPolyRender</c> alpha list.
/// Scene-particle parts and ordinary GfxObj parts enter one stable,
/// far-to-near stream instead of being composited in renderer-local passes.
///
/// Retail oracle:
/// <list type="bullet">
/// <item><c>CShadowPart::insertion_sort</c> 0x006B5130 orders all cell parts by
/// <c>CPhysicsPart::CYpt</c>.</item>
/// <item><c>D3DPolyRender::AddMeshToAlphaList</c> 0x0059C230 appends delayed
/// surface subsets in that established order.</item>
/// <item><c>D3DPolyRender::FlushAlphaList</c> 0x0059D2E0 drains without another
/// material/renderer sort.</item>
/// </list>
/// </summary>
internal sealed class RetailAlphaQueue
{
private readonly List<RetailAlphaSubmission> _submissions = new(256);
private readonly List<IRetailAlphaDrawSource> _sources = new(4);
private int[] _tokenScratch = new int[256];
private long _nextSequence;
public bool IsCollecting { get; private set; }
internal int PendingCount => _submissions.Count;
public void BeginFrame()
{
if (IsCollecting)
throw new InvalidOperationException("Retail alpha frame is already active.");
if (_submissions.Count != 0 || _sources.Count != 0)
throw new InvalidOperationException("Retail alpha queue retained payload outside a frame.");
_nextSequence = 0;
IsCollecting = true;
}
public void Submit(IRetailAlphaDrawSource source, int token, float viewerDistance)
{
ArgumentNullException.ThrowIfNull(source);
if (!IsCollecting)
throw new InvalidOperationException("Retail alpha submission requires an active frame.");
if (token < 0)
throw new ArgumentOutOfRangeException(nameof(token));
_submissions.Add(new RetailAlphaSubmission(
source,
token,
NormalizeDistance(viewerDistance),
_nextSequence++));
for (int i = 0; i < _sources.Count; i++)
if (ReferenceEquals(_sources[i], source))
return;
_sources.Add(source);
}
/// <summary>
/// Drains the current retail alpha scope and keeps the frame open so the
/// caller can collect the post-depth-clear scope. Only adjacent entries
/// from one renderer are handed over as a batch; the queue never groups by
/// renderer across another entry and therefore cannot alter compositing.
/// </summary>
public void Flush()
{
if (!IsCollecting)
throw new InvalidOperationException("Retail alpha flush requires an active frame.");
try
{
if (_submissions.Count == 0)
return;
_submissions.Sort(CompareRetailOrder);
EnsureTokenCapacity(_submissions.Count);
int start = 0;
while (start < _submissions.Count)
{
IRetailAlphaDrawSource source = _submissions[start].Source;
int end = start + 1;
while (end < _submissions.Count
&& ReferenceEquals(_submissions[end].Source, source))
end++;
int count = end - start;
for (int i = 0; i < count; i++)
_tokenScratch[i] = _submissions[start + i].Token;
source.DrawAlphaBatch(_tokenScratch.AsSpan(0, count));
start = end;
}
}
finally
{
for (int i = 0; i < _sources.Count; i++)
_sources[i].ResetAlphaSubmissions();
_sources.Clear();
_submissions.Clear();
}
}
public void EndFrame()
{
if (!IsCollecting)
throw new InvalidOperationException("Retail alpha frame is not active.");
try
{
Flush();
}
finally
{
IsCollecting = false;
}
}
private static int CompareRetailOrder(RetailAlphaSubmission left, RetailAlphaSubmission right)
{
// CShadowPart::insertion_sort 0x006B5130 produces descending CYpt
// (far-to-near). Sequence makes the List.Sort result stable for equal
// distances, preserving part/surface append order like retail.
int distance = right.ViewerDistance.CompareTo(left.ViewerDistance);
return distance != 0 ? distance : left.Sequence.CompareTo(right.Sequence);
}
private static float NormalizeDistance(float value)
=> float.IsFinite(value) && value >= 0f ? value : 0f;
private void EnsureTokenCapacity(int count)
{
if (_tokenScratch.Length >= count)
return;
Array.Resize(ref _tokenScratch, count + 256);
}
}

View file

@ -539,6 +539,11 @@ public sealed class RetailPViewRenderer
if (!ctx.RootCell.IsOutdoorNode)
ctx.DrawUnattachedSceneParticles?.Invoke();
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
// immediately after LScape::draw and before the optional depth clear.
// The queue remains active for the post-clear/final-world scope.
ctx.FlushLandscapeAlpha?.Invoke();
// T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
@ -1105,6 +1110,10 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
/// end of the landscape stage (pre-clear). Outdoor roots draw them via
/// GameWindow's dedicated post-frame pass instead.</summary>
public Action? DrawUnattachedSceneParticles { get; init; }
/// <summary>Drains retail's delayed landscape alpha list before the
/// optional outside-to-inside depth clear. The same frame-scoped queue
/// remains open for the final world alpha scope.</summary>
public Action? FlushLandscapeAlpha { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}

View file

@ -8,6 +8,8 @@ namespace AcDream.App.Rendering.Wb;
/// subPart contributes its own <see cref="CachedBatch"/> entries, with
/// <see cref="RestPose"/> already containing the
/// <c>subPart.PartTransform * meshRef.PartTransform</c> product.
/// <see cref="LocalSortCenter"/> preserves the authored GfxObj key used by
/// retail's delayed-alpha viewer-distance ordering on cache hits.
///
/// Accessibility: <c>internal</c> because <see cref="GroupKey"/> is
/// <c>internal</c> and shows up in this struct's constructor / <c>Deconstruct</c>
@ -18,7 +20,8 @@ namespace AcDream.App.Rendering.Wb;
internal readonly record struct CachedBatch(
GroupKey Key,
ulong BindlessTextureHandle,
Matrix4x4 RestPose);
Matrix4x4 RestPose,
Vector3 LocalSortCenter = default);
/// <summary>
/// One entity's cached classification. <see cref="Batches"/> is flat across

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