perf(render): consume prepared mesh package at runtime

This commit is contained in:
Erik 2026-07-24 15:07:25 +02:00
parent c42f93b323
commit f05afc07c1
22 changed files with 328 additions and 370 deletions

View file

@ -84,6 +84,7 @@ namespace AcDream.App.Rendering.Wb
public uint SurfaceId { get; set; }
public TextureKey Key { get; set; }
public DatReaderWriter.Enums.CullMode CullMode { get; set; }
public AcDream.Core.Meshing.TranslucencyKind Translucency { get; set; }
public bool IsTransparent { get; set; }
public bool IsAdditive { get; set; }
public bool HasWrappingUVs { get; set; }
@ -102,18 +103,15 @@ namespace AcDream.App.Rendering.Wb
public class ObjectMeshManager : IDisposable
{
private readonly OpenGLGraphicsDevice _graphicsDevice;
private readonly IDatReaderWriter _dats;
private readonly IPreparedAssetSource _preparedAssets;
private readonly ILogger _logger;
/// <summary>
/// MP1a (2026-07-05): the GL-free CPU extraction half, verbatim-moved to
/// AcDream.Content so the MP1b bake tool can run it without a GL context.
/// Owns the dat read → mesh build → inline texture decode pipeline; this
/// class keeps the queue/worker lifecycle and all GL upload.
/// The immutable prepared-payload source is injected by composition.
/// Production uses the validated pak; UI Studio explicitly supplies the
/// live-DAT tooling adapter. This class owns queue/worker lifecycle,
/// bounded CPU staging, and all GL upload.
/// </summary>
private readonly MeshExtractor _extractor;
internal IDatReaderWriter Dats => _dats;
public bool IsDisposed { get; private set; }
private readonly object _disposeGate = new();
@ -141,7 +139,6 @@ namespace AcDream.App.Rendering.Wb
private readonly Dictionary<ulong, RetryableResourceReleaseLedger> _uploadRollbacks = new();
private readonly Queue<ulong> _uploadRollbackQueue = new();
private readonly MeshOwnershipCounter _ownership = new();
private readonly ConcurrentDictionary<ulong, (Vector3 Min, Vector3 Max)?> _boundsCache = new();
private readonly ConcurrentDictionary<ulong, Task<ObjectMeshData?>> _preparationTasks = new();
// LRU Cache for Unused objects
@ -280,12 +277,11 @@ namespace AcDream.App.Rendering.Wb
internal CacheStats CpuMeshCacheStats => _cpuMeshCache.Stats;
/// <summary>Decoded-texture cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).</summary>
internal CacheStats DecodedTextureCacheStats => _extractor.DecodedTextureCacheStats;
internal CacheStats DecodedTextureCacheStats =>
_preparedAssets.DecodedTextureCacheStats;
private sealed class PreparationRequest(
ulong id,
bool isSetup,
EnvCellGeomRequest? envCell,
PreparedAssetRequest asset,
ObjectMeshData? cachedData,
TaskCompletionSource<ObjectMeshData?> completion,
CancellationTokenSource cancellation)
@ -296,9 +292,8 @@ namespace AcDream.App.Rendering.Wb
private bool _disposeRequested;
private bool _cancellationDisposed;
public ulong Id { get; } = id;
public bool IsSetup { get; } = isSetup;
public EnvCellGeomRequest? EnvCell { get; } = envCell;
public PreparedAssetRequest Asset { get; } = asset;
public ulong Id => Asset.RuntimeObjectId;
public ObjectMeshData? CachedData { get; } = cachedData;
public TaskCompletionSource<ObjectMeshData?> Completion { get; } = completion;
public CancellationTokenSource Cancellation { get; } = cancellation;
@ -402,26 +397,18 @@ namespace AcDream.App.Rendering.Wb
public bool IsQueued { get; set; }
}
public ObjectMeshManager(OpenGLGraphicsDevice graphicsDevice, IDatReaderWriter dats, ILogger<ObjectMeshManager> logger)
public ObjectMeshManager(
OpenGLGraphicsDevice graphicsDevice,
IPreparedAssetSource preparedAssets,
ILogger<ObjectMeshManager> logger)
{
_graphicsDevice = graphicsDevice;
_dats = dats;
_logger = logger;
// Side-stage sink: particle-preload meshes staged mid-extraction go
// straight onto the staged-upload queue, exactly as the pre-MP1a code
// did — immediate enqueue, surviving a later throw in the same
// Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the
// extractor's up-to-4 concurrent decode workers.
_graphicsDevice = graphicsDevice
?? throw new ArgumentNullException(nameof(graphicsDevice));
_preparedAssets = preparedAssets
?? throw new ArgumentNullException(nameof(preparedAssets));
_logger = logger
?? throw new ArgumentNullException(nameof(logger));
_cpuMeshCache = new CpuMeshUploadCache(_maxCpuCacheSize);
_extractor = new MeshExtractor(_dats, _logger, data =>
{
// Side-staged particle/setup dependencies obey the same bounded
// producer policy as primary results. Keeping them in the CPU
// cache makes a later explicit owner able to re-arm upload.
_cpuMeshCache.Store(data);
if (_ownership.IsOwned(data.ObjectId))
_stagedMeshData.Stage(data);
});
_useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless;
if (_useModernRendering)
{
@ -809,6 +796,7 @@ namespace AcDream.App.Rendering.Wb
public struct EnvCellGeomRequest
{
public uint SourceCellId;
public uint EnvironmentId;
public ushort CellStructure;
public List<ushort> Surfaces;
@ -817,12 +805,19 @@ namespace AcDream.App.Rendering.Wb
/// <summary>
/// Phase 1 (Background Thread): Prepare CPU-side mesh data for deduplicated EnvCell geometry.
/// </summary>
public Task<ObjectMeshData?> PrepareEnvCellGeomMeshDataAsync(ulong geomId, uint environmentId, ushort cellStructure, List<ushort> surfaces, CancellationToken ct = default)
public Task<ObjectMeshData?> PrepareEnvCellGeomMeshDataAsync(
ulong geomId,
uint sourceCellId,
uint environmentId,
ushort cellStructure,
List<ushort> surfaces,
CancellationToken ct = default)
{
if (IsDisposed || HasRenderData(geomId)) return Task.FromResult<ObjectMeshData?>(null);
var envCell = new EnvCellGeomRequest
{
SourceCellId = sourceCellId,
EnvironmentId = environmentId,
CellStructure = cellStructure,
Surfaces = surfaces
@ -874,9 +869,12 @@ namespace AcDream.App.Rendering.Wb
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct);
_preparationTasks[geomId] = task;
var request = new PreparationRequest(
geomId,
false,
envCell,
PreparedAssetRequest.EnvCellGeometry(
sourceCellId,
geomId,
environmentId,
cellStructure,
surfaces),
deferredCachedData,
tcs,
cancellation);
@ -933,10 +931,18 @@ namespace AcDream.App.Rendering.Wb
EnvCellGeomRequest? envCell = _envCellDescriptors.TryGetValue(id, out EnvCellGeomRequest descriptor)
? descriptor
: null;
PreparedAssetRequest asset = envCell is { } env
? PreparedAssetRequest.EnvCellGeometry(
env.SourceCellId,
id,
env.EnvironmentId,
env.CellStructure,
env.Surfaces)
: isSetup
? PreparedAssetRequest.Setup(checked((uint)id))
: PreparedAssetRequest.GfxObj(checked((uint)id));
var request = new PreparationRequest(
id,
isSetup,
envCell,
asset,
deferredCachedData,
tcs,
cancellation);
@ -956,7 +962,7 @@ namespace AcDream.App.Rendering.Wb
lock (_pendingRequests)
{
// IsDisposed re-check lets Dispose cancel and join every
// tracked worker before the DAT mappings are released.
// tracked worker before prepared content mappings are released.
// Exit WITHOUT resetting the shared manual-reset event:
// Dispose sets it once to wake all four persistent workers.
// If the first worker reset it, the remaining three slept
@ -991,7 +997,6 @@ namespace AcDream.App.Rendering.Wb
}
ulong id = request.Id;
bool isSetup = request.IsSetup;
TaskCompletionSource<ObjectMeshData?> tcs = request.Completion;
CancellationToken ct = request.Cancellation.Token;
@ -1010,38 +1015,21 @@ namespace AcDream.App.Rendering.Wb
{
ObjectMeshData? data = request.CachedData;
if (data is null && request.EnvCell is { } req)
if (data is null)
{
uint envId = 0x0D000000u | req.EnvironmentId;
if (_dats.Portal.TryGet<DatReaderWriter.DBObjs.Environment>(envId, out var environment))
PreparedAssetReadResult read =
_preparedAssets.Read(request.Asset, ct);
data = read.Data;
if (read.Status != PreparedAssetReadStatus.Loaded)
{
if (environment.Cells.TryGetValue(req.CellStructure, out var cellStruct))
{
data = _extractor.PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, ct);
// TEMP diagnostic #105 (strip with fix): a null prep here means
// this deduplicated cell geometry will NEVER render anywhere.
if (data == null)
Console.WriteLine($"[geom-null] prepare-null geom=0x{id:X10} env=0x{envId:X8} cs=0x{req.CellStructure:X4}");
}
else
{
Console.WriteLine($"[geom-null] cellstruct-missing geom=0x{id:X10} env=0x{envId:X8} cs=0x{req.CellStructure:X4}");
}
_logger.LogError(
"Prepared mesh {Status} for {Type} source " +
"0x{SourceId:X8}, runtime 0x{RuntimeId:X10}",
read.Status,
request.Asset.Type,
request.Asset.SourceFileId,
request.Asset.RuntimeObjectId);
}
else
{
Console.WriteLine($"[geom-null] env-read-failed geom=0x{id:X10} env=0x{envId:X8}");
}
}
else if (data is null)
{
// TEMP diagnostic #105 (strip with fix): an EnvCell geom id (bit 33)
// whose pending request vanished gets misrouted to the generic path,
// where its hash-derived low bits resolve to nothing -> silent null.
if ((id & 0x2_0000_0000UL) != 0)
Console.WriteLine($"[geom-misroute] envcell geom 0x{id:X10} had no pending request — generic path will null it");
// If it's a direct setup or gfxobj, make sure background loads don't abort half-way
data = _extractor.PrepareMeshData(id, isSetup, ct);
}
if (ct.IsCancellationRequested)
{
@ -1168,7 +1156,7 @@ namespace AcDream.App.Rendering.Wb
/// Readiness barrier used by the streaming publisher. Missing owned
/// data is re-armed through its retained schema, so synthetic EnvCell
/// IDs can never fall into generic GfxObj decoding after cancellation.
/// A deterministic DAT failure is retained until the next explicit
/// A deterministic prepared-payload failure is retained until the next explicit
/// ownership schedule instead of being retried every render frame.
/// </summary>
internal bool EnsureRenderDataReady(ulong id)
@ -1194,6 +1182,7 @@ namespace AcDream.App.Rendering.Wb
{
_ = PrepareEnvCellGeomMeshDataAsync(
id,
req.SourceCellId,
req.EnvironmentId,
req.CellStructure,
req.Surfaces);
@ -1206,16 +1195,16 @@ namespace AcDream.App.Rendering.Wb
}
/// <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.
///
/// MP1a (2026-07-05): delegates to <see cref="MeshExtractor.PrepareMeshData"/>,
/// the verbatim-moved GL-free extraction dispatcher. See <see cref="_extractor"/>.
/// Synchronously reads an immutable prepared payload without creating
/// GPU resources. The production source is the validated pak; explicit
/// tooling adapters may use live DAT extraction.
/// </summary>
public ObjectMeshData? PrepareMeshData(ulong id, bool isSetup, CancellationToken ct = default)
{
return _extractor.PrepareMeshData(id, isSetup, ct);
PreparedAssetRequest request = isSetup
? PreparedAssetRequest.Setup(checked((uint)id))
: PreparedAssetRequest.GfxObj(checked((uint)id));
return _preparedAssets.Read(request, ct).Data;
}
/// <summary>
@ -1709,86 +1698,6 @@ namespace AcDream.App.Rendering.Wb
}
}
/// <summary>
/// Gets bounding box for an object (for frustum culling).
/// </summary>
public (Vector3 Min, Vector3 Max)? GetBounds(ulong id, bool isSetup)
{
if (_boundsCache.TryGetValue(id, out var cachedBounds))
{
return cachedBounds;
}
try
{
(Vector3 Min, Vector3 Max)? result = null;
uint datId = (uint)(id & 0xFFFFFFFFu);
var resolutions = _dats.ResolveId(datId).ToList();
var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault();
if (selectedResolution == null) return null;
var type = selectedResolution.Type;
var db = selectedResolution.Database;
if (type == DBObjType.Setup)
{
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
bool hasBounds = false;
var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>();
_extractor.CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None);
result = hasBounds ? (min, max) : null;
}
else if (type == DBObjType.EnvCell)
{
if (!db.TryGet<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))
{
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
foreach (var vert in cellStruct.VertexArray.Vertices.Values)
{
min = Vector3.Min(min, vert.Origin);
max = Vector3.Max(max, vert.Origin);
}
result = (min, max);
}
}
}
else
{
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
bool hasBounds = false;
var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>();
_extractor.CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None);
result = hasBounds ? (min, max) : null;
}
}
else
{
if (!db.TryGet<GfxObj>(datId, out var gfxObj)) return null;
result = _extractor.ComputeBounds(gfxObj, Vector3.One);
}
_boundsCache[id] = result;
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error computing bounds for 0x{Id:X8}", id);
return null;
}
}
#region Private: Background Preparation
/// <summary>
@ -1981,6 +1890,7 @@ namespace AcDream.App.Rendering.Wb
TextureIndex = textureIndex,
TextureSize = (format.Width, format.Height),
TextureFormat = format.Format,
Translucency = batch.Translucency,
IsTransparent = batch.IsTransparent,
IsAdditive = batch.IsAdditive,
HasWrappingUVs = batch.HasWrappingUVs,