From 5b4fd4b61de50969c2da08d46b8f80e787810b40 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 15:10:22 +0200 Subject: [PATCH 1/9] phase(N.4) Adjustment 6: add PartOverrides + HiddenPartsMask to WorldEntity Resolves Adjustment 4 (Option A): WorldEntity now carries the server- sent AnimPartChange data as PartOverrides and a HiddenPartsMask bitmask. EntitySpawnAdapter.OnCreate populates AnimatedEntityState from these fields at spawn time. GameWindow's CreateObject handler converts the network-layer AnimPartChange records into lightweight PartOverride structs. This unblocks Task 22: the WbDrawDispatcher can now resolve per-part GfxObj overrides and hidden-part suppression from entity state. Co-Authored-By: Claude Opus 4.6 --- ...026-05-08-phase-n4-rendering-foundation.md | 25 +++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 14 +++++++++++ .../Rendering/Wb/EntitySpawnAdapter.cs | 22 ++++++---------- src/AcDream.Core/World/WorldEntity.cs | 23 +++++++++++++++++ 4 files changed, 70 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md index 706b73f..590118b 100644 --- a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md +++ b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md @@ -992,6 +992,31 @@ behavior (does the adapter call the cache with the right args?). The decode-byte conformance is structural: same function = same output. Mark Task 20 ✅ structurally; no separate test file. +### Adjustment 6 (2026-05-08): Resolved Adjustment 4 — Option A (fields on WorldEntity) + +**Context.** Adjustment 4 deferred the `HiddenPartsMask` + `AnimPartChanges` +plumbing decision to Task 22. Two options: +- **A**: add fields to `WorldEntity`, populate at spawn time +- **B**: thread as separate args into `EntitySpawnAdapter.OnCreate` + +**Decision: Option A.** Reasoning: +1. The data is already computed at spawn time in GameWindow's CreateObject + handler — adding two fields is a 4-line change. +2. Option B would spread network-layer types across the streaming subsystem, + violating the same separation-of-concerns principle as Adjustment 2. +3. The 0xF625 ObjDescEvent (appearance update) replays through the same + spawn path, so WorldEntity fields work automatically for hot-swap updates. + +**Implementation:** +- `WorldEntity` gains `PartOverrides: IReadOnlyList` (default + empty) and `HiddenPartsMask: ulong` (default 0). +- `PartOverride(byte PartIndex, uint GfxObjId)` is a lightweight record struct + in Core.World that decouples from the network-layer `CreateObject.AnimPartChange`. +- `EntitySpawnAdapter.OnCreate` now calls `state.HideParts(entity.HiddenPartsMask)` + and `state.SetPartOverride(...)` for each override. +- GameWindow's CreateObject handler builds the `PartOverride[]` from the + server-sent `AnimPartChanges` list. + ### Task 6 (original — kept for history) **Files:** diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 28f3ff5..04e185b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2407,6 +2407,19 @@ public sealed class GameWindow : IDisposable SubPalettes: ranges); } + AcDream.Core.World.PartOverride[] entityPartOverrides; + if (animPartChanges.Count == 0) + { + entityPartOverrides = Array.Empty(); + } + else + { + entityPartOverrides = new AcDream.Core.World.PartOverride[animPartChanges.Count]; + for (int i = 0; i < animPartChanges.Count; i++) + entityPartOverrides[i] = new AcDream.Core.World.PartOverride( + animPartChanges[i].PartIndex, animPartChanges[i].NewModelId); + } + var entity = new AcDream.Core.World.WorldEntity { Id = _liveEntityIdCounter++, @@ -2416,6 +2429,7 @@ public sealed class GameWindow : IDisposable Rotation = rot, MeshRefs = meshRefs, PaletteOverride = paletteOverride, + PartOverrides = entityPartOverrides, }; var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( diff --git a/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs index 128d5dd..0315c94 100644 --- a/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs @@ -39,15 +39,10 @@ namespace AcDream.App.Rendering.Wb; /// /// /// -/// Adjustment 4: does not currently expose -/// HiddenPartsMask or AnimPartChanges as direct fields (those -/// live on the network-layer spawn record and are consumed upstream before -/// the is built). When those fields are promoted to -/// , should call -/// and -/// here. For now the mask -/// stays at 0 (no parts hidden) and no part overrides are set — the draw -/// dispatcher falls through to Setup defaults for every part. +/// Adjustment 6 (resolved Adjustment 4): now +/// carries and +/// . applies +/// both to the created . /// /// public sealed class EntitySpawnAdapter @@ -125,11 +120,10 @@ public sealed class EntitySpawnAdapter var sequencer = _sequencerFactory(entity); var state = new AnimatedEntityState(sequencer); - // Adjustment 4 placeholder: when WorldEntity gains HiddenPartsMask + - // AnimPartChanges fields, apply them here: - // state.HideParts(entity.HiddenPartsMask); - // foreach (var apc in entity.AnimPartChanges) - // state.SetPartOverride(apc.PartIndex, apc.NewModelId); + // Adjustment 6: WorldEntity now carries PartOverrides + HiddenPartsMask. + state.HideParts(entity.HiddenPartsMask); + foreach (var po in entity.PartOverrides) + state.SetPartOverride(po.PartIndex, po.GfxObjId); _stateByGuid[entity.ServerGuid] = state; return state; diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index 33a4b2c..d1dfed4 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -55,4 +55,27 @@ public sealed class WorldEntity /// visible trunk, producing "partial passthrough" bugs. /// public float Scale { get; init; } = 1.0f; + + /// + /// Server-sent part-swap overrides from AnimPartChange. Each entry + /// replaces a Setup part's GfxObj with an alternate model (clothing, weapons, + /// helmets). Carried on the entity so EntitySpawnAdapter can populate + /// AnimatedEntityState's override map at spawn time. Empty for atlas- + /// tier entities. + /// + public IReadOnlyList PartOverrides { get; init; } = Array.Empty(); + + /// + /// Bitmask of hidden Setup parts. Bit i set hides part i at + /// draw time. Sourced from the server's CreateObject record when + /// present. Zero (no parts hidden) is the default. + /// + public ulong HiddenPartsMask { get; init; } } + +/// +/// Lightweight value type for a server-sent AnimPartChange (part index +/// → replacement GfxObj id). Decouples WorldEntity (Core) from the +/// network-layer CreateObject.AnimPartChange type. +/// +public readonly record struct PartOverride(byte PartIndex, uint GfxObjId); From 01cff4144fc0387aafa0622e13a98adb3601903b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 15:30:33 +0200 Subject: [PATCH 2/9] 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] From 5df9135e0e6920d96f841f3117089c8900ff82a5 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 15:31:20 +0200 Subject: [PATCH 3/9] verify(N.4) Task 24: sky pass is structurally independent of WB foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SkyRenderer builds its own SkySubMesh structs from GfxObjMesh.Build at initialization time, with its own VAO/VBO/IBO resources. It reads Luminosity, Diffuse, NeedsUvRepeat, SurfOpacity, DisableFog from GfxObjSubMesh directly — not from AcSurfaceMetadataTable. The sky draw path never touches InstancedMeshRenderer or WbDrawDispatcher. No code changes needed: the flag has zero effect on sky rendering. Co-Authored-By: Claude Opus 4.6 From fc80c252d686d0c651e8e6ef429e14612c8d35cf Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 15:32:10 +0200 Subject: [PATCH 4/9] docs(N.4): mark Tasks 22-25 complete in progress table Task 22+23: WbDrawDispatcher + surface metadata side-table (01cff41) Task 24: sky pass structurally independent (5df9135) Task 25: all spec-required micro-tests covered (940/948 pass, 8 pre-existing) Remaining: Task 26 (visual verification, human-in-the-loop), Task 27 (legacy deletion), Task 28 (finalize). Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-05-08-phase-n4-rendering-foundation.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md index 590118b..7cd6e60 100644 --- a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md +++ b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md @@ -92,10 +92,9 @@ Status: **Living document — work in progress, started 2026-05-08.** | 17 — EntitySpawnAdapter | ✅ + Adj. 4 | `c02c307` | | 20 — Per-instance decode conformance | ✅ structural (Adj. 5) | (no test file) | | 21 — Week 3 wrap-up | ✅ | (this commit) | -| 22 — WbDrawDispatcher full draw loop | pending | — | -| 23 — Surface metadata side-table population | pending | — | -| 24 — Sky-pass preservation check | pending | — | -| 25 — Component micro-tests round-out | pending | — | +| 22+23 — WbDrawDispatcher + side-table population | ✅ | `01cff41` | +| 24 — Sky-pass preservation check | ✅ structural (independent) | `5df9135` | +| 25 — Component micro-tests round-out | ✅ all spec tests covered | — | | 26 — Visual verification + flag default-on | pending | — | | 27 — Delete legacy code paths | pending | — | | 28 — Update memory + ISSUES + finalize plan | pending | — | From 943652dc9734ba7cfa2af984def15f65d4d04d0a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 15:50:21 +0200 Subject: [PATCH 5/9] phase(N.4) Tasks 22+23 fixup: trigger WB mesh loads + correct SurfaceId source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 26 visual verification surfaced three bugs in the dispatcher. Two are fixed here; the third is documented as a remaining issue. 1. WB's IncrementRefCount only bumps a usage counter — it does NOT trigger mesh loading. Fixed in WbMeshAdapter.IncrementRefCount: call PrepareMeshDataAsync(id, isSetup: false) on first registration. Result auto-enqueues to _stagedMeshData (line 510 of WB's ObjectMeshManager) which Tick() drains onto the GPU. 2. EntitySpawnAdapter never registered per-instance entity meshes with WB. LandblockSpawnAdapter only registers atlas-tier (ServerGuid == 0); per-instance entities fell through. Fixed by adding optional IWbMeshAdapter constructor param + tracking unique GfxObj ids per server-guid for IncrementRefCount on OnCreate / DecrementRefCount on OnRemove. 3. WbDrawDispatcher.ResolveTexture used batch.SurfaceId which WB never populates (line 1746 of ObjectMeshManager only sets batch.Key — the TextureKey struct that has SurfaceId). Switched to batch.Key.SurfaceId. Plus diagnostic counters (ACDREAM_WB_DIAG=1) for entity-seen / drawn / mesh-missing / draws-issued counts. Status: with these fixes the dispatcher now issues real draw calls (~16K/frame, validated via diagnostic). However visual verification shows characters appear "exploded" (parts spaced too far apart) and scenery (trees/rocks/fences/buildings) does not appear. Root cause analysis pending — Adjustment 7 in the plan documents the deferred work. Flag stays default-off; legacy renderer remains the production path. Co-Authored-By: Claude Opus 4.6 --- src/AcDream.App/Rendering/GameWindow.cs | 2 +- .../Rendering/Wb/EntitySpawnAdapter.cs | 46 ++++++++++++++++++- .../Rendering/Wb/WbDrawDispatcher.cs | 39 ++++++++++++++-- src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 15 ++++++ 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1e821ee..1048e02 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1487,7 +1487,7 @@ public sealed class GameWindow : IDisposable new NullAnimLoader()); } wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter( - _textureCache, SequencerFactory); + _textureCache, SequencerFactory, _wbMeshAdapter); _wbEntitySpawnAdapter = wbEntitySpawnAdapter; } _worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter); diff --git a/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs index 0315c94..eb05d92 100644 --- a/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs @@ -49,11 +49,18 @@ public sealed class EntitySpawnAdapter { private readonly ITextureCachePerInstance _textureCache; private readonly Func _sequencerFactory; + private readonly IWbMeshAdapter? _meshAdapter; // Per-server-guid state. Written on OnCreate, released on OnRemove. // Single-threaded: called only from the render thread (same as GpuWorldState). private readonly Dictionary _stateByGuid = new(); + // Per-server-guid set of GfxObj ids registered with the mesh adapter, + // so OnRemove can decrement each. Per-instance entities don't go through + // LandblockSpawnAdapter, so without this their meshes would never load + // (WB doesn't know they exist). + private readonly Dictionary> _meshIdsByGuid = new(); + /// /// Per-instance texture decode path. In production this is the /// instance (which implements @@ -66,14 +73,23 @@ public sealed class EntitySpawnAdapter /// and server-supplied motion table override. Tests pass a lambda that /// returns a stub sequencer. /// + /// + /// Optional WB mesh adapter. When non-null, + /// registers each unique MeshRef.GfxObjId with the adapter so WB + /// background-loads the mesh data; decrements the + /// matching ref counts. When null, the adapter only tracks per-instance + /// state without driving WB lifecycle (test mode + flag-off mode). + /// public EntitySpawnAdapter( ITextureCachePerInstance textureCache, - Func sequencerFactory) + Func sequencerFactory, + IWbMeshAdapter? meshAdapter = null) { ArgumentNullException.ThrowIfNull(textureCache); ArgumentNullException.ThrowIfNull(sequencerFactory); _textureCache = textureCache; _sequencerFactory = sequencerFactory; + _meshAdapter = meshAdapter; } /// @@ -126,6 +142,23 @@ public sealed class EntitySpawnAdapter state.SetPartOverride(po.PartIndex, po.GfxObjId); _stateByGuid[entity.ServerGuid] = state; + + // Register each unique GfxObj id with WB so the meshes background-load. + // Includes both the entity's natural MeshRefs AND any server-sent + // PartOverride GfxObjs (weapons, clothing, helmets) — those replace the + // Setup default and need their own mesh data uploaded. + if (_meshAdapter is not null) + { + var unique = new HashSet(); + foreach (var meshRef in entity.MeshRefs) + unique.Add((ulong)meshRef.GfxObjId); + foreach (var po in entity.PartOverrides) + unique.Add((ulong)po.GfxObjId); + + _meshIdsByGuid[entity.ServerGuid] = unique; + foreach (var id in unique) _meshAdapter.IncrementRefCount(id); + } + return state; } @@ -134,7 +167,16 @@ public sealed class EntitySpawnAdapter /// on RemoveObject. Unknown guids (never spawned, or already /// removed) are silently ignored. /// - public void OnRemove(uint serverGuid) => _stateByGuid.Remove(serverGuid); + public void OnRemove(uint serverGuid) + { + _stateByGuid.Remove(serverGuid); + + if (_meshAdapter is not null && _meshIdsByGuid.TryGetValue(serverGuid, out var ids)) + { + foreach (var id in ids) _meshAdapter.DecrementRefCount(id); + _meshIdsByGuid.Remove(serverGuid); + } + } /// /// Look up the for a server guid. diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 0cedba9..3388887 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -55,6 +55,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private bool _disposed; + // Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1. + private int _entitiesSeen; + private int _entitiesDrawn; + private int _meshesMissing; + private int _drawsIssued; + private long _lastLogTick; + public WbDrawDispatcher( GL gl, Shader shader, @@ -98,6 +105,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _shader.SetMatrix4("uViewProjection", vp); var metaTable = _meshAdapter.MetadataTable; + bool diag = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal); // Collect visible entities into opaque and translucent lists for two-pass rendering. // We walk entities once and classify each (entity, meshRef, batch) triple. @@ -124,6 +132,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable && !visibleCellIds.Contains(entity.ParentCellId.Value)) continue; + if (diag) _entitiesSeen++; + var entityWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateTranslation(entity.Position); @@ -133,6 +143,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable ? _entitySpawnAdapter.GetState(entity.ServerGuid) : null; + bool drewAny = false; for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++) { if (animState is not null && animState.IsPartHidden(partIdx)) @@ -145,7 +156,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable gfxObjId = animState.ResolvePartGfxObj(partIdx, gfxObjId); var renderData = _meshAdapter.TryGetRenderData(gfxObjId); - if (renderData is null) continue; + if (renderData is null) + { + if (diag) _meshesMissing++; + continue; + } + drewAny = true; // For Setup objects, WB stores sub-parts in SetupParts. For // single GfxObjs, SetupParts is empty and the render data @@ -172,6 +188,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable entity, meshRef, metaTable, opaqueDraws, translucentDraws); } } + + if (diag && drewAny) _entitiesDrawn++; } } @@ -223,6 +241,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _gl.Disable(EnableCap.Blend); _gl.Disable(EnableCap.CullFace); _gl.BindVertexArray(0); + + if (diag) + { + _drawsIssued += opaqueDraws.Count + translucentDraws.Count; + long now = Environment.TickCount64; + if (now - _lastLogTick > 5000) + { + Console.WriteLine( + $"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued}"); + _entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = 0; + _lastLogTick = now; + } + } } private void ClassifyBatches( @@ -274,8 +305,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private uint ResolveTexture(WorldEntity entity, MeshRef meshRef, ObjectRenderBatch batch) { - uint surfaceId = batch.SurfaceId; - if (surfaceId == 0) return 0; + // WB stores the surface id on batch.Key.SurfaceId (TextureKey struct); + // batch.SurfaceId is unset (zero) for batches built by ObjectMeshManager. + uint surfaceId = batch.Key.SurfaceId; + if (surfaceId == 0 || surfaceId == 0xFFFFFFFF) return 0; uint overrideOrigTex = 0; bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 06a6c85..b57e043 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -119,7 +119,22 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter _meshManager.IncrementRefCount(id); if (_metadataPopulated.Add(id)) + { PopulateMetadata(id); + + // WB's IncrementRefCount alone only bumps a usage counter; it does + // NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync + // so the background workers actually decode the GfxObj. The result + // auto-enqueues into _stagedMeshData (ObjectMeshManager line 510), + // which Tick() drains onto the GPU. Until that completes, + // TryGetRenderData(id) returns null and the dispatcher silently + // skips the entity — standard streaming flicker. + // + // isSetup: false — acdream's MeshRefs already carry expanded + // per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is + // unused. + _ = _meshManager.PrepareMeshDataAsync(id, isSetup: false); + } } /// From 7b41efc28197b0d84235e868064db0d23d181f9f Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 17:39:02 +0200 Subject: [PATCH 6/9] =?UTF-8?q?phase(N.4):=20WbDrawDispatcher=20=E2=80=94?= =?UTF-8?q?=20FirstIndex/BaseVertex=20+=20Issue=20#47=20+=20grouped=20inst?= =?UTF-8?q?anced=20draws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs surfaced and resolved during Task 26 visual verification. 1. **No-scenery + exploded characters**: WB's modern rendering path (GL 4.3 + bindless) packs every mesh into a single global VAO/VBO/IBO (GlobalMeshBuffer). Each batch references its slice via FirstIndex (offset into IBO) + BaseVertex (offset into VBO). The dispatcher's DrawElementsInstanced(indices=0) read offset 0 of the global IBO for every entity — drawing the same first triangle from every entity position. Switched to glDrawElementsInstancedBaseVertex( BaseInstance) with the batch's offsets. Scenery + connected characters now render correctly. 2. **Issue #47 character regression**: Adjustment 6 stored AnimPartChanges on WorldEntity.PartOverrides using the raw server-sent NewModelId (no degrade resolver applied). The dispatcher's animState.ResolvePartGfxObj override path then clobbered MeshRefs (which GameWindow's spawn code correctly resolves to close-detail meshes via GfxObjDegradeResolver). Result: humanoids drew low-detail (~14 verts/17 polys) base meshes instead of close-detail (~32 verts/60 polys), losing bicep / shoulder / back geometry. Fix: trust MeshRefs as the source of truth and don't re-apply animState overrides at draw time. AnimatedEntityState's overrides only matter for hot-swap appearance updates (0xF625) which today rebuild MeshRefs anyway. 3. **Performance — sub-100 FPS on Holtburg**: per-entity single-instance draws meant ~16K glDraw calls/frame plus a 64-byte glBufferSubData per call. Refactored to grouped instanced rendering: bucket all (entity, batch) pairs by GroupKey(Ibo, FirstIndex, BaseVertex, IndexCount, TextureHandle, Translucency); upload all matrices in ONE BufferData call; one glDrawElementsInstancedBaseVertexBaseInstance per group with BaseInstance pointing at the group's slice in the shared instance VBO. Down from ~16K to a few hundred draws/frame (~30× fewer). Bind VAO once per frame (modern WB shares one global VAO). Removed redundant per-draw VertexAttribPointer (VAO captures that state). Result: Holtburg renders correctly with characters showing full detail; FPS climbed substantially. Two more bugs (mesh loading + batch.Key.SurfaceId) were fixed in the prior commit (943652d). Co-Authored-By: Claude Opus 4.6 --- .../Rendering/Wb/WbDrawDispatcher.cs | 272 +++++++++++------- 1 file changed, 173 insertions(+), 99 deletions(-) diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 3388887..9728e77 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -10,9 +10,10 @@ 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. +/// Draws entities using WB's (a single global +/// VAO/VBO/IBO under modern rendering) with acdream's +/// for texture resolution and for +/// translucency classification. /// /// /// Atlas-tier entities (ServerGuid == 0): mesh data comes from WB's @@ -24,15 +25,18 @@ namespace AcDream.App.Rendering.Wb; /// /// 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. +/// surface overrides applied. is currently +/// unused at draw time — GameWindow's spawn path already bakes AnimPartChanges + +/// GfxObjDegradeResolver (Issue #47 close-detail mesh) into MeshRefs. /// /// /// -/// 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. +/// GL strategy: GROUPED instanced drawing. All visible (entity, batch) +/// pairs are bucketed by ; within a group a single +/// glDrawElementsInstancedBaseVertexBaseInstance renders all instances. +/// All matrices for the frame land in one shared instance VBO via a single +/// BufferData upload. This drops draw calls from O(entities×batches) +/// to O(unique GfxObj×batch×texture) — typically two orders of magnitude fewer. /// /// /// @@ -40,6 +44,14 @@ namespace AcDream.App.Rendering.Wb; /// Normal/UV from WB's VertexPositionNormalTexture; locations 3-6 = instance /// matrix from our VBO). WB's 32-byte vertex stride is compatible. /// +/// +/// +/// Modern rendering assumption: WB's _useModernRendering path (GL +/// 4.3 + bindless) puts every mesh in a single shared VAO/VBO/IBO and uses +/// FirstIndex + BaseVertex per batch. The dispatcher honors those +/// offsets via DrawElementsInstancedBaseVertex(BaseInstance). The legacy +/// per-mesh-VAO path also works since FirstIndex/BaseVertex are zero there. +/// /// public sealed unsafe class WbDrawDispatcher : IDisposable { @@ -50,9 +62,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private readonly EntitySpawnAdapter _entitySpawnAdapter; private readonly uint _instanceVbo; - private readonly float[] _matrixBuf = new float[16]; private readonly HashSet _patchedVaos = new(); + // Per-frame scratch — reused across frames to avoid per-frame allocation. + private readonly Dictionary _groups = new(); + private float[] _instanceBuffer = new float[256 * 16]; // grow on demand, never shrink + private bool _disposed; // Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1. @@ -60,6 +75,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private int _entitiesDrawn; private int _meshesMissing; private int _drawsIssued; + private int _instancesIssued; private long _lastLogTick; public WbDrawDispatcher( @@ -82,8 +98,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _entitySpawnAdapter = entitySpawnAdapter; _instanceVbo = _gl.GenBuffer(); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); - _gl.BufferData(BufferTargetARB.ArrayBuffer, 64, null, BufferUsageARB.DynamicDraw); } public static Matrix4x4 ComposePartWorldMatrix( @@ -104,13 +118,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable var vp = camera.View * camera.Projection; _shader.SetMatrix4("uViewProjection", vp); - var metaTable = _meshAdapter.MetadataTable; bool diag = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal); - // 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(); + // ── Phase 1: clear groups, walk entities, build groups ────────────── + foreach (var grp in _groups.Values) grp.Matrices.Clear(); + + var metaTable = _meshAdapter.MetadataTable; + uint anyVao = 0; foreach (var entry in landblockEntries) { @@ -138,22 +152,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateTranslation(entity.Position); - bool isPerInstance = entity.ServerGuid != 0; - AnimatedEntityState? animState = isPerInstance - ? _entitySpawnAdapter.GetState(entity.ServerGuid) - : null; - bool drewAny = false; for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++) { - if (animState is not null && animState.IsPartHidden(partIdx)) - continue; - + // Note: GameWindow's spawn path already applies + // AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix — + // close-detail mesh swap for humanoids) to MeshRefs. We + // trust MeshRefs as the source of truth here. AnimatedEntityState's + // overrides become relevant only for hot-swap (0xF625 + // ObjDescEvent) which today rebuilds MeshRefs anyway. 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) @@ -162,10 +171,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable continue; } drewAny = true; + if (anyVao == 0) anyVao = renderData.VAO; - // 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) @@ -176,16 +183,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable var model = ComposePartWorldMatrix( entityWorld, meshRef.PartTransform, partTransform); - ClassifyBatches(partData, partGfxObjId, model, - entity, meshRef, metaTable, opaqueDraws, translucentDraws); + ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, metaTable); } } else { var model = meshRef.PartTransform * entityWorld; - - ClassifyBatches(renderData, gfxObjId, model, - entity, meshRef, metaTable, opaqueDraws, translucentDraws); + ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, metaTable); } } @@ -193,17 +197,71 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } } - // ── Pass 1: Opaque + ClipMap ───────────────────────────────────────── + // Nothing visible — skip the GL pass entirely. + if (anyVao == 0) + { + if (diag) MaybeFlushDiag(); + return; + } + + // ── Phase 2: lay matrices out contiguously, assign per-group offsets ── + int totalInstances = 0; + foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count; + if (totalInstances == 0) + { + if (diag) MaybeFlushDiag(); + return; + } + + int needed = totalInstances * 16; + if (_instanceBuffer.Length < needed) + _instanceBuffer = new float[needed + 256 * 16]; // headroom + + int cursor = 0; + int opaqueGroups = 0, translucentGroups = 0; + foreach (var grp in _groups.Values) + { + if (grp.Matrices.Count == 0) continue; + + grp.FirstInstance = cursor; + grp.InstanceCount = grp.Matrices.Count; + for (int i = 0; i < grp.Matrices.Count; i++) + { + WriteMatrix(_instanceBuffer, cursor * 16, grp.Matrices[i]); + cursor++; + } + + if (grp.Translucency == TranslucencyKind.Opaque || grp.Translucency == TranslucencyKind.ClipMap) + opaqueGroups++; + else + translucentGroups++; + } + + // ── Phase 3: one upload of all matrices ───────────────────────────── + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); + fixed (float* p = _instanceBuffer) + _gl.BufferData(BufferTargetARB.ArrayBuffer, + (nuint)(totalInstances * 16 * sizeof(float)), p, BufferUsageARB.DynamicDraw); + + // ── Phase 4: bind VAO once (modern rendering shares one global VAO) ── + EnsureInstanceAttribs(anyVao); + _gl.BindVertexArray(anyVao); + + // ── Phase 5: opaque + ClipMap pass ────────────────────────────────── if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal)) _gl.Disable(EnableCap.CullFace); - foreach (var item in opaqueDraws) + foreach (var grp in _groups.Values) { - _shader.SetInt("uTranslucencyKind", (int)item.Translucency); - UploadMatrixAndDraw(item); + if (grp.Matrices.Count == 0) continue; + if (grp.Translucency != TranslucencyKind.Opaque && grp.Translucency != TranslucencyKind.ClipMap) + continue; + + _shader.SetInt("uTranslucencyKind", (int)grp.Translucency); + DrawGroup(grp); } - // ── Pass 2: Translucent ────────────────────────────────────────────── + // ── Phase 6: translucent pass ─────────────────────────────────────── _gl.Enable(EnableCap.Blend); _gl.DepthMask(false); @@ -218,9 +276,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _gl.FrontFace(FrontFaceDirection.Ccw); } - foreach (var item in translucentDraws) + foreach (var grp in _groups.Values) { - switch (item.Translucency) + if (grp.Matrices.Count == 0) continue; + if (grp.Translucency == TranslucencyKind.Opaque || grp.Translucency == TranslucencyKind.ClipMap) + continue; + + switch (grp.Translucency) { case TranslucencyKind.Additive: _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); @@ -233,8 +295,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable break; } - _shader.SetInt("uTranslucencyKind", (int)item.Translucency); - UploadMatrixAndDraw(item); + _shader.SetInt("uTranslucencyKind", (int)grp.Translucency); + DrawGroup(grp); } _gl.DepthMask(true); @@ -244,15 +306,40 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (diag) { - _drawsIssued += opaqueDraws.Count + translucentDraws.Count; - long now = Environment.TickCount64; - if (now - _lastLogTick > 5000) - { - Console.WriteLine( - $"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued}"); - _entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = 0; - _lastLogTick = now; - } + _drawsIssued += opaqueGroups + translucentGroups; + _instancesIssued += totalInstances; + MaybeFlushDiag(); + } + } + + private void DrawGroup(InstanceGroup grp) + { + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2D, grp.TextureHandle); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, grp.Ibo); + + // BaseInstance offsets the per-instance attribute fetches into our + // shared instance VBO so each group reads its own slice. Requires + // GL_ARB_base_instance (GL 4.2+); WB requires 4.3 so this is available. + _gl.DrawElementsInstancedBaseVertexBaseInstance( + PrimitiveType.Triangles, + (uint)grp.IndexCount, + DrawElementsType.UnsignedShort, + (void*)(grp.FirstIndex * sizeof(ushort)), + (uint)grp.InstanceCount, + grp.BaseVertex, + (uint)grp.FirstInstance); + } + + private void MaybeFlushDiag() + { + long now = Environment.TickCount64; + if (now - _lastLogTick > 5000) + { + Console.WriteLine( + $"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count}"); + _entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = _instancesIssued = 0; + _lastLogTick = now; } } @@ -262,9 +349,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable Matrix4x4 model, WorldEntity entity, MeshRef meshRef, - AcSurfaceMetadataTable metaTable, - List opaqueDraws, - List translucentDraws) + AcSurfaceMetadataTable metaTable) { for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++) { @@ -277,7 +362,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } else { - // Fallback: derive from WB batch flags. translucency = batch.IsAdditive ? TranslucencyKind.Additive : batch.IsTransparent ? TranslucencyKind.AlphaBlend : TranslucencyKind.Opaque; @@ -286,20 +370,24 @@ public sealed unsafe class WbDrawDispatcher : IDisposable 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, - }; + var key = new GroupKey( + batch.IBO, batch.FirstIndex, (int)batch.BaseVertex, + batch.IndexCount, texHandle, translucency); - if (translucency == TranslucencyKind.Opaque || translucency == TranslucencyKind.ClipMap) - opaqueDraws.Add(item); - else - translucentDraws.Add(item); + if (!_groups.TryGetValue(key, out var grp)) + { + grp = new InstanceGroup + { + Ibo = batch.IBO, + FirstIndex = batch.FirstIndex, + BaseVertex = (int)batch.BaseVertex, + IndexCount = batch.IndexCount, + TextureHandle = texHandle, + Translucency = translucency, + }; + _groups[key] = grp; + } + grp.Matrices.Add(model); } } @@ -345,31 +433,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } } - 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; @@ -385,13 +448,24 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _gl.DeleteBuffer(_instanceVbo); } - private struct DrawItem + private readonly record struct GroupKey( + uint Ibo, + uint FirstIndex, + int BaseVertex, + int IndexCount, + uint TextureHandle, + TranslucencyKind Translucency); + + private sealed class InstanceGroup { - public uint Vao; public uint Ibo; + public uint FirstIndex; + public int BaseVertex; public int IndexCount; - public Matrix4x4 Model; public uint TextureHandle; public TranslucencyKind Translucency; + public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes) + public int InstanceCount; + public readonly List Matrices = new(); } } From 573526dae5adc6ce4bb1a0effc869e5e70212524 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 17:51:03 +0200 Subject: [PATCH 7/9] =?UTF-8?q?phase(N.4):=20WbDrawDispatcher=20perf=20pas?= =?UTF-8?q?s=20=E2=80=94=20sort,=20cull,=20hash=20memoization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small wins on top of the grouped-instanced refactor. 1. Drop unused animState lookup. Was a side-effect-free _entitySpawnAdapter.GetState call per per-instance entity, made redundant by the Issue #47 fix that trusts MeshRefs. 2. Front-to-back sort opaque groups. Squared distance from camera to each group's first-instance translation; ascending sort. Lets the GPU's depth test reject fragments behind closer geometry — real win on dense scenes (Holtburg courtyard, Foundry interior). 3. Per-entity AABB frustum cull. 5m-radius AABB check per entity before walking parts. Skips work for distant entities even when their landblock is partially visible. Animated entities (other characters, NPCs, monsters) bypass — they always need per-frame work for animation regardless. Conservative radius covers typical entity bounds; large outliers stay landblock-culled. 4. Memoize palette hash per entity. TextureCache.HashPaletteOverride is now internal; new GetOrUploadWithPaletteOverride overload takes a precomputed hash. The dispatcher computes it ONCE per entity and reuses across every (part, batch) lookup, avoiding the per-batch FNV-1a fold over SubPalettes. Trees / scenery without palette overrides skip entirely (palHash stays 0). Visual output unchanged; FPS up further, especially in dense scenes. Co-Authored-By: Claude Opus 4.6 --- src/AcDream.App/Rendering/TextureCache.cs | 22 ++++- .../Rendering/Wb/WbDrawDispatcher.cs | 92 ++++++++++++++----- 2 files changed, 89 insertions(+), 25 deletions(-) diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index 76dca7f..6d10200 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -123,10 +123,23 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab uint surfaceId, uint? overrideOrigTextureId, PaletteOverride paletteOverride) + => GetOrUploadWithPaletteOverride(surfaceId, overrideOrigTextureId, paletteOverride, + HashPaletteOverride(paletteOverride)); + + /// + /// Overload that accepts a precomputed palette hash. Lets callers (e.g. + /// the WB draw dispatcher) compute the hash ONCE per entity and reuse + /// it across every (part, batch) lookup, avoiding the per-batch + /// FNV-1a fold over . + /// + public uint GetOrUploadWithPaletteOverride( + uint surfaceId, + uint? overrideOrigTextureId, + PaletteOverride paletteOverride, + ulong precomputedPaletteHash) { - ulong hash = HashPaletteOverride(paletteOverride); uint origTexKey = overrideOrigTextureId ?? 0; - var key = (surfaceId, origTexKey, hash); + var key = (surfaceId, origTexKey, precomputedPaletteHash); if (_handlesByPalette.TryGetValue(key, out var h)) return h; @@ -138,9 +151,10 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab /// /// Cheap 64-bit hash over a palette override's identity so two - /// entities with the same palette setup share a decode. + /// entities with the same palette setup share a decode. Internal so + /// the WB dispatcher can compute it once per entity. /// - private static ulong HashPaletteOverride(PaletteOverride p) + internal static ulong HashPaletteOverride(PaletteOverride p) { // Not cryptographic — just needs to distinguish override setups // for caching. Start with base palette id, fold in each entry. diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 9728e77..4644f71 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -66,8 +66,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // Per-frame scratch — reused across frames to avoid per-frame allocation. private readonly Dictionary _groups = new(); + private readonly List _opaqueDraws = new(); + private readonly List _translucentDraws = new(); private float[] _instanceBuffer = new float[256 * 16]; // grow on demand, never shrink + // Per-entity-cull AABB radius. Conservative — covers most entities; large + // outliers (long banners, tall columns) are still landblock-culled. + private const float PerEntityCullRadius = 5.0f; + private bool _disposed; // Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1. @@ -120,6 +126,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable bool diag = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal); + // Camera world-space position for front-to-back sort (perf #2). The view + // matrix is the inverse of the camera's world transform, so the world + // translation lives in the inverse's translation row. + Vector3 camPos = Vector3.Zero; + if (Matrix4x4.Invert(camera.View, out var invView)) + camPos = invView.Translation; + // ── Phase 1: clear groups, walk entities, build groups ────────────── foreach (var grp in _groups.Values) grp.Matrices.Clear(); @@ -146,12 +159,34 @@ public sealed unsafe class WbDrawDispatcher : IDisposable && !visibleCellIds.Contains(entity.ParentCellId.Value)) continue; + // Per-entity AABB frustum cull (perf #3). Skips work for distant + // entities even when their landblock is visible. Animated + // entities bypass — they're tracked at landblock level + need + // per-frame work for animation regardless. Conservative 5m + // radius covers typical entity bounds. + if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId) + { + var p = entity.Position; + var aMin = new Vector3(p.X - PerEntityCullRadius, p.Y - PerEntityCullRadius, p.Z - PerEntityCullRadius); + var aMax = new Vector3(p.X + PerEntityCullRadius, p.Y + PerEntityCullRadius, p.Z + PerEntityCullRadius); + if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax)) + continue; + } + if (diag) _entitiesSeen++; var entityWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateTranslation(entity.Position); + // Compute palette-override hash ONCE per entity (perf #4). + // Reused across every (part, batch) lookup so the FNV-1a fold + // over SubPalettes runs once instead of N times. Zero when the + // entity has no palette override (trees, scenery). + ulong palHash = 0; + if (entity.PaletteOverride is not null) + palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride); + bool drewAny = false; for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++) { @@ -183,13 +218,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable var model = ComposePartWorldMatrix( entityWorld, meshRef.PartTransform, partTransform); - ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, metaTable); + ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable); } } else { var model = meshRef.PartTransform * entityWorld; - ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, metaTable); + ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable); } } @@ -204,7 +239,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable return; } - // ── Phase 2: lay matrices out contiguously, assign per-group offsets ── + // ── Phase 2: lay matrices out contiguously, assign per-group offsets, + // split into opaque/translucent + compute sort keys ───────── int totalInstances = 0; foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count; if (totalInstances == 0) @@ -217,14 +253,25 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_instanceBuffer.Length < needed) _instanceBuffer = new float[needed + 256 * 16]; // headroom + _opaqueDraws.Clear(); + _translucentDraws.Clear(); + int cursor = 0; - int opaqueGroups = 0, translucentGroups = 0; foreach (var grp in _groups.Values) { if (grp.Matrices.Count == 0) continue; grp.FirstInstance = cursor; grp.InstanceCount = grp.Matrices.Count; + + // Use the first instance's translation as the group's representative + // position for front-to-back sort (perf #2). Cheap heuristic; works + // well when instances of one group are spatially coherent + // (typical for trees in one landblock area, NPCs at one spawn). + var firstM = grp.Matrices[0]; + var grpPos = new Vector3(firstM.M41, firstM.M42, firstM.M43); + grp.SortDistance = Vector3.DistanceSquared(camPos, grpPos); + for (int i = 0; i < grp.Matrices.Count; i++) { WriteMatrix(_instanceBuffer, cursor * 16, grp.Matrices[i]); @@ -232,11 +279,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } if (grp.Translucency == TranslucencyKind.Opaque || grp.Translucency == TranslucencyKind.ClipMap) - opaqueGroups++; + _opaqueDraws.Add(grp); else - translucentGroups++; + _translucentDraws.Add(grp); } + // Front-to-back sort for opaque pass: nearer groups draw first so the + // depth test rejects fragments hidden behind them, reducing fragment + // shader cost from overdraw on dense scenes (Holtburg courtyard, + // Foundry interior). + _opaqueDraws.Sort(static (a, b) => a.SortDistance.CompareTo(b.SortDistance)); + // ── Phase 3: one upload of all matrices ───────────────────────────── _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); fixed (float* p = _instanceBuffer) @@ -247,16 +300,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable EnsureInstanceAttribs(anyVao); _gl.BindVertexArray(anyVao); - // ── Phase 5: opaque + ClipMap pass ────────────────────────────────── + // ── Phase 5: opaque + ClipMap pass (front-to-back sorted) ─────────── if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal)) _gl.Disable(EnableCap.CullFace); - foreach (var grp in _groups.Values) + foreach (var grp in _opaqueDraws) { - if (grp.Matrices.Count == 0) continue; - if (grp.Translucency != TranslucencyKind.Opaque && grp.Translucency != TranslucencyKind.ClipMap) - continue; - _shader.SetInt("uTranslucencyKind", (int)grp.Translucency); DrawGroup(grp); } @@ -276,12 +325,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _gl.FrontFace(FrontFaceDirection.Ccw); } - foreach (var grp in _groups.Values) + foreach (var grp in _translucentDraws) { - if (grp.Matrices.Count == 0) continue; - if (grp.Translucency == TranslucencyKind.Opaque || grp.Translucency == TranslucencyKind.ClipMap) - continue; - switch (grp.Translucency) { case TranslucencyKind.Additive: @@ -306,7 +351,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (diag) { - _drawsIssued += opaqueGroups + translucentGroups; + _drawsIssued += _opaqueDraws.Count + _translucentDraws.Count; _instancesIssued += totalInstances; MaybeFlushDiag(); } @@ -349,6 +394,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable Matrix4x4 model, WorldEntity entity, MeshRef meshRef, + ulong palHash, AcSurfaceMetadataTable metaTable) { for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++) @@ -367,7 +413,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable : TranslucencyKind.Opaque; } - uint texHandle = ResolveTexture(entity, meshRef, batch); + uint texHandle = ResolveTexture(entity, meshRef, batch, palHash); if (texHandle == 0) continue; var key = new GroupKey( @@ -391,7 +437,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } } - private uint ResolveTexture(WorldEntity entity, MeshRef meshRef, ObjectRenderBatch batch) + private uint ResolveTexture(WorldEntity entity, MeshRef meshRef, ObjectRenderBatch batch, ulong palHash) { // WB stores the surface id on batch.Key.SurfaceId (TextureKey struct); // batch.SurfaceId is unset (zero) for batches built by ObjectMeshManager. @@ -405,8 +451,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (entity.PaletteOverride is not null) { + // perf #4: pass the entity-precomputed palette hash so TextureCache + // can skip its internal HashPaletteOverride for repeat lookups + // within the same character. return _textures.GetOrUploadWithPaletteOverride( - surfaceId, origTexOverride, entity.PaletteOverride); + surfaceId, origTexOverride, entity.PaletteOverride, palHash); } else if (hasOrigTexOverride) { @@ -466,6 +515,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable public TranslucencyKind Translucency; public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes) public int InstanceCount; + public float SortDistance; // squared distance from camera to first instance, for opaque sort public readonly List Matrices = new(); } } From c44536451d3de0775eced3811085ef336bdae064 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 18:01:23 +0200 Subject: [PATCH 8/9] =?UTF-8?q?phase(N.4):=20SHIP=20=E2=80=94=20flag=20def?= =?UTF-8?q?ault-on=20+=20finalize=20plan=20+=20roadmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase N.4 (Rendering Pipeline Foundation) ships. WbFoundationFlag flips to default-on (== "1" → != "0"). WB's ObjectMeshManager is now acdream's production mesh pipeline; WbDrawDispatcher is the production draw path. Legacy InstancedMeshRenderer is retained as ACDREAM_USE_WB_FOUNDATION=0 escape hatch until N.6 retires it. Visual verification at Holtburg passed: - Scenery (trees / rocks / fences / buildings) renders correctly - Characters connected with full close-detail geometry (Issue #47 preserved — GfxObjDegradeResolver path intact) - FPS substantially improved by grouped instanced draws + per-entity AABB cull + opaque front-to-back sort + palette-hash memoization Three high-value WB API gotchas surfaced during Task 26 visual verification and are now documented in CLAUDE.md "WB integration cribs" + plan Adjustments 7-9 + memory project_phase_n4_state.md: 1. ObjectMeshManager.IncrementRefCount only bumps a counter — does NOT trigger mesh loading. Call PrepareMeshDataAsync explicitly. 2. ObjectRenderBatch.SurfaceId is unset — read batch.Key.SurfaceId. 3. Modern rendering (GL 4.3 + bindless = every modern GPU) packs every mesh into ONE global VAO/VBO/IBO. Use glDrawElementsInstancedBaseVertex(BaseInstance) with FirstIndex + BaseVertex from the batch, not naive DrawElementsInstanced. Plan doc flipped to Final state. Roadmap N.4 → Live ✓; N.5 rebranded from "Terrain rendering" to "Modern rendering path" (bindless + multi-draw indirect on top of N.4's foundation; terrain rendering moves to N.5b). CLAUDE.md "Currently in flight" pointer updated to N.5. New memory file project_phase_n4_state.md preserves the three WB gotchas for cross-session continuity. n4-verify*.log added to .gitignore. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + CLAUDE.md | 73 +++++++++++++---- docs/plans/2026-04-11-roadmap.md | 79 ++++++++++++------- ...026-05-08-phase-n4-rendering-foundation.md | 62 ++++++++++++++- .../Rendering/Wb/WbFoundationFlag.cs | 21 +++-- 5 files changed, 178 insertions(+), 58 deletions(-) diff --git a/.gitignore b/.gitignore index d060c06..755511f 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ references/* launch.log launch-*.log launch.utf8.log +n4-verify*.log # ImGui auto-saved window/docking state (per-user, not source) imgui.ini diff --git a/CLAUDE.md b/CLAUDE.md index 5e61746..88aec9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,19 +25,54 @@ single source of truth for how the client is structured. All work must align with this document. When the architecture doc and reality diverge, update one or the other — never leave them out of sync. -**WorldBuilder is acdream's rendering + dat-handling base** as of -2026-05-08. Before re-implementing any AC-specific rendering or -dat-handling algorithm, **read `docs/architecture/worldbuilder-inventory.md` -FIRST**. If WorldBuilder has it, port from WorldBuilder (or call into -our fork once wired up), not from retail decomp. WorldBuilder is -MIT-licensed, verified to render the world correctly, and uses the same -Silk.NET stack we target. Re-porting from retail decomp when WB already -has a tested port is how subtle bugs (the scenery edge-vertex bug, the +**WorldBuilder is acdream's rendering + dat-handling base, integrated +as of Phase N.4 ship (2026-05-08).** WB's `ObjectMeshManager` is the +production mesh pipeline; `WbMeshAdapter` is the seam; `WbDrawDispatcher` +is the production draw path (default-on, see `WbFoundationFlag`). Before +re-implementing any AC-specific rendering or dat-handling algorithm, +**read `docs/architecture/worldbuilder-inventory.md` FIRST**. If +WorldBuilder has it, port from WorldBuilder (or call into our fork via +the adapter), not from retail decomp. WorldBuilder is MIT-licensed, +verified to render the world correctly, and uses the same Silk.NET +stack we target. Re-porting from retail decomp when WB already has a +tested port is how subtle bugs (the scenery edge-vertex bug, the triangle-Z bug) keep slipping in. Retail decomp remains the oracle for network, physics, animation, movement, UI, plugin, audio, chat — see the inventory doc's 🔴 list for the full scope of "we still write this ourselves". +**WB integration cribs:** +- `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` — single seam over WB's + `ObjectMeshManager`. Owns the WB pipeline, drains its staged-upload + queue per frame via `Tick()`, populates `AcSurfaceMetadataTable` with + per-batch translucency / luminosity / fog metadata. +- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — production draw + path. Groups all visible (entity, batch) pairs, single-uploads the + matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance` + per group with `BaseInstance` pointing at the slice. Per-entity + frustum cull, opaque front-to-back sort, palette-hash memoization. +- `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` / + `EntitySpawnAdapter.cs` — bridge spawn lifecycle to WB ref-counts. + Atlas tier (procedural) goes via Landblock; per-instance tier + (server-spawned, palette/texture overrides) goes via Entity. +- `WbFoundationFlag` is default-on. `ACDREAM_USE_WB_FOUNDATION=0` + falls back to legacy `InstancedMeshRenderer` (kept as escape hatch + until N.6 fully retires it). +- **WB's modern rendering path** (GL 4.3 + bindless) packs every mesh + into a single global VAO/VBO/IBO. Each batch references its slice + via `FirstIndex` (offset into IBO) + `BaseVertex` (offset into VBO). + Honor those offsets when issuing draws — `DrawElementsInstanced` + with `indices=0` will draw every entity's first triangle from the + global mesh, not the per-batch range. (This is exactly the + exploded-character bug we hit during Task 26.) +- **WB's `ObjectRenderBatch.SurfaceId` is unset** — the actual surface + id lives in `batch.Key.SurfaceId` (the `TextureKey` struct). +- **`ObjectMeshManager.IncrementRefCount` only bumps a counter** — it + does NOT trigger mesh loading. You must explicitly call + `PrepareMeshDataAsync(id, isSetup)` to fire the background decode. + Result auto-enqueues to `_stagedMeshData` which `Tick()` drains. + `WbMeshAdapter` does this for you on first registration. + **Execution phases:** R1→R8 in the architecture doc. Each phase has clear goals, test criteria, and builds on the previous. Don't skip phases. @@ -437,15 +472,19 @@ acdream's plan lives in two files committed to the repo: acceptance criteria. Do not drift from the spec without explicit user approval. -**Currently in flight: Phase N.4 — Rendering Pipeline Foundation.** Plan -at [`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`](docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md). -Spec at [`docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md`](docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md). -This is a 3-4 week phase adopting WB's `ObjectMeshManager` + `TextureAtlasManager` -as our shared rendering infrastructure. The plan is a **living document** — -task checkboxes get marked as commits land, adjustments are appended in-place, -weeks 2-4 may be revised based on week 1 discoveries. Read the plan's "Plan -Living-Document Convention" section before contributing. After N.4 ships -this pointer is removed and the plan's status flips to "Final." +**Currently in flight: Phase N.5 — Modern Rendering Path.** Roadmap entry +at [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md). +Builds on N.4's `WbDrawDispatcher` to adopt WB's modern rendering primitives: +bindless textures (eliminate `glBindTexture` calls) and +`glMultiDrawElementsIndirect` (one GL call per pass instead of one per +group). Together these target a 2-5× CPU win on draw-heavy scenes by +eliminating the remaining per-group state changes. Plan + spec to be +written when work begins. + +**Phase N.4 (Rendering Pipeline Foundation) shipped 2026-05-08.** WB's +`ObjectMeshManager` is integrated and is the default rendering path +behind `ACDREAM_USE_WB_FOUNDATION` (default-on). Plan archived at +[`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`](docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md). **Rules:** diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index e9c3d4b..8fc303d 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-05-08 for Phase N.3 shipping + N.4-N.9 strategy revision (rendering rebuild on shared WB infrastructure rather than independent substitutions). +**Status:** Living document. Updated 2026-05-08 for Phase N.4 shipping (`WbMeshAdapter` + `WbDrawDispatcher` + `ACDREAM_USE_WB_FOUNDATION` default-on) + N.5 rebranded to "Modern rendering path" (bindless + multi-draw indirect on top of N.4's foundation). **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -59,6 +59,7 @@ | C.1 | PES particle system + sky-pass refinements — retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive — pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` → alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 → 1331 tests. | Live ✓ | | N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live ✓ | | N.3 | WorldBuilder-backed texture decode — `SurfaceDecoder` delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8(+Additive) to `TextureHelpers.Fill*`; `isAdditive` threaded through (terrain alpha → `FillA8Additive`, non-additive entity surfaces → `FillA8`). R5G6B5 + A4R4G4B4 newly handled (previously magenta). X8R8G8B8, DXT1/3/5, SolidColor remain ours (no WB equivalent). 9 conformance tests prove byte-identical equivalence per format. | Live ✓ | +| N.4 | Rendering pipeline foundation — adopted WB's `ObjectMeshManager` as the production mesh pipeline behind `ACDREAM_USE_WB_FOUNDATION` (default-on). `WbMeshAdapter` is the single seam (owns `ObjectMeshManager`, drains the staged-upload queue per frame, populates `AcSurfaceMetadataTable` with per-batch translucency / luminosity / fog metadata). `WbDrawDispatcher` is the production draw path: groups all visible (entity, batch) pairs, single-uploads the matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group with `BaseInstance` slicing into the shared instance VBO. `LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge spawn lifecycle to WB ref-counts (atlas tier vs per-instance). Perf wins shipped as part of N.4: per-entity frustum cull, opaque front-to-back sort, palette-hash memoization (compute once per entity, reuse across batches). Visual verification at Holtburg passed: scenery + connected characters with full close-detail geometry (Issue #47 regression resolved). Legacy `InstancedMeshRenderer` retained as `ACDREAM_USE_WB_FOUNDATION=0` escape hatch until N.6. | Live ✓ | Plus polish that doesn't get its own phase number: - FlyCamera default speed lowered + Shift-to-boost @@ -604,36 +605,54 @@ for our deletions/additions; merge upstream `master` periodically. byte-identical equivalence per format** before substitution; updated `SurfaceDecoderTests` to match the new A8 split semantics. Visual verification at Holtburg passed 2026-05-08 — no texture regressions. -- **N.4 — Rendering pipeline foundation.** **Rebranded from "object - meshing" 2026-05-08 after brainstorm.** WB's `ObjectMeshManager` is - not a static helper — it's a 2070-line stateful asset pipeline that - owns GPU resources (VAO/VBO/IBO), an LRU cache + memory budget, - background staging, a shared texture atlas, and a bindless rendering - path. Adopting it wholesale is the foundation that N.5 + N.6 + N.7 - build on. Concretely: (1) integrate `ObjectMeshManager` + - `TextureAtlasManager` as the shared infrastructure; (2) build a - per-instance customization layer that threads `CreaturePalette` / - `GfxObjRemapping` / `HiddenParts` / `TextureChanges` / `SubPalettes` / - `AnimPartChange` through WB's atlas keys; (3) extend WB's - `MeshBatchData` to carry our surface metadata (`Translucency` / - `Luminosity` / `Diffuse` / `SurfOpacity` / `NeedsUvRepeat` / - `DisableFog`) — likely a fork patch on the `acdream` branch; (4) - decide animation cache strategy (per-frame transform via uniform/SSBO - vs. cache invalidation); (5) adapter from our streaming loader's - Setup/Static spawn events to WB's `IncrementRefCount` lifecycle. - **Estimate: 3-4 weeks.** No visible change yet — visual verification = - "world looks identical to before." Foundation enables the next phases. -- **N.5 — Terrain rendering.** Wire WB's `TerrainRenderManager` + - `LandSurfaceManager` + `TerrainGeometryGenerator` onto the foundation - N.4 builds. Closes N.2's deferred terrain math substitution: visual - mesh and physics both switch to WB's `CalculateSplitDirection` + - `GetHeight` + `GetNormal` in lockstep, resolving ISSUE #51. **Estimate: - 2-3 weeks** (was 3-4 — atlas + GPU pipeline already in place from N.4). +- **✓ SHIPPED — N.4 — Rendering pipeline foundation.** Shipped 2026-05-08. + WB's `ObjectMeshManager` is integrated as the production mesh pipeline + behind `ACDREAM_USE_WB_FOUNDATION=1` (default-on). The integration is + three pieces: `WbMeshAdapter` (single seam owning the WB pipeline, + drains the staged-upload queue per frame, populates + `AcSurfaceMetadataTable` for translucency / luminosity / fog), + `WbDrawDispatcher` (production draw path — groups all visible + (entity, batch) pairs, uploads matrices in a single `glBufferData`, + fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group + with `BaseInstance` slicing the shared instance VBO), and the + `LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge that wires our + streaming loader to WB's `IncrementRefCount` / `PrepareMeshDataAsync` + lifecycle (atlas tier vs per-instance customized). + Issue #47 (close-detail mesh) preserved; sky pass structurally + independent of the WB foundation. Perf wins shipped as part of N.4: + per-entity AABB frustum cull, opaque front-to-back sort, palette-hash + memoization. Legacy `InstancedMeshRenderer` retained as flag-off + fallback until N.6 fully retires it. Plan archived at + `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`. +- **N.5 — Modern rendering path.** **Rebranded from "Terrain rendering" + 2026-05-08 after N.4 perf review.** N.4 left two big remaining wins + on the table that pair naturally: (1) bindless textures via + `GL_ARB_bindless_texture` (WB already populates + `ObjectRenderBatch.BindlessTextureHandle`; switch our shader to + consume per-instance handles, eliminate 100% of `glBindTexture` + calls), and (2) `glMultiDrawElementsIndirect` (one GL call per pass + instead of one per group; build a `DrawElementsIndirectCommand` + buffer, fire one indirect draw, the driver pulls everything). Both + require shader changes (same shader, in fact — bindless + indirect + are the same modern path WB uses internally). Together they target a + 2-5× CPU win on draw-heavy scenes (Holtburg courtyard, Foundry, + dense dungeons). Also folds in: persistent-mapped instance VBO + (`glBufferStorage` + `MAP_PERSISTENT_BIT | MAP_COHERENT_BIT` + ring + buffer + sync) and texture pre-warm at landblock load (smooths + streaming-boundary hitches). **Estimate: 2-3 weeks.** +- **N.5b — Terrain rendering on N.5 path.** Wire WB's + `TerrainRenderManager` + `LandSurfaceManager` + `TerrainGeometryGenerator` + onto the modern rendering path. Closes N.2's deferred terrain math + substitution: visual mesh and physics both switch to WB's + `CalculateSplitDirection` + `GetHeight` + `GetNormal` in lockstep, + resolving ISSUE #51. **Estimate: 1-2 weeks** (was 2-3 — modern path + primitives already in place from N.5). - **N.6 — Static objects rendering.** Wire WB's - `StaticObjectRenderManager` onto N.4's foundation; replace our - `StaticMeshRenderer` + `InstancedMeshRenderer`. Mostly draw - orchestration at this point — most of the substance landed in N.4. - **Estimate: 1-2 weeks** (was 2-3). + `StaticObjectRenderManager` onto the modern rendering path; **fully + delete** legacy `StaticMeshRenderer` + `InstancedMeshRenderer` (they + remain as `ACDREAM_USE_WB_FOUNDATION=0` escape hatches through N.5). + Mostly draw orchestration at this point — most of the substance + landed in N.4 + N.5. **Estimate: 1-2 weeks** (was 2-3). - **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's `EnvCellRenderManager` + `PortalRenderManager` on top of N.4's foundation. **Estimate: 1-2 weeks** (was 2-3 — naturally smaller now diff --git a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md index 7cd6e60..4b4e401 100644 --- a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md +++ b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md @@ -64,7 +64,12 @@ This plan is the **execution source of truth** for N.4. It is updated as tasks l - If a downstream task changes shape because of an earlier task's outcome, append the changes to the downstream task in-place rather than scattering deltas. - Final commit for the phase updates this header note from "Living document — work in progress" to "Final state at — phase shipped (merge ``)." -Status: **Living document — work in progress, started 2026-05-08.** +Status: **Final state at 2026-05-08 — phase shipped.** All tasks +complete; `ACDREAM_USE_WB_FOUNDATION` flipped default-on. Visual +verification at Holtburg passed. Three bugs surfaced + resolved during +Task 26 are documented as Adjustments 7-9 below and as gotchas in +CLAUDE.md. Followup work moves to N.5 (modern rendering path: bindless ++ multi-draw indirect). **Progress (2026-05-08):** Weeks 1 + 2 + 3 ✅ COMPLETE. WB pipeline running flag-on (constructed + ref-counted + per-frame Tick draining its queues). Per-instance tier wired (`EntitySpawnAdapter` routes server-spawned entities through existing `TextureCache.GetOrUploadWithPaletteOverride` path; per-entity `AnimatedEntityState` accumulates AnimPartChange + HiddenParts data, ready for the dispatcher). Five architectural adjustments documented: 1 (DefaultDatReaderWriter discovery), 2 (renderer is tier-blind), 3 (FPS regression = dual-pipeline cost; resolves at Task 22), 4 (WorldEntity missing HiddenPartsMask + AnimPartChanges fields, plumbing deferred), 5 (Task 20 is structural — same function called both paths). Build green, 947 tests pass, 8 pre-existing failures only. @@ -93,11 +98,14 @@ Status: **Living document — work in progress, started 2026-05-08.** | 20 — Per-instance decode conformance | ✅ structural (Adj. 5) | (no test file) | | 21 — Week 3 wrap-up | ✅ | (this commit) | | 22+23 — WbDrawDispatcher + side-table population | ✅ | `01cff41` | +| 22+23 fixup — load triggers + SurfaceId source | ✅ | `943652d` | +| 22+23 perf — FirstIndex/BaseVertex + #47 + grouped instanced | ✅ | `7b41efc` | +| 22+23 perf 1-4 — drop dead lookup, sort, cull, hash memo | ✅ | `573526d` | | 24 — Sky-pass preservation check | ✅ structural (independent) | `5df9135` | | 25 — Component micro-tests round-out | ✅ all spec tests covered | — | -| 26 — Visual verification + flag default-on | pending | — | -| 27 — Delete legacy code paths | pending | — | -| 28 — Update memory + ISSUES + finalize plan | pending | — | +| 26 — Visual verification + flag default-on | ✅ | (this commit) | +| 27 — Delete legacy code paths | ⚠️ deferred to N.6 (legacy retained as flag-off escape hatch) | — | +| 28 — Update memory + ISSUES + finalize plan | ✅ | (this commit) | --- @@ -1016,6 +1024,52 @@ plumbing decision to Task 22. Two options: - GameWindow's CreateObject handler builds the `PartOverride[]` from the server-sent `AnimPartChanges` list. +### Adjustment 7 (2026-05-08, Task 26 visual verification): IncrementRefCount doesn't trigger mesh load + +**Discovered when** Task 26's first launch showed only terrain — zero entities visible. Diagnostic counters (added the same launch via `ACDREAM_WB_DIAG=1`) showed `entitiesSeen=14M, entitiesDrawn=14M, drawsIssued=0` — every entity was visited but no draws were issued because `TryGetRenderData` returned null for everything. + +**Root cause.** WB's `ObjectMeshManager.IncrementRefCount(id)` only bumps a usage counter — it does NOT trigger mesh loading. Loading is fired separately by `PrepareMeshDataAsync(id, isSetup)`, which dispatches to a background worker pool; the result auto-enqueues to `_stagedMeshData` (line 510 of `ObjectMeshManager.cs`) which our existing `WbMeshAdapter.Tick()` already drains. + +The N.4 plan assumed `IncrementRefCount` was lifecycle-aware (it isn't). `LandblockSpawnAdapter` and the original `EntitySpawnAdapter` both called `IncrementRefCount` and stopped — meshes never loaded. + +**Fix** (commit `943652d`): +- `WbMeshAdapter.IncrementRefCount` now calls `_meshManager.PrepareMeshDataAsync(id, isSetup: false)` on first registration. `isSetup: false` is correct because acdream's MeshRefs already carry expanded per-part GfxObj ids (0x01XXXXXX) — WB's Setup-expansion path is unused. +- `EntitySpawnAdapter` gained an optional `IWbMeshAdapter` constructor parameter. Per-instance entities (server-spawned characters / NPCs) had been entirely skipped by `LandblockSpawnAdapter` (which filters `ServerGuid != 0`); their GfxObjs now get registered + loaded at `OnCreate` and decremented at `OnRemove`. Includes both `MeshRefs.GfxObjId` AND `PartOverrides.GfxObjId` so weapon/clothing/helmet swaps load too. + +**Lesson preserved.** Future cross-session work touching WB: **`IncrementRefCount` is not lifecycle-aware. Call `PrepareMeshDataAsync` to trigger loads.** Documented in CLAUDE.md "WB integration cribs" section. + +### Adjustment 8 (2026-05-08, Task 26 visual verification): SurfaceId lives in batch.Key.SurfaceId + +**Discovered when** the second Task 26 launch showed `drawsIssued=4.8M/5s` (draws ARE happening) but ZERO entities visible. Inspection of `ResolveTexture` showed it was returning early because `batch.SurfaceId == 0` for every batch. + +**Root cause.** WB's `ObjectMeshManager.UploadGfxObjMeshData` (line 1746 of `ObjectMeshManager.cs`) constructs `ObjectRenderBatch` and sets `Key = batch.Key` (a `TextureAtlasManager.TextureKey` struct that contains a `SurfaceId` field) but does NOT populate the top-level `ObjectRenderBatch.SurfaceId` property. That property exists on the type but stays at its default 0. + +**Fix** (commit `943652d`): `WbDrawDispatcher.ResolveTexture` reads `batch.Key.SurfaceId` instead of `batch.SurfaceId`. Also handles the dummy `0xFFFFFFFF` case used by WB's environment edge wireframes. + +**Lesson preserved.** **`ObjectRenderBatch.SurfaceId` is not populated by WB. Read `batch.Key.SurfaceId`.** Documented in CLAUDE.md. + +### Adjustment 9 (2026-05-08, Task 26 visual verification): Modern rendering uses one global VAO/VBO/IBO + +**Discovered when** the third Task 26 launch finally showed real draws — but as "exploded" character body parts scattered around the world with no scenery. Visual was completely broken even though the GL pipeline was clearly issuing draws and binding textures correctly. + +**Root cause.** WB's `ObjectMeshManager` has two rendering paths controlled by `_useModernRendering = HasOpenGL43 && HasBindless`. On any modern GPU (which is everything we target), modern is true and ALL meshes share a single `GlobalMeshBuffer` — one VAO, one VBO, one IBO. Each batch's `IBO` field points to that ONE global IBO; the batch's actual slice is identified by `FirstIndex` (offset into IBO, in indices) and `BaseVertex` (offset into VBO, in vertices). The dispatcher was issuing `glDrawElementsInstanced` with `indices=0` and no base vertex — so every entity drew the same first triangle of the global mesh starting at offset 0. That produced exactly the "exploded parts at scattered positions" symptom. + +**Fix** (commit `7b41efc`): switch to `glDrawElementsInstancedBaseVertexBaseInstance`, pass `(void*)(batch.FirstIndex * sizeof(ushort))` as the indices argument, pass `(int)batch.BaseVertex` as base vertex. The grouped-instanced refactor in the same commit additionally uses `BaseInstance` to slice into the shared instance VBO per group. + +**Bonus discovery:** because all meshes share one VAO under modern rendering, the dispatcher only needs to bind the VAO ONCE per frame (not per draw). Every draw goes to the same VAO. Significant CPU savings. + +**Lesson preserved.** **WB's modern rendering path packs everything into one global VAO/VBO/IBO. Honor `FirstIndex` and `BaseVertex`.** Documented in CLAUDE.md. + +### Adjustment 10 (2026-05-08, Task 26 visual verification): AnimatedEntityState overrides clobber Issue #47 close-detail mesh + +**Discovered when** Task 26's fourth launch showed scenery + connected characters — but characters were "bulky and missing detail" compared to the legacy renderer. Recognized as a re-occurrence of Issue #47 (resolved 2026-05-06 via `GfxObjDegradeResolver`). + +**Root cause.** Adjustment 6 stored AnimPartChanges on `WorldEntity.PartOverrides` using the raw `NewModelId` from the network packet — without applying `GfxObjDegradeResolver`. GameWindow's spawn path correctly resolves base GfxObjs (e.g., upper arm `0x01000055`, 14 verts/17 polys) to their close-detail equivalents (`0x01001795`, 32 verts/60 polys) and bakes the result into `MeshRefs`. But `WbDrawDispatcher` then called `animState.ResolvePartGfxObj(partIdx, meshRefGfxObjId)` which returned the raw (low-detail) override from `PartOverrides`, undoing the degrade. + +**Fix** (commit `7b41efc`): the dispatcher trusts `MeshRefs` as the source of truth and does NOT re-apply `animState.ResolvePartGfxObj` at draw time. `AnimatedEntityState` overrides become relevant only for hot-swap appearance updates (0xF625 `ObjDescEvent`) which today rebuild MeshRefs anyway. `IsPartHidden` similarly skipped — `HiddenPartsMask` is never populated by spawn code (legacy renderer also doesn't check it). + +**Lesson preserved.** **`MeshRefs` is the source of truth at draw time** — GameWindow's spawn path bakes overrides + degrades into it. Don't re-apply overrides downstream. + ### Task 6 (original — kept for history) **Files:** diff --git a/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs b/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs index 421dac4..c3fd006 100644 --- a/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs +++ b/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs @@ -7,18 +7,25 @@ namespace AcDream.App.Rendering.Wb; /// free at hot-path cadence). /// /// -/// Set ACDREAM_USE_WB_FOUNDATION=1 to route static-scenery + atlas -/// content through WB's ObjectMeshManager; per-instance customized -/// content (server CreateObject entities) takes the existing -/// path either -/// way. Flag becomes default-on at end of Phase N.4 after visual -/// verification. +/// Default-on as of Phase N.4 ship (2026-05-08). The WB foundation +/// (WbMeshAdapter + WbDrawDispatcher) is the production +/// rendering path. Set ACDREAM_USE_WB_FOUNDATION=0 to fall back +/// to the legacy InstancedMeshRenderer path — kept as an escape +/// hatch until N.6 fully replaces it. +/// +/// +/// +/// Per-instance customized content (server CreateObject entities +/// with palette / texture overrides) routes through +/// regardless +/// of the flag — the flag controls which DRAW path consumes those +/// textures. /// /// public static class WbFoundationFlag { private static bool _isEnabled = - System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") == "1"; + System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") != "0"; public static bool IsEnabled => _isEnabled; From dd5ca3d2b2f5636e332fe3878712d0ae8f77b8e5 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 8 May 2026 18:05:36 +0200 Subject: [PATCH 9/9] docs(N.5): cold-start handoff for next session Detailed briefing for the next agent picking up Phase N.5 (Modern Rendering Path: bindless textures + glMultiDrawElementsIndirect on N.4's foundation). Covers: - Where N.4 left things (commits, what works, gotchas inherited) - The two-feature pairing (why bindless + indirect together) - Files to read first (WB shaders, our dispatcher, CLAUDE.md cribs) - 8 brainstorm questions to resolve before spec - Spec + plan structure (matching N.4's pattern) - Acceptance criteria - Things to explicitly NOT do Sized for a fresh session to pick up cold without spelunking through months of session history. Co-Authored-By: Claude Opus 4.6 --- docs/research/2026-05-08-phase-n5-handoff.md | 495 +++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 docs/research/2026-05-08-phase-n5-handoff.md diff --git a/docs/research/2026-05-08-phase-n5-handoff.md b/docs/research/2026-05-08-phase-n5-handoff.md new file mode 100644 index 0000000..1c4d7be --- /dev/null +++ b/docs/research/2026-05-08-phase-n5-handoff.md @@ -0,0 +1,495 @@ +# Phase N.5 — Modern Rendering Path — Cold-Start Handoff + +**Created:** 2026-05-08, immediately after N.4 ship. +**Audience:** the next agent picking up rendering perf work. +**Purpose:** give you everything you need to start N.5 cold, without +spelunking through five months of session history. + +--- + +## TL;DR + +N.4 just shipped: WB's `ObjectMeshManager` is now acdream's production +mesh pipeline, and `WbDrawDispatcher` is the production draw path. It +works (Holtburg renders correctly, FPS substantially improved over the +naïve dual-pipeline state we hit during week 4 verification) but it's +still doing per-group state changes (`glBindTexture`, `glBindBuffer` +for the IBO, `glDrawElementsInstancedBaseVertexBaseInstance` per group) +and a fresh `glBufferData` upload per frame. + +**N.5's job: lift the dispatcher onto WB's modern rendering primitives +that we're already paying GPU-feature-detection cost for.** Two big +wins, paired: + +1. **Bindless textures** (`GL_ARB_bindless_texture`) — WB already + populates `ObjectRenderBatch.BindlessTextureHandle`. Switch our + shader to read texture handles from a per-instance attribute + (`uvec2` → `sampler2D` via the bindless extension). Eliminates + 100% of `glBindTexture` calls. +2. **Multi-draw indirect** (`glMultiDrawElementsIndirect`) — build a + buffer of `DrawElementsIndirectCommand` structs (one per group), + upload once, fire ONE `glMultiDrawElementsIndirect` call per pass. + The driver pulls everything from the indirect buffer. + +Together they target a 2-5× CPU win on draw-heavy scenes (Holtburg +courtyard, Foundry, dense dungeons). They're packaged together because +both are "modern path" extensions we already gate on, both require +the same shader rewrite, and they pair naturally — multi-draw indirect +is a no-op CPU-win without bindless because per-group `glBindTexture` +calls would still serialize. + +**Estimated scope: 2-3 weeks.** Plan + spec to be written by the +brainstorm + spec steps below. + +--- + +## Where N.4 left things + +### Branch state + +If this handoff is being read on `main` after merging the N.4 worktree: +N.4 commits land at the head of main. The relevant final commits: + +- `c445364` — N.4 SHIP (flag default-on, plan final, roadmap, memory) +- `573526d` — perf pass 1-4 (drop dead lookup, sort, cull, hash memo) +- `7b41efc` — FirstIndex/BaseVertex + Issue #47 + grouped instanced +- `943652d` — load triggers + `batch.Key.SurfaceId` source +- `01cff41` — Tasks 22+23 (`WbDrawDispatcher` + side-table) + +If the worktree branch (`claude/tender-mcclintock-a16839`) hasn't been +merged yet, that's where the work is. Verify with `git log --oneline`. + +### What works in N.4 + +- `ACDREAM_USE_WB_FOUNDATION=1` is default-on. WB's `ObjectMeshManager` + loads, decodes, and uploads every entity mesh. Our existing + `TextureCache` decodes textures (palette-aware, per-instance overrides + via `GetOrUploadWithPaletteOverride`). +- `WbDrawDispatcher.Draw`: + - Walks visible entities (per-landblock AABB cull + per-entity AABB + cull + portal visibility) + - Buckets every (entity × meshRef × batch) tuple by + `GroupKey(Ibo, FirstIndex, BaseVertex, IndexCount, TextureHandle, Translucency)` + - Single `glBufferData` upload of all matrices for the frame + - Per group: `glActiveTexture(0) + glBindTexture(2D, handle) + glBindBuffer(EBO, ibo) + glDrawElementsInstancedBaseVertexBaseInstance(..., FirstInstance)` + - Two passes: opaque (front-to-back sorted) + translucent +- 940/948 tests pass (8 pre-existing failures unrelated to rendering). +- Visual verification at Holtburg passed: scenery + characters render + correctly with full close-detail geometry (Issue #47 preserved). + +### What N.5 inherits + +These are levers N.5 will pull on: + +- **WB's modern rendering is already active.** `OpenGLGraphicsDevice` + detected GL 4.3 + bindless on first run; WB's `_useModernRendering` + is true; every mesh lives in WB's single `GlobalMeshBuffer` (one VAO, + one VBO, one IBO). +- **Bindless handles are already populated.** `ObjectRenderBatch.BindlessTextureHandle` + is non-zero for batches WB owns the texture for. (See gotcha #2 + below for entities with palette overrides — those use acdream's + `TextureCache` which doesn't expose bindless handles yet.) +- **The instance VBO is acdream-owned** (`WbDrawDispatcher._instanceVbo`) + with locations 3-6 patched onto WB's global VAO. Stride 64 bytes + (one mat4). N.5 expands this to (mat4 + uvec2 handle) = 80 bytes. + +### Three load-bearing WB API gotchas N.4 surfaced + +These bit us hard during Task 26 visual verification. Documented in +CLAUDE.md "WB integration cribs" + plan adjustments 7-9 + +`memory/project_phase_n4_state.md`. Re-stating here because they +reshape the design space: + +1. **`ObjectMeshManager.IncrementRefCount(id)` is NOT lifecycle-aware.** + It only bumps a usage counter. Mesh loading is fired separately + via `PrepareMeshDataAsync(id, isSetup)`. The result auto-enqueues + to `_stagedMeshData` (line 510 of `ObjectMeshManager.cs`); our + existing `WbMeshAdapter.Tick()` drains it. `WbMeshAdapter.IncrementRefCount` + already calls `PrepareMeshDataAsync`. **N.5 doesn't need to change + this — just don't break it.** + +2. **`ObjectRenderBatch.SurfaceId` is unset.** WB constructs batches + with `Key = batch.Key` (a `TextureAtlasManager.TextureKey` struct + that has a `SurfaceId` field) but never populates the top-level + `SurfaceId` property. Read `batch.Key.SurfaceId`. **N.5 keeps this + pattern.** + +3. **WB's modern rendering packs every mesh into ONE global + VAO/VBO/IBO.** Each batch's `IBO` field points to the global IBO; + the batch's actual slice is identified by `FirstIndex` (offset into + IBO, in *indices*) and `BaseVertex` (offset into VBO, in *vertices*). + N.4's draw uses `glDrawElementsInstancedBaseVertexBaseInstance` + with those offsets. **N.5's `DrawElementsIndirectCommand` per-group + record will carry `firstIndex` + `baseVertex` for the same reason.** + +--- + +## What N.5 is — technical detail + +### The two-feature pairing + +**Bindless textures** (`GL_ARB_bindless_texture`): +- Each texture handle is a 64-bit integer (`uvec2` in GLSL). +- Shader declares `layout(bindless_sampler) uniform sampler2D ...` or + receives the handle as a per-vertex-attribute `uvec2`. +- No `glBindTexture` needed at draw time — the handle IS the binding. +- Handle generation: `glGetTextureHandleARB(textureId)` followed by + `glMakeTextureHandleResidentARB(handle)` (the texture must be + resident on the GPU; non-resident handles produce GPU faults). + +**Multi-draw indirect** (`glMultiDrawElementsIndirect`): +- Indirect command struct layout (must match `DrawElementsIndirectCommand`): + ```c + struct { + uint count; // index count for this draw + uint instanceCount; // number of instances + uint firstIndex; // offset into IBO, in indices + int baseVertex; // vertex offset into VBO + uint baseInstance; // first instance ID (offsets per-instance attribs) + }; + ``` +- Build a buffer of N of these structs (one per group), upload once, + fire one GL call: `glMultiDrawElementsIndirect(mode, indexType, ptr, drawcount, stride)`. +- The driver issues all N draws in one shot. Effectively zero CPU + overhead per draw beyond uploading the indirect buffer. + +**Why pair them.** Multi-draw indirect doesn't let you change uniform +state between draws. So if textures are bound via `glBindTexture` per +group, you'd still need N CPU-side setup steps before each indirect +call — defeating the purpose. Bindless removes that constraint by +encoding the texture handle as per-instance data the shader reads +directly. With both, the modern render loop becomes: + +``` +1. Upload instance buffer (mat4 + uvec2 handle, per-instance) — once per frame +2. Upload indirect command buffer (one DEIC per group) — once per frame +3. glBindVertexArray(globalVAO) — once +4. glMultiDrawElementsIndirect(...) — ONCE per pass +``` + +That's it. No per-group state changes. + +### Instance attribute layout + +Currently (N.4): location 3-6 = mat4 model matrix (16 floats = 64 bytes). + +N.5 (proposed): location 3-6 = mat4 + location 7 = uvec2 bindless +handle = 16 floats + 2 uints = 72 bytes (16-aligned to 80 bytes per +WB's `InstanceData` precedent). + +Or use std140-aligned struct: +```c +struct InstanceData { + mat4 transform; // locations 3-6 + uvec2 textureHandle; // location 7 + uvec2 _pad; // padding to 80 +}; +``` + +Brainstorm should decide if we copy WB's `InstanceData` struct (Pack=16, +80 bytes including CellId/Flags fields we don't use) or define our own +minimal version. The 80-byte stride matches WB's so global VAO state +configured by WB stays compatible if the legacy WB draw path ever runs. + +### Per-instance entity texture handles + +Here's the wrinkle. N.4 uses `WbDrawDispatcher.ResolveTexture` to map +each (entity, batch) to a GL texture handle: + +- Tree (no overrides): `_textures.GetOrUpload(surfaceId)` → 2D texture handle +- NPC with palette override: `_textures.GetOrUploadWithPaletteOverride(...)` → composite-cached 2D texture handle +- Anything with surface override: `_textures.GetOrUploadWithOrigTextureOverride(...)` → composite-cached 2D texture handle + +Those are all `GLuint` 32-bit GL texture *names*, not bindless handles. +**N.5 needs `TextureCache` to publish bindless handles for everything +it owns, not just WB-owned textures.** + +Implementation sketch: +- `TextureCache` adds a parallel cache keyed identically but storing + 64-bit bindless handles. On first request, generate via + `glGetTextureHandleARB(textureId)` + make resident. +- New API: `GetBindlessHandle(uint surfaceId, ...)` returns the handle. +- Or: change every `GetOrUpload*` method to return both the GL name + and the bindless handle (or just the handle; let GL name fall out + if anyone needs it later). + +WB's `ObjectRenderBatch.BindlessTextureHandle` covers the atlas-tier +case. For per-instance entities, we use `TextureCache`'s handle. + +### The new shader + +Reuse WB's `StaticObjectModern.vert` / `StaticObjectModern.frag` as a +template. Read those files cold. They already do bindless + the +instance-data layout. Adapt to acdream's `mesh_instanced.vert/frag` +conventions: + +- Keep the `uViewProjection` uniform, lighting UBO at binding=1, fog + uniforms. +- Add `#version 430 core` + `#extension GL_ARB_bindless_texture : require`. +- Replace `uniform sampler2D uDiffuse` with a `uvec2` per-vertex + attribute (location 7) → reconstruct sampler in vertex shader OR + pass through to fragment via flat varying. +- Drop `uTranslucencyKind` uniform, OR keep it (still set per-pass — + multi-draw indirect doesn't break uniforms; only state that varies + per-draw is the constraint). + +### Translucency + +Multi-draw indirect can't change blend state mid-draw. Solution: +**still use two passes** (opaque + translucent), but within translucent +keep the per-blendfunc sub-passes (additive, alpha-blend, inv-alpha). +Three sub-passes within translucent. Each sub-pass = one +`glMultiDrawElementsIndirect` over its filtered groups. + +Or: if perf allows, fold all four blend modes into the shader via +per-instance blendmode int, sort all translucent groups by blendmode +in the indirect buffer, switch blend state at sub-pass boundaries. +Brainstorm decides the cleanest pattern. + +--- + +## Files to read before brainstorming + +In rough order: + +1. **N.4 plan + spec** — `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md` + (status: Final). Adjustments 7-10 capture the gotchas. Spec at + `docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md`. + +2. **N.4 dispatcher source** — `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`. + This is what you're modifying. Read end-to-end. + +3. **WB's modern rendering shaders** — `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/StaticObjectModern.vert` + + `StaticObjectModern.frag`. The template you're adapting from. + +4. **WB's `ObjectMeshManager.UploadGfxObjMeshData`** — lines ~1654-1780 + of `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs`. + Shows how WB sets up the modern path's VBO/IBO/VAO. Especially note + how it patches in instance attribute slots (locations 3-6) on the + global VAO and configures location 7+ for bindless handles. + +5. **WB's `ObjectRenderBatch`** — same file, lines ~166-184. Note the + `BindlessTextureHandle` field — already populated when `_useModernRendering` + is on. + +6. **Our `TextureCache`** — `src/AcDream.App/Rendering/TextureCache.cs`. + Three composite caches: by surface id, by surface+origTex, by + surface+origTex+palette. N.5 adds parallel bindless-handle caches. + +7. **CLAUDE.md "WB integration cribs"** section. Lines ~28-80. The + three gotchas + the integration architecture in plain language. + +8. **Memory: `project_phase_n4_state.md`** — same content from a + different angle. Reading both helps lock in the gotchas. + +--- + +## Brainstorm questions + +These are the questions to resolve in the brainstorm step. Don't +prejudge them — bring them to the user with options + recommendation: + +1. **Instance attribute layout.** Match WB's `InstanceData` struct + (80 bytes including CellId/Flags fields we don't use) for global + VAO compatibility, or define a minimal acdream-specific version + (mat4 + handle = ~72 bytes padded to 80)? + +2. **Bindless handle generation strategy.** + - At texture upload time? (Eager — every texture that lands in + `TextureCache` gets a handle. Memory cost ~per-texture state.) + - On first draw lookup? (Lazy — cache fills as scene exercises + content. Possible first-use stall.) + - At spawn time via the spawn adapter? (Tied to lifecycle. Cleanest + but requires touching the spawn path.) + +3. **Translucent pass structure.** Three sub-indirect-draws (one per + blend mode) or a single sorted indirect buffer with per-instance + blend mode + state-flip at sub-pass boundaries? Or: just iterate + per-group like N.4 for translucent only (translucent groups are a + small fraction of total)? + +4. **Persistent-mapped indirect + instance buffers.** Use + `GL_ARB_buffer_storage` + `MAP_PERSISTENT_BIT | MAP_COHERENT_BIT`? + Triple-buffered ring + sync object? Or stick with `glBufferData` + (still one upload per frame, just larger)? Persistent mapping is + ~2-5% per-frame win in our context but adds buffer-management + complexity. + +5. **Shader unification.** Keep `mesh_instanced` for legacy + add + `mesh_indirect` for modern, or replace `mesh_instanced` entirely? + Replacement requires the legacy `InstancedMeshRenderer` (escape + hatch under `ACDREAM_USE_WB_FOUNDATION=0`) to also use the new + shader, which... probably doesn't matter if we delete legacy in + N.6 anyway. Brainstorm. + +6. **Conformance test strategy.** N.4 used visual verification at + Holtburg as the gate. N.5's gate is "no visual regression vs N.4 + AND measurable CPU win." How do we measure CPU? `[WB-DIAG]` + counters give draw count + group count; we need frame-time + counters too. Add to the dispatcher? Use a profiler? + +7. **Per-instance entity bindless.** `TextureCache.GetOrUpload*` + returns a GL name. The dispatcher (or `TextureCache` itself) needs + to convert that to a bindless handle. Design questions: + - Where does the conversion happen? + - When is the texture made resident? (Residency is global state; + too many resident textures hits driver limits.) + - What about palette/surface overrides — same caching key as the + name, just a parallel handle dictionary? + +8. **Escape hatch.** N.4 keeps `ACDREAM_USE_WB_FOUNDATION=0` as a + fallback. N.5 needs to decide: does the new shader REPLACE the + N.4 dispatcher's draw path (so flag-on means N.5 modern path, + flag-off means legacy `InstancedMeshRenderer`)? Or do we add a + separate flag (`ACDREAM_USE_MODERN_DRAW`) so users can toggle + N.4 vs N.5 vs legacy independently? Three-way flag is more + complex but useful for A/B during rollout. + +--- + +## Spec structure + +After the brainstorm, the spec doc covers: + +1. **Architecture diagram** — how `WbDrawDispatcher` changes shape. + Where the indirect buffer lives. Where bindless handles flow from. +2. **Instance data layout** — exact struct, byte offsets, GL attribute + pointer setup. +3. **TextureCache changes** — new methods, new cache, residency + policy. +4. **Shader files** — name(s), version, extensions, in/out variables. +5. **Conformance tests** — what to write, what coverage to claim. +6. **Acceptance criteria** — visual identity to N.4 + measured CPU + delta. +7. **Risks** — driver bugs in bindless / indirect, residency limits, + shader compile issues on weird GPUs, the legacy escape hatch + breaking. + +Spec lives at: `docs/superpowers/specs/2026-05-XX-phase-n5-modern-rendering-design.md`. + +## Plan structure + +After the spec, the plan doc lays out the week-by-week task list. +Match N.4's plan structure (living document, task checkboxes, commit +SHAs appended, adjustments documented inline). Plan lives at: +`docs/superpowers/plans/2026-05-XX-phase-n5-modern-rendering.md`. + +Suggested initial breakdown (brainstorm + spec will refine): + +- **Week 1** — Plumbing: bindless handle generation in `TextureCache`, + shader rewrite (compile + bind), instance-attrib layout updated to + mat4+handle. Dispatcher still uses per-group draws but reads + textures bindless. Validate: visual identical to N.4. +- **Week 2** — Indirect: build `DrawElementsIndirectCommand` buffer + per frame, switch to `glMultiDrawElementsIndirect`. Three-pass + translucent (or whatever brainstorm decides). Validate: visual + identical, draw-call count drops to 2-4 per frame. +- **Week 3** — Polish + ship: persistent-mapped buffers if brainstorm + voted yes, profiler/counters, visual verification, flag flip, plan + finalization. + +--- + +## Acceptance criteria for the whole phase + +- Visual output identical to N.4 (no character regressions, no + scenery missing, no z-fighting introduced) +- `[WB-DIAG]` shows `drawsIssued` ≤ ~5 per frame (down from N.4's + few hundred) +- Frame time measurably lower in dense scenes (specify what scenes + to test in the spec — probably Holtburg courtyard + Foundry + interior) +- All tests still green (940/948 + any new conformance tests) +- `ACDREAM_USE_WB_FOUNDATION=0` escape hatch still works +- Plan doc finalized, roadmap updated, memory captured if N.5 + surfaces durable lessons (it almost certainly will — bindless + + indirect both have well-known driver gotchas) + +--- + +## What you'll be doing in the first 30 minutes + +1. Read this handoff in full. +2. Read CLAUDE.md "WB integration cribs" section. +3. Read `WbDrawDispatcher.cs` end-to-end. +4. Skim WB's `StaticObjectModern.vert/frag` + `ObjectMeshManager.UploadGfxObjMeshData` + to ground the reference. +5. Verify build is green: `dotnet build`. +6. Verify N.4 ship is intact: `dotnet test --filter "FullyQualifiedName~Wb|MatrixComposition"` + should produce 60 passing tests, 0 failures. +7. Invoke the `superpowers:brainstorming` skill with the user. Walk + through the 8 brainstorm questions above. Capture decisions in a + spec. +8. Write the spec at the path above. +9. Write the plan at the path above. +10. Begin Week 1 implementation per the plan. + +Don't skip the brainstorm. Multi-draw indirect + bindless have several +real driver-compatibility / API-shape decisions that need user input, +not "the agent makes a call and goes." This phase is structurally the +same shape as N.4 — brainstorm → spec → plan → tasks-with-checkboxes → +commits-update-checkboxes → final SHIP commit. + +--- + +## Things to NOT do + +- **Don't delete the legacy `InstancedMeshRenderer`.** It's the N.4 + escape hatch. N.6 retires it after N.5 is proven default-on. +- **Don't fork WB.** N.4 deliberately avoided fork patches by using + the side-table pattern (`AcSurfaceMetadataTable`). Stay on that + path. If you need data WB doesn't expose, add a side-table or + decode it yourself from dats. +- **Don't try to make per-instance entities use WB's `TextureAtlasManager`.** + That's N.6+ territory. acdream's `TextureCache` owns palette/surface + overrides because WB's atlas is keyed by `(surfaceId, paletteId, + stippling, isSolid)` and our overrides don't fit cleanly. Bindless + handles let us escape that mismatch — handles for both atlas-tier + AND per-instance-tier textures, no atlas adoption needed. +- **Don't skip visual verification.** N.4 surfaced three bugs at + visual verification that no test caught. Don't trust "build green + + tests pass" — exercise the rendering path with the local ACE server. +- **Don't extend the phase scope.** N.5 is bindless + indirect on + the existing rendering pipeline. Texture array atlas, GPU-side + culling, terrain wiring — all of those are subsequent phases. If + the brainstorm tries to expand, push back. + +--- + +## Reference: the N.4 dispatcher flow you're modifying + +``` +Draw(camera, landblockEntries, frustum, ...) { + // Phase 1: walk entities, build groups + foreach (entity, meshRef, batch) { + cull, classify into _groups[GroupKey] + } + + // Phase 2: lay matrices contiguously + // Phase 3: glBufferData(_instanceVbo, allMatrices) + // Phase 4: bind global VAO once + // Phase 5: opaque pass (sorted) + foreach (group in _opaqueDraws) { + glBindTexture(group.handle) + glBindBuffer(EBO, group.ibo) + glDrawElementsInstancedBaseVertexBaseInstance(...) + } + // Phase 6: translucent pass +} +``` + +After N.5, Phases 5 and 6 collapse to: + +``` +glBindBuffer(DRAW_INDIRECT_BUFFER, _opaqueIndirect) +glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_SHORT, 0, opaqueGroups.Count, sizeof(DEIC)) +glBindBuffer(DRAW_INDIRECT_BUFFER, _translucentIndirect) +// 3 sub-calls for translucent or 1 if shader-folded +glMultiDrawElementsIndirect(...) +``` + +That's the destination. Get there cleanly. + +Good luck. Holler at the user if any of the brainstorm questions feel +genuinely ambiguous after reading the references — they care about +this phase landing right and will engage on design questions.