diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index 9cf6dc5..2912472 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -529,6 +529,108 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_gl.DeleteBuffer(_indirectBuffer);
}
+ // ── Public types + helpers for BuildIndirectArrays (Task 9) ─────────────
+ //
+ // These are public so the pure-CPU unit tests in AcDream.Core.Tests can
+ // exercise BuildIndirectArrays without needing a GL context.
+
+ ///
+ /// Public view of the per-group inputs to — used in tests.
+ ///
+ public readonly record struct IndirectGroupInput(
+ int IndexCount,
+ uint FirstIndex,
+ int BaseVertex,
+ int InstanceCount,
+ int FirstInstance,
+ ulong TextureHandle,
+ uint TextureLayer,
+ TranslucencyKind Translucency);
+
+ ///
+ /// Public mirror of the per-group uploaded to the SSBO.
+ /// Tests verify the layout. Same field shape as the private BatchData.
+ ///
+ [StructLayout(LayoutKind.Sequential, Pack = 8)]
+ public struct BatchDataPublic
+ {
+ public ulong TextureHandle;
+ public uint TextureLayer;
+ public uint Flags;
+ }
+
+ /// Result of .
+ public readonly record struct IndirectLayoutResult(
+ int OpaqueCount,
+ int TransparentCount,
+ int TransparentByteOffset);
+
+ ///
+ /// Lays out the indirect commands + parallel BatchData array contiguously:
+ /// opaque section first (caller sorts before calling), transparent section second.
+ /// Pure CPU, no GL state. Caller passes pre-sized scratch arrays.
+ ///
+ ///
+ /// Classification: Opaque + ClipMap → opaque pass (ClipMap uses discard, not
+ /// blending). Everything else (AlphaBlend, Additive, InvAlpha) → transparent pass.
+ ///
+ public static IndirectLayoutResult BuildIndirectArrays(
+ IReadOnlyList groups,
+ DrawElementsIndirectCommand[] indirectScratch,
+ BatchDataPublic[] batchScratch)
+ {
+ int opaqueCount = 0;
+ int transparentCount = 0;
+
+ foreach (var g in groups)
+ {
+ if (IsOpaque(g.Translucency)) opaqueCount++;
+ else transparentCount++;
+ }
+
+ int oi = 0; // opaque write cursor (fills [0..opaqueCount))
+ int ti = opaqueCount; // transparent write cursor (fills [opaqueCount..end))
+
+ foreach (var g in groups)
+ {
+ var dec = new DrawElementsIndirectCommand
+ {
+ Count = (uint)g.IndexCount,
+ InstanceCount = (uint)g.InstanceCount,
+ FirstIndex = g.FirstIndex,
+ BaseVertex = g.BaseVertex,
+ BaseInstance = (uint)g.FirstInstance,
+ };
+ var bd = new BatchDataPublic
+ {
+ TextureHandle = g.TextureHandle,
+ TextureLayer = g.TextureLayer,
+ Flags = 0,
+ };
+
+ if (IsOpaque(g.Translucency))
+ {
+ indirectScratch[oi] = dec;
+ batchScratch[oi] = bd;
+ oi++;
+ }
+ else
+ {
+ indirectScratch[ti] = dec;
+ batchScratch[ti] = bd;
+ ti++;
+ }
+ }
+
+ const int SizeofDEIC = 20; // sizeof(DrawElementsIndirectCommand) — 5 × uint
+ return new IndirectLayoutResult(opaqueCount, transparentCount, opaqueCount * SizeofDEIC);
+ }
+
+ private static bool IsOpaque(TranslucencyKind t)
+ => t == TranslucencyKind.Opaque || t == TranslucencyKind.ClipMap;
+
+ // ────────────────────────────────────────────────────────────────────────
+
private readonly record struct GroupKey(
uint Ibo,
uint FirstIndex,
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs
new file mode 100644
index 0000000..1f2e552
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs
@@ -0,0 +1,94 @@
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Meshing;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+///
+/// Pure CPU test of .
+/// Verifies that a synthetic group set lays out into the indirect buffer
+/// + parallel batch data with opaque section first, transparent second,
+/// per-group fields propagated correctly.
+///
+public sealed class WbDrawDispatcherIndirectBuilderTests
+{
+ [Fact]
+ public void TwoOpaqueGroupsAndOneTransparent_LaysOutContiguouslyOpaqueFirst()
+ {
+ // Arrange — three groups: 2 opaque (12+1 instances) + 1 transparent (12 instances)
+ var groups = new List
+ {
+ new(IndexCount: 100, FirstIndex: 0, BaseVertex: 0, InstanceCount: 12, FirstInstance: 0, TextureHandle: 0xAA, TextureLayer: 0, Translucency: TranslucencyKind.Opaque),
+ new(IndexCount: 200, FirstIndex: 100, BaseVertex: 0, InstanceCount: 12, FirstInstance: 12, TextureHandle: 0xBB, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend),
+ new(IndexCount: 50, FirstIndex: 300, BaseVertex: 100, InstanceCount: 1, FirstInstance: 24, TextureHandle: 0xCC, TextureLayer: 0, Translucency: TranslucencyKind.Opaque),
+ };
+
+ var indirect = new DrawElementsIndirectCommand[16];
+ var batch = new WbDrawDispatcher.BatchDataPublic[16];
+
+ // Act
+ var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch);
+
+ // Assert layout
+ Assert.Equal(2, result.OpaqueCount);
+ Assert.Equal(1, result.TransparentCount);
+ Assert.Equal(2 * 20, result.TransparentByteOffset); // sizeof(DEIC) = 20
+
+ // Opaque section, in input order (Task 10 callers sort)
+ Assert.Equal(100u, indirect[0].Count);
+ Assert.Equal(0u, indirect[0].FirstIndex);
+ Assert.Equal(0, indirect[0].BaseVertex);
+ Assert.Equal(12u, indirect[0].InstanceCount);
+ Assert.Equal(0u, indirect[0].BaseInstance);
+
+ Assert.Equal(50u, indirect[1].Count);
+ Assert.Equal(300u, indirect[1].FirstIndex);
+ Assert.Equal(100, indirect[1].BaseVertex);
+ Assert.Equal(1u, indirect[1].InstanceCount);
+ Assert.Equal(24u, indirect[1].BaseInstance);
+
+ // Transparent section
+ Assert.Equal(200u, indirect[2].Count);
+ Assert.Equal(100u, indirect[2].FirstIndex);
+ Assert.Equal(12u, indirect[2].InstanceCount);
+ Assert.Equal(12u, indirect[2].BaseInstance);
+
+ // BatchData parallel — same indices as indirect
+ Assert.Equal(0xAAul, batch[0].TextureHandle);
+ Assert.Equal(0xCCul, batch[1].TextureHandle);
+ Assert.Equal(0xBBul, batch[2].TextureHandle);
+ }
+
+ [Fact]
+ public void EmptyGroupList_ProducesZeroCounts()
+ {
+ var groups = new List();
+ var indirect = new DrawElementsIndirectCommand[0];
+ var batch = new WbDrawDispatcher.BatchDataPublic[0];
+
+ var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch);
+
+ Assert.Equal(0, result.OpaqueCount);
+ Assert.Equal(0, result.TransparentCount);
+ Assert.Equal(0, result.TransparentByteOffset);
+ }
+
+ [Fact]
+ public void ClipMapTreatedAsOpaque()
+ {
+ // ClipMap surfaces (alpha-cutout) belong with the opaque pass
+ // because the discard handles transparency, not blending.
+ var groups = new List
+ {
+ new(IndexCount: 10, FirstIndex: 0, BaseVertex: 0, InstanceCount: 1, FirstInstance: 0, TextureHandle: 0x1, TextureLayer: 0, Translucency: TranslucencyKind.ClipMap),
+ };
+ var indirect = new DrawElementsIndirectCommand[4];
+ var batch = new WbDrawDispatcher.BatchDataPublic[4];
+
+ var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch);
+
+ Assert.Equal(1, result.OpaqueCount);
+ Assert.Equal(0, result.TransparentCount);
+ }
+}