From 01cff4144fc0387aafa0622e13a98adb3601903b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 15:30:33 +0200 Subject: [PATCH] phase(N.4) Tasks 22+23: WbDrawDispatcher + surface metadata side-table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WbDrawDispatcher draws all entities through WB's ObjectRenderData (VAO/VBO per GfxObj, per-batch IBO) using acdream's TextureCache for texture resolution. Two-pass rendering (opaque+ClipMap, then translucent) matching the existing InstancedMeshRenderer pattern. Per-entity single-instance drawing for N.4 simplicity — true instancing grouping deferred to N.6. Atlas-tier entities: mesh from WB, texture from TextureCache via batch SurfaceId. Per-instance-tier entities: AnimatedEntityState drives part overrides + hidden-parts, palette/surface overrides resolve through TextureCache's composite-key caches. Side-table population (Task 23 folded in): WbMeshAdapter now takes DatCollection and populates AcSurfaceMetadataTable on first IncrementRefCount per GfxObj. The side-table provides TranslucencyKind (critical for ClipMap alpha-test on vegetation) plus Luminosity, Diffuse, SurfOpacity, NeedsUvRepeat, DisableFog for sky-pass and lighting. GameWindow wiring: when WbFoundationFlag is enabled, WbDrawDispatcher draws everything and InstancedMeshRenderer is skipped. Flag-off path is unchanged. Matrix composition: restPose * animOverride * entityWorld, matching the spec. Three MatrixCompositionTests verify the contract. Co-Authored-By: Claude Opus 4.6 --- src/AcDream.App/Rendering/GameWindow.cs | 31 +- .../Rendering/Wb/WbDrawDispatcher.cs | 364 ++++++++++++++++++ src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 54 ++- .../Rendering/Wb/MatrixCompositionTests.cs | 64 +++ .../Rendering/Wb/WbMeshAdapterTests.cs | 2 +- 5 files changed, 507 insertions(+), 8 deletions(-) create mode 100644 src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs create mode 100644 tests/AcDream.Core.Tests/Rendering/Wb/MatrixCompositionTests.cs 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]