From d16d8cd4e59d2028dd8f2097fb37701443ab715a Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 21 May 2026 16:37:55 +0200 Subject: [PATCH] feat(O-T4): extract ObjectMeshManager + mesh pipeline closure into AcDream.App.Rendering.Wb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase O Task 4: extract the WB mesh pipeline (ObjectMeshManager + 7 support files) from references/WorldBuilder into src/AcDream.App/Rendering/Wb/ and bridge dat I/O through our DatCollection via a thin DatCollectionAdapter. O-D7 adapter path taken: ObjectMeshManager has 26 _dats.X call sites (threshold 20), so a DatCollectionAdapter : IDatReaderWriter is introduced rather than refactoring ObjectMeshManager's internal dat access directly. Files added (verbatim copies, namespace-only changes): - ObjectMeshManager.cs — mesh pipeline hub; IDatReaderWriter field satisfied by adapter - GlobalMeshBuffer.cs — single global VAO/VBO/IBO manager - EdgeLineBuilder.cs — wireframe edge geometry from CellStruct polygons - ModernRenderData.cs — ModernBatchData + LandblockMdiCommand structs - TextureAtlasManager.cs — texture array grouping by (Width, Height, Format) - ParticleBatcher.cs — GPU particle batching; T4 interim uses BaseObjectRenderManager static fields from Chorizite.OpenGLSDLBackend.Lib (stays until T7) - ParticleEmitterRenderer.cs — per-emitter particle lifecycle + rendering - ActiveParticleEmitter.cs — wrapper holding renderer + part index + local offset - DatCollectionAdapter.cs — NEW: bridges DatCollection → IDatReaderWriter; implements ResolveId() via DatDatabase.TypeFromId + Tree.TryGetFile in HighRes→Portal→Language→Cell order matching DefaultDatReaderWriter; DatDatabaseWrapper wraps DatDatabase as IDatDatabase WbMeshAdapter.cs changes (T4 Step 6): - _graphicsDevice switched from Chorizite.OpenGLSDLBackend.OpenGLGraphicsDevice to extracted AcDream.App.Rendering.Wb.OpenGLGraphicsDevice - ParticleBatcher = new ParticleBatcher(_graphicsDevice) restored (T3 had null! placeholder) - ObjectMeshManager now constructed with new DatCollectionAdapter(dats) instead of _wbDats - _wbDats field + its construction + disposal + [indoor-upload] NULL_RESULT diagnostic block left intact — T7 cleanup removes these once WorldBuilder project ref is dropped EmbeddedResourceReader.cs: replaced assembly manifest lookup (wrong prefix for our assembly) with disk-based lookup mapping "Shaders.Particle.vert" → Rendering/Shaders/wb_particle.vert; consistent with all other acdream shaders. wb_particle.vert / wb_particle.frag: WB particle shaders copied verbatim with wb_ prefix to distinguish from acdream's own particle.vert. OpenGLGraphicsDevice.cs: ParticleBatcher property type updated to extracted ParticleBatcher; setter changed from private to internal so WbMeshAdapter (same assembly) can assign post-ctor. Build: green (0 errors, 0 warnings in AcDream.App). Tests: 1147+8 baseline maintained (8 pre-existing failures unchanged). Co-Authored-By: Claude Sonnet 4.6 --- .../Rendering/Shaders/wb_particle.frag | 22 + .../Rendering/Shaders/wb_particle.vert | 52 + .../Rendering/Wb/ActiveParticleEmitter.cs | 37 + .../Rendering/Wb/DatCollectionAdapter.cs | 167 ++ .../Rendering/Wb/EdgeLineBuilder.cs | 134 ++ .../Rendering/Wb/EmbeddedResourceReader.cs | 45 +- .../Rendering/Wb/GlobalMeshBuffer.cs | 127 + .../Rendering/Wb/ModernRenderData.cs | 31 + .../Rendering/Wb/ObjectMeshManager.cs | 2079 +++++++++++++++++ .../Rendering/Wb/OpenGLGraphicsDevice.cs | 2 +- .../Rendering/Wb/ParticleBatcher.cs | 231 ++ .../Rendering/Wb/ParticleEmitterRenderer.cs | 495 ++++ .../Rendering/Wb/TextureAtlasManager.cs | 120 + src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 12 +- 14 files changed, 3535 insertions(+), 19 deletions(-) create mode 100644 src/AcDream.App/Rendering/Shaders/wb_particle.frag create mode 100644 src/AcDream.App/Rendering/Shaders/wb_particle.vert create mode 100644 src/AcDream.App/Rendering/Wb/ActiveParticleEmitter.cs create mode 100644 src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs create mode 100644 src/AcDream.App/Rendering/Wb/EdgeLineBuilder.cs create mode 100644 src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs create mode 100644 src/AcDream.App/Rendering/Wb/ModernRenderData.cs create mode 100644 src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs create mode 100644 src/AcDream.App/Rendering/Wb/ParticleBatcher.cs create mode 100644 src/AcDream.App/Rendering/Wb/ParticleEmitterRenderer.cs create mode 100644 src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs diff --git a/src/AcDream.App/Rendering/Shaders/wb_particle.frag b/src/AcDream.App/Rendering/Shaders/wb_particle.frag new file mode 100644 index 0000000..1f88e28 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/wb_particle.frag @@ -0,0 +1,22 @@ +#version 330 core + +in vec2 TexCoord; +in float Opacity; +in float TextureIndex; + +uniform sampler2DArray uTextureArray; + +out vec4 FragColor; + +void main() { + // Reverting to standard non-premultiplied sampling. + vec4 color = texture(uTextureArray, vec3(TexCoord, TextureIndex)); + + // Standard alpha blending: SrcAlpha, OneMinusSrcAlpha. + color.a *= Opacity; + + // Alpha test to discard fully transparent pixels (standard AC behavior) + if (color.a < 0.005) discard; + + FragColor = color; +} \ No newline at end of file diff --git a/src/AcDream.App/Rendering/Shaders/wb_particle.vert b/src/AcDream.App/Rendering/Shaders/wb_particle.vert new file mode 100644 index 0000000..6bdbc08 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/wb_particle.vert @@ -0,0 +1,52 @@ +#version 330 core + +layout (location = 0) in vec3 aPosition; // Basic quad vertex (-0.5 to 0.5) +layout (location = 1) in vec2 aTexCoord; + +// Instance attributes +layout (location = 2) in vec3 iPosition; +layout (location = 3) in vec3 iScaleOpacityActive; // x=Scale, y=Opacity, z=Active +layout (location = 4) in float iTextureIndex; +layout (location = 5) in vec4 iRotation; // Quaternion +layout (location = 6) in vec2 iSize; +layout (location = 7) in float iIsBillboard; + +uniform mat4 uViewProjection; +uniform vec3 uCameraUp; +uniform vec3 uCameraRight; + +out vec2 TexCoord; +out float Opacity; +out float TextureIndex; + +vec3 rotate_vector(vec3 v, vec4 q) { + return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v); +} + +void main() { + TexCoord = aTexCoord; + Opacity = iScaleOpacityActive.y; + TextureIndex = iTextureIndex; + + float scale = iScaleOpacityActive.x; + vec3 worldPos; + + if (iIsBillboard > 0.5) { + // Spherical billboarding - always face camera + vec3 billboardRight = uCameraRight; + vec3 billboardUp = uCameraUp; + + worldPos = iPosition + + billboardRight * aPosition.x * iSize.x * scale + + billboardUp * aPosition.z * iSize.y * scale; + } else { + // Standard 3D rotation using quaternion + vec3 localPos = vec3(aPosition.x * iSize.x * scale, + 0.0, + aPosition.z * iSize.y * scale); + + worldPos = iPosition + rotate_vector(localPos, iRotation); + } + + gl_Position = uViewProjection * vec4(worldPos, 1.0); +} diff --git a/src/AcDream.App/Rendering/Wb/ActiveParticleEmitter.cs b/src/AcDream.App/Rendering/Wb/ActiveParticleEmitter.cs new file mode 100644 index 0000000..3314589 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/ActiveParticleEmitter.cs @@ -0,0 +1,37 @@ +using System.Numerics; +using Chorizite.OpenGLSDLBackend.Lib; +using WorldBuilder.Shared.Models; + +namespace AcDream.App.Rendering.Wb { + public class ActiveParticleEmitter { + public ParticleEmitterRenderer Renderer { get; } + public uint PartIndex { get; } + public Matrix4x4 LocalOffset { get; } + + // Store reference info instead of struct copy + public ObjectLandblock? ParentLandblock { get; set; } + public ObjectId? ParentInstanceId { get; set; } + + public ActiveParticleEmitter(ParticleEmitterRenderer renderer, uint partIndex, Matrix4x4 localOffset, ObjectLandblock? parentLandblock = null, ObjectId? parentInstanceId = null) { + Renderer = renderer; + PartIndex = partIndex; + LocalOffset = localOffset; + ParentLandblock = parentLandblock; + ParentInstanceId = parentInstanceId; + } + + public void Update(float deltaTime, Matrix4x4 parentTransform) { + Renderer.ParentTransform = parentTransform; + Renderer.LocalOffset = LocalOffset; + Renderer.Update(deltaTime); + } + + public void Render(ParticleBatcher batcher) { + Renderer.Render(batcher); + } + + public void Dispose() { + Renderer.Dispose(); + } + } +} diff --git a/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs b/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs new file mode 100644 index 0000000..874fd33 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs @@ -0,0 +1,167 @@ +using DatReaderWriter; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib.IO; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using WorldBuilder.Shared.Services; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Adapts acdream's to WB's interface. +/// +/// O-D7 fallback path: taken because ObjectMeshManager has 26 _dats.X call sites (threshold is 20), +/// making a full refactor to DatCollection larger than spec permits in a single task. +/// This adapter lets ObjectMeshManager stay byte-identical to the WB original while +/// routing all DAT I/O through our single DatCollection. The adapter is dropped in T7 +/// when the WorldBuilder project reference is removed entirely. +/// +internal sealed class DatCollectionAdapter : IDatReaderWriter +{ + private readonly DatCollection _dats; + private readonly DatDatabaseWrapper _portal; + private readonly DatDatabaseWrapper _cell; + private readonly DatDatabaseWrapper _highRes; + private readonly DatDatabaseWrapper _language; + private readonly ReadOnlyDictionary _cellRegions; + + public DatCollectionAdapter(DatCollection dats) + { + ArgumentNullException.ThrowIfNull(dats); + _dats = dats; + _portal = new DatDatabaseWrapper(dats.Portal); + _cell = new DatDatabaseWrapper(dats.Cell); + _highRes = new DatDatabaseWrapper(dats.HighRes); + _language = new DatDatabaseWrapper(dats.Local); + + // DatCollection has a single Cell, not multiple cell regions. + // Expose it as region 0 to satisfy callers that iterate CellRegions. + var regions = new Dictionary { [0u] = _cell }; + _cellRegions = new ReadOnlyDictionary(regions); + } + + /// Source directory of the underlying DatCollection. + public string SourceDirectory => _dats.Options.DatDirectory ?? string.Empty; + + public IDatDatabase Portal => _portal; + public ReadOnlyDictionary CellRegions => _cellRegions; + public IDatDatabase HighRes => _highRes; + public IDatDatabase Language => _language; + + // RegionFileMap is used by some WB internals but not by ObjectMeshManager. + public ReadOnlyDictionary RegionFileMap => + new ReadOnlyDictionary(new Dictionary()); + + // Iteration properties — not used by ObjectMeshManager, so delegate to 0. + public int PortalIteration => 0; + public int CellIteration => 0; + public int HighResIteration => 0; + public int LanguageIteration => 0; + + public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) + { + // Route to cell db (the only region we expose) + return _dats.Cell.TryGetFileBytes(fileId, ref bytes, out bytesRead); + } + + /// + /// Resolves a DAT id to all databases that contain it, along with the DBObjType. + /// Mirrors DefaultDatReaderWriter.ResolveId — checks each underlying DatDatabase + /// via DatDatabase.TypeFromId (which reads the type range tables). + /// + public IEnumerable ResolveId(uint id) + { + var results = new List(); + + void CheckDb(DatDatabaseWrapper wrapper) + { + var rawDb = wrapper.RawDatabase; + if (rawDb.Tree.TryGetFile(id, out _)) + { + var type = rawDb.TypeFromId(id); + if (type != DBObjType.Unknown) + results.Add(new IDatReaderWriter.IdResolution(wrapper, type)); + } + } + + // Match DefaultDatReaderWriter ordering: HighRes → Portal → Language → Cell + CheckDb(_highRes); + CheckDb(_portal); + CheckDb(_language); + CheckDb(_cell); + + return results; + } + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException("DatCollectionAdapter is read-only."); + + public bool TrySave(uint regionId, T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException("DatCollectionAdapter is read-only."); + + public void Dispose() + { + // The underlying DatCollection is owned by the caller — do not dispose it here. + // Individual wrapper objects hold no unmanaged resources. + } +} + +/// +/// Wraps a as . +/// Mirrors WorldBuilder.Shared.Services.DefaultDatDatabase but lives in our namespace +/// so the WorldBuilder project reference can be dropped in T7. +/// +internal sealed class DatDatabaseWrapper : IDatDatabase +{ + private readonly DatDatabase _db; + private readonly ConcurrentDictionary<(Type, uint), IDBObj> _cache = new(); + + public DatDatabaseWrapper(DatDatabase db) + { + ArgumentNullException.ThrowIfNull(db); + _db = db; + } + + /// Exposes the raw DatDatabase for ResolveId's Tree.TryGetFile + TypeFromId calls. + internal DatDatabase RawDatabase => _db; + + public DatDatabase Db => _db; + public int Iteration => _db.Iteration?.CurrentIteration ?? 0; + + public IEnumerable GetAllIdsOfType() where T : IDBObj => + _db.GetAllIdsOfType(); + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj + { + if (_cache.TryGetValue((typeof(T), fileId), out var cached)) + { + value = (T)cached; + return true; + } + + if (_db.TryGet(fileId, out value)) + { + _cache.TryAdd((typeof(T), fileId), value); + return true; + } + + return false; + } + + public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) => + _db.TryGetFileBytes(fileId, out value); + + public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) => + _db.TryGetFileBytes(fileId, ref bytes, out bytesRead); + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException("DatDatabaseWrapper is read-only."); + + public void Dispose() + { + // The underlying DatDatabase is owned by DatCollection — do not dispose here. + } +} diff --git a/src/AcDream.App/Rendering/Wb/EdgeLineBuilder.cs b/src/AcDream.App/Rendering/Wb/EdgeLineBuilder.cs new file mode 100644 index 0000000..cec2314 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/EdgeLineBuilder.cs @@ -0,0 +1,134 @@ +using System.Numerics; +using DatReaderWriter.Types; + +namespace AcDream.App.Rendering.Wb { + public static class EdgeLineBuilder { + public static List BuildEdgeLines(CellStruct cellStruct) { + var edgeMap = new Dictionary>(); + + foreach (var kvp in cellStruct.Polygons) { + var polyIdx = kvp.Key; + var vertexIds = kvp.Value.VertexIds; + + var v0 = cellStruct.VertexArray.Vertices[(ushort)vertexIds[0]].Origin; + + // AC polys can either be triangles or triangle fans + for (var i = 1; i < vertexIds.Count - 1; i++) { + var v1 = cellStruct.VertexArray.Vertices[(ushort)vertexIds[i]].Origin; + var v2 = cellStruct.VertexArray.Vertices[(ushort)vertexIds[i + 1]].Origin; + + AddEdge(edgeMap, polyIdx, v0, v1); + AddEdge(edgeMap, polyIdx, v1, v2); + AddEdge(edgeMap, polyIdx, v2, v0); + } + } + + var output = new List(); + var processedEdges = new HashSet(); + + foreach (var kvp in edgeMap) { + var edgeKey = kvp.Key; + var edgeList = kvp.Value; + + if (processedEdges.Contains(edgeKey)) continue; + + processedEdges.Add(edgeKey); + + if (edgeList.Count == 2) { + var poly1 = cellStruct.Polygons[edgeList[0].PolyIdx]; + var poly2 = cellStruct.Polygons[edgeList[1].PolyIdx]; + + if (HaveSameTexture(poly1, poly2) && IsCoplanar(poly1, poly2, cellStruct)) + continue; + } + + output.Add(edgeList[0].P0); + output.Add(edgeList[0].P1); + } + + return output; + } + + private static void AddEdge(Dictionary> edgeMap, ushort polyIdx, Vector3 p0, Vector3 p1) { + var key = new EdgeKey(p0, p1); + var edge = new Edge(polyIdx, p0, p1); + + if (!edgeMap.ContainsKey(key)) + edgeMap[key] = new List(); + + edgeMap[key].Add(edge); + } + + private static bool HaveSameTexture(Polygon a, Polygon b) { + return a.PosSurface == b.PosSurface; + } + + private static Vector3 CalculateNormal(Polygon poly, CellStruct cellStruct) { + var vertexIds = poly.VertexIds; + var verts = cellStruct.VertexArray.Vertices; + + var v0 = verts[(ushort)vertexIds[0]]; + var v1 = verts[(ushort)vertexIds[1]]; + var v2 = verts[(ushort)vertexIds[2]]; + + var edge1 = v1.Origin - v0.Origin; + var edge2 = v2.Origin - v0.Origin; + return Vector3.Normalize(Vector3.Cross(edge1, edge2)); + } + + private static bool IsCoplanar(Polygon a, Polygon b, CellStruct cellStruct) { + var normA = CalculateNormal(a, cellStruct); + var normB = CalculateNormal(b, cellStruct); + + var dp = Vector3.Dot(normA, normB); + + // If dot product is 1 or -1, normals are parallel (coplanar) + // Allow for both same and opposite facing normals + const float tolerance = 0.01f; + return Math.Abs(Math.Abs(dp) - 1) < tolerance; + } + + private class Edge { + public ushort PolyIdx { get; } + public Vector3 P0 { get; } + public Vector3 P1 { get; } + + public Edge(ushort polyIdx, Vector3 p0, Vector3 p1) { + PolyIdx = polyIdx; + P0 = p0; + P1 = p1; + } + } + + private class EdgeKey : IEquatable { + private readonly Vector3 _p0; + private readonly Vector3 _p1; + + public EdgeKey(Vector3 p0, Vector3 p1) { + if (CompareVector3(p0, p1) > 0) { + _p0 = p1; + _p1 = p0; + } + else { + _p0 = p0; + _p1 = p1; + } + } + + public bool Equals(EdgeKey? e) { + if (e == null) return false; + return _p0 == e._p0 && _p1 == e._p1; + } + + public override int GetHashCode() { + return HashCode.Combine(_p0, _p1); + } + + private static int CompareVector3(Vector3 a, Vector3 b) { + if (a.X != b.X) return a.X.CompareTo(b.X); + if (a.Y != b.Y) return a.Y.CompareTo(b.Y); + return a.Z.CompareTo(b.Z); + } + } + } +} diff --git a/src/AcDream.App/Rendering/Wb/EmbeddedResourceReader.cs b/src/AcDream.App/Rendering/Wb/EmbeddedResourceReader.cs index ebf55b4..24e2ff6 100644 --- a/src/AcDream.App/Rendering/Wb/EmbeddedResourceReader.cs +++ b/src/AcDream.App/Rendering/Wb/EmbeddedResourceReader.cs @@ -1,22 +1,45 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; +using System.IO; namespace AcDream.App.Rendering.Wb { + /// + /// Resolves WB-style shader resource names (e.g. "Shaders.Particle.vert") to + /// the corresponding file under the acdream binary's Rendering/Shaders directory. + /// + /// WB embeds shaders as assembly resources under the Chorizite.OpenGLSDLBackend + /// namespace. acdream ships shaders as plain files copied to the output directory. + /// This class adapts between the two conventions so ParticleBatcher and + /// ParticleEmitterRenderer can call GetEmbeddedResource without modification. + /// + /// Mapping rule: "Shaders.Foo.vert" → Rendering/Shaders/wb_foo.vert + /// (lower-case, wb_ prefix to distinguish WB-origin shaders from acdream's own) + /// public static class EmbeddedResourceReader { internal static string GetEmbeddedResource(string filename) { - var assembly = Assembly.GetExecutingAssembly(); - var resourceName = "Chorizite.OpenGLSDLBackend." + filename; + // Convert "Shaders.Particle.vert" → "wb_particle.vert" + // Strip leading "Shaders." then lowercase and prefix with wb_ + string leafName; + const string shadersPrefix = "Shaders."; + if (filename.StartsWith(shadersPrefix, StringComparison.Ordinal)) + { + var rest = filename.Substring(shadersPrefix.Length); // e.g. "Particle.vert" + leafName = "wb_" + rest.ToLowerInvariant(); // e.g. "wb_particle.vert" + } + else + { + leafName = "wb_" + filename.ToLowerInvariant(); + } - using var stream = assembly.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException($"Could not find embedded resource '{resourceName}'"); - using var reader = new StreamReader(stream); + var shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"); + var fullPath = Path.Combine(shadersDir, leafName); - return reader.ReadToEnd(); + if (!File.Exists(fullPath)) + throw new InvalidOperationException( + $"WB shader not found: '{fullPath}' (mapped from resource '{filename}'). " + + $"Ensure {leafName} is in src/AcDream.App/Rendering/Shaders/ with CopyToOutputDirectory."); + + return File.ReadAllText(fullPath); } } } diff --git a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs new file mode 100644 index 0000000..3441c66 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs @@ -0,0 +1,127 @@ +using Chorizite.Core.Render.Enums; +using Silk.NET.OpenGL; +using System; + +namespace AcDream.App.Rendering.Wb { + public class GlobalMeshBuffer : IDisposable { + private readonly GL _gl; + public uint VAO { get; private set; } + public uint VBO { get; private set; } + public uint IBO { get; private set; } + + private int _vboCapacity = 1024 * 1024; // 1M vertices (~32MB) + private int _iboCapacity = 3 * 1024 * 1024; // 3M indices (~6MB) + private int _vboOffset = 0; + private int _iboOffset = 0; + + public GlobalMeshBuffer(GL gl) { + _gl = gl; + InitBuffers(); + } + + private unsafe void InitBuffers() { + _gl.GenVertexArrays(1, out uint vao); + VAO = vao; + _gl.BindVertexArray(VAO); + + _gl.GenBuffers(1, out uint vbo); + VBO = vbo; + _gl.BindBuffer(GLEnum.ArrayBuffer, VBO); + _gl.BufferData(GLEnum.ArrayBuffer, (nuint)(_vboCapacity * VertexPositionNormalTexture.Size), null, GLEnum.StaticDraw); + + int stride = VertexPositionNormalTexture.Size; + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0); + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float))); + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); + + _gl.GenBuffers(1, out uint ibo); + IBO = ibo; + _gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO); + _gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(_iboCapacity * sizeof(ushort)), null, GLEnum.StaticDraw); + + _gl.BindVertexArray(0); + } + + public unsafe (int baseVertex, int firstIndex) Append(VertexPositionNormalTexture[] vertices, ushort[] indices) { + if (vertices.Length == 0 || indices.Length == 0) return (0, 0); + + // Check capacity + if (_vboOffset + vertices.Length > _vboCapacity) { + ResizeVBO(Math.Max(_vboCapacity * 2, _vboCapacity + vertices.Length)); + } + if (_iboOffset + indices.Length > _iboCapacity) { + ResizeIBO(Math.Max(_iboCapacity * 2, _iboCapacity + indices.Length)); + } + + int baseVertex = _vboOffset; + int firstIndex = _iboOffset; + + _gl.BindBuffer(GLEnum.ArrayBuffer, VBO); + fixed (VertexPositionNormalTexture* ptr = vertices) { + _gl.BufferSubData(GLEnum.ArrayBuffer, (nint)(baseVertex * VertexPositionNormalTexture.Size), (nuint)(vertices.Length * VertexPositionNormalTexture.Size), ptr); + } + + _gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO); + fixed (ushort* ptr = indices) { + _gl.BufferSubData(GLEnum.ElementArrayBuffer, (nint)(firstIndex * sizeof(ushort)), (nuint)(indices.Length * sizeof(ushort)), ptr); + } + + _vboOffset += vertices.Length; + _iboOffset += indices.Length; + + return (baseVertex, firstIndex); + } + + private unsafe void ResizeVBO(int newCapacity) { + _gl.GenBuffers(1, out uint newVbo); + _gl.BindBuffer(GLEnum.ArrayBuffer, newVbo); + _gl.BufferData(GLEnum.ArrayBuffer, (nuint)(newCapacity * VertexPositionNormalTexture.Size), null, GLEnum.StaticDraw); + + _gl.BindBuffer(GLEnum.CopyReadBuffer, VBO); + _gl.BindBuffer(GLEnum.CopyWriteBuffer, newVbo); + _gl.CopyBufferSubData(GLEnum.CopyReadBuffer, GLEnum.CopyWriteBuffer, 0, 0, (nuint)(_vboOffset * VertexPositionNormalTexture.Size)); + + _gl.DeleteBuffer(VBO); + VBO = newVbo; + _vboCapacity = newCapacity; + + // Re-bind to VAO + _gl.BindVertexArray(VAO); + _gl.BindBuffer(GLEnum.ArrayBuffer, VBO); + int stride = VertexPositionNormalTexture.Size; + _gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0); + _gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float))); + _gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); + _gl.BindVertexArray(0); + } + + private unsafe void ResizeIBO(int newCapacity) { + _gl.GenBuffers(1, out uint newIbo); + _gl.BindBuffer(GLEnum.ElementArrayBuffer, newIbo); + _gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(newCapacity * sizeof(ushort)), null, GLEnum.StaticDraw); + + _gl.BindBuffer(GLEnum.CopyReadBuffer, IBO); + _gl.BindBuffer(GLEnum.CopyWriteBuffer, newIbo); + _gl.CopyBufferSubData(GLEnum.CopyReadBuffer, GLEnum.CopyWriteBuffer, 0, 0, (nuint)(_iboOffset * sizeof(ushort))); + + _gl.DeleteBuffer(IBO); + IBO = newIbo; + _iboCapacity = newCapacity; + + // Re-bind to VAO + _gl.BindVertexArray(VAO); + _gl.BindBuffer(GLEnum.ElementArrayBuffer, IBO); + _gl.BindVertexArray(0); + } + + public void Dispose() { + if (VAO != 0) _gl.DeleteVertexArray(VAO); + if (VBO != 0) _gl.DeleteBuffer(VBO); + if (IBO != 0) _gl.DeleteBuffer(IBO); + VAO = VBO = IBO = 0; + } + } +} diff --git a/src/AcDream.App/Rendering/Wb/ModernRenderData.cs b/src/AcDream.App/Rendering/Wb/ModernRenderData.cs new file mode 100644 index 0000000..927631d --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/ModernRenderData.cs @@ -0,0 +1,31 @@ +using System.Runtime.InteropServices; +using DatReaderWriter.Enums; +using Chorizite.Core.Render; + +namespace AcDream.App.Rendering.Wb { + /// + /// Per-batch (draw call) data for modern rendering. + /// Consists of a bindless texture handle to a texture array and the layer index. + /// Indexed by gl_DrawIDARB in the vertex shader. + /// + [StructLayout(LayoutKind.Sequential, Pack = 8)] + public struct ModernBatchData { + public ulong TextureHandle; // 8 bytes + public uint TextureIndex; // 4 bytes + public uint Padding; // 4 bytes + } + + public struct LandblockMdiCommand { + public ulong SortKey; + public ulong ObjectId; + public DrawElementsIndirectCommand Command; + public ModernBatchData BatchData; + public uint TextureIndex; + public ManagedGLTextureArray Atlas; + public uint VAO; + public uint IBO; + public bool IsTransparent; + public bool IsAdditive; + public bool HasWrappingUVs; + } +} diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs new file mode 100644 index 0000000..8a6dda1 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -0,0 +1,2079 @@ +using Chorizite.Core.Lib; +using Chorizite.Core.Render; +using Chorizite.Core.Render.Enums; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using CullMode = DatReaderWriter.Enums.CullMode; +using DatReaderWriter.Types; +using Microsoft.Extensions.Logging; +using Silk.NET.OpenGL; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using WorldBuilder.Shared.Services; +using Chorizite.OpenGLSDLBackend.Lib; +using PixelFormat = Silk.NET.OpenGL.PixelFormat; +using BoundingBox = Chorizite.Core.Lib.BoundingBox; +using BCnEncoder.Decoder; +using BCnEncoder.Shared; +using BCnEncoder.ImageSharp; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using WorldBuilder.Shared.Lib; + +namespace AcDream.App.Rendering.Wb { + /// + /// Vertex format for scenery mesh rendering: position, normal, UV. + /// + [StructLayout(LayoutKind.Sequential)] + public struct VertexPositionNormalTexture { + public Vector3 Position; + public Vector3 Normal; + public Vector2 UV; + + public static int Size => 8 * sizeof(float); // 3+3+2 = 8 floats = 32 bytes + + public VertexPositionNormalTexture(Vector3 position, Vector3 normal, Vector2 uv) { + Position = position; + Normal = normal; + UV = uv; + } + } + + /// + /// Staged data for a particle emitter to be created on the GL thread. + /// + public struct StagedEmitter { + public ParticleEmitter Emitter; + public uint PartIndex; + public Matrix4x4 Offset; + } + + /// + /// CPU-side mesh data prepared on a background thread. + /// Contains vertex data and per-batch index/texture info, but NO GPU resources. + /// + public class ObjectMeshData { + public ulong ObjectId { get; set; } + public bool IsSetup { get; set; } + public VertexPositionNormalTexture[] Vertices { get; set; } = Array.Empty(); + public List Batches { get; set; } = new(); + + /// For EnvCell: the geometry of the cell itself. + public ObjectMeshData? EnvCellGeometry { get; set; } + + /// For Setup objects: parts with their local transforms. + public List<(ulong GfxObjId, Matrix4x4 Transform)> SetupParts { get; set; } = new(); + + /// Particle emitters from physics scripts. + public List ParticleEmitters { get; set; } = new(); + + /// Per-format texture atlas data (to be uploaded to GPU on main thread). + public Dictionary<(int Width, int Height, TextureFormat Format), List> TextureBatches { get; set; } = new(); + + /// Local bounding box. + public BoundingBox BoundingBox { get; set; } + + /// Approximate center point used for depth sorting / transparency ordering. + public Vector3 SortCenter { get; set; } + + /// DataID of a simpler GfxObj to use at long distance / low quality, or GfxObjDegradeInfo. + public uint DIDDegrade { get; set; } + + /// Sphere used for mouse selection. + public Sphere? SelectionSphere { get; set; } + + /// Edge line vertices for Environment wireframe rendering. + public Vector3[] EdgeLines { get; set; } = Array.Empty(); + } + + /// + /// CPU-side data for a single rendering batch (indices + texture reference). + /// + public class MeshBatchData { + public ushort[] Indices { get; set; } = Array.Empty(); + public (int Width, int Height, TextureFormat Format) TextureFormat { get; set; } + public TextureAtlasManager.TextureKey TextureKey { get; set; } + public int TextureIndex { get; set; } + public byte[] TextureData { get; set; } = Array.Empty(); + public PixelFormat? UploadPixelFormat { get; set; } + public PixelType? UploadPixelType { get; set; } + public DatReaderWriter.Enums.CullMode CullMode { get; set; } + } + + /// + /// CPU-side texture info for deduplication during background preparation. + /// + public class TextureBatchData { + public TextureAtlasManager.TextureKey Key { get; set; } + public byte[] TextureData { get; set; } = Array.Empty(); + public PixelFormat? UploadPixelFormat { get; set; } + public PixelType? UploadPixelType { get; set; } + public List Indices { get; set; } = new(); + public DatReaderWriter.Enums.CullMode CullMode { get; set; } + public bool IsTransparent { get; set; } + public bool IsAdditive { get; set; } + public bool HasWrappingUVs { get; set; } + } + + /// + /// GPU-side render data created on the main thread. + /// + public class ObjectRenderData { + public uint VAO { get; set; } + public uint VBO { get; set; } + public int VertexCount { get; set; } + public List Batches { get; set; } = new(); + public bool IsSetup { get; set; } + public List<(ulong GfxObjId, Matrix4x4 Transform)> SetupParts { get; set; } = new(); + + /// Particle emitters from physics scripts. + public List ParticleEmitters { get; set; } = new(); + + /// CPU-side vertex positions for raycasting. + public Vector3[] CPUPositions { get; set; } = Array.Empty(); + + /// CPU-side indices for raycasting. + public ushort[] CPUIndices { get; set; } = Array.Empty(); + + /// CPU-side edge line vertices for Environment wireframe rendering. + public Vector3[] CPUEdgeLines { get; set; } = Array.Empty(); + + /// Local bounding box. + public BoundingBox BoundingBox { get; set; } + + /// Approximate center point used for depth sorting / transparency ordering. + public Vector3 SortCenter { get; set; } + + /// DataID of a simpler GfxObj to use at long distance / low quality, or GfxObjDegradeInfo. + public uint DIDDegrade { get; set; } + + /// Sphere used for mouse selection. + public Sphere? SelectionSphere { get; set; } + + /// Estimated GPU memory usage in bytes. + public long MemorySize { get; set; } + } + + /// + /// A single GPU draw batch: IBO + texture array layer. + /// + public class ObjectRenderBatch { + public uint IBO { get; set; } + public int IndexCount { get; set; } + public TextureAtlasManager Atlas { get; set; } = null!; + public int TextureIndex { get; set; } + public (int Width, int Height) TextureSize { get; set; } + public TextureFormat TextureFormat { get; set; } + public uint SurfaceId { get; set; } + public TextureAtlasManager.TextureKey Key { get; set; } + public DatReaderWriter.Enums.CullMode CullMode { get; set; } + public bool IsTransparent { get; set; } + public bool IsAdditive { get; set; } + public bool HasWrappingUVs { get; set; } + + // Modern rendering path fields + public uint FirstIndex { get; set; } + public uint BaseVertex { get; set; } + public ulong BindlessTextureHandle { get; set; } + } + + /// + /// Manages scenery mesh loading, GPU resource creation, and reference counting. + /// Key design: mesh data is prepared on background threads via PrepareMeshData(), + /// then GPU resources are created on the main thread via UploadMeshData(). + /// + public class ObjectMeshManager : IDisposable { + private readonly OpenGLGraphicsDevice _graphicsDevice; + private readonly IDatReaderWriter _dats; + private readonly ILogger _logger; + + internal IDatReaderWriter Dats => _dats; + + public bool IsDisposed { get; private set; } + private readonly ConcurrentDictionary _renderData = new(); + private readonly ConcurrentDictionary _usageCount = new(); + private readonly ConcurrentDictionary _boundsCache = new(); + private readonly ConcurrentDictionary> _preparationTasks = new(); + + // LRU Cache for Unused objects + private readonly LinkedList _lruList = new(); + private readonly long _maxGpuMemory = 1024 * 1024 * 1024; // 1GB + private readonly int _maxCachedObjects = 50; // Max number of cached objects (count-based limit) + private long _currentGpuMemory = 0; + + // Shared atlases grouped by (Width, Height, Format) + private readonly Dictionary<(int Width, int Height, TextureFormat Format), List> _globalAtlases = new(); + + // CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT) + private readonly Dictionary _cpuMeshCache = new(); + private readonly LinkedList _cpuLruList = new(); + private readonly int _maxCpuCacheSize = 100; + + private readonly ConcurrentQueue _stagedMeshData = new(); + public ConcurrentQueue StagedMeshData => _stagedMeshData; + + // Cache for decoded textures to avoid redundant BCn decoding + private readonly ConcurrentQueue _decodedTextureLru = new(); + private readonly ConcurrentDictionary _decodedTextureCache = new(); + private const int MaxDecodedTextures = 128; + private readonly ThreadLocal _bcDecoder = new(() => new BcDecoder()); + + public GlobalMeshBuffer? GlobalBuffer { get; } + private readonly bool _useModernRendering; + + private readonly List<(ulong Id, bool IsSetup, TaskCompletionSource Tcs, CancellationToken Ct)> _pendingRequests = new(); + private int _activeWorkers = 0; + private const int MaxParallelLoads = 4; + public ObjectMeshManager(OpenGLGraphicsDevice graphicsDevice, IDatReaderWriter dats, ILogger logger) { + _graphicsDevice = graphicsDevice; + _dats = dats; + _logger = logger; + _useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless; + if (_useModernRendering) { + GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL); + } + } + + /// + /// Get existing GPU render data for an object, or null if not yet uploaded. + /// Increments reference count. + /// + public ObjectRenderData? GetRenderData(ulong id) { + if (_renderData.TryGetValue(id, out var data)) { + _usageCount.AddOrUpdate(id, 1, (_, count) => count + 1); + + if (data.IsSetup) { + foreach (var (partId, _) in data.SetupParts) { + IncrementRefCount(partId); + } + } + else { + // Increment ref counts for all textures in this GfxObj + foreach (var batch in data.Batches) { + if (batch.Atlas != null) { + batch.Atlas.AddTexture(batch.Key, Array.Empty()); + } + } + } + + // If it was in LRU, remove it as it's now in use + lock (_lruList) { + _lruList.Remove(id); + } + + return data; + } + return null; + } + + /// + /// Check if GPU render data exists for an object. + /// + public bool HasRenderData(ulong id) => _renderData.ContainsKey(id); + + /// + /// Get existing GPU render data without modifying reference count. + /// Use this for render-loop lookups where you don't want to affect lifecycle. + /// + public ObjectRenderData? TryGetRenderData(ulong id) { + return _renderData.TryGetValue(id, out var data) ? data : null; + } + + /// + /// Increment reference count for an object (e.g. when a landblock starts using it). + /// + public void IncrementRefCount(ulong id) { + _usageCount.AddOrUpdate(id, 1, (_, count) => count + 1); + lock (_lruList) { + _lruList.Remove(id); + } + } + + public void GenerateMipmaps() { + foreach (var atlasList in _globalAtlases.Values) { + foreach (var atlas in atlasList) { + atlas.TextureArray.ProcessDirtyUpdates(); + } + } + } + + /// + /// Decrement reference count and unload GPU resources if no longer needed. + /// + public void DecrementRefCount(ulong id) { + var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1); + if (newCount <= 0) { + // Instead of unloading, move to LRU + lock (_lruList) { + _lruList.Remove(id); + _lruList.AddLast(id); + } + } + } + + /// + /// Decrement reference count and unload if no longer needed. + /// + public void ReleaseRenderData(ulong id) { + if (_usageCount.TryGetValue(id, out var count) && count > 0) { + var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1); + if (newCount <= 0) { + // Instead of unloading, move to LRU + lock (_lruList) { + _lruList.Remove(id); + _lruList.AddLast(id); + } + } + } + } + + private void EvictOldResources(long neededBytes = 0) { + lock (_lruList) { + // Evict based on memory OR count limit + while ((_currentGpuMemory + neededBytes) > _maxGpuMemory || _lruList.Count > _maxCachedObjects) { + var idToEvict = _lruList.First!.Value; + _lruList.RemoveFirst(); + + if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) { + UnloadObject(idToEvict); + _usageCount.TryRemove(idToEvict, out _); + } + } + } + } + + /// + /// Force evict all unused objects from the cache. + /// Use this when navigating away from a view or changing filters to free memory. + /// + public void EvictAllUnused() { + lock (_lruList) { + while (_lruList.Count > 0) { + var idToEvict = _lruList.First!.Value; + _lruList.RemoveFirst(); + + if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) { + UnloadObject(idToEvict); + _usageCount.TryRemove(idToEvict, out _); + } + } + } + + // Also clear CPU mesh cache + lock (_cpuMeshCache) { + _cpuMeshCache.Clear(); + _cpuLruList.Clear(); + } + } + + public struct EnvCellGeomRequest { + public uint EnvironmentId; + public ushort CellStructure; + public List Surfaces; + } + + private readonly ConcurrentDictionary _pendingEnvCellRequests = new(); + + /// + /// Phase 1 (Background Thread): Prepare CPU-side mesh data for deduplicated EnvCell geometry. + /// + public Task PrepareEnvCellGeomMeshDataAsync(ulong geomId, uint environmentId, ushort cellStructure, List surfaces, CancellationToken ct = default) { + if (HasRenderData(geomId)) return Task.FromResult(null); + + // Check CPU cache first + lock (_cpuMeshCache) { + if (_cpuMeshCache.TryGetValue(geomId, out var cachedData)) { + _cpuLruList.Remove(geomId); + _cpuLruList.AddLast(geomId); + return Task.FromResult(cachedData); + } + } + + // Return existing task if already running or queued + if (_preparationTasks.TryGetValue(geomId, out var existing)) { + return existing; + } + + var tcs = new TaskCompletionSource(); + var task = tcs.Task; + _preparationTasks[geomId] = task; + + lock (_pendingRequests) { + // Special handling for EnvCell geometry - we need to store the cell data for the worker + _pendingEnvCellRequests[geomId] = new EnvCellGeomRequest { + EnvironmentId = environmentId, + CellStructure = cellStructure, + Surfaces = surfaces + }; + _pendingRequests.Add((geomId, false, tcs, ct)); + if (_activeWorkers < MaxParallelLoads) { + _activeWorkers++; + Task.Run(ProcessQueueAsync); + } + } + + return task; + } + + public Task PrepareMeshDataAsync(ulong id, bool isSetup, CancellationToken ct = default) { + if (HasRenderData(id)) return Task.FromResult(null); + + // Check CPU cache first + lock (_cpuMeshCache) { + if (_cpuMeshCache.TryGetValue(id, out var cachedData)) { + _cpuLruList.Remove(id); + _cpuLruList.AddLast(id); + return Task.FromResult(cachedData); + } + } + + // Return existing task if already running or queued + if (_preparationTasks.TryGetValue(id, out var existing)) { + if (!existing.IsFaulted && !existing.IsCanceled) { + lock (_pendingRequests) { + int idx = _pendingRequests.FindIndex(r => r.Id == id); + if (idx >= 0) { + var req = _pendingRequests[idx]; + _pendingRequests.RemoveAt(idx); + _pendingRequests.Add(req); + } + } + return existing; + } + _preparationTasks.TryRemove(id, out _); + } + + var tcs = new TaskCompletionSource(); + var task = tcs.Task; + _preparationTasks[id] = task; + + lock (_pendingRequests) { + _pendingRequests.Add((id, isSetup, tcs, ct)); + if (_activeWorkers < MaxParallelLoads) { + _activeWorkers++; + Task.Run(ProcessQueueAsync); + } + } + + return task; + } + + private async Task ProcessQueueAsync() { + try { + while (true) { + ulong id; + bool isSetup; + TaskCompletionSource tcs; + CancellationToken ct; + + lock (_pendingRequests) { + if (_pendingRequests.Count == 0) { + return; + } + + // LIFO: Pick the most recent request + var index = _pendingRequests.Count - 1; + (id, isSetup, tcs, ct) = _pendingRequests[index]; + _pendingRequests.RemoveAt(index); + } + + try { + ObjectMeshData? data = null; + if (_pendingEnvCellRequests.TryRemove(id, out var req)) { + uint envId = 0x0D000000u | req.EnvironmentId; + if (_dats.Portal.TryGet(envId, out var environment)) { + if (environment.Cells.TryGetValue(req.CellStructure, out var cellStruct)) { + data = PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, CancellationToken.None); + } + } + } + else { + // If it's a direct setup or gfxobj, make sure background loads don't abort half-way + data = PrepareMeshData(id, isSetup, CancellationToken.None); + } + if (data != null) { + lock (_cpuMeshCache) { + if (_cpuMeshCache.Count >= _maxCpuCacheSize) { + var oldest = _cpuLruList.First!.Value; + _cpuLruList.RemoveFirst(); + _cpuMeshCache.Remove(oldest); + } + _cpuMeshCache[id] = data; + _cpuLruList.AddLast(id); + } + _stagedMeshData.Enqueue(data); + } + tcs.TrySetResult(data); + } + catch (OperationCanceledException) { + tcs.TrySetCanceled(ct); + } + catch (Exception ex) { + _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X8}", id); + tcs.TrySetException(ex); + } + finally { + _preparationTasks.TryRemove(id, out _); + } + } + } + finally { + lock (_pendingRequests) { + _activeWorkers--; + } + } + } + + /// + /// Phase 1 (Background Thread): Prepare CPU-side mesh data from DAT. + /// This loads vertices, indices, and texture data but creates NO GPU resources. + /// Thread-safe: only reads from DAT files. + /// + public ObjectMeshData? PrepareMeshData(ulong id, bool isSetup, CancellationToken ct = default) { + try { + // Use the low 32 bits as the DAT file ID + var datId = (uint)(id & 0xFFFFFFFFu); + var resolutions = _dats.ResolveId(datId).ToList(); + var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault(); + if (selectedResolution == null) return null; + + var type = selectedResolution.Type; + var db = selectedResolution.Database; + + if (type == DBObjType.Setup) { + if (!db.TryGet(datId, out var setup)) return null; + return PrepareSetupMeshData(id, setup, ct); + } + else if (type == DBObjType.GfxObj) { + if (!db.TryGet(datId, out var gfxObj)) return null; + return PrepareGfxObjMeshData(id, gfxObj, Vector3.One, ct); + } + else if (type == DBObjType.EnvCell) { + if (!db.TryGet(datId, out var envCell)) return null; + + // If bit 32 is set, this is a request for the cell's synthetic geometry only + if ((id & 0x1_0000_0000UL) != 0) { + uint envId = 0x0D000000u | envCell.EnvironmentId; + if (_dats.Portal.TryGet(envId, out var environment)) { + if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { + return PrepareCellStructMeshData(id, cellStruct, envCell.Surfaces, Matrix4x4.Identity, ct); + } + } + return null; + } + + return PrepareEnvCellMeshData(id, envCell, ct); + } + else if (type == DBObjType.Environment) { + if (!db.TryGet(datId, out var environment)) return null; + + // For Environment objects, create wireframe-only edge geometry + if (environment.Cells.Count > 0) { + var result = PrepareCellStructEdgeLineData(id, environment.Cells, Matrix4x4.Identity, ct); + return result; + } + return null; + } + return null; + } + catch (OperationCanceledException) { + // Ignore + return null; + } + catch (Exception ex) { + _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id); + return null; + } + } + + /// + /// Cancel preparation tasks for IDs that are no longer needed. + /// + public void CancelStagedUploads(IEnumerable ids) { + foreach (var id in ids) { + _preparationTasks.TryRemove(id, out _); + } + } + + /// + /// Phase 2 (Main Thread): Upload prepared mesh data to GPU. + /// Creates VAO, VBO, IBOs, and texture arrays. + /// Must be called from the GL thread. + /// + public ObjectRenderData? UploadMeshData(ObjectMeshData meshData) { + try { + if (_renderData.TryGetValue(meshData.ObjectId, out var existing)) { + _preparationTasks.TryRemove(meshData.ObjectId, out _); + if (existing.IsSetup) { + foreach (var (partId, _) in existing.SetupParts) { + IncrementRefCount(partId); + lock (_lruList) { + _lruList.Remove(partId); + } + } + } + else { + // Increment ref counts for all textures in this GfxObj + foreach (var batch in existing.Batches) { + if (batch.Atlas != null) { + batch.Atlas.AddTexture(batch.Key, Array.Empty()); + } + } + } + IncrementRefCount(meshData.ObjectId); + lock (_lruList) { + _lruList.Remove(meshData.ObjectId); + } + return existing; + } + + // Estimated size - evict before allocation + long estimatedSize = meshData.IsSetup ? 1024 : + (meshData.Vertices.Length * VertexPositionNormalTexture.Size) + + meshData.TextureBatches.Values.SelectMany(l => l).Sum(b => (long)b.Indices.Count * sizeof(ushort)); + + EvictOldResources(estimatedSize); + + _preparationTasks.TryRemove(meshData.ObjectId, out _); + if (meshData.IsSetup) { + // Upload EnvCell geometry if present to ensure it's in _renderData + if (meshData.EnvCellGeometry != null) { + UploadMeshData(meshData.EnvCellGeometry); + } + + // Setup objects are multi-part - each part needs its own render data + var data = new ObjectRenderData { + IsSetup = true, + SetupParts = meshData.SetupParts, + ParticleEmitters = meshData.ParticleEmitters, + Batches = new List(), + BoundingBox = meshData.BoundingBox, + SortCenter = meshData.SortCenter, + DIDDegrade = meshData.DIDDegrade, + SelectionSphere = meshData.SelectionSphere, + MemorySize = 1024 // Small overhead for the setup itself + }; + _renderData.TryAdd(meshData.ObjectId, data); + IncrementRefCount(meshData.ObjectId); + _currentGpuMemory += data.MemorySize; + + // Increment ref counts for all parts + foreach (var (partId, _) in meshData.SetupParts) { + IncrementRefCount(partId); + } + + return data; + } + + var renderData = UploadGfxObjMeshData(meshData); + if (renderData == null) { + renderData = new ObjectRenderData(); + } + + renderData.BoundingBox = meshData.BoundingBox; + renderData.SortCenter = meshData.SortCenter; + renderData.DIDDegrade = meshData.DIDDegrade; + renderData.SelectionSphere = meshData.SelectionSphere; + _renderData.TryAdd(meshData.ObjectId, renderData); + IncrementRefCount(meshData.ObjectId); + _currentGpuMemory += renderData.MemorySize; + + // Clear texture data after upload to save RAM + foreach (var batchList in meshData.TextureBatches.Values) { + foreach (var batch in batchList) { + batch.TextureData = Array.Empty(); + } + } + return renderData; + } + catch (Exception ex) { + _logger.LogError(ex, "Error uploading mesh data for 0x{Id:X8}", meshData.ObjectId); + return null; + } + } + + /// + /// Gets bounding box for an object (for frustum culling). + /// + public (Vector3 Min, Vector3 Max)? GetBounds(ulong id, bool isSetup) { + if (_boundsCache.TryGetValue(id, out var cachedBounds)) { + return cachedBounds; + } + + try { + (Vector3 Min, Vector3 Max)? result = null; + uint datId = (uint)(id & 0xFFFFFFFFu); + var resolutions = _dats.ResolveId(datId).ToList(); + var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault(); + if (selectedResolution == null) return null; + + var type = selectedResolution.Type; + var db = selectedResolution.Database; + + if (type == DBObjType.Setup) { + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + bool hasBounds = false; + var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); + + CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); + result = hasBounds ? (min, max) : null; + } + else if (type == DBObjType.EnvCell) { + if (!db.TryGet(datId, out var envCell)) return null; + + // If bit 32 is set, this is a request for the cell's synthetic geometry only + if ((id & 0x1_0000_0000UL) != 0) { + uint envId = 0x0D000000u | envCell.EnvironmentId; + if (_dats.Portal.TryGet(envId, out var environment)) { + if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var vert in cellStruct.VertexArray.Vertices.Values) { + min = Vector3.Min(min, vert.Origin); + max = Vector3.Max(max, vert.Origin); + } + result = (min, max); + } + } + } + else { + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + bool hasBounds = false; + var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); + + CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); + result = hasBounds ? (min, max) : null; + } + } + else { + if (!db.TryGet(datId, out var gfxObj)) return null; + result = ComputeBounds(gfxObj, Vector3.One); + } + _boundsCache[id] = result; + return result; + } + catch (Exception ex) { + _logger.LogError(ex, "Error computing bounds for 0x{Id:X8}", id); + return null; + } + } + + #region Private: Background Preparation + + private ObjectMeshData? PrepareSetupMeshData(ulong id, Setup setup, CancellationToken ct) { + var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + bool hasBounds = false; + + CollectParts((uint)(id & 0xFFFFFFFFu), Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, ct); + + var emitters = new List(); + var processedScripts = new HashSet(); + if (setup.DefaultScript.DataId != 0) { + if (processedScripts.Add(setup.DefaultScript.DataId)) { + CollectEmittersFromScript(setup.DefaultScript.DataId, emitters, ct); + } + } + + return new ObjectMeshData { + ObjectId = id, + IsSetup = true, + SetupParts = parts, + ParticleEmitters = emitters, + BoundingBox = hasBounds ? new BoundingBox(min, max) : default, + SelectionSphere = setup.SelectionSphere + }; + } + + private void CollectEmittersFromScript(uint scriptId, List emitters, CancellationToken ct) { + if (_dats.Portal.TryGet(scriptId, out var script)) { + foreach (var hook in script.ScriptData) { + if (hook.Hook.HookType == AnimationHookType.CreateParticle && hook.Hook is CreateParticleHook particleHook) { + if (_dats.Portal.TryGet(particleHook.EmitterInfoId.DataId, out var emitter)) { + emitters.Add(new StagedEmitter { + Emitter = emitter, + PartIndex = particleHook.PartIndex, + Offset = Matrix4x4.CreateFromQuaternion(particleHook.Offset.Orientation) * Matrix4x4.CreateTranslation(particleHook.Offset.Origin) + }); + + // Pre-load and stage the particle's GfxObjs + if (emitter.HwGfxObjId.DataId != 0) { + var meshData = PrepareMeshData(emitter.HwGfxObjId.DataId, false, ct); + if (meshData != null) { + _stagedMeshData.Enqueue(meshData); + } + } + if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) { + var meshData = PrepareMeshData(emitter.GfxObjId.DataId, false, ct); + if (meshData != null) { + _stagedMeshData.Enqueue(meshData); + } + } + } + } + } + } + } + + private void CollectParts(uint id, Matrix4x4 currentTransform, List<(ulong GfxObjId, Matrix4x4 Transform)> parts, ref Vector3 min, ref Vector3 max, ref bool hasBounds, CancellationToken ct, int depth = 0) { + if (depth > 50) { + _logger.LogWarning("Max recursion depth reached while collecting parts for 0x{Id:X8}. Possible circular dependency.", id); + return; + } + ct.ThrowIfCancellationRequested(); + + var resolutions = _dats.ResolveId(id).ToList(); + var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault(); + if (selectedResolution == null) return; + + var type = selectedResolution.Type; + var db = selectedResolution.Database; + + if (type == DBObjType.Setup) { + if (!db.TryGet(id, out var setup)) return; + + // Use Resting placement first, then default + if (!setup.PlacementFrames.TryGetValue(Placement.Resting, out var placementFrame)) { + if (!setup.PlacementFrames.TryGetValue(Placement.Default, out placementFrame)) { + placementFrame = setup.PlacementFrames.Values.FirstOrDefault(); + } + } + if (placementFrame == null) return; + + for (int i = 0; i < setup.Parts.Count; i++) { + var partId = setup.Parts[i]; + var transform = Matrix4x4.Identity; + + if (setup.Flags.HasFlag(SetupFlags.HasDefaultScale) && setup.DefaultScale.Count > i) { + transform *= Matrix4x4.CreateScale(setup.DefaultScale[i]); + } + + if (placementFrame.Frames != null && i < placementFrame.Frames.Count) { + var orientation = new System.Numerics.Quaternion( + (float)placementFrame.Frames[i].Orientation.X, + (float)placementFrame.Frames[i].Orientation.Y, + (float)placementFrame.Frames[i].Orientation.Z, + (float)placementFrame.Frames[i].Orientation.W + ); + transform *= Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin); + } + + CollectParts(partId, transform * currentTransform, parts, ref min, ref max, ref hasBounds, ct, depth + 1); + } + } + else if (type == DBObjType.EnvCell) { + if (!db.TryGet(id, out var envCell)) return; + + // Calculate the inverse transform of the cell to localize its contents + var cellOrientation = new System.Numerics.Quaternion( + (float)envCell.Position.Orientation.X, + (float)envCell.Position.Orientation.Y, + (float)envCell.Position.Orientation.Z, + (float)envCell.Position.Orientation.W + ); + var cellTransform = Matrix4x4.CreateFromQuaternion(cellOrientation) * + Matrix4x4.CreateTranslation(envCell.Position.Origin); + if (!Matrix4x4.Invert(cellTransform, out var invertCellTransform)) { + invertCellTransform = Matrix4x4.Identity; + } + + // Include cell geometry + uint envId = 0x0D000000u | envCell.EnvironmentId; + if (_dats.Portal.TryGet(envId, out var environment)) { + if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { + foreach (var vert in cellStruct.VertexArray.Vertices.Values) { + var transformed = Vector3.Transform(vert.Origin, currentTransform); + min = Vector3.Min(min, transformed); + max = Vector3.Max(max, transformed); + } + hasBounds = true; + + // Add synthetic geometry ID to parts list + parts.Add(((ulong)id | 0x1_0000_0000UL, currentTransform)); + } + } + + foreach (var stab in envCell.StaticObjects) { + var orientation = new System.Numerics.Quaternion( + (float)stab.Frame.Orientation.X, + (float)stab.Frame.Orientation.Y, + (float)stab.Frame.Orientation.Z, + (float)stab.Frame.Orientation.W + ); + var transform = Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(stab.Frame.Origin); + // Localize static object transform relative to the cell + var localizedTransform = transform * invertCellTransform; + + CollectParts(stab.Id, localizedTransform * currentTransform, parts, ref min, ref max, ref hasBounds, ct, depth + 1); + } + } + else if (type == DBObjType.GfxObj) { + parts.Add((id, currentTransform)); + + if (db.TryGet(id, out var partGfx)) { + var (partMin, partMax) = ComputeBounds(partGfx, Vector3.One); + var corners = new Vector3[8]; + corners[0] = new Vector3(partMin.X, partMin.Y, partMin.Z); + corners[1] = new Vector3(partMin.X, partMin.Y, partMax.Z); + corners[2] = new Vector3(partMin.X, partMax.Y, partMin.Z); + corners[3] = new Vector3(partMin.X, partMax.Y, partMax.Z); + corners[4] = new Vector3(partMax.X, partMin.Y, partMin.Z); + corners[5] = new Vector3(partMax.X, partMin.Y, partMax.Z); + corners[6] = new Vector3(partMax.X, partMax.Y, partMin.Z); + corners[7] = new Vector3(partMax.X, partMax.Y, partMax.Z); + + foreach (var corner in corners) { + var transformed = Vector3.Transform(corner, currentTransform); + min = Vector3.Min(min, transformed); + max = Vector3.Max(max, transformed); + } + hasBounds = true; + } + } + } + + private ObjectMeshData? PrepareGfxObjMeshData(ulong id, GfxObj gfxObj, Vector3 scale, CancellationToken ct) { + var vertices = new List(); + var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>(); + var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List>(); + + var (min, max) = ComputeBounds(gfxObj, scale); + var boundingBox = new BoundingBox(min, max); + + foreach (var poly in gfxObj.Polygons.Values) { + ct.ThrowIfCancellationRequested(); + if (poly.VertexIds.Count < 3) continue; + + // Handle Positive Surface + if (!poly.Stippling.HasFlag(StipplingType.NoPos)) { + AddSurfaceToBatch(poly, poly.PosSurface, false); + } + + // Handle Negative Surface + // Some objects use Clockwise CullMode to indicate negative surface data is present + bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) || + poly.Stippling.HasFlag(StipplingType.Both) || + (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise); + + if (hasNeg) { + AddSurfaceToBatch(poly, poly.NegSurface, true); + } + + void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool isNeg) { + if (surfaceIdx < 0 || surfaceIdx >= gfxObj.Surfaces.Count) return; + + var surfaceId = gfxObj.Surfaces[surfaceIdx]; + if (!_dats.Portal.TryGet(surfaceId, out var surface)) return; + + int texWidth, texHeight; + byte[] textureData; + TextureFormat textureFormat; + PixelFormat? uploadPixelFormat = null; + PixelType? uploadPixelType = null; + bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid); + bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); + uint paletteId = 0; + bool isDxt3or5 = false; + DatReaderWriter.Enums.PixelFormat? sourceFormat = null; + var isAdditive = false; + var isTransparent = false; + + if (isSolid) { + texWidth = texHeight = 32; + textureData = TextureHelpers.CreateSolidColorTexture(surface.ColorValue, texWidth, texHeight); + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + } + else if (_dats.Portal.TryGet(surface.OrigTextureId, out var surfaceTexture)) { + var renderSurfaceId = surfaceTexture.Textures.First(); + if (!_dats.Portal.TryGet(renderSurfaceId, out var renderSurface)) { + // check highres + if (!_dats.HighRes.TryGet(renderSurfaceId, out var hrRenderSurface)) { + throw new Exception($"Unable to load RenderSurface: 0x{renderSurfaceId:X8}"); + } + + renderSurface = hrRenderSurface; + } + + texWidth = renderSurface.Width; + texHeight = renderSurface.Height; + paletteId = renderSurface.DefaultPaletteId; + sourceFormat = renderSurface.Format; + + if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { + isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5; + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + + if (_decodedTextureCache.TryGetValue(renderSurfaceId, out textureData!)) { + // use cached data + } + else { + textureData = new byte[texWidth * texHeight * 4]; + + CompressionFormat compressionFormat = renderSurface.Format switch { + DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1, + DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2, + DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3, + _ => throw new NotSupportedException($"Unsupported compressed format: {renderSurface.Format}") + }; + + using (var image = _bcDecoder.Value!.DecodeRawToImageRgba32(renderSurface.SourceData, texWidth, texHeight, compressionFormat)) { + image.CopyPixelDataTo(textureData); + } + _decodedTextureCache.TryAdd(renderSurfaceId, textureData); + } + + if (isClipMap && textureData != null) { + // If we got this from the cache, we need to clone it so we don't scale the cached raw data + if (_decodedTextureCache.ContainsKey(renderSurfaceId)) { + var clonedData = new byte[textureData.Length]; + System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); + textureData = clonedData; + } + + for (int i = 0; i < textureData.Length; i += 4) { + if (textureData[i] == 0 && textureData[i + 1] == 0 && textureData[i + 2] == 0) { + textureData[i + 3] = 0; + } + } + } + } + else { + textureFormat = TextureFormat.RGBA8; + textureData = renderSurface.SourceData; + switch (renderSurface.Format) { + case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16: + if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var paletteData)) + throw new Exception($"Unable to load Palette: 0x{renderSurface.DefaultPaletteId:X8}"); + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_P8: + if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var p8PaletteData)) + throw new Exception($"Unable to load Palette: 0x{renderSurface.DefaultPaletteId:X8}"); + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_A8: + case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA: + textureData = new byte[texWidth * texHeight * 4]; + if (surface.Type.HasFlag(SurfaceType.Additive)) { + TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + } + else { + TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + } + uploadPixelFormat = PixelFormat.Rgba; + break; + default: + throw new NotSupportedException($"Unsupported surface format: {renderSurface.Format}"); + } + } + + if (surface.Translucency > 0.0f && textureData != null) { + // If we got this from the cache, we need to clone it so we don't scale the cached raw data + if (sourceFormat.HasValue && TextureHelpers.IsCompressedFormat(sourceFormat.Value) && _decodedTextureCache.ContainsKey(renderSurfaceId)) { + var clonedData = new byte[textureData.Length]; + System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); + textureData = clonedData; + } + + float alphaScale = 1.0f - surface.Translucency; + for (int i = 3; i < textureData.Length; i += 4) { + textureData[i] = (byte)(textureData[i] * alphaScale); + } + } + + isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive); + isTransparent = isSolid ? surface.ColorValue.Alpha < 255 : + (surface.Type.HasFlag(SurfaceType.Translucent) || + surface.Type.HasFlag(SurfaceType.Base1ClipMap) || + ((uint)surface.Type & 0x100) != 0 || // Alpha + ((uint)surface.Type & 0x200) != 0 || // InvAlpha + isAdditive || + (surface.Translucency > 0.0f && surface.Translucency < 1.0f) || + textureFormat == TextureFormat.A8 || + textureFormat == TextureFormat.Rgba32f || + isDxt3or5 || + (sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5))); + } + else { + return; + } + + var format = (texWidth, texHeight, textureFormat); + var key = new TextureAtlasManager.TextureKey { + SurfaceId = surfaceId, + PaletteId = paletteId, + Stippling = poly.Stippling, + IsSolid = isSolid + }; + + if (!batchesByFormat.TryGetValue(format, out var batches)) { + batches = new List(); + batchesByFormat[format] = batches; + } + + var batch = batches.FirstOrDefault(b => b.Key.Equals(key) && b.CullMode == poly.SidesType); + if (batch == null) { + batch = new TextureBatchData { + Key = key, + CullMode = poly.SidesType, + TextureData = textureData!, + UploadPixelFormat = uploadPixelFormat, + UploadPixelType = uploadPixelType, + IsTransparent = isTransparent, + IsAdditive = isAdditive + }; + batches.Add(batch); + } + + bool batchHasWrappingUVs = batch.HasWrappingUVs; + BuildPolygonIndices(poly, gfxObj, scale, UVLookup, vertices, batch.Indices, isNeg, ref batchHasWrappingUVs); + batch.HasWrappingUVs = batchHasWrappingUVs; + } + } + + return new ObjectMeshData { + ObjectId = id, + IsSetup = false, + Vertices = vertices.ToArray(), + TextureBatches = batchesByFormat, + BoundingBox = boundingBox, + SortCenter = gfxObj?.SortCenter ?? Vector3.Zero, + DIDDegrade = gfxObj != null && gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) ? gfxObj.DIDDegrade : 0, + SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f } + }; + } + + private ObjectMeshData? PrepareEnvCellMeshData(ulong id, EnvCell envCell, CancellationToken ct) { + var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + bool hasBounds = false; + + // Calculate the inverse transform of the cell to localize its contents + var cellOrientation = new System.Numerics.Quaternion( + (float)envCell.Position.Orientation.X, + (float)envCell.Position.Orientation.Y, + (float)envCell.Position.Orientation.Z, + (float)envCell.Position.Orientation.W + ); + var cellTransform = Matrix4x4.CreateFromQuaternion(cellOrientation) * + Matrix4x4.CreateTranslation(envCell.Position.Origin); + if (!Matrix4x4.Invert(cellTransform, out var invertCellTransform)) { + invertCellTransform = Matrix4x4.Identity; + } + + // Add static objects + var emitters = new List(); + foreach (var stab in envCell.StaticObjects) { + var orientation = new System.Numerics.Quaternion( + (float)stab.Frame.Orientation.X, + (float)stab.Frame.Orientation.Y, + (float)stab.Frame.Orientation.Z, + (float)stab.Frame.Orientation.W + ); + var transform = Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(stab.Frame.Origin); + + // Localize static object transform relative to the cell + var localizedTransform = transform * invertCellTransform; + + CollectParts(stab.Id, localizedTransform, parts, ref min, ref max, ref hasBounds, ct); + + // For EnvCell static objects, we need to manually collect emitters if they are Setups. + // Bugfix 2026-05-19 (acdream): pre-check the Setup-prefix (0x02xxxxxx) before calling + // TryGet. Without this, calling TryGet on a GfxObj-prefixed id + // (0x01xxxxxx) throws ArgumentOutOfRangeException as DatReaderWriter tries to parse + // GfxObj bytes as a Setup record. The exception bubbles up through PrepareMeshData's + // outer catch and the entire cell fails to upload — manifesting as missing floors + // in any building whose StaticObjects include a GfxObj-typed stab (very common). + // Confirmed via acdream's Phase 2 indoor-cell-rendering diagnostic probes; see + // docs/research/2026-05-19-indoor-cell-rendering-cause.md in the acdream repo. + if ((stab.Id & 0xFF000000u) == 0x02000000u + && _dats.Portal.TryGet(stab.Id, out var stabSetup)) { + var stabEmitters = new List(); + var processedScripts = new HashSet(); + if (stabSetup.DefaultScript.DataId != 0) { + if (processedScripts.Add(stabSetup.DefaultScript.DataId)) { + CollectEmittersFromScript(stabSetup.DefaultScript.DataId, stabEmitters, ct); + } + } + + foreach (var emitter in stabEmitters) { + emitters.Add(new StagedEmitter { + Emitter = emitter.Emitter, + PartIndex = emitter.PartIndex, + Offset = emitter.Offset * localizedTransform + }); + } + } + } + + // Load environment and cell structure geometry + uint envId = 0x0D000000u | envCell.EnvironmentId; + ObjectMeshData? cellGeometry = null; + if (_dats.Portal.TryGet(envId, out var environment)) { + if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { + var cellGeomId = id | 0x1_0000_0000UL; + cellGeometry = PrepareCellStructMeshData(cellGeomId, cellStruct, envCell.Surfaces, Matrix4x4.Identity, ct); + if (cellGeometry != null) { + parts.Add((cellGeomId, Matrix4x4.Identity)); + min = Vector3.Min(min, cellGeometry.BoundingBox.Min); + max = Vector3.Max(max, cellGeometry.BoundingBox.Max); + hasBounds = true; + } + } + } + + return new ObjectMeshData { + ObjectId = id, + IsSetup = true, + SetupParts = parts, + ParticleEmitters = emitters, + EnvCellGeometry = cellGeometry, + BoundingBox = hasBounds ? new BoundingBox(min, max) : default, + SelectionSphere = new Sphere { Origin = hasBounds ? (min + max) / 2f : Vector3.Zero, Radius = hasBounds ? Vector3.Distance(max, min) / 2.0f : 0f } + }; + } + + private ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, List surfaceOverrides, Matrix4x4 transform, CancellationToken ct) { + var vertices = new List(); + var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>(); + var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List>(); + + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var vert in cellStruct.VertexArray.Vertices.Values) { + var localizedPos = Vector3.Transform(vert.Origin, transform); + min = Vector3.Min(min, localizedPos); + max = Vector3.Max(max, localizedPos); + } + var boundingBox = new BoundingBox(min, max); + + foreach (var poly in cellStruct.Polygons.Values) { + ct.ThrowIfCancellationRequested(); + if (poly.VertexIds.Count < 3) continue; + + // Handle Positive Surface + if (!poly.Stippling.HasFlag(StipplingType.NoPos)) { + AddSurfaceToBatch(poly, poly.PosSurface, false); + } + + // Handle Negative Surface + bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) || + poly.Stippling.HasFlag(StipplingType.Both) || + (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise); + + if (hasNeg) { + AddSurfaceToBatch(poly, poly.NegSurface, true); + } + + void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool isNeg) { + if (surfaceIdx < 0) return; + + uint surfaceId; + if (surfaceIdx < surfaceOverrides.Count) { + surfaceId = 0x08000000u | surfaceOverrides[surfaceIdx]; + } + else { + _logger.LogWarning($"Failed to find surface override for index {surfaceIdx} in CellStruct 0x{cellStruct:X4}"); + return; + } + + if (!_dats.Portal.TryGet(surfaceId, out var surface)) return; + + int texWidth, texHeight; + byte[] textureData; + TextureFormat textureFormat; + PixelFormat? uploadPixelFormat = null; + PixelType? uploadPixelType = null; + bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid); + bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); + uint paletteId = 0; + bool isDxt3or5 = false; + DatReaderWriter.Enums.PixelFormat? sourceFormat = null; + var isAdditive = false; + var isTransparent = false; + + if (isSolid) { + texWidth = texHeight = 32; + textureData = TextureHelpers.CreateSolidColorTexture(surface.ColorValue, texWidth, texHeight); + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + } + else if (_dats.Portal.TryGet(surface.OrigTextureId, out var surfaceTexture)) { + var renderSurfaceId = surfaceTexture.Textures.First(); + if (!_dats.Portal.TryGet(renderSurfaceId, out var renderSurface)) { + if (!_dats.HighRes.TryGet(renderSurfaceId, out var hrRenderSurface)) { + return; + } + renderSurface = hrRenderSurface; + } + + texWidth = renderSurface.Width; + texHeight = renderSurface.Height; + paletteId = renderSurface.DefaultPaletteId; + sourceFormat = renderSurface.Format; + + if (_decodedTextureCache.TryGetValue(renderSurfaceId, out var cachedData)) { + textureData = cachedData; + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + } + else { + if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { + isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5; + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + + textureData = new byte[texWidth * texHeight * 4]; + CompressionFormat compressionFormat = renderSurface.Format switch { + DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1, + DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2, + DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3, + _ => throw new NotSupportedException($"Unsupported compressed format: {renderSurface.Format}") + }; + + using (var image = _bcDecoder.Value!.DecodeRawToImageRgba32(renderSurface.SourceData, texWidth, texHeight, compressionFormat)) { + image.CopyPixelDataTo(textureData); + } + } + else { + textureFormat = TextureFormat.RGBA8; + textureData = renderSurface.SourceData; + switch (renderSurface.Format) { + case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16: + if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var paletteData)) return; + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_P8: + if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var p8PaletteData)) return; + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_A8: + case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA: + textureData = new byte[texWidth * texHeight * 4]; + if (surface.Type.HasFlag(SurfaceType.Additive)) { + TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + } + else { + TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + } + uploadPixelFormat = PixelFormat.Rgba; + break; + default: return; + } + } + + // Add to cache with LRU logic + if (textureData != null && _decodedTextureCache.TryAdd(renderSurfaceId, textureData)) { + _decodedTextureLru.Enqueue(renderSurfaceId); + if (_decodedTextureCache.Count > MaxDecodedTextures) { + if (_decodedTextureLru.TryDequeue(out var evictedId)) { + _decodedTextureCache.TryRemove(evictedId, out _); + } + } + } + } + + if (isClipMap && textureData != null) { + // If we got this from the cache, we need to clone it so we don't scale the cached raw data + var clonedData = new byte[textureData.Length]; + System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); + textureData = clonedData; + + for (int i = 0; i < textureData.Length; i += 4) { + if (textureData[i] == 0 && textureData[i + 1] == 0 && textureData[i + 2] == 0) { + textureData[i + 3] = 0; + } + } + } + } + else { + return; + } + + isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive); + isTransparent = isSolid ? surface.ColorValue.Alpha < 255 : + (surface.Type.HasFlag(SurfaceType.Translucent) || + surface.Type.HasFlag(SurfaceType.Base1ClipMap) || + ((uint)surface.Type & 0x100) != 0 || // Alpha + ((uint)surface.Type & 0x200) != 0 || // InvAlpha + isAdditive || + (surface.Translucency > 0.0f && surface.Translucency < 1.0f) || + textureFormat == TextureFormat.A8 || + textureFormat == TextureFormat.Rgba32f || + isDxt3or5 || + (sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5))); + + var format = (texWidth, texHeight, textureFormat); + var key = new TextureAtlasManager.TextureKey { + SurfaceId = surfaceId, + PaletteId = paletteId, + Stippling = poly.Stippling, + IsSolid = isSolid + }; + + if (!batchesByFormat.TryGetValue(format, out var batches)) { + batches = new List(); + batchesByFormat[format] = batches; + } + + var batch = batches.FirstOrDefault(b => b.Key.Equals(key) && b.CullMode == poly.SidesType); + if (batch == null) { + batch = new TextureBatchData { + Key = key, + CullMode = poly.SidesType, + TextureData = textureData!, + UploadPixelFormat = uploadPixelFormat, + UploadPixelType = uploadPixelType, + IsTransparent = isTransparent, + IsAdditive = isAdditive + }; + batches.Add(batch); + } + + // Helper for CellStruct vertices + bool batchHasWrappingUVs = batch.HasWrappingUVs; + BuildCellStructPolygonIndices(poly, cellStruct, UVLookup, vertices, batch.Indices, isNeg, transform, ref batchHasWrappingUVs); + batch.HasWrappingUVs = batchHasWrappingUVs; + } + } + + return new ObjectMeshData { + ObjectId = id, + IsSetup = false, + Vertices = vertices.ToArray(), + TextureBatches = batchesByFormat, + BoundingBox = boundingBox, + SortCenter = Vector3.Zero, + SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f } + }; + } + + private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct, + Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup, + List vertices, List indices, bool useNegSurface, Matrix4x4 transform, ref bool hasWrappingUVs) { + + var polyIndices = new List(); + + for (int i = 0; i < poly.VertexIds.Count; i++) { + ushort vertId = (ushort)poly.VertexIds[i]; + ushort uvIdx = 0; + + if (useNegSurface && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) + uvIdx = poly.NegUVIndices[i]; + else if (!useNegSurface && poly.PosUVIndices != null && i < poly.PosUVIndices.Count) + uvIdx = poly.PosUVIndices[i]; + + if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue; + + if (uvIdx >= vertex.UVs.Count) { + uvIdx = 0; + } + + var key = (vertId, uvIdx, useNegSurface); + + if (!hasWrappingUVs) { + var uvCheck = vertex.UVs.Count > 0 + ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) + : Vector2.Zero; + if (uvCheck.X < 0f || uvCheck.X > 1f || uvCheck.Y < 0f || uvCheck.Y > 1f) { + hasWrappingUVs = true; + } + } + + if (!UVLookup.TryGetValue(key, out var idx)) { + var uv = vertex.UVs.Count > 0 + ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) + : Vector2.Zero; + + var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform)); + if (useNegSurface) { + normal = -normal; + } + + idx = (ushort)vertices.Count; + vertices.Add(new VertexPositionNormalTexture( + Vector3.Transform(vertex.Origin, transform), + normal, + uv + )); + UVLookup[key] = idx; + } + polyIndices.Add(idx); + } + + if (useNegSurface) { + for (int i = 2; i < polyIndices.Count; i++) { + indices.Add(polyIndices[0]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[i]); + } + } + else { + for (int i = 2; i < polyIndices.Count; i++) { + indices.Add(polyIndices[i]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[0]); + } + } + } + + private void BuildPolygonIndices(Polygon poly, GfxObj gfxObj, Vector3 scale, + Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup, + List vertices, List indices, bool useNegSurface, ref bool hasWrappingUVs) { + + var polyIndices = new List(); + + for (int i = 0; i < poly.VertexIds.Count; i++) { + ushort vertId = (ushort)poly.VertexIds[i]; + ushort uvIdx = 0; + + if (useNegSurface && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) + uvIdx = poly.NegUVIndices[i]; + else if (!useNegSurface && poly.PosUVIndices != null && i < poly.PosUVIndices.Count) + uvIdx = poly.PosUVIndices[i]; + + if (!gfxObj.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue; + + if (uvIdx >= vertex.UVs.Count) { + uvIdx = 0; + } + + var key = (vertId, uvIdx, useNegSurface); + + if (!hasWrappingUVs) { + var uvCheck = vertex.UVs.Count > 0 + ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) + : Vector2.Zero; + if (uvCheck.X < 0f || uvCheck.X > 1f || uvCheck.Y < 0f || uvCheck.Y > 1f) { + hasWrappingUVs = true; + } + } + + if (!UVLookup.TryGetValue(key, out var idx)) { + var uv = vertex.UVs.Count > 0 + ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) + : Vector2.Zero; + + var normal = Vector3.Normalize(vertex.Normal); + if (useNegSurface) { + normal = -normal; + } + + idx = (ushort)vertices.Count; + vertices.Add(new VertexPositionNormalTexture( + vertex.Origin * scale, + normal, + uv + )); + UVLookup[key] = idx; + } + polyIndices.Add(idx); + } + + if (useNegSurface) { + // Reverse winding for negative surface so it's visible from the other side + for (int i = 2; i < polyIndices.Count; i++) { + indices.Add(polyIndices[0]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[i]); + } + } + else { + for (int i = 2; i < polyIndices.Count; i++) { + indices.Add(polyIndices[i]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[0]); + } + } + } + + #endregion + + #region Private: GPU Upload + + private unsafe ObjectRenderData? UploadGfxObjMeshData(ObjectMeshData meshData) { + if (meshData.Vertices.Length == 0) return null; + + var gl = _graphicsDevice.GL; + uint vao = 0, vbo = 0; + + if (_useModernRendering) { + // Everything goes into the global VBO/IBO + vao = GlobalBuffer!.VAO; + vbo = GlobalBuffer!.VBO; + } + else { + gl.GenVertexArrays(1, out vao); + gl.BindVertexArray(vao); + + gl.GenBuffers(1, out vbo); + gl.BindBuffer(GLEnum.ArrayBuffer, vbo); + fixed (VertexPositionNormalTexture* ptr = meshData.Vertices) { + gl.BufferData(GLEnum.ArrayBuffer, (nuint)(meshData.Vertices.Length * VertexPositionNormalTexture.Size), ptr, GLEnum.StaticDraw); + } + GpuMemoryTracker.TrackAllocation(meshData.Vertices.Length * VertexPositionNormalTexture.Size, GpuResourceType.Buffer); + + int stride = VertexPositionNormalTexture.Size; + // Position (location 0) + gl.EnableVertexAttribArray(0); + gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0); + // Normal (location 1) + gl.EnableVertexAttribArray(1); + gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float))); + // TexCoord (location 2) + gl.EnableVertexAttribArray(2); + gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float))); + + // Instance data (shared VBO) + gl.BindBuffer(GLEnum.ArrayBuffer, _graphicsDevice.InstanceVBO); + for (uint i = 0; i < 4; i++) { + var loc = 3 + i; + gl.EnableVertexAttribArray(loc); + gl.VertexAttribPointer(loc, 4, GLEnum.Float, false, (uint)sizeof(InstanceData), (void*)(i * 16)); + gl.VertexAttribDivisor(loc, 1); + } + gl.EnableVertexAttribArray(8); + gl.VertexAttribIPointer(8, 1, GLEnum.UnsignedInt, (uint)sizeof(InstanceData), (void*)64); + gl.VertexAttribDivisor(8, 1); + } + + var renderBatches = new List(); + + foreach (var (format, batches) in meshData.TextureBatches) { + foreach (var batch in batches) { + if (batch.Indices.Count == 0) continue; + + uint ibo = 0; + TextureAtlasManager? atlasManager = null; + int textureIndex = 0; + uint firstIndex = 0; + int batchBaseVertex = 0; + + // Find or create a shared atlas with free space + if (!_globalAtlases.TryGetValue(format, out var atlasList)) { + atlasList = new List(); + _globalAtlases[format] = atlasList; + } + + atlasManager = atlasList.FirstOrDefault(a => a.FreeSlots > 0 || a.HasTexture(batch.Key)); + if (atlasManager == null) { + atlasManager = new TextureAtlasManager(_graphicsDevice, format.Width, format.Height, format.Format); + atlasList.Add(atlasManager); + } + + textureIndex = atlasManager.AddTexture(batch.Key, batch.TextureData, batch.UploadPixelFormat, batch.UploadPixelType); + + if (_useModernRendering) { + ibo = GlobalBuffer!.IBO; + var appended = GlobalBuffer.Append(meshData.Vertices, batch.Indices.ToArray()); + batchBaseVertex = appended.baseVertex; + firstIndex = (uint)appended.firstIndex; + } + else { + gl.GenBuffers(1, out ibo); + gl.BindBuffer(GLEnum.ElementArrayBuffer, ibo); + var indexArray = batch.Indices.ToArray(); + fixed (ushort* iptr = indexArray) { + gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(indexArray.Length * sizeof(ushort)), iptr, GLEnum.StaticDraw); + } + GpuMemoryTracker.TrackAllocation(indexArray.Length * sizeof(ushort), GpuResourceType.Buffer); + } + + ulong bindlessHandle = batch.HasWrappingUVs + ? atlasManager.TextureArray.BindlessWrapHandle + : atlasManager.TextureArray.BindlessClampHandle; + + renderBatches.Add(new ObjectRenderBatch { + IBO = ibo, + IndexCount = batch.Indices.Count, + Atlas = atlasManager!, + TextureIndex = textureIndex, + TextureSize = (format.Width, format.Height), + TextureFormat = format.Format, + IsTransparent = batch.IsTransparent, + IsAdditive = batch.IsAdditive, + HasWrappingUVs = batch.HasWrappingUVs, + Key = batch.Key, + CullMode = batch.CullMode, + FirstIndex = firstIndex, + BaseVertex = (uint)batchBaseVertex, + BindlessTextureHandle = bindlessHandle, + }); + } + } + + var renderData = new ObjectRenderData { + VAO = vao, + VBO = vbo, + VertexCount = meshData.Vertices.Length, + Batches = renderBatches, + ParticleEmitters = meshData.ParticleEmitters, + DIDDegrade = meshData.DIDDegrade, + CPUPositions = meshData.Vertices.Select(v => v.Position).ToArray(), + CPUIndices = meshData.TextureBatches.Values.SelectMany(l => l).SelectMany(b => b.Indices).ToArray(), + CPUEdgeLines = meshData.EdgeLines, + MemorySize = (meshData.Vertices.Length * VertexPositionNormalTexture.Size) + + renderBatches.Sum(b => (long)b.IndexCount * sizeof(ushort)) + }; + + if (!_useModernRendering) { + gl.BindVertexArray(0); + } + return renderData; + } + + #endregion + + #region Private: Utilities + + #region Raycasting + + public bool IntersectMesh(ObjectRenderData renderData, Matrix4x4 transform, Vector3 rayOrigin, Vector3 rayDirection, out float distance, out Vector3 normal) { + return IntersectMeshInternal(renderData, transform, rayOrigin, rayDirection, 0, out distance, out normal); + } + + private bool IntersectMeshInternal(ObjectRenderData renderData, Matrix4x4 transform, Vector3 rayOrigin, Vector3 rayDirection, int depth, out float distance, out Vector3 normal) { + distance = float.MaxValue; + normal = Vector3.UnitZ; + bool hit = false; + + if (depth > 32) return false; // Prevent stack overflow from circular setups + + if (renderData.IsSetup) { + foreach (var part in renderData.SetupParts) { + var partData = TryGetRenderData(part.GfxObjId); + if (partData != null) { + if (IntersectMeshInternal(partData, part.Transform * transform, rayOrigin, rayDirection, depth + 1, out float d, out Vector3 n)) { + if (d < distance) { + distance = d; + normal = n; + hit = true; + } + } + } + } + return hit; + } + + if (renderData.CPUPositions.Length == 0 || renderData.CPUIndices.Length == 0) { + // Fallback to sphere if no CPU mesh data + if (renderData.SelectionSphere != null && renderData.SelectionSphere.Radius > 0.001f) { + var worldOrigin = Vector3.Transform(renderData.SelectionSphere.Origin, transform); + float radius = renderData.SelectionSphere.Radius * transform.Translation.Length(); // Rough scale + if (GeometryUtils.RayIntersectsSphere(rayOrigin, rayDirection, worldOrigin, radius, out distance)) { + normal = Vector3.Normalize(rayOrigin + rayDirection * distance - worldOrigin); + return true; + } + } + return false; + } + + // Transform ray to local space + if (!Matrix4x4.Invert(transform, out var invTransform)) return false; + Vector3 localOrigin = Vector3.Transform(rayOrigin, invTransform); + Vector3 localDirection = Vector3.Normalize(Vector3.TransformNormal(rayDirection, invTransform)); + + // Iterate through triangles + for (int i = 0; i < renderData.CPUIndices.Length; i += 3) { + Vector3 v0 = renderData.CPUPositions[renderData.CPUIndices[i]]; + Vector3 v1 = renderData.CPUPositions[renderData.CPUIndices[i + 1]]; + Vector3 v2 = renderData.CPUPositions[renderData.CPUIndices[i + 2]]; + + if (GeometryUtils.RayIntersectsTriangle(localOrigin, localDirection, v0, v1, v2, out float t)) { + // Convert t back to world space distance + Vector3 hitPointLocal = localOrigin + localDirection * t; + Vector3 hitPointWorld = Vector3.Transform(hitPointLocal, transform); + float worldDist = Vector3.Distance(rayOrigin, hitPointWorld); + + if (worldDist < distance) { + distance = worldDist; + + // Calculate normal in local space and transform to world space + Vector3 localNormal = Vector3.Normalize(Vector3.Cross(v1 - v0, v2 - v0)); + normal = Vector3.Normalize(Vector3.TransformNormal(localNormal, transform)); + + // Ensure normal faces the ray + if (Vector3.Dot(normal, rayDirection) > 0) { + normal = -normal; + } + + hit = true; + } + } + } + + return hit; + } + + #endregion + + private (Vector3 Min, Vector3 Max) ComputeBounds(GfxObj gfxObj, Vector3 scale) { + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var vert in gfxObj.VertexArray.Vertices.Values) { + var p = vert.Origin * scale; + min = Vector3.Min(min, p); + max = Vector3.Max(max, p); + } + return (min, max); + } + + private void UnloadObject(ulong key) { + if (!_renderData.TryGetValue(key, out var data)) return; + + var gl = _graphicsDevice.GL; + if (!_useModernRendering) { + if (data.VAO != 0) gl.DeleteVertexArray(data.VAO); + if (data.VBO != 0) { + gl.DeleteBuffer(data.VBO); + GpuMemoryTracker.TrackDeallocation(data.VertexCount * VertexPositionNormalTexture.Size, GpuResourceType.Buffer); + } + + foreach (var batch in data.Batches) { + if (batch.IBO != 0) { + gl.DeleteBuffer(batch.IBO); + GpuMemoryTracker.TrackDeallocation(batch.IndexCount * sizeof(ushort), GpuResourceType.Buffer); + } + if (batch.Atlas != null) { + batch.Atlas.ReleaseTexture(batch.Key); + if (batch.Atlas.UsedSlots == 0) { + batch.Atlas.Dispose(); + var keyTuple = (batch.TextureSize.Width, batch.TextureSize.Height, batch.TextureFormat); + if (_globalAtlases.TryGetValue(keyTuple, out var list)) { + list.Remove(batch.Atlas); + } + } + } + } + } + else { + foreach (var batch in data.Batches) { + if (batch.Atlas != null) { + batch.Atlas.ReleaseTexture(batch.Key); + if (batch.Atlas.UsedSlots == 0) { + batch.Atlas.Dispose(); + var keyTuple = (batch.TextureSize.Width, batch.TextureSize.Height, batch.TextureFormat); + if (_globalAtlases.TryGetValue(keyTuple, out var list)) { + list.Remove(batch.Atlas); + } + } + } + } + } + + if (data.IsSetup) { + foreach (var (partId, _) in data.SetupParts) { + DecrementRefCount(partId); + } + } + + _currentGpuMemory -= data.MemorySize; + _renderData.TryRemove(key, out _); + lock (_lruList) { + _lruList.Remove(key); + } + } + + #endregion + + public void Dispose() { + if (IsDisposed) return; + IsDisposed = true; + _graphicsDevice.QueueGLAction(gl => { + foreach (var data in _renderData.Values) { + if (!_useModernRendering) { + if (data.VAO != 0) gl.DeleteVertexArray(data.VAO); + if (data.VBO != 0) { + gl.DeleteBuffer(data.VBO); + GpuMemoryTracker.TrackDeallocation(data.VertexCount * VertexPositionNormalTexture.Size, GpuResourceType.Buffer); + } + foreach (var batch in data.Batches) { + if (batch.IBO != 0) { + gl.DeleteBuffer(batch.IBO); + GpuMemoryTracker.TrackDeallocation(batch.IndexCount * sizeof(ushort), GpuResourceType.Buffer); + } + } + } + } + _renderData.Clear(); + + foreach (var atlasList in _globalAtlases.Values) { + foreach (var atlas in atlasList) { + atlas.Dispose(); + } + } + _globalAtlases.Clear(); + + if (_useModernRendering) { + GlobalBuffer?.Dispose(); + } + }); + } + + private ObjectMeshData? PrepareCellStructEdgeLineData(ulong id, Dictionary cellStructs, Matrix4x4 transform, CancellationToken ct) { + var cellStructList = cellStructs.ToList(); + if (cellStructList.Count == 0) { + return null; + } + + // Calculate bounding box from ALL vertices in all cell structures + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + var allEdgeLines = new List(); + + // Process each CellStruct and collect all edge lines + foreach (var cellStructKvp in cellStructList) { + var cellStruct = cellStructKvp.Value; + + // Build edge lines for this CellStruct + var edgeLines = EdgeLineBuilder.BuildEdgeLines(cellStruct); + + // Transform edge lines to world space and add to collection + foreach (var edgeLine in edgeLines) { + allEdgeLines.Add(Vector3.Transform(edgeLine, transform)); + } + + // Update bounding box with vertices from this CellStruct + foreach (var vert in cellStruct.VertexArray.Vertices.Values) { + var localizedPos = Vector3.Transform(vert.Origin, transform); + min = Vector3.Min(min, localizedPos); + max = Vector3.Max(max, localizedPos); + } + } + + if (allEdgeLines.Count == 0) { + return null; + } + + var boundingBox = new BoundingBox(min, max); + + // Create minimal mesh data for edge line rendering + // We still need some vertices for rendering system to work, but they'll be transparent + var vertices = new List { + new VertexPositionNormalTexture { Position = Vector3.Zero, Normal = Vector3.UnitZ, UV = Vector2.Zero } + }; + var indices = new List { 0, 0, 0 }; // Dummy triangle + + // Create a transparent texture for base triangles (so only edge lines are visible) + var transparentTexture = TextureHelpers.CreateSolidColorTexture(new ColorARGB { Alpha = 0, Red = 255, Green = 255, Blue = 255 }, 1, 1); + + var result = new ObjectMeshData { + ObjectId = id, + IsSetup = false, + Vertices = vertices.ToArray(), + Batches = new List { + new MeshBatchData { + Indices = indices.ToArray(), + TextureFormat = (1, 1, TextureFormat.RGBA8), + TextureKey = new TextureAtlasManager.TextureKey { + SurfaceId = 0xFFFFFFFF, // Dummy surface ID + PaletteId = 0, + Stippling = StipplingType.NoPos, + IsSolid = true + }, + TextureIndex = 0, + TextureData = transparentTexture, + UploadPixelFormat = PixelFormat.Rgba, + UploadPixelType = PixelType.UnsignedByte, + CullMode = CullMode.None + } + }, + // Also populate TextureBatches for GPU upload + TextureBatches = new Dictionary<(int Width, int Height, TextureFormat Format), List> { + [(1, 1, TextureFormat.RGBA8)] = new List { + new TextureBatchData { + Indices = indices.ToList(), + Key = new TextureAtlasManager.TextureKey { + SurfaceId = 0xFFFFFFFF, // Dummy surface ID + PaletteId = 0, + Stippling = StipplingType.NoPos, + IsSolid = true + }, + TextureData = transparentTexture, + UploadPixelFormat = PixelFormat.Rgba, + UploadPixelType = PixelType.UnsignedByte, + CullMode = CullMode.None, + IsTransparent = false // Render in opaque pass but transparent + } + } + }, + BoundingBox = boundingBox, + SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f } + }; + + // Store all edge lines in mesh data for later use in UploadMeshData + result.EdgeLines = allEdgeLines.ToArray(); + + return result; + } + } +} diff --git a/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs b/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs index 3e42bd9..02f20b7 100644 --- a/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs +++ b/src/AcDream.App/Rendering/Wb/OpenGLGraphicsDevice.cs @@ -55,7 +55,7 @@ namespace AcDream.App.Rendering.Wb { public uint SharedDebugVAO { get; private set; } public uint SharedDebugInstanceVBO { get; private set; } - public Chorizite.OpenGLSDLBackend.Lib.ParticleBatcher ParticleBatcher { get; private set; } = null!; + public ParticleBatcher ParticleBatcher { get; internal set; } = null!; /// OpenGL sampler object with TextureWrapMode.Repeat (for meshes with wrapping UVs). public uint WrapSampler { get; private set; } diff --git a/src/AcDream.App/Rendering/Wb/ParticleBatcher.cs b/src/AcDream.App/Rendering/Wb/ParticleBatcher.cs new file mode 100644 index 0000000..fcfe139 --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/ParticleBatcher.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Runtime.InteropServices; +using Chorizite.Core.Render; +using Chorizite.OpenGLSDLBackend.Lib; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Wb { + [StructLayout(LayoutKind.Sequential)] + public struct ParticleInstance { + public Vector3 Position; + public Vector3 ScaleOpacityActive; // x=scale, y=opacity, z=active (1.0 or 0.0) + public float TextureIndex; + public Quaternion Rotation; + public Vector2 Size; + public float IsBillboard; // 1.0 for true, 0.0 for false + } + + public struct ParticleRenderData { + public ParticleInstance Instance; + public float DistanceSq; + public ManagedGLTextureArray? Atlas; + public bool IsAdditive; + } + + public unsafe class ParticleBatcher : IDisposable { + private const int MAX_PARTICLES_TOTAL = 65536; + + private readonly OpenGLGraphicsDevice _graphicsDevice; + private readonly uint _vao; + private readonly uint _vbo; + private readonly uint _ibo; + private readonly uint _instanceVbo; + private readonly IShader _shader; + private readonly ParticleInstance[] _instanceData = new ParticleInstance[MAX_PARTICLES_TOTAL]; + private readonly List _allParticles = new(); + private int _currentInstanceCount = 0; + + private ManagedGLTextureArray? _currentAtlas; + private bool _currentIsAdditive; + private Matrix4x4 _viewProjection; + private Vector3 _cameraUp; + private Vector3 _cameraRight; + + public ParticleBatcher(OpenGLGraphicsDevice graphicsDevice) { + _graphicsDevice = graphicsDevice; + var gl = _graphicsDevice.GL; + + var vertSource = EmbeddedResourceReader.GetEmbeddedResource("Shaders.Particle.vert"); + var fragSource = EmbeddedResourceReader.GetEmbeddedResource("Shaders.Particle.frag"); + _shader = _graphicsDevice.CreateShader("Particle", vertSource, fragSource); + + // Create quad vertices - centered to match ACViewer expansion logic + float[] vertices = { + // x, y, z, u, v + -0.5f, 0.0f, -0.5f, 0.0f, 1.0f, + 0.5f, 0.0f, -0.5f, 1.0f, 1.0f, + 0.5f, 0.0f, 0.5f, 1.0f, 0.0f, + -0.5f, 0.0f, 0.5f, 0.0f, 0.0f + }; + + ushort[] indices = { 0, 1, 2, 2, 3, 0 }; + + _vao = gl.GenVertexArray(); + gl.BindVertexArray(_vao); + + _vbo = gl.GenBuffer(); + gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); + unsafe { + fixed (float* p = vertices) { + gl.BufferData(BufferTargetARB.ArrayBuffer, (uint)(vertices.Length * sizeof(float)), p, BufferUsageARB.StaticDraw); + } + } + + _ibo = gl.GenBuffer(); + gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ibo); + unsafe { + fixed (ushort* p = indices) { + gl.BufferData(BufferTargetARB.ElementArrayBuffer, (uint)(indices.Length * sizeof(ushort)), p, BufferUsageARB.StaticDraw); + } + } + + // Quad attributes + gl.EnableVertexAttribArray(0); + gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 5 * sizeof(float), (void*)0); + gl.EnableVertexAttribArray(1); + gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, 5 * sizeof(float), (void*)(3 * sizeof(float))); + + // Instance attributes + _instanceVbo = gl.GenBuffer(); + gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); + gl.BufferData(BufferTargetARB.ArrayBuffer, (uint)(MAX_PARTICLES_TOTAL * Marshal.SizeOf()), (void*)0, BufferUsageARB.DynamicDraw); + + uint stride = (uint)Marshal.SizeOf(); + + // iPosition + gl.EnableVertexAttribArray(2); + gl.VertexAttribPointer(2, 3, VertexAttribPointerType.Float, false, stride, (void*)0); + gl.VertexAttribDivisor(2, 1); + + // iScaleOpacityActive + gl.EnableVertexAttribArray(3); + gl.VertexAttribPointer(3, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); + gl.VertexAttribDivisor(3, 1); + + // iTextureIndex + gl.EnableVertexAttribArray(4); + gl.VertexAttribPointer(4, 1, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float))); + gl.VertexAttribDivisor(4, 1); + + // iRotation (Quaternion) + gl.EnableVertexAttribArray(5); + gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, stride, (void*)(7 * sizeof(float))); + gl.VertexAttribDivisor(5, 1); + + // iSize + gl.EnableVertexAttribArray(6); + gl.VertexAttribPointer(6, 2, VertexAttribPointerType.Float, false, stride, (void*)(11 * sizeof(float))); + gl.VertexAttribDivisor(6, 1); + + // iIsBillboard + gl.EnableVertexAttribArray(7); + gl.VertexAttribPointer(7, 1, VertexAttribPointerType.Float, false, stride, (void*)(13 * sizeof(float))); + gl.VertexAttribDivisor(7, 1); + + gl.BindVertexArray(0); + + _shader.Bind(); + _shader.SetUniform("uTextureArray", 0); + _shader.Unbind(); + } + + public void Begin(Matrix4x4 viewProjection, Vector3 cameraUp, Vector3 cameraRight) { + _viewProjection = viewProjection; + _cameraUp = cameraUp; + _cameraRight = cameraRight; + _allParticles.Clear(); + } + + public void AddParticle(ManagedGLTextureArray? atlas, bool isAdditive, ParticleInstance instance, float distanceSq) { + _allParticles.Add(new ParticleRenderData { + Instance = instance, + DistanceSq = distanceSq, + Atlas = atlas, + IsAdditive = isAdditive + }); + } + + public void Flush() { + if (_allParticles.Count == 0) return; + + // Sort back-to-front + _allParticles.Sort((a, b) => b.DistanceSq.CompareTo(a.DistanceSq)); + + var gl = _graphicsDevice.GL; + + gl.BindVertexArray(_vao); + gl.DepthMask(false); + gl.Enable(EnableCap.DepthTest); + gl.Disable(EnableCap.StencilTest); + gl.Disable(EnableCap.CullFace); + gl.Disable(EnableCap.SampleAlphaToCoverage); + gl.Disable(EnableCap.SampleAlphaToOne); + gl.Enable(EnableCap.Blend); + + int i = 0; + while (i < _allParticles.Count) { + var p = _allParticles[i]; + _currentAtlas = p.Atlas; + _currentIsAdditive = p.IsAdditive; + _currentInstanceCount = 0; + + while (i < _allParticles.Count && _allParticles[i].Atlas == _currentAtlas && _allParticles[i].IsAdditive == _currentIsAdditive) { + _instanceData[_currentInstanceCount++] = _allParticles[i].Instance; + i++; + if (_currentInstanceCount >= MAX_PARTICLES_TOTAL) break; + } + + if (_currentInstanceCount > 0 && _currentAtlas != null) { + if (_currentIsAdditive) { + gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); + } + else { + gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + } + + gl.ActiveTexture(TextureUnit.Texture0); + gl.BindTexture(GLEnum.Texture2DArray, (uint)_currentAtlas.NativePtr); + // T4 interim: BaseObjectRenderManager state fields stay on the WB type until T7 + // when the WorldBuilder project reference is dropped entirely. + BaseObjectRenderManager.CurrentAtlas = (uint)_currentAtlas.Slot; + + gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); + unsafe { + fixed (ParticleInstance* pData = _instanceData) { + gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (uint)(_currentInstanceCount * Marshal.SizeOf()), pData); + } + } + + _shader.Bind(); + _shader.SetUniform("uViewProjection", _viewProjection); + _shader.SetUniform("uCameraUp", _cameraUp); + _shader.SetUniform("uCameraRight", _cameraRight); + + gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedShort, (void*)0, (uint)_currentInstanceCount); + } + } + + gl.DepthMask(true); + _allParticles.Clear(); + + // T4 interim: BaseObjectRenderManager state fields stay on the WB type until T7 + BaseObjectRenderManager.CurrentVAO = 0; + BaseObjectRenderManager.CurrentIBO = 0; + } + + public void End() { + Flush(); + } + + public void Dispose() { + var gl = _graphicsDevice.GL; + gl.DeleteVertexArray(_vao); + gl.DeleteBuffer(_vbo); + gl.DeleteBuffer(_instanceVbo); + gl.DeleteBuffer(_ibo); + (_shader as IDisposable)?.Dispose(); + } + } +} diff --git a/src/AcDream.App/Rendering/Wb/ParticleEmitterRenderer.cs b/src/AcDream.App/Rendering/Wb/ParticleEmitterRenderer.cs new file mode 100644 index 0000000..9476aef --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/ParticleEmitterRenderer.cs @@ -0,0 +1,495 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Runtime.InteropServices; +using Chorizite.Core.Lib; +using Chorizite.Core.Render; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Wb { + public class ParticleEmitterRenderer : IDisposable { + private const float EPSILON = 0.0002f; + + private readonly OpenGLGraphicsDevice _graphicsDevice; + private readonly ObjectMeshManager _meshManager; + private readonly ParticleEmitter _emitter; + private readonly List _particles = new(); + private readonly Random _random = new(); + + private ObjectRenderData? _gfxRenderData; + private ObjectRenderData? _textureRenderData; + private bool _isPointSprite; + private Quaternion _planeRotation = Quaternion.Identity; + private float _emissionTimer; + private int _totalEmitted; + private float _timeRunning; + private float _deadTimer; + + public bool IsActive => true; // Previews always loop + + public Matrix4x4 ParentTransform { get; set; } = Matrix4x4.Identity; + public Matrix4x4 LocalOffset { get; set; } = Matrix4x4.Identity; + + struct Particle { + public Vector3 WorldOffset; + public Vector3 WorldA; + public Vector3 WorldB; + public Vector3 WorldC; + public float Lifetime; + public float MaxLifetime; + public float FinalStartScale; + public float FinalFinalScale; + public float FinalStartTrans; + public float FinalFinalTrans; + public bool IsActive; + public Vector3 EmissionOrigin; + public Quaternion Orientation; + + public Vector3 CalculatedPosition; + public float DistanceToCameraSq; + } + + public ParticleEmitterRenderer(OpenGLGraphicsDevice graphicsDevice, ObjectMeshManager meshManager, ParticleEmitter emitter) { + _graphicsDevice = graphicsDevice; + _meshManager = meshManager; + _emitter = emitter; + + if (emitter.HwGfxObjId.DataId != 0) { + _meshManager.IncrementRefCount(emitter.HwGfxObjId.DataId); + } + if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) { + _meshManager.IncrementRefCount(emitter.GfxObjId.DataId); + } + } + + public void Update(float deltaTime) { + // Make sure textures are loaded + if (_gfxRenderData == null) { + var gfxId = _emitter.HwGfxObjId.DataId != 0 ? _emitter.HwGfxObjId.DataId : _emitter.GfxObjId.DataId; + if (gfxId != 0) { + _gfxRenderData = _meshManager.TryGetRenderData(gfxId); + } + } + if (_textureRenderData == null && _emitter.GfxObjId.DataId != 0) { + _textureRenderData = _meshManager.TryGetRenderData(_emitter.GfxObjId.DataId); + } + + _isPointSprite = _gfxRenderData == null; + if (_gfxRenderData != null) { + var degradeId = _gfxRenderData.DIDDegrade; + if (degradeId != 0) { + if (_meshManager.Dats.Portal.TryGet(degradeId, out var degrades) && degrades.Degrades.Count > 0) { + _isPointSprite = degrades.Degrades[0].DegradeMode == 2; + } + } + } + + bool isPersistent = _emitter.TotalParticles == 0 && _emitter.TotalSeconds == 0; + bool isPersistentStill = isPersistent && _emitter.ParticleType == ParticleType.Still; + + // 1. Update existing particles and kill immediately if expired + for (int i = _particles.Count - 1; i >= 0; i--) { + var p = _particles[i]; + + if (isPersistentStill) { + p.Lifetime = 0; + } + else { + p.Lifetime += deltaTime; + } + + if (!isPersistentStill && p.Lifetime >= p.MaxLifetime) { + _particles.RemoveAt(i); + continue; + } + + p.CalculatedPosition = CalculatePosition(ref p); + _particles[i] = p; + } + + _timeRunning += deltaTime; + + // 2. Emission check + bool canEmit = (isPersistent || _timeRunning < _emitter.TotalSeconds) && + (_emitter.TotalParticles == 0 || _totalEmitted < _emitter.TotalParticles); + + if (!canEmit && _particles.Count == 0) { + _deadTimer += deltaTime; + if (_deadTimer >= 1.0f) { + _timeRunning = 0; + _totalEmitted = 0; + _emissionTimer = 0; + _deadTimer = 0f; + canEmit = true; + } + } else { + _deadTimer = 0f; + } + + if (canEmit) { + if (_totalEmitted == 0 && _emitter.InitialParticles > 0) { + for (int i = 0; i < _emitter.InitialParticles; i++) { + if (_particles.Count < _emitter.MaxParticles) { + Emit(); + } + } + } + + if (_emitter.EmitterType == EmitterType.BirthratePerSec || _emitter.EmitterType == EmitterType.Unknown) { + _emissionTimer += deltaTime; + float interval = (float)_emitter.Birthrate; + if (interval <= 0.001f) { + while (_particles.Count < Math.Max(1, _emitter.MaxParticles)) { + if (_emitter.TotalParticles > 0 && _totalEmitted >= _emitter.TotalParticles) break; + Emit(); + } + } else { + while (_emissionTimer >= interval) { + if (_emitter.TotalParticles > 0 && _totalEmitted >= _emitter.TotalParticles) break; + + if (_particles.Count < _emitter.MaxParticles) { + Emit(); + _emissionTimer -= interval; + } + else { + // Cap timer debt if we're full + _emissionTimer = interval; + break; + } + } + } + } + } + } + + private void Emit() { + var p = new Particle(); + p.Lifetime = 0; + p.MaxLifetime = GetRandomLifespan(); + if (p.MaxLifetime < 0.001f) p.MaxLifetime = 0.001f; + + var localRandomOffset = GetRandomOffset(); + var localA = GetRandomA(); + var localB = GetRandomB(); + var localC = GetRandomC(); + + var startFrame = LocalOffset * ParentTransform; + p.EmissionOrigin = startFrame.Translation; + + p.WorldOffset = Vector3.Transform(localRandomOffset, startFrame) - p.EmissionOrigin; + + // AC Client Logic for vector spaces (Particle::Init): + p.WorldA = localA; + p.WorldB = localB; + p.WorldC = localC; + + switch (_emitter.ParticleType) { + case ParticleType.LocalVelocity: // 2 + case ParticleType.ParabolicLVGA: // 3 + p.WorldA = Vector3.TransformNormal(localA, startFrame); + break; + + case ParticleType.ParabolicLVLA: // 8 + p.WorldA = Vector3.TransformNormal(localA, startFrame); + p.WorldB = Vector3.TransformNormal(localB, startFrame); + break; + + case ParticleType.ParabolicLVGAGR: // 4 + p.WorldA = Vector3.TransformNormal(localA, startFrame); + p.WorldC = localC; + break; + + case ParticleType.Swarm: // 5 + p.WorldA = Vector3.TransformNormal(localA, startFrame); + break; + + case ParticleType.Explode: // 6 + // Type 6 (Explode) A and B are global + p.WorldA = localA; + p.WorldB = localB; + + // Special WorldC initialization for Explode + float randA = (float)(_random.NextDouble() * 2.0 * Math.PI - Math.PI); + float randB = (float)(_random.NextDouble() * 2.0 * Math.PI - Math.PI); + float cosB = (float)Math.Cos(randB); + + p.WorldC = new Vector3( + (float)(Math.Cos(randA) * localC.X * cosB), + (float)(Math.Sin(randA) * localC.Y * cosB), + (float)(Math.Sin(randB) * localC.Z) + ); + if (NormalizeCheckSmall(ref p.WorldC)) p.WorldC = Vector3.Zero; + break; + + case ParticleType.Implode: // 7 + p.WorldOffset *= localC.X; + p.WorldC = p.WorldOffset; + break; + + case ParticleType.ParabolicLVLALR: // 9 + p.WorldA = Vector3.TransformNormal(localA, startFrame); + p.WorldC = Vector3.TransformNormal(localC, startFrame); + break; + + case ParticleType.ParabolicGVGAGR: // 11 + p.WorldC = localC; + break; + } + + p.FinalStartScale = Math.Clamp(_emitter.StartScale + (float)(_random.NextDouble() * 2.0 - 1.0) * _emitter.ScaleRand, 0.1f, 10.0f); + p.FinalFinalScale = Math.Clamp(_emitter.FinalScale + (float)(_random.NextDouble() * 2.0 - 1.0) * _emitter.ScaleRand, 0.1f, 10.0f); + p.FinalStartTrans = Math.Clamp(_emitter.StartTrans + (float)(_random.NextDouble() * 2.0 - 1.0) * _emitter.TransRand, 0.0f, 1.0f); + p.FinalFinalTrans = Math.Clamp(_emitter.FinalTrans + (float)(_random.NextDouble() * 2.0 - 1.0) * _emitter.TransRand, 0.0f, 1.0f); + + p.IsActive = true; + p.Orientation = Quaternion.CreateFromRotationMatrix(startFrame); + + p.CalculatedPosition = CalculatePosition(ref p); + + _particles.Add(p); + _totalEmitted++; + } + + private float GetRandomLifespan() { + var result = (_random.NextDouble() * 2.0 - 1.0) * _emitter.LifespanRand + _emitter.Lifespan; + return (float)Math.Max(0.0, result); + } + + private Vector3 GetRandomOffset() { + var rng = new Vector3( + (float)(_random.NextDouble() * 2.0 - 1.0), + (float)(_random.NextDouble() * 2.0 - 1.0), + (float)(_random.NextDouble() * 2.0 - 1.0) + ); + + var offsetDir = _emitter.OffsetDir; + var dot = Vector3.Dot(offsetDir, rng); + var randomAngle = rng - offsetDir * dot; + + if (NormalizeCheckSmall(ref randomAngle)) + return Vector3.Zero; + + var magnitude = (float)(_random.NextDouble() * (_emitter.MaxOffset - _emitter.MinOffset) + _emitter.MinOffset); + return randomAngle * magnitude; + } + + private Vector3 GetRandomA() { + var magnitude = (_emitter.MaxA - _emitter.MinA) * _random.NextDouble() + _emitter.MinA; + return _emitter.A * (float)magnitude; + } + + private Vector3 GetRandomB() { + var magnitude = (_emitter.MaxB - _emitter.MinB) * _random.NextDouble() + _emitter.MinB; + return _emitter.B * (float)magnitude; + } + + private Vector3 GetRandomC() { + var magnitude = (_emitter.MaxC - _emitter.MinC) * _random.NextDouble() + _emitter.MinC; + return _emitter.C * (float)magnitude; + } + + private bool NormalizeCheckSmall(ref Vector3 v) { + var dist = v.Length(); + if (dist < EPSILON) + return true; + + v *= 1.0f / dist; + return false; + } + + private Vector3 CalculatePosition(ref Particle p) { + float t = p.Lifetime; + Vector3 parentOrigin = _emitter.IsParentLocal ? (LocalOffset * ParentTransform).Translation : p.EmissionOrigin; + + switch (_emitter.ParticleType) { + case ParticleType.Still: + return parentOrigin + p.WorldOffset; + + case ParticleType.LocalVelocity: + case ParticleType.GlobalVelocity: + return parentOrigin + p.WorldOffset + (t * p.WorldA); + + case ParticleType.ParabolicLVGA: + case ParticleType.ParabolicLVLA: + case ParticleType.ParabolicGVGA: + return parentOrigin + p.WorldOffset + (t * p.WorldA) + (0.5f * t * t * p.WorldB); + + case ParticleType.ParabolicLVGAGR: + case ParticleType.ParabolicLVLALR: + case ParticleType.ParabolicGVGAGR: + return parentOrigin + p.WorldOffset + (t * p.WorldA) + (0.5f * t * t * p.WorldB); + + case ParticleType.Swarm: + var swarmOrigin = parentOrigin + p.WorldOffset + (t * p.WorldA); + return new Vector3( + (float)Math.Cos(t * p.WorldB.X) * p.WorldC.X + swarmOrigin.X, + (float)Math.Sin(t * p.WorldB.Y) * p.WorldC.Y + swarmOrigin.Y, + (float)Math.Cos(t * p.WorldB.Z) * p.WorldC.Z + swarmOrigin.Z + ); + + case ParticleType.Explode: + return new Vector3( + (t * p.WorldB.X + p.WorldC.X * p.WorldA.X) * t + p.WorldOffset.X + parentOrigin.X, + (t * p.WorldB.Y + p.WorldC.Y * p.WorldA.X) * t + p.WorldOffset.Y + parentOrigin.Y, + (t * p.WorldB.Z + p.WorldC.Z * p.WorldA.X + p.WorldA.Z) * t + p.WorldOffset.Z + parentOrigin.Z + ); + + case ParticleType.Implode: + return ((float)Math.Cos(p.WorldA.X * t) * p.WorldC) + (t * t * p.WorldB) + parentOrigin + p.WorldOffset; + + default: + return parentOrigin + p.WorldOffset + (t * p.WorldA); + } + } + + + public unsafe void Render(ParticleBatcher batcher) { + if (_particles.Count == 0) return; + + // Decide which data to use for texturing. + // ACViewer uses HwGfxObjId for both geometry and texture. + var textureData = _gfxRenderData ?? _textureRenderData; + + var cameraPos = _graphicsDevice.CurrentSceneData.CameraPosition; + + // ACViewer PointSprite logic: + // Effective scale is 0.9 * BoundingBox size (1.8 * 0.5 in ACViewer shader) + // For DrawGfxObj, it uses actual scale. + float baseScale = _isPointSprite ? 0.9f : 1.0f; + Vector2 particleSize = new Vector2(1.0f, 1.0f); + Vector3 localCenter = Vector3.Zero; + _planeRotation = Quaternion.Identity; + if (_gfxRenderData != null) { + var size = _gfxRenderData.BoundingBox.Max - _gfxRenderData.BoundingBox.Min; + localCenter = (_gfxRenderData.BoundingBox.Max + _gfxRenderData.BoundingBox.Min) / 2.0f; + + if (!_isPointSprite) { + if (size.Y > size.X && size.Y > size.Z) { + // Primarily in XY plane (if X is also large) or YZ plane (if Z is also large) + if (size.X > size.Z) { + // XY plane: Map shader X->X, Z->Y + particleSize.X = size.X; + particleSize.Y = size.Y; + _planeRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, -MathF.PI / 2.0f); + } else { + // YZ plane: Map shader X->Y, Z->Z + particleSize.X = size.Y; + particleSize.Y = size.Z; + _planeRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, MathF.PI / 2.0f); + } + } else if (size.X > size.Y && size.X > size.Z) { + // Primarily in XZ plane (normal Y) or XY plane (normal Z) + if (size.Z > size.Y) { + // XZ plane: Already matches shader + particleSize.X = size.X; + particleSize.Y = size.Z; + _planeRotation = Quaternion.Identity; + } else { + // XY plane: Map shader X->X, Z->Y + particleSize.X = size.X; + particleSize.Y = size.Y; + _planeRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, -MathF.PI / 2.0f); + } + } else { + // Primarily in XZ or YZ + if (size.X > size.Y) { + // XZ plane + particleSize.X = size.X; + particleSize.Y = size.Z; + _planeRotation = Quaternion.Identity; + } else { + // YZ plane: Map shader X->Y, Z->Z + particleSize.X = size.Y; + particleSize.Y = size.Z; + _planeRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, MathF.PI / 2.0f); + } + } + } else { + // Point sprite always uses XZ size + particleSize.X = size.X; + particleSize.Y = size.Z; + _planeRotation = Quaternion.Identity; + } + + // If it's a unit quad, dimensions will be 1.0 + if (particleSize.X < 0.001f) particleSize.X = 1.0f; + if (particleSize.Y < 0.001f) particleSize.Y = 1.0f; + } + + // Update particle distances + for (int i = 0; i < _particles.Count; i++) { + var p = _particles[i]; + p.DistanceToCameraSq = Vector3.DistanceSquared(p.CalculatedPosition, cameraPos); + _particles[i] = p; + } + + // Prepare instance data + ManagedGLTextureArray? atlas = null; + uint textureIndex = 0; + bool isAdditive = false; + + if (textureData?.Batches.Count > 0) { + var batch = textureData.Batches[0]; + isAdditive = batch.IsAdditive; + textureIndex = (uint)batch.TextureIndex; + if (batch.Atlas != null && batch.Atlas.TextureArray is ManagedGLTextureArray managedTexArray) { + atlas = managedTexArray; + } + } + + for (int i = 0; i < _particles.Count; i++) { + var p = _particles[i]; + float lerp = Math.Clamp(p.Lifetime / p.MaxLifetime, 0f, 1f); + + float currentScale = (p.FinalStartScale + (p.FinalFinalScale - p.FinalStartScale) * lerp) * baseScale; + float opacity = 1.0f - (p.FinalStartTrans + (p.FinalFinalTrans - p.FinalStartTrans) * lerp); + + var pos = p.CalculatedPosition; + var orientation = p.Orientation; + + if (_emitter.ParticleType == ParticleType.ParabolicLVGAGR || + _emitter.ParticleType == ParticleType.ParabolicLVLALR || + _emitter.ParticleType == ParticleType.ParabolicGVGAGR) { + var w = p.WorldC * (lerp * p.MaxLifetime); + var magSq = w.LengthSquared(); + if (magSq > 0.00000001f) { + var mag = MathF.Sqrt(magSq); + orientation *= Quaternion.CreateFromAxisAngle(w / mag, mag); + } + } + + var offset = localCenter * currentScale; + // Align particle to the BoundingBox center since we render a mathematically centered quad. + if (_isPointSprite) { + pos.Z += offset.Z; // For billboards we only shift vertically to stay upright + } else { + pos += Vector3.Transform(offset, orientation); + } + + var instance = new ParticleInstance { + Position = pos, + ScaleOpacityActive = new Vector3(currentScale, opacity, 1.0f), + TextureIndex = (float)textureIndex, + Rotation = _isPointSprite ? orientation : orientation * _planeRotation, + Size = particleSize, + IsBillboard = _isPointSprite ? 1.0f : 0.0f + }; + + + batcher.AddParticle(atlas, isAdditive, instance, p.DistanceToCameraSq); + } + } + + public void Dispose() { + // Decrement reference counts that were incremented when the renderer was created/initialized + if (_emitter.HwGfxObjId.DataId != 0) { + _meshManager.ReleaseRenderData(_emitter.HwGfxObjId.DataId); + } + if (_emitter.GfxObjId.DataId != 0 && _emitter.GfxObjId.DataId != _emitter.HwGfxObjId.DataId) { + _meshManager.ReleaseRenderData(_emitter.GfxObjId.DataId); + } + } + } +} diff --git a/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs b/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs new file mode 100644 index 0000000..8ce969d --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs @@ -0,0 +1,120 @@ +using Chorizite.Core.Render; +using Chorizite.Core.Render.Enums; +using DatReaderWriter.Enums; +using Silk.NET.OpenGL; +using System; +using System.Collections.Generic; +using PixelFormat = Silk.NET.OpenGL.PixelFormat; + +namespace AcDream.App.Rendering.Wb { + /// + /// Manages texture arrays grouped by (Width, Height, Format). + /// Deduplicates textures by a TextureKey and supports reference counting. + /// + public class TextureAtlasManager : IDisposable { + private static uint _nextSlot = 1; + private readonly OpenGLGraphicsDevice _graphicsDevice; + private readonly int _textureWidth; + private readonly int _textureHeight; + private readonly TextureFormat _format; + private readonly Dictionary _textureIndices = new(); + private readonly Dictionary _refCounts = new(); + private readonly Stack _freeSlots = new(); + private int _nextIndex = 0; + private const int InitialCapacity = 32; + + public uint Slot { get; } + public ManagedGLTextureArray TextureArray { get; private set; } = null!; + public int UsedSlots => _textureIndices.Count; + public int TotalSlots => TextureArray?.Size ?? InitialCapacity; + public int FreeSlots => TotalSlots - UsedSlots; + + public TextureAtlasManager(OpenGLGraphicsDevice graphicsDevice, int width, int height, TextureFormat format = TextureFormat.RGBA8) { + Slot = _nextSlot++; + _graphicsDevice = graphicsDevice; + _textureWidth = width; + _textureHeight = height; + _format = format; + TextureArray = (ManagedGLTextureArray)graphicsDevice.CreateTextureArrayInternal(format, width, height, InitialCapacity, TextureParameters.ClampToEdge); + } + + public int AddTexture(TextureKey key, byte[] data, PixelFormat? uploadPixelFormat = null, PixelType? uploadPixelType = null) { + if (_textureIndices.TryGetValue(key, out var existingIndex)) { + _refCounts[existingIndex]++; + return existingIndex; + } + + int index; + if (_freeSlots.Count > 0) { + index = _freeSlots.Pop(); + } + else { + index = _nextIndex++; + if (index >= TextureArray.Size) { + throw new Exception($"Texture atlas is full! {TextureArray.Size} / {_nextIndex} used."); + } + } + + try { + TextureArray.UpdateLayer(index, data, uploadPixelFormat, uploadPixelType); + _textureIndices[key] = index; + _refCounts[index] = 1; + return index; + } + catch (Exception) { + if (!_textureIndices.ContainsKey(key)) { + _freeSlots.Push(index); + } + throw; + } + } + + public void ReleaseTexture(TextureKey key) { + if (!_textureIndices.TryGetValue(key, out var index)) return; + + if (!_refCounts.ContainsKey(index)) return; + + _refCounts[index]--; + if (_refCounts[index] <= 0) { + _textureIndices.Remove(key); + _refCounts.Remove(index); + _freeSlots.Push(index); + TextureArray?.RemoveLayer(index); + } + } + + public bool HasTexture(TextureKey key) => _textureIndices.ContainsKey(key); + + public int GetTextureIndex(TextureKey key) => + _textureIndices.TryGetValue(key, out var index) ? index : -1; + + public void Dispose() { + TextureArray?.Dispose(); + _textureIndices.Clear(); + _refCounts.Clear(); + _freeSlots.Clear(); + } + + public struct TextureKey : IEquatable { + public uint SurfaceId; + public uint PaletteId; + public StipplingType Stippling; + public bool IsSolid; + + public bool Equals(TextureKey other) { + return SurfaceId == other.SurfaceId && + PaletteId == other.PaletteId && + Stippling == other.Stippling && + IsSolid == other.IsSolid; + } + + public override bool Equals(object? obj) { + return obj is TextureKey other && Equals(other); + } + + public override int GetHashCode() { + return HashCode.Combine(SurfaceId, PaletteId, Stippling, IsSolid); + } + } + } +} diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index b79354d..b5c6a80 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -10,6 +10,7 @@ using DatReaderWriter.DBObjs; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Silk.NET.OpenGL; +using WorldBuilder.Shared.Models; using WorldBuilder.Shared.Services; namespace AcDream.App.Rendering.Wb; @@ -28,9 +29,7 @@ namespace AcDream.App.Rendering.Wb; /// public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter { - // T3 interim: ObjectMeshManager (T4-to-be-extracted) still expects the WB-original type. - // Will become AcDream.App.Rendering.Wb.OpenGLGraphicsDevice when T4 is done. - private readonly Chorizite.OpenGLSDLBackend.OpenGLGraphicsDevice? _graphicsDevice; + private readonly OpenGLGraphicsDevice? _graphicsDevice; private readonly DefaultDatReaderWriter? _wbDats; private readonly ObjectMeshManager? _meshManager; private readonly DatCollection? _dats; @@ -75,9 +74,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter ArgumentNullException.ThrowIfNull(logger); _dats = dats; - // T3 interim: construct the WB-original device for ObjectMeshManager compatibility. - // Will swap to AcDream.App.Rendering.Wb.OpenGLGraphicsDevice when T4 extracts ObjectMeshManager. - _graphicsDevice = new Chorizite.OpenGLSDLBackend.OpenGLGraphicsDevice(gl, logger, new WorldBuilder.Shared.Models.DebugRenderSettings()); + _graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings()); + _graphicsDevice.ParticleBatcher = new ParticleBatcher(_graphicsDevice); _wbDats = new DefaultDatReaderWriter(datDir); // Phase 2 diagnostic — replace NullLogger with a Console-backed // logger so WB's internal catch block at ObjectMeshManager.cs:589 @@ -86,7 +84,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // so successful operations stay quiet. _meshManager = new ObjectMeshManager( _graphicsDevice, - _wbDats, + new DatCollectionAdapter(dats), new ConsoleErrorLogger()); }