Three small verified MeshExtractor.cs fixes from an allocation audit: - CellStruct clip-map clone guard: the CellStruct polygon-surface path cloned decoded texture data unconditionally before applying clip-map transparency, even though the comment above it says "if we got this from the cache, we need to clone it." The GfxObj path already had the correct pattern (clone only when textureDataIsCached). Wired the same textureDataIsCached flag through CellStruct's TryGet/GetOrCreate/ RetainOrUse call sites and gated the clone on it, matching GfxObj exactly — freshly-decoded arrays (the common case) now skip a redundant clone + BlockCopy. - Solid-color texture cache: isSolid surfaces (Base1Solid / NoPos- stippled polys) allocated a fresh 32x32 RGBA array (4 KiB) on every polygon, even for repeated colors. Added a MeshExtractor-scoped ConcurrentDictionary<uint, byte[]> keyed by packed ARGB, bounded by distinct colors observed (a few hundred at most) rather than call volume. Traced every downstream consumer of the solid-color array (clip-map clone-guard, translucency clone-guard, GL upload, pak serialization) — none currently mutate a solid-color array in place, since isSolid is mutually exclusive with the texture-decode branch those guards live in — but wired textureDataIsCached = true at both call sites anyway so the existing clone-before-mutate machinery protects the shared array if that ever changes. - Dropped the redundant .ToList() on both _dats.ResolveId(...) calls: DatCollectionAdapter.ResolveId already returns a materialized List<T> typed as IEnumerable; the immediate .OrderByDescending().FirstOrDefault() enumerates once, so the extra copy was pure waste. Added SolidColorTextureCacheTests (dat-gated, matching suite convention) exercising the new cache directly via internal visibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit e9f9d7539966cc26343e45d653a751c5d1045810)
1262 lines
64 KiB
C#
1262 lines
64 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.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;
|
|
|
|
// Canonical decoded pixels are immutable and bounded by bytes as well as
|
|
// count. A count-only cache can retain hundreds of MiB of large RGBA data.
|
|
private readonly DecodedTextureCache _decodedTextureCache = new(
|
|
maximumBytes: 64L * 1024 * 1024,
|
|
maximumEntries: 128);
|
|
private readonly ThreadLocal<BcDecoder> _bcDecoder = new(() => new BcDecoder());
|
|
|
|
// Solid-color surfaces (isSolid: Base1Solid surfaces / NoPos-stippled polys)
|
|
// bake a flat ARGB fill to a fixed 32x32 RGBA array. Distinct surface colors
|
|
// across an install are a few hundred at most, so this is bounded by DISTINCT
|
|
// COLORS observed rather than by call count — unlike DecodedTextureCache's
|
|
// byte-bounded LRU, which exists to cap large per-surface pixel arrays, not a
|
|
// handful of 4 KiB solid fills. ConcurrentDictionary: MeshExtractor is shared
|
|
// by up to MaxParallelLoads (4) decode workers.
|
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<uint, byte[]> _solidColorTextureCache = new();
|
|
|
|
/// <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);
|
|
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);
|
|
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;
|
|
bool textureDataIsCached = false;
|
|
DatReaderWriter.Enums.PixelFormat? sourceFormat = null;
|
|
var isAdditive = false;
|
|
var isTransparent = false;
|
|
|
|
if (isSolid) {
|
|
texWidth = texHeight = 32;
|
|
textureData = GetOrCreateSolidColorTexture(surface.ColorValue, texWidth, texHeight);
|
|
textureFormat = TextureFormat.RGBA8;
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
textureDataIsCached = true;
|
|
}
|
|
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;
|
|
isDxt3or5 = renderSurface.Format is
|
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT3 or
|
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
|
var decodedTextureKey = CreateDecodedTextureKey(
|
|
renderSurfaceId,
|
|
renderSurface.Format,
|
|
isClipMap,
|
|
surface.Type.HasFlag(SurfaceType.Additive));
|
|
|
|
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 = _decodedTextureCache.GetOrCreate(
|
|
decodedTextureKey,
|
|
() => DecodeCompressedTexture(renderSurface, texWidth, texHeight),
|
|
out textureDataIsCached);
|
|
|
|
if (isClipMap && textureData != null) {
|
|
// Cached pixels are canonical and immutable. Surface-local
|
|
// alpha processing must never modify the shared array.
|
|
if (textureDataIsCached) {
|
|
var clonedData = new byte[textureData.Length];
|
|
System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length);
|
|
textureData = clonedData;
|
|
textureDataIsCached = false;
|
|
}
|
|
|
|
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;
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
if (_decodedTextureCache.TryGet(
|
|
decodedTextureKey,
|
|
out byte[] cachedPixels)) {
|
|
textureData = cachedPixels;
|
|
textureDataIsCached = true;
|
|
}
|
|
else {
|
|
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}");
|
|
}
|
|
|
|
textureData = _decodedTextureCache.RetainOrUse(
|
|
decodedTextureKey,
|
|
textureData,
|
|
out textureDataIsCached);
|
|
}
|
|
}
|
|
|
|
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 (textureDataIsCached) {
|
|
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;
|
|
bool textureDataIsCached = false;
|
|
DatReaderWriter.Enums.PixelFormat? sourceFormat = null;
|
|
var isAdditive = false;
|
|
var isTransparent = false;
|
|
|
|
if (isSolid) {
|
|
texWidth = texHeight = 32;
|
|
textureData = GetOrCreateSolidColorTexture(surface.ColorValue, texWidth, texHeight);
|
|
textureFormat = TextureFormat.RGBA8;
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
textureDataIsCached = true;
|
|
}
|
|
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;
|
|
isDxt3or5 = renderSurface.Format is
|
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT3 or
|
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
|
var decodedTextureKey = CreateDecodedTextureKey(
|
|
renderSurfaceId,
|
|
renderSurface.Format,
|
|
isClipMap,
|
|
surface.Type.HasFlag(SurfaceType.Additive));
|
|
|
|
if (_decodedTextureCache.TryGet(decodedTextureKey, out var cachedData)) {
|
|
textureData = cachedData;
|
|
textureFormat = TextureFormat.RGBA8;
|
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
|
textureDataIsCached = true;
|
|
}
|
|
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 = _decodedTextureCache.GetOrCreate(
|
|
decodedTextureKey,
|
|
() => DecodeCompressedTexture(renderSurface, texWidth, texHeight),
|
|
out textureDataIsCached);
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
if (!TextureHelpers.IsCompressedFormat(renderSurface.Format))
|
|
{
|
|
textureData = _decodedTextureCache.RetainOrUse(
|
|
decodedTextureKey,
|
|
textureData,
|
|
out textureDataIsCached);
|
|
}
|
|
}
|
|
|
|
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 (textureDataIsCached) {
|
|
var clonedData = new byte[textureData.Length];
|
|
System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length);
|
|
textureData = clonedData;
|
|
textureDataIsCached = false;
|
|
}
|
|
|
|
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]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the shared 32x32 RGBA fill for <paramref name="color"/>, decoding
|
|
/// once per distinct ARGB value. Both call sites (GfxObj and CellStruct isSolid
|
|
/// surfaces) hardcode 32x32 immediately before calling this, so keying purely
|
|
/// on the packed ARGB value cannot collide with a different-sized request.
|
|
/// The returned array is shared and must be treated as immutable by callers,
|
|
/// same contract as <see cref="DecodedTextureCache"/> pixels.
|
|
/// internal (not private): exercised directly by
|
|
/// SolidColorTextureCacheTests (AcDream.Content.csproj grants
|
|
/// InternalsVisibleTo to AcDream.Content.Tests).
|
|
/// </summary>
|
|
internal byte[] GetOrCreateSolidColorTexture(DatReaderWriter.Types.ColorARGB color, int width, int height) {
|
|
uint key = ((uint)color.Alpha << 24) | ((uint)color.Red << 16) | ((uint)color.Green << 8) | color.Blue;
|
|
return _solidColorTextureCache.GetOrAdd(
|
|
key,
|
|
_ => TextureHelpers.CreateSolidColorTexture(color, width, height));
|
|
}
|
|
|
|
private static DecodedTextureKey CreateDecodedTextureKey(
|
|
uint renderSurfaceId,
|
|
DatReaderWriter.Enums.PixelFormat format,
|
|
bool isClipMap,
|
|
bool isAdditive)
|
|
{
|
|
bool clipAffectsDecode = format is
|
|
DatReaderWriter.Enums.PixelFormat.PFID_INDEX16 or
|
|
DatReaderWriter.Enums.PixelFormat.PFID_P8;
|
|
bool additiveAffectsDecode = format is
|
|
DatReaderWriter.Enums.PixelFormat.PFID_A8 or
|
|
DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA;
|
|
return new DecodedTextureKey(
|
|
renderSurfaceId,
|
|
clipAffectsDecode && isClipMap,
|
|
additiveAffectsDecode && isAdditive);
|
|
}
|
|
|
|
private byte[] DecodeCompressedTexture(
|
|
RenderSurface renderSurface,
|
|
int width,
|
|
int height)
|
|
{
|
|
var textureData = new byte[width * height * 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,
|
|
width,
|
|
height,
|
|
compressionFormat);
|
|
image.CopyPixelDataTo(textureData);
|
|
return textureData;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|