phase(N.4) Tasks 22+23: WbDrawDispatcher + surface metadata side-table

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-08 15:30:33 +02:00
parent 5b4fd4b61d
commit 01cff4144f
5 changed files with 507 additions and 8 deletions

View file

@ -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);
}
}

View file

@ -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<ArgumentNullException>(() =>
new WbMeshAdapter(gl: null!, datDir: "some/path", logger: NullLogger<WbMeshAdapter>.Instance));
new WbMeshAdapter(gl: null!, datDir: "some/path", dats: null!, logger: NullLogger<WbMeshAdapter>.Instance));
}
[Fact]