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