acdream/src/AcDream.Content/MeshExtractor.cs
Erik 363e046112 feat(vfx): decode retail hooks and typed tables
Replace the incomplete package path with one DatCollection-backed compatibility seam for PhysicsScripts and Animations. Preserve CreateBlockingParticle's inherited payload and following cursor, route every production and audit consumer through the corrected loaders, and apply retail's post-UnPack StartTime ordering.

Add exact stored-order PhysicsScriptTable upper-threshold resolution, high-byte DID and embedded-ID validation, plus live effect profiles with Setup-to-PhysicsDesc precedence across top-level and attached entity lifetimes. Keep blocking execution deferred and narrow TS-11 accordingly.

Pin synthetic malformed/cursor/order fixtures, installed-DAT blocking and recall audits, high-index IDs, IEEE boundaries, profile teardown, and ordinary decoder parity; synchronize architecture, inventory, milestones, roadmap, research, and memory.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 08:17:44 +02:00

1176 lines
60 KiB
C#

using AcDream.Core.Rendering.Wb;
using AcDream.Content.Vfx;
using BCnEncoder.Decoder;
using BCnEncoder.ImageSharp;
using BCnEncoder.Shared;
using Chorizite.Core.Lib;
using Chorizite.Core.Render.Enums;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Threading;
using CullMode = DatReaderWriter.Enums.CullMode;
using BoundingBox = Chorizite.Core.Lib.BoundingBox;
namespace AcDream.Content;
/// <summary>
/// MP1a (2026-07-05): the GL-free CPU half of the former ObjectMeshManager —
/// dat read → polygon walk → vertex/index build → inline texture decode →
/// <see cref="ObjectMeshData"/>. Extracted VERBATIM so the MP1b bake tool
/// and the live client run the SAME extraction code (byte-identical output
/// is the pak conformance foundation). ObjectMeshManager (App) retains the
/// queue/worker lifecycle and all GL upload; it delegates here.
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §6.1.
/// </summary>
public sealed class MeshExtractor {
private readonly IDatReaderWriter _dats;
private readonly ILogger _logger;
private readonly RetailPhysicsScriptLoader _physicsScripts;
// Cache for decoded textures to avoid redundant BCn decoding
private readonly ConcurrentQueue<uint> _decodedTextureLru = new();
private readonly ConcurrentDictionary<uint, byte[]> _decodedTextureCache = new();
private const int MaxDecodedTextures = 128;
private readonly ThreadLocal<BcDecoder> _bcDecoder = new(() => new BcDecoder());
/// <summary>
/// MP1a mechanical seam: receives particle-preload meshes staged
/// mid-extraction (see <see cref="CollectEmittersFromScript"/>). The App
/// wires this to its staged-upload queue, restoring the original
/// immediate-enqueue semantics — entries must survive a subsequent throw
/// in the same Prepare* call (retail's code enqueued directly onto
/// ObjectMeshManager's <c>_stagedMeshData</c> mid-Prepare, so preloads
/// staged before a malformed-dat texture-decode throw were already safe).
/// The MP1b bake tool passes its own collector. The sink must be
/// thread-safe: one MeshExtractor is shared by up to MaxParallelLoads (4)
/// decode workers.
/// The constructor argument is REQUIRED (no default): a bake tool that
/// forgot the sink would silently lose particle-preload meshes; requiring
/// the argument forces the decision. The type stays nullable so a caller
/// can consciously pass null when preloads are irrelevant.
/// </summary>
private readonly Action<ObjectMeshData>? _sideStagedSink;
public MeshExtractor(IDatReaderWriter dats, ILogger logger, Action<ObjectMeshData>? sideStagedSink) {
_dats = dats;
_logger = logger;
_sideStagedSink = sideStagedSink;
_physicsScripts = new RetailPhysicsScriptLoader(dats.Portal);
}
/// <summary>
/// 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.
/// </summary>
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<Setup>(datId, out var setup)) return null;
return PrepareSetupMeshData(id, setup, ct);
}
else if (type == DBObjType.GfxObj) {
if (!db.TryGet<GfxObj>(datId, out var gfxObj)) return null;
return PrepareGfxObjMeshData(id, gfxObj, Vector3.One, ct);
}
else if (type == DBObjType.EnvCell) {
if (!db.TryGet<EnvCell>(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<DatReaderWriter.DBObjs.Environment>(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<DatReaderWriter.DBObjs.Environment>(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;
}
}
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<StagedEmitter>();
var processedScripts = new HashSet<uint>();
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<StagedEmitter> emitters, CancellationToken ct) {
var script = _physicsScripts.LoadPhysicsScript(scriptId);
if (script is not null) {
foreach (var hook in script.ScriptData) {
if (hook.Hook is CreateParticleHook particleHook) {
if (_dats.Portal.TryGet<ParticleEmitter>(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) {
_sideStagedSink?.Invoke(meshData);
}
}
if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) {
var meshData = PrepareMeshData(emitter.GfxObjId.DataId, false, ct);
if (meshData != null) {
_sideStagedSink?.Invoke(meshData);
}
}
}
}
}
}
}
public 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<Setup>(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<EnvCell>(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<DatReaderWriter.DBObjs.Environment>(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<GfxObj>(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<VertexPositionNormalTexture>();
var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>();
var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>>();
var (min, max) = ComputeBounds(gfxObj, scale);
var boundingBox = new BoundingBox(min, max);
// #113 (2026-06-11): retail draws a GfxObj by TRAVERSING its drawing
// BSP — a polygon present in the Polygons dictionary but referenced by
// no DrawingBSP node is never rendered (physics/no-draw geometry).
// The Holtburg meeting hall (0x010014C3) keeps its walkable exterior
// stair-ramp as dictionary polys {0,1}: in the PhysicsBSP (NPCs walk
// it) but absent from every DrawingBSP node — retail shows a plain
// wall; iterating the dictionary draws the "phantom staircase"
// (invisible-but-walkable in retail, visible in acdream). The hill
// cottage (0x01000827) carries 8 such orphans.
//
// ⚠️ FILTER NOT APPLIED (e46d3d9 un-applied same day): naively
// filtering to CollectDrawingBspPolygonIds(gfxObj) made DOORS
// disappear across Holtburg (user gate 2026-06-11) — the naive
// PosNode/NegNode walk evidently misses polys some models reference
// another way (portal-type nodes? leaf indexing? DatReaderWriter
// parse gap?). Diagnose with the histogram fact in
// Issue113PhantomStairsDumpTests on a door GfxObj BEFORE re-landing.
// The full retail draw is BSP-TRAVERSAL ORDER drawing, not a
// dictionary iteration with a filter — see the holistic port handoff
// docs/research/2026-06-11-building-render-holistic-port-handoff.md.
foreach (var polyEntry in gfxObj.Polygons) {
ct.ThrowIfCancellationRequested();
var poly = polyEntry.Value;
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<Surface>(surfaceId, out var surface)) {
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-skip] gfxobj Surface 0x{surfaceId:X8} miss -> poly batch dropped (obj 0x{gfxObj.Id:X8})");
return;
}
int texWidth, texHeight;
byte[] textureData;
TextureFormat textureFormat;
UploadPixelFormat? uploadPixelFormat = null;
UploadPixelType? 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 = UploadPixelFormat.Rgba;
}
else if (_dats.Portal.TryGet<SurfaceTexture>(surface.OrigTextureId, out var surfaceTexture)) {
var renderSurfaceId = surfaceTexture.Textures.First();
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var renderSurface)) {
// check highres
if (!_dats.HighRes.TryGet<RenderSurface>(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 = UploadPixelFormat.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 = UploadPixelFormat.Rgba;
break;
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
textureData = new byte[texWidth * texHeight * 4];
TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
uploadPixelFormat = UploadPixelFormat.Rgba;
break;
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
if (!_dats.Portal.TryGet<Palette>(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 = UploadPixelFormat.Rgba;
break;
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
if (!_dats.Portal.TryGet<Palette>(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 = UploadPixelFormat.Rgba;
break;
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
textureData = new byte[texWidth * texHeight * 4];
TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
uploadPixelFormat = UploadPixelFormat.Rgba;
break;
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
textureData = new byte[texWidth * texHeight * 4];
TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
uploadPixelFormat = UploadPixelFormat.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 = UploadPixelFormat.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 {
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-skip] gfxobj SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> poly batch dropped (surface 0x{surfaceId:X8})");
return;
}
var format = (texWidth, texHeight, textureFormat);
var key = new TextureKey {
SurfaceId = surfaceId,
PaletteId = paletteId,
Stippling = poly.Stippling,
IsSolid = isSolid
};
if (!batchesByFormat.TryGetValue(format, out var batches)) {
batches = new List<TextureBatchData>();
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<StagedEmitter>();
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<Setup>. Without this, calling TryGet<Setup> 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<Setup>(stab.Id, out var stabSetup)) {
var stabEmitters = new List<StagedEmitter>();
var processedScripts = new HashSet<uint>();
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<DatReaderWriter.DBObjs.Environment>(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 }
};
}
public ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, List<ushort> surfaceOverrides, Matrix4x4 transform, CancellationToken ct) {
var vertices = new List<VertexPositionNormalTexture>();
var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>();
var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>>();
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;
// Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this
// DatReaderWriter "CullMode" as CPolygon::sides_type, not as a
// GL cull enum: 0 = pos, 1 = pos twice with reversed winding,
// 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still
// suppress hidden portal/cap faces before they reach our mesh.
bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos);
bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg);
if (hasPos)
AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false);
if (hasPos && poly.SidesType == CullMode.None) {
AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true);
}
else if (hasNeg && poly.SidesType == CullMode.Clockwise) {
AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false);
}
void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) {
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<Surface>(surfaceId, out var surface)) {
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-skip] cellstruct Surface 0x{surfaceId:X8} miss -> WALL poly batch dropped (cellstruct 0x{cellStruct:X4})");
return;
}
int texWidth, texHeight;
byte[] textureData;
TextureFormat textureFormat;
UploadPixelFormat? uploadPixelFormat = null;
UploadPixelType? 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 = UploadPixelFormat.Rgba;
}
else if (_dats.Portal.TryGet<SurfaceTexture>(surface.OrigTextureId, out var surfaceTexture)) {
var renderSurfaceId = surfaceTexture.Textures.First();
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var renderSurface)) {
if (!_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out var hrRenderSurface)) {
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-skip] cellstruct RenderSurface 0x{renderSurfaceId:X8} miss (portal+highres) -> WALL poly batch dropped");
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 = UploadPixelFormat.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 = UploadPixelFormat.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 = UploadPixelFormat.Rgba;
break;
case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8:
textureData = new byte[texWidth * texHeight * 4];
TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
uploadPixelFormat = UploadPixelFormat.Rgba;
break;
case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16:
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var paletteData)) return;
textureData = new byte[texWidth * texHeight * 4];
TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
uploadPixelFormat = UploadPixelFormat.Rgba;
break;
case DatReaderWriter.Enums.PixelFormat.PFID_P8:
if (!_dats.Portal.TryGet<Palette>(renderSurface.DefaultPaletteId, out var p8PaletteData)) return;
textureData = new byte[texWidth * texHeight * 4];
TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap);
uploadPixelFormat = UploadPixelFormat.Rgba;
break;
case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5:
textureData = new byte[texWidth * texHeight * 4];
TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
uploadPixelFormat = UploadPixelFormat.Rgba;
break;
case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4:
textureData = new byte[texWidth * texHeight * 4];
TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
uploadPixelFormat = UploadPixelFormat.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 = UploadPixelFormat.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 {
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-skip] cellstruct SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> WALL poly batch dropped (surface 0x{surfaceId:X8})");
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 TextureKey {
SurfaceId = surfaceId,
PaletteId = paletteId,
Stippling = poly.Stippling,
IsSolid = isSolid
};
if (!batchesByFormat.TryGetValue(format, out var batches)) {
batches = new List<TextureBatchData>();
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,
useNegUv,
invertNormal,
reverseWinding,
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 invertNormal), ushort> UVLookup,
List<VertexPositionNormalTexture> vertices, List<ushort> indices,
bool useNegUv, bool invertNormal, bool reverseWinding,
Matrix4x4 transform, ref bool hasWrappingUVs) {
var polyIndices = new List<ushort>();
for (int i = 0; i < poly.VertexIds.Count; i++) {
ushort vertId = (ushort)poly.VertexIds[i];
ushort uvIdx = 0;
if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count)
uvIdx = poly.NegUVIndices[i];
else if (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, invertNormal);
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 (invertNormal) {
normal = -normal;
}
idx = (ushort)vertices.Count;
vertices.Add(new VertexPositionNormalTexture(
Vector3.Transform(vertex.Origin, transform),
normal,
uv
));
UVLookup[key] = idx;
}
polyIndices.Add(idx);
}
if (reverseWinding) {
for (int i = 2; i < polyIndices.Count; i++) {
indices.Add(polyIndices[i]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[0]);
}
}
else {
for (int i = 2; i < polyIndices.Count; i++) {
indices.Add(polyIndices[0]);
indices.Add(polyIndices[i - 1]);
indices.Add(polyIndices[i]);
}
}
}
private void BuildPolygonIndices(Polygon poly, GfxObj gfxObj, Vector3 scale,
Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup,
List<VertexPositionNormalTexture> vertices, List<ushort> indices, bool useNegSurface, ref bool hasWrappingUVs) {
var polyIndices = new List<ushort>();
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]);
}
}
}
public (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 ObjectMeshData? PrepareCellStructEdgeLineData(ulong id, Dictionary<uint, CellStruct> 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<Vector3>();
// 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<VertexPositionNormalTexture> {
new VertexPositionNormalTexture { Position = Vector3.Zero, Normal = Vector3.UnitZ, UV = Vector2.Zero }
};
var indices = new List<ushort> { 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<MeshBatchData> {
new MeshBatchData {
Indices = indices.ToArray(),
TextureFormat = (1, 1, TextureFormat.RGBA8),
TextureKey = new TextureKey {
SurfaceId = 0xFFFFFFFF, // Dummy surface ID
PaletteId = 0,
Stippling = StipplingType.NoPos,
IsSolid = true
},
TextureIndex = 0,
TextureData = transparentTexture,
UploadPixelFormat = UploadPixelFormat.Rgba,
UploadPixelType = UploadPixelType.UnsignedByte,
CullMode = CullMode.None
}
},
// Also populate TextureBatches for GPU upload
TextureBatches = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> {
[(1, 1, TextureFormat.RGBA8)] = new List<TextureBatchData> {
new TextureBatchData {
Indices = indices.ToList(),
Key = new TextureKey {
SurfaceId = 0xFFFFFFFF, // Dummy surface ID
PaletteId = 0,
Stippling = StipplingType.NoPos,
IsSolid = true
},
TextureData = transparentTexture,
UploadPixelFormat = UploadPixelFormat.Rgba,
UploadPixelType = UploadPixelType.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;
}
}