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,128 @@
using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class RetailAlphaQueueTests
{
[Fact]
public void Flush_InterleavesRenderersInDescendingCyptOrder()
{
var log = new List<string>();
var objects = new RecordingSource("object", log);
var particles = new RecordingSource("particle", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.Submit(objects, token: 0, viewerDistance: 10f); // near crystal
queue.Submit(particles, token: 0, viewerDistance: 30f); // far smoke
queue.Submit(objects, token: 1, viewerDistance: 25f);
queue.Submit(particles, token: 1, viewerDistance: 20f);
queue.Submit(objects, token: 2, viewerDistance: 5f);
queue.EndFrame();
Assert.Equal(
new[]
{
"particle:0",
"object:1",
"particle:1",
"object:0",
"object:2",
},
log);
Assert.Equal(1, objects.ResetCount);
Assert.Equal(1, particles.ResetCount);
}
[Fact]
public void Flush_EqualDistancePreservesSubmissionOrderAcrossRenderers()
{
var log = new List<string>();
var objects = new RecordingSource("object", log);
var particles = new RecordingSource("particle", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.Submit(objects, 7, 12f);
queue.Submit(particles, 4, 12f);
queue.Submit(objects, 8, 12f);
queue.EndFrame();
Assert.Equal(new[] { "object:7", "particle:4", "object:8" }, log);
}
[Fact]
public void Flush_KeepsFrameOpenForPostDepthClearScope()
{
var log = new List<string>();
var source = new RecordingSource("alpha", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.Submit(source, 1, 8f);
queue.Flush();
Assert.True(queue.IsCollecting);
Assert.Equal(0, queue.PendingCount);
queue.Submit(source, 2, 3f);
queue.EndFrame();
Assert.False(queue.IsCollecting);
Assert.Equal(new[] { "alpha:1", "alpha:2" }, log);
Assert.Equal(2, source.ResetCount);
}
[Fact]
public void Flush_BatchesOnlyAdjacentEntriesFromSameRenderer()
{
var log = new List<string>();
var objects = new RecordingSource("object", log);
var particles = new RecordingSource("particle", log);
var queue = new RetailAlphaQueue();
queue.BeginFrame();
queue.Submit(objects, 0, 40f);
queue.Submit(objects, 1, 35f);
queue.Submit(particles, 0, 30f);
queue.Submit(objects, 2, 25f);
queue.EndFrame();
Assert.Equal(new[] { 2, 1 }, objects.BatchSizes);
Assert.Equal(new[] { 1 }, particles.BatchSizes);
}
[Fact]
public void ComputeViewerDistance_UsesTransformedGfxSortCenter()
{
// Retail CPhysicsPart::UpdateViewerDistance 0x0050E030 applies scale,
// orientation, and translation to gfxobj.sort_center before CYpt.
var model = Matrix4x4.CreateScale(2f)
* Matrix4x4.CreateRotationZ(MathF.PI / 2f)
* Matrix4x4.CreateTranslation(10f, 20f, 30f);
Vector3 localSortCenter = new(1f, 0f, 0f);
Vector3 camera = new(10f, 20f, 30f);
float cypt = RetailAlphaOrdering.ComputeViewerDistance(
localSortCenter,
model,
camera);
Assert.Equal(2f, cypt, precision: 5);
}
private sealed class RecordingSource(string name, List<string> log) : IRetailAlphaDrawSource
{
public List<int> BatchSizes { get; } = new();
public int ResetCount { get; private set; }
public void DrawAlphaBatch(ReadOnlySpan<int> tokens)
{
BatchSizes.Add(tokens.Length);
foreach (int token in tokens)
log.Add($"{name}:{token}");
}
public void ResetAlphaSubmissions() => ResetCount++;
}
}

View file

@ -7,8 +7,9 @@ namespace AcDream.App.Tests.Rendering.Wb;
public class InstanceGroupClearTests
{
// #193 (regression from #188, 2026-07-09): WbDrawDispatcher's InstanceGroup holds
// six per-instance parallel lists — Matrices, Slots, LightSets, IndoorFlags,
// Opacities, SelectionLighting — appended in lockstep (one entry per drawn instance) every frame. The
// seven per-instance parallel lists — Matrices, LocalSortCenters, Slots,
// LightSets, IndoorFlags, Opacities, SelectionLighting — appended in lockstep
// (one entry per drawn instance) every frame. The
// per-frame reset must clear ALL of them. #188 added Opacities but left it out of
// the inline clear loop, so it grew one float per instance per frame forever; as
// List<float>'s backing array doubled it produced ~128 MB / ~512 MB LOH float[]
@ -19,6 +20,7 @@ public class InstanceGroupClearTests
{
var grp = new WbDrawDispatcher.InstanceGroup();
grp.Matrices.Add(Matrix4x4.Identity);
grp.LocalSortCenters.Add(Vector3.Zero);
grp.Slots.Add(1u);
grp.LightSets.Add(-1);
grp.IndoorFlags.Add(0u);
@ -28,6 +30,7 @@ public class InstanceGroupClearTests
grp.ClearPerInstanceData();
Assert.Empty(grp.Matrices);
Assert.Empty(grp.LocalSortCenters);
Assert.Empty(grp.Slots);
Assert.Empty(grp.LightSets);
Assert.Empty(grp.IndoorFlags);

View file

@ -413,7 +413,12 @@ public sealed class WbDrawDispatcherBucketingTests
/// produces under the collector pattern.
/// </summary>
private static CachedBatch MakeCachedBatch(
uint ibo, uint firstIndex, int indexCount, ulong texHandle, Matrix4x4? restPose = null)
uint ibo,
uint firstIndex,
int indexCount,
ulong texHandle,
Matrix4x4? restPose = null,
Vector3? localSortCenter = null)
{
var key = new GroupKey(
Ibo: ibo,
@ -423,7 +428,11 @@ public sealed class WbDrawDispatcherBucketingTests
BindlessTextureHandle: texHandle,
TextureLayer: 0,
Translucency: TranslucencyKind.Opaque);
return new CachedBatch(key, texHandle, restPose ?? Matrix4x4.Identity);
return new CachedBatch(
key,
texHandle,
restPose ?? Matrix4x4.Identity,
localSortCenter ?? Vector3.Zero);
}
[Fact]
@ -479,7 +488,8 @@ public sealed class WbDrawDispatcherBucketingTests
// Production code: this is the !isAnimated && _cache.TryGet branch
// at the top of the per-entity loop body in Draw.
var groups = new Dictionary<GroupKey, List<Matrix4x4>>();
void AppendInstance(GroupKey k, Matrix4x4 m)
var sortCenters = new List<Vector3>();
void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter)
{
if (!groups.TryGetValue(k, out var list))
{
@ -487,6 +497,7 @@ public sealed class WbDrawDispatcherBucketingTests
groups[k] = list;
}
list.Add(m);
sortCenters.Add(localSortCenter);
}
Assert.True(cache.TryGet(EntityId, LandblockId, out var entryHit));
@ -508,6 +519,35 @@ public sealed class WbDrawDispatcherBucketingTests
// appended matrix must equal entityWorld.
foreach (var (_, list) in groups)
Assert.Equal(entityWorld, list[0]);
Assert.All(sortCenters, center => Assert.Equal(Vector3.Zero, center));
}
[Fact]
public void ApplyCacheHit_PreservesAuthoredSortCenter()
{
Vector3 authoredCenter = new(1.25f, -2.5f, 7.75f);
var entry = new EntityCacheEntry
{
EntityId = 100,
LandblockHint = 0xA9B40000u,
Batches =
[
MakeCachedBatch(
ibo: 1,
firstIndex: 0,
indexCount: 6,
texHandle: 0xAA,
localSortCenter: authoredCenter),
],
};
Vector3 observedCenter = default;
WbDrawDispatcher.ApplyCacheHit(
entry,
Matrix4x4.Identity,
(_, _, center) => observedCenter = center);
Assert.Equal(authoredCenter, observedCenter);
}
[Fact]
@ -748,7 +788,7 @@ public sealed class WbDrawDispatcherBucketingTests
const uint EntityId = 100;
const int MeshRefCount = 3;
void AppendInstance(GroupKey k, Matrix4x4 m)
void AppendInstance(GroupKey k, Matrix4x4 m, Vector3 localSortCenter)
{
if (!groups.TryGetValue(k, out var list))
{