diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 04e185b..1e821ee 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -31,6 +31,8 @@ public sealed class GameWindow : IDisposable
/// Phase N.4: WB-backed rendering pipeline adapter. Non-null only
/// when ACDREAM_USE_WB_FOUNDATION=1 is set; null otherwise.
private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter;
+ private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
+ private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
private SamplerCache? _samplerCache;
private DebugLineRenderer? _debugLines;
// K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder
@@ -1434,7 +1436,7 @@ public sealed class GameWindow : IDisposable
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled)
{
var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
- _wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _datDir, wbLogger);
+ _wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _datDir, _dats, wbLogger);
Console.WriteLine("[N.4] WbFoundation flag is ENABLED — routing static content through ObjectMeshManager.");
}
@@ -1486,12 +1488,20 @@ public sealed class GameWindow : IDisposable
}
wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache, SequencerFactory);
+ _wbEntitySpawnAdapter = wbEntitySpawnAdapter;
}
_worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter);
}
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache, _wbMeshAdapter);
+ if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled
+ && _wbMeshAdapter is not null && _wbEntitySpawnAdapter is not null)
+ {
+ _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
+ _gl, _meshShader, _textureCache, _wbMeshAdapter, _wbEntitySpawnAdapter);
+ }
+
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
// with depth writes off + far plane 1e6 so celestial meshes
// never clip. Shares the TextureCache with the static pipeline.
@@ -6326,10 +6336,20 @@ public sealed class GameWindow : IDisposable
animatedIds.Add(k);
}
- _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum,
- neverCullLandblockId: playerLb,
- visibleCellIds: visibility?.VisibleCellIds,
- animatedEntityIds: animatedIds);
+ if (_wbDrawDispatcher is not null)
+ {
+ _wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum,
+ neverCullLandblockId: playerLb,
+ visibleCellIds: visibility?.VisibleCellIds,
+ animatedEntityIds: animatedIds);
+ }
+ else
+ {
+ _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum,
+ neverCullLandblockId: playerLb,
+ visibleCellIds: visibility?.VisibleCellIds,
+ animatedEntityIds: animatedIds);
+ }
// Phase G.1 / E.3: draw all live particles after opaque
// scene geometry so alpha blending composites correctly.
@@ -8710,6 +8730,7 @@ public sealed class GameWindow : IDisposable
_combatChatTranslator?.Dispose();
_liveSession?.Dispose();
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
+ _wbDrawDispatcher?.Dispose();
_staticMesh?.Dispose();
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_samplerCache?.Dispose();
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
new file mode 100644
index 0000000..0cedba9
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -0,0 +1,364 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Meshing;
+using AcDream.Core.Terrain;
+using AcDream.Core.World;
+using Chorizite.OpenGLSDLBackend.Lib;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Draws entities using WB's (VAO/VBO per GfxObj,
+/// per-batch IBO) with acdream's for texture resolution
+/// and for translucency classification.
+///
+///
+/// Atlas-tier entities (ServerGuid == 0): mesh data comes from WB's
+/// via .
+/// Textures resolve through using the batch's
+/// SurfaceId.
+///
+///
+///
+/// Per-instance-tier entities (ServerGuid != 0): mesh data also from
+/// WB, but textures resolve through with palette and
+/// surface overrides applied. Part overrides and hidden-parts from
+/// control which GfxObj renders per part.
+///
+///
+///
+/// GL strategy: per-entity single-instance drawing. Each draw call uploads
+/// one model matrix to the instance VBO, binds WB's VAO (with instance attribute
+/// slots patched on first use), binds the batch's IBO, and calls DrawElements with
+/// instance count 1. True instancing grouping deferred to N.6.
+///
+///
+///
+/// Shader: reuses mesh_instanced (vert locations 0-2 = Position/
+/// Normal/UV from WB's VertexPositionNormalTexture; locations 3-6 = instance
+/// matrix from our VBO). WB's 32-byte vertex stride is compatible.
+///
+///
+public sealed unsafe class WbDrawDispatcher : IDisposable
+{
+ private readonly GL _gl;
+ private readonly Shader _shader;
+ private readonly TextureCache _textures;
+ private readonly WbMeshAdapter _meshAdapter;
+ private readonly EntitySpawnAdapter _entitySpawnAdapter;
+
+ private readonly uint _instanceVbo;
+ private readonly float[] _matrixBuf = new float[16];
+ private readonly HashSet _patchedVaos = new();
+
+ private bool _disposed;
+
+ public WbDrawDispatcher(
+ GL gl,
+ Shader shader,
+ TextureCache textures,
+ WbMeshAdapter meshAdapter,
+ EntitySpawnAdapter entitySpawnAdapter)
+ {
+ ArgumentNullException.ThrowIfNull(gl);
+ ArgumentNullException.ThrowIfNull(shader);
+ ArgumentNullException.ThrowIfNull(textures);
+ ArgumentNullException.ThrowIfNull(meshAdapter);
+ ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
+
+ _gl = gl;
+ _shader = shader;
+ _textures = textures;
+ _meshAdapter = meshAdapter;
+ _entitySpawnAdapter = entitySpawnAdapter;
+
+ _instanceVbo = _gl.GenBuffer();
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
+ _gl.BufferData(BufferTargetARB.ArrayBuffer, 64, null, BufferUsageARB.DynamicDraw);
+ }
+
+ public static Matrix4x4 ComposePartWorldMatrix(
+ Matrix4x4 entityWorld,
+ Matrix4x4 animOverride,
+ Matrix4x4 restPose)
+ => restPose * animOverride * entityWorld;
+
+ public void Draw(
+ ICamera camera,
+ IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries,
+ FrustumPlanes? frustum = null,
+ uint? neverCullLandblockId = null,
+ HashSet? visibleCellIds = null,
+ HashSet? animatedEntityIds = null)
+ {
+ _shader.Use();
+ var vp = camera.View * camera.Projection;
+ _shader.SetMatrix4("uViewProjection", vp);
+
+ var metaTable = _meshAdapter.MetadataTable;
+
+ // Collect visible entities into opaque and translucent lists for two-pass rendering.
+ // We walk entities once and classify each (entity, meshRef, batch) triple.
+ var opaqueDraws = new List();
+ var translucentDraws = new List();
+
+ foreach (var entry in landblockEntries)
+ {
+ bool landblockVisible = frustum is null
+ || entry.LandblockId == neverCullLandblockId
+ || FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
+
+ if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
+ continue;
+
+ foreach (var entity in entry.Entities)
+ {
+ if (entity.MeshRefs.Count == 0) continue;
+
+ bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
+ if (!landblockVisible && !isAnimated) continue;
+
+ if (entity.ParentCellId.HasValue && visibleCellIds is not null
+ && !visibleCellIds.Contains(entity.ParentCellId.Value))
+ continue;
+
+ var entityWorld =
+ Matrix4x4.CreateFromQuaternion(entity.Rotation) *
+ Matrix4x4.CreateTranslation(entity.Position);
+
+ bool isPerInstance = entity.ServerGuid != 0;
+ AnimatedEntityState? animState = isPerInstance
+ ? _entitySpawnAdapter.GetState(entity.ServerGuid)
+ : null;
+
+ for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++)
+ {
+ if (animState is not null && animState.IsPartHidden(partIdx))
+ continue;
+
+ var meshRef = entity.MeshRefs[partIdx];
+
+ ulong gfxObjId = meshRef.GfxObjId;
+ if (animState is not null)
+ gfxObjId = animState.ResolvePartGfxObj(partIdx, gfxObjId);
+
+ var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
+ if (renderData is null) continue;
+
+ // For Setup objects, WB stores sub-parts in SetupParts. For
+ // single GfxObjs, SetupParts is empty and the render data
+ // itself contains the batches.
+ if (renderData.IsSetup && renderData.SetupParts.Count > 0)
+ {
+ foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
+ {
+ var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
+ if (partData is null) continue;
+
+ var model = ComposePartWorldMatrix(
+ entityWorld, meshRef.PartTransform, partTransform);
+
+ ClassifyBatches(partData, partGfxObjId, model,
+ entity, meshRef, metaTable, opaqueDraws, translucentDraws);
+ }
+ }
+ else
+ {
+ var model = meshRef.PartTransform * entityWorld;
+
+ ClassifyBatches(renderData, gfxObjId, model,
+ entity, meshRef, metaTable, opaqueDraws, translucentDraws);
+ }
+ }
+ }
+ }
+
+ // ── Pass 1: Opaque + ClipMap ─────────────────────────────────────────
+ if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
+ _gl.Disable(EnableCap.CullFace);
+
+ foreach (var item in opaqueDraws)
+ {
+ _shader.SetInt("uTranslucencyKind", (int)item.Translucency);
+ UploadMatrixAndDraw(item);
+ }
+
+ // ── Pass 2: Translucent ──────────────────────────────────────────────
+ _gl.Enable(EnableCap.Blend);
+ _gl.DepthMask(false);
+
+ if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
+ {
+ _gl.Disable(EnableCap.CullFace);
+ }
+ else
+ {
+ _gl.Enable(EnableCap.CullFace);
+ _gl.CullFace(TriangleFace.Back);
+ _gl.FrontFace(FrontFaceDirection.Ccw);
+ }
+
+ foreach (var item in translucentDraws)
+ {
+ switch (item.Translucency)
+ {
+ case TranslucencyKind.Additive:
+ _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
+ break;
+ case TranslucencyKind.InvAlpha:
+ _gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
+ break;
+ default:
+ _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
+ break;
+ }
+
+ _shader.SetInt("uTranslucencyKind", (int)item.Translucency);
+ UploadMatrixAndDraw(item);
+ }
+
+ _gl.DepthMask(true);
+ _gl.Disable(EnableCap.Blend);
+ _gl.Disable(EnableCap.CullFace);
+ _gl.BindVertexArray(0);
+ }
+
+ private void ClassifyBatches(
+ ObjectRenderData renderData,
+ ulong gfxObjId,
+ Matrix4x4 model,
+ WorldEntity entity,
+ MeshRef meshRef,
+ AcSurfaceMetadataTable metaTable,
+ List opaqueDraws,
+ List translucentDraws)
+ {
+ for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
+ {
+ var batch = renderData.Batches[batchIdx];
+
+ TranslucencyKind translucency;
+ if (metaTable.TryLookup(gfxObjId, batchIdx, out var meta))
+ {
+ translucency = meta.Translucency;
+ }
+ else
+ {
+ // Fallback: derive from WB batch flags.
+ translucency = batch.IsAdditive ? TranslucencyKind.Additive
+ : batch.IsTransparent ? TranslucencyKind.AlphaBlend
+ : TranslucencyKind.Opaque;
+ }
+
+ uint texHandle = ResolveTexture(entity, meshRef, batch);
+ if (texHandle == 0) continue;
+
+ var item = new DrawItem
+ {
+ Vao = renderData.VAO,
+ Ibo = batch.IBO,
+ IndexCount = batch.IndexCount,
+ Model = model,
+ TextureHandle = texHandle,
+ Translucency = translucency,
+ };
+
+ if (translucency == TranslucencyKind.Opaque || translucency == TranslucencyKind.ClipMap)
+ opaqueDraws.Add(item);
+ else
+ translucentDraws.Add(item);
+ }
+ }
+
+ private uint ResolveTexture(WorldEntity entity, MeshRef meshRef, ObjectRenderBatch batch)
+ {
+ uint surfaceId = batch.SurfaceId;
+ if (surfaceId == 0) return 0;
+
+ uint overrideOrigTex = 0;
+ bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
+ && meshRef.SurfaceOverrides.TryGetValue(surfaceId, out overrideOrigTex);
+ uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
+
+ if (entity.PaletteOverride is not null)
+ {
+ return _textures.GetOrUploadWithPaletteOverride(
+ surfaceId, origTexOverride, entity.PaletteOverride);
+ }
+ else if (hasOrigTexOverride)
+ {
+ return _textures.GetOrUploadWithOrigTextureOverride(surfaceId, overrideOrigTex);
+ }
+ else
+ {
+ return _textures.GetOrUpload(surfaceId);
+ }
+ }
+
+ private void EnsureInstanceAttribs(uint vao)
+ {
+ if (!_patchedVaos.Add(vao)) return;
+
+ _gl.BindVertexArray(vao);
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
+ for (uint row = 0; row < 4; row++)
+ {
+ uint loc = 3 + row;
+ _gl.EnableVertexAttribArray(loc);
+ _gl.VertexAttribPointer(loc, 4, VertexAttribPointerType.Float, false, 64, (void*)(row * 16));
+ _gl.VertexAttribDivisor(loc, 1);
+ }
+ }
+
+ private void UploadMatrixAndDraw(in DrawItem item)
+ {
+ WriteMatrix(_matrixBuf, 0, item.Model);
+
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
+ fixed (float* p = _matrixBuf)
+ _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, 64, p);
+
+ EnsureInstanceAttribs(item.Vao);
+ _gl.BindVertexArray(item.Vao);
+
+ // Re-point instance attributes to offset 0 (single matrix).
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
+ for (uint row = 0; row < 4; row++)
+ _gl.VertexAttribPointer(3 + row, 4, VertexAttribPointerType.Float, false, 64, (void*)(row * 16));
+
+ _gl.ActiveTexture(TextureUnit.Texture0);
+ _gl.BindTexture(TextureTarget.Texture2D, item.TextureHandle);
+
+ _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, item.Ibo);
+ _gl.DrawElementsInstanced(PrimitiveType.Triangles,
+ (uint)item.IndexCount, DrawElementsType.UnsignedShort,
+ (void*)0, 1);
+ }
+
+ private static void WriteMatrix(float[] buf, int offset, in Matrix4x4 m)
+ {
+ buf[offset + 0] = m.M11; buf[offset + 1] = m.M12; buf[offset + 2] = m.M13; buf[offset + 3] = m.M14;
+ buf[offset + 4] = m.M21; buf[offset + 5] = m.M22; buf[offset + 6] = m.M23; buf[offset + 7] = m.M24;
+ buf[offset + 8] = m.M31; buf[offset + 9] = m.M32; buf[offset + 10] = m.M33; buf[offset + 11] = m.M34;
+ buf[offset + 12] = m.M41; buf[offset + 13] = m.M42; buf[offset + 14] = m.M43; buf[offset + 15] = m.M44;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _gl.DeleteBuffer(_instanceVbo);
+ }
+
+ private struct DrawItem
+ {
+ public uint Vao;
+ public uint Ibo;
+ public int IndexCount;
+ public Matrix4x4 Model;
+ public uint TextureHandle;
+ public TranslucencyKind Translucency;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
index b8a3a23..06a6c85 100644
--- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
+++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
@@ -1,6 +1,10 @@
using System;
+using System.Collections.Generic;
+using AcDream.Core.Meshing;
using Chorizite.OpenGLSDLBackend;
using Chorizite.OpenGLSDLBackend.Lib;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Silk.NET.OpenGL;
@@ -26,6 +30,9 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
private readonly OpenGLGraphicsDevice? _graphicsDevice;
private readonly DefaultDatReaderWriter? _wbDats;
private readonly ObjectMeshManager? _meshManager;
+ private readonly DatCollection? _dats;
+ private readonly AcSurfaceMetadataTable _metadataTable = new();
+ private readonly HashSet _metadataPopulated = new();
///
/// True when this instance was created via ;
@@ -43,14 +50,19 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
/// thread (construction runs GL queries; call from OnLoad).
/// Path to the dat directory (same as the one supplied
/// to our DatCollection). DefaultDatReaderWriter opens its own file handles.
+ /// acdream's DatCollection, used to populate the surface
+ /// metadata side-table via GfxObjMesh.Build. Shares file handles with
+ /// the rest of the client; read-only access from the render thread.
/// Logger for the adapter; ObjectMeshManager uses
/// NullLogger internally.
- public WbMeshAdapter(GL gl, string datDir, ILogger logger)
+ public WbMeshAdapter(GL gl, string datDir, DatCollection dats, ILogger logger)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(datDir);
+ ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(logger);
+ _dats = dats;
_graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings());
_wbDats = new DefaultDatReaderWriter(datDir);
_meshManager = new ObjectMeshManager(
@@ -70,9 +82,18 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
/// underlying mesh manager. Public methods are all no-ops.
public static WbMeshAdapter CreateUninitialized() => new();
+ ///
+ /// The surface metadata side-table populated on each first
+ /// . Queried by the draw dispatcher
+ /// to determine translucency, luminosity, and fog behavior per batch.
+ ///
+ public AcSurfaceMetadataTable MetadataTable => _metadataTable;
+
///
/// Returns the WB render data for , or null if not
- /// yet uploaded or if this adapter is uninitialized.
+ /// yet uploaded or if this adapter is uninitialized. Increments WB's
+ /// internal usage counter — use for
+ /// render-loop lookups that should not affect lifecycle.
///
public ObjectRenderData? GetRenderData(ulong id)
{
@@ -80,11 +101,25 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
return _meshManager.GetRenderData(id);
}
+ ///
+ /// Returns the WB render data for without
+ /// modifying reference counts. Returns null if the mesh is not yet
+ /// uploaded. Safe for render-loop lookups.
+ ///
+ public ObjectRenderData? TryGetRenderData(ulong id)
+ {
+ if (_isUninitialized || _meshManager is null) return null;
+ return _meshManager.TryGetRenderData(id);
+ }
+
///
public void IncrementRefCount(ulong id)
{
if (_isUninitialized || _meshManager is null) return;
_meshManager.IncrementRefCount(id);
+
+ if (_metadataPopulated.Add(id))
+ PopulateMetadata(id);
}
///
@@ -126,6 +161,21 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
}
}
+ private void PopulateMetadata(ulong id)
+ {
+ if (_dats is null) return;
+ if (!_dats.Portal.TryGet((uint)id, out var gfxObj)) return;
+
+ var subMeshes = GfxObjMesh.Build(gfxObj, _dats);
+ for (int i = 0; i < subMeshes.Count; i++)
+ {
+ var sm = subMeshes[i];
+ _metadataTable.Add(id, i, new AcSurfaceMetadata(
+ sm.Translucency, sm.Luminosity, sm.Diffuse,
+ sm.SurfOpacity, sm.NeedsUvRepeat, sm.DisableFog));
+ }
+ }
+
///
public void Dispose()
{
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs
new file mode 100644
index 0000000..7671574
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs
@@ -0,0 +1,64 @@
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class MatrixCompositionTests
+{
+ [Fact]
+ public void Compose_EntityAnimRest_ProducesExpectedWorldMatrix()
+ {
+ var entityWorld = Matrix4x4.CreateTranslation(100, 200, 300);
+ var animOverride = Matrix4x4.CreateRotationZ(MathF.PI / 4);
+ var restPose = Matrix4x4.CreateTranslation(1, 0, 0);
+
+ var result = WbDrawDispatcher.ComposePartWorldMatrix(entityWorld, animOverride, restPose);
+
+ var expected = restPose * animOverride * entityWorld;
+ AssertMatrixEqual(expected, result);
+ }
+
+ [Fact]
+ public void Compose_IdentityAnim_EqualsRestTimesEntity()
+ {
+ var entityWorld = Matrix4x4.CreateFromQuaternion(
+ Quaternion.CreateFromYawPitchRoll(0.5f, 0, 0)) *
+ Matrix4x4.CreateTranslation(10, 20, 30);
+ var restPose = Matrix4x4.CreateTranslation(0.5f, -0.3f, 0.1f);
+
+ var result = WbDrawDispatcher.ComposePartWorldMatrix(
+ entityWorld, Matrix4x4.Identity, restPose);
+
+ var expected = restPose * entityWorld;
+ AssertMatrixEqual(expected, result);
+ }
+
+ [Fact]
+ public void Compose_AllIdentity_ReturnsIdentity()
+ {
+ var result = WbDrawDispatcher.ComposePartWorldMatrix(
+ Matrix4x4.Identity, Matrix4x4.Identity, Matrix4x4.Identity);
+
+ AssertMatrixEqual(Matrix4x4.Identity, result);
+ }
+
+ private static void AssertMatrixEqual(Matrix4x4 expected, Matrix4x4 actual, float eps = 1e-5f)
+ {
+ Assert.Equal(expected.M11, actual.M11, eps);
+ Assert.Equal(expected.M12, actual.M12, eps);
+ Assert.Equal(expected.M13, actual.M13, eps);
+ Assert.Equal(expected.M14, actual.M14, eps);
+ Assert.Equal(expected.M21, actual.M21, eps);
+ Assert.Equal(expected.M22, actual.M22, eps);
+ Assert.Equal(expected.M23, actual.M23, eps);
+ Assert.Equal(expected.M24, actual.M24, eps);
+ Assert.Equal(expected.M31, actual.M31, eps);
+ Assert.Equal(expected.M32, actual.M32, eps);
+ Assert.Equal(expected.M33, actual.M33, eps);
+ Assert.Equal(expected.M34, actual.M34, eps);
+ Assert.Equal(expected.M41, actual.M41, eps);
+ Assert.Equal(expected.M42, actual.M42, eps);
+ Assert.Equal(expected.M43, actual.M43, eps);
+ Assert.Equal(expected.M44, actual.M44, eps);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
index 5758026..1053f85 100644
--- a/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
@@ -14,7 +14,7 @@ public sealed class WbMeshAdapterTests
// We can't pass a real GL (no context in tests), so we verify only the
// null-GL guard. The real pipeline is tested via integration.
Assert.Throws(() =>
- new WbMeshAdapter(gl: null!, datDir: "some/path", logger: NullLogger.Instance));
+ new WbMeshAdapter(gl: null!, datDir: "some/path", dats: null!, logger: NullLogger.Instance));
}
[Fact]