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

@ -25,6 +25,7 @@ internal sealed record ContentAudioGraph(
internal sealed record ContentEffectsAudioResult(
IDatReaderWriter Dats,
IPreparedAssetSource PreparedAssets,
MagicCatalog MagicCatalog,
IAnimationLoader AnimationLoader,
LiveEntityCollisionBuilder CollisionBuilder,
@ -41,6 +42,7 @@ internal sealed record ContentEffectsAudioResult(
internal sealed record ContentEffectsAudioDependencies(
string DatDirectory,
string PreparedAssetPath,
PhysicsDataCache PhysicsDataCache,
bool DumpMotionEnabled,
Spellbook SpellBook,
@ -56,6 +58,7 @@ internal sealed record ContentEffectsAudioDependencies(
internal interface IGameWindowContentEffectsAudioPublication
{
void PublishDatCollection(IDatReaderWriter value);
void PublishPreparedAssetSource(IPreparedAssetSource value);
void PublishMagicCatalog(MagicCatalog value);
void PublishAnimationLoader(IAnimationLoader value);
void PublishLiveEntityCollisionBuilder(LiveEntityCollisionBuilder value);
@ -74,6 +77,10 @@ internal interface IGameWindowContentEffectsAudioPublication
internal interface IContentEffectsAudioCompositionFactory
{
IDatReaderWriter OpenDatCollection(string datDirectory);
IPreparedAssetSource OpenPreparedAssetSource(
string path,
IDatReaderWriter dats,
Action<string> diagnostic);
MagicCatalog LoadMagicCatalog(IDatReaderWriter dats);
void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog);
int GetSpellCount(MagicCatalog catalog);
@ -116,6 +123,12 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
public IDatReaderWriter OpenDatCollection(string datDirectory) =>
RuntimeDatCollectionFactory.OpenReadOnly(datDirectory);
public IPreparedAssetSource OpenPreparedAssetSource(
string path,
IDatReaderWriter dats,
Action<string> diagnostic) =>
new PakPreparedAssetSource(path, dats, diagnostic);
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) =>
MagicCatalog.Load(dats);
@ -196,6 +209,7 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
internal enum ContentEffectsAudioCompositionPoint
{
DatCollectionPublished,
PreparedAssetSourcePublished,
MagicCatalogPublished,
SpellMetadataInstalled,
AnimationLoaderPublished,
@ -268,6 +282,18 @@ internal sealed class ContentEffectsAudioCompositionPhase :
_publication.PublishDatCollection);
Fault(ContentEffectsAudioCompositionPoint.DatCollectionPublished);
IPreparedAssetSource preparedAssets = mainScope.Acquire(
"prepared asset source",
() => _factory.OpenPreparedAssetSource(
_dependencies.PreparedAssetPath,
dats,
_dependencies.Error),
static value => value.Dispose()).Publish(
_publication.PublishPreparedAssetSource);
Fault(ContentEffectsAudioCompositionPoint.PreparedAssetSourcePublished);
_dependencies.Log(
$"prepared assets: opened '{_dependencies.PreparedAssetPath}'");
MagicCatalog magic = _factory.LoadMagicCatalog(dats);
_publication.PublishMagicCatalog(magic);
Fault(ContentEffectsAudioCompositionPoint.MagicCatalogPublished);
@ -354,6 +380,7 @@ internal sealed class ContentEffectsAudioCompositionPhase :
mainScope.Complete();
return new ContentEffectsAudioResult(
dats,
preparedAssets,
magic,
animations,
collision,

View file

@ -100,6 +100,7 @@ internal interface IWorldRenderCompositionFactory
WbMeshAdapter CreateMeshAdapter(
GL gl,
IDatReaderWriter dats,
IPreparedAssetSource preparedAssets,
IGpuResourceRetirementQueue retirement);
TextureCache CreateTextureCache(
GL gl,
@ -261,10 +262,12 @@ internal sealed class RetailWorldRenderCompositionFactory
public WbMeshAdapter CreateMeshAdapter(
GL gl,
IDatReaderWriter dats,
IPreparedAssetSource preparedAssets,
IGpuResourceRetirementQueue retirement) =>
new(
gl,
dats,
preparedAssets,
NullLogger<WbMeshAdapter>.Instance,
retirement);
@ -430,6 +433,7 @@ internal sealed class WorldRenderCompositionPhase
() => _factory.CreateMeshAdapter(
gl,
content.Dats,
content.PreparedAssets,
_dependencies.ResourceRetirement),
_publication.PublishWbMeshAdapter,
WorldRenderCompositionPoint.MeshAdapterPublished);

View file

@ -46,6 +46,7 @@ public sealed class GameWindow :
private Shader? _terrainModernShader;
private CameraController? _cameraController;
private IDatReaderWriter? _dats;
private IPreparedAssetSource? _preparedAssets;
private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new();
private AcDream.App.Input.CameraPointerInputController? _cameraPointerInput;
private Shader? _meshShader;
@ -711,6 +712,13 @@ public sealed class GameWindow :
IDatReaderWriter value) =>
PublishCompositionOwner(ref _dats, value, "DAT collection");
void IGameWindowContentEffectsAudioPublication.PublishPreparedAssetSource(
IPreparedAssetSource value) =>
PublishCompositionOwner(
ref _preparedAssets,
value,
"prepared asset source");
void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog(
AcDream.App.Spells.MagicCatalog value) =>
PublishCompositionOwner(ref _magicCatalog, value, "magic catalog");
@ -1132,6 +1140,7 @@ public sealed class GameWindow :
new ContentEffectsAudioCompositionPhase(
new ContentEffectsAudioDependencies(
_datDir,
_options.PreparedAssetPath,
_physicsDataCache,
_animationDiagnostics.DumpMotionEnabled,
SpellBook,
@ -1612,6 +1621,7 @@ public sealed class GameWindow :
_glConstructionCleanup),
new PlatformShutdownRoots(
_dats,
_preparedAssets,
_input,
_gl));
private void OnFocusChanged(bool focused)

View file

@ -120,6 +120,7 @@ internal sealed record RenderShutdownRoots(
internal sealed record PlatformShutdownRoots(
IDatReaderWriter? Dats,
IPreparedAssetSource? PreparedAssets,
IInputContext? Input,
GL? Gl);
@ -460,6 +461,7 @@ internal static class GameWindowShutdownManifest
]),
new ResourceShutdownStage("content mappings",
[
Hard("prepared asset source", () => platform.PreparedAssets?.Dispose()),
Hard("DAT collection", () => platform.Dats?.Dispose()),
]),
new ResourceShutdownStage("input context",

View file

@ -171,7 +171,11 @@ public static class RenderBootstrap
// --- WbMeshAdapter (GameWindow ~2286-2287) ---
var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance;
var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger, frameFlights);
var meshAdapter = Wb.WbMeshAdapter.CreateWithLiveDatPreparedAssets(
gl,
dats,
wbLogger,
frameFlights);
// --- SequencerFactory (GameWindow ~2306-2334) ---
var capturedDats = dats;

View file

@ -1,21 +0,0 @@
using AcDream.Core.Meshing;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// AC-specific surface render metadata that WB's <c>MeshBatchData</c>
/// doesn't carry. Computed at mesh-extraction time and looked up by the
/// draw dispatcher to drive translucency / sky-pass / fog behavior.
///
/// <para>
/// All fields mirror those on today's <see cref="GfxObjSubMesh"/> so
/// behavior is preserved bit-for-bit through the migration.
/// </para>
/// </summary>
public sealed record AcSurfaceMetadata(
TranslucencyKind Translucency,
float Luminosity,
float Diffuse,
float SurfOpacity,
bool NeedsUvRepeat,
bool DisableFog);

View file

@ -1,36 +0,0 @@
using System.Collections.Concurrent;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Thread-safe side-table mapping <c>(gfxObjId, surfaceIdx)</c> to
/// <see cref="AcSurfaceMetadata"/>. Populated when a GfxObj's mesh data
/// is extracted; queried at draw time.
///
/// <para>
/// Keyed by <c>(gfxObjId, surfaceIdx)</c> not by WB's runtime batch
/// identity because batch objects can be evicted and re-loaded by WB's
/// LRU; the (gfxObj, surface) pair is stable across cycles.
/// </para>
/// </summary>
public sealed class AcSurfaceMetadataTable
{
private readonly ConcurrentDictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata> _table = new();
public void Add(ulong gfxObjId, int surfaceIdx, AcSurfaceMetadata meta)
=> _table[(gfxObjId, surfaceIdx)] = meta;
public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
=> _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);
public void Remove(ulong gfxObjId)
{
foreach ((ulong Id, int SurfaceIndex) key in _table.Keys)
{
if (key.Id == gfxObjId)
_table.TryRemove(key, out _);
}
}
public void Clear() => _table.Clear();
}

View file

@ -20,6 +20,7 @@ public static class EnvCellMeshPreparationScheduler
continue;
_ = meshManager.PrepareEnvCellGeomMeshDataAsync(
shell.GeometryId,
shell.CellId,
shell.EnvironmentId,
shell.CellStructure,
new List<ushort>(shell.Surfaces));

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,

View file

@ -16,8 +16,8 @@ namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Draws entities using WB's <see cref="ObjectRenderData"/> (a single global
/// VAO/VBO/IBO under modern rendering) with acdream's <see cref="TextureCache"/>
/// for bindless texture resolution and <see cref="AcSurfaceMetadataTable"/> for
/// translucency classification.
/// for bindless texture resolution. Exact pass classification travels with
/// each immutable prepared mesh batch.
///
/// <para>
/// <b>Atlas-tier</b> entities (<c>ServerGuid == 0</c>): mesh data comes from WB's
@ -1359,7 +1359,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
foreach (InstanceGroup group in _groups.Values)
group.ClearPerInstanceData();
var metaTable = _meshAdapter.MetadataTable;
uint anyVao = 0;
// Project the 5-tuple enumerable into LandblockEntry records for WalkEntities.
@ -1787,7 +1786,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0
}
if (!ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, paletteIdentity, metaTable, restPose, opacityMultiplier, collector))
if (!ClassifyBatches(partData, model, entity, meshRef, paletteIdentity, restPose, opacityMultiplier, collector))
currentEntityIncomplete = true;
_selectionSink?.AddVisiblePart(
entity,
@ -1817,7 +1816,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (!fullyInvisible)
{
var model = meshRef.PartTransform * entityWorld;
if (!ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, paletteIdentity, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector))
if (!ClassifyBatches(renderData, model, entity, meshRef, paletteIdentity, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector))
currentEntityIncomplete = true;
_selectionSink?.AddVisiblePart(
entity,
@ -3239,12 +3238,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private bool ClassifyBatches(
ObjectRenderData renderData,
ulong gfxObjId,
Matrix4x4 model,
WorldEntity entity,
MeshRef meshRef,
PaletteCompositeIdentity paletteIdentity,
AcSurfaceMetadataTable metaTable,
Matrix4x4 restPose,
float opacityMultiplier = 1.0f,
List<CachedBatch>? collector = null)
@ -3254,17 +3251,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{
var batch = renderData.Batches[batchIdx];
TranslucencyKind translucency;
if (metaTable.TryLookup(gfxObjId, batchIdx, out var meta))
{
translucency = meta.Translucency;
}
else
{
translucency = batch.IsAdditive ? TranslucencyKind.Additive
: batch.IsTransparent ? TranslucencyKind.AlphaBlend
: TranslucencyKind.Opaque;
}
TranslucencyKind translucency = batch.Translucency;
// #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap
// must route through the alpha-blend pass so mesh_modern.frag's

View file

@ -1,10 +1,8 @@
using System;
using System.Collections.Generic;
using AcDream.Content;
using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Silk.NET.OpenGL;
@ -48,9 +46,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
private readonly OpenGLGraphicsDevice? _graphicsDevice;
private readonly ObjectMeshManager? _meshManager;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement;
private readonly IDatReaderWriter? _dats;
private readonly AcSurfaceMetadataTable _metadataTable = new();
private readonly HashSet<ulong> _metadataPopulated = new();
private readonly IPreparedAssetSource? _ownedPreparedAssets;
private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits(
MaximumUploadsPerFrame,
MaximumUploadBytesPerFrame,
@ -95,35 +91,72 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_meshManager?.CpuCacheDiagnostics ?? default;
/// <summary>
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → shared bounded
/// DAT facade → ObjectMeshManager.
/// Constructs the UI-Studio/tooling WB pipeline. Production composition
/// supplies the validated pak source through the internal overload below;
/// this explicit tooling seam retains live-DAT extraction.
/// </summary>
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current
/// thread (construction runs GL queries; call from OnLoad).</param>
/// <param name="dats">acdream's shared runtime DAT facade, used to populate the surface
/// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with
/// the rest of the client; read-only access from the render thread.</param>
/// <param name="dats">acdream's shared runtime DAT facade. Tooling uses it
/// through an explicitly owned <see cref="DatPreparedAssetSource"/>.</param>
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses
/// NullLogger internally.</param>
public WbMeshAdapter(GL gl, IDatReaderWriter dats, ILogger<WbMeshAdapter> logger)
: this(gl, dats, logger, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance)
: this(
gl,
dats,
preparedAssets: null,
logger,
AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance,
ownsPreparedAssets: true)
{
}
internal static WbMeshAdapter CreateWithLiveDatPreparedAssets(
GL gl,
IDatReaderWriter dats,
ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement) =>
new(
gl,
dats,
preparedAssets: null,
logger,
resourceRetirement,
ownsPreparedAssets: true);
internal WbMeshAdapter(
GL gl,
IDatReaderWriter dats,
IPreparedAssetSource preparedAssets,
ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement)
: this(
gl,
dats,
preparedAssets,
logger,
resourceRetirement,
ownsPreparedAssets: false)
{
}
private WbMeshAdapter(
GL gl,
IDatReaderWriter dats,
IPreparedAssetSource? preparedAssets,
ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
bool ownsPreparedAssets)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(logger);
_dats = dats;
_resourceRetirement = resourceRetirement;
var resources = new AcDream.App.Rendering.ResourceCleanupGroup();
OpenGLGraphicsDevice? graphicsDevice = null;
IPreparedAssetSource? resolvedPreparedAssets = preparedAssets;
ObjectMeshManager? meshManager = null;
try
{
@ -145,11 +178,20 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
}
});
resources.Add("WB graphics device", graphicsDeviceRelease.Run);
if (resolvedPreparedAssets is null)
{
resolvedPreparedAssets = new DatPreparedAssetSource(
dats,
new ConsoleErrorLogger<ObjectMeshManager>());
resources.Add(
"WB tooling prepared asset source",
resolvedPreparedAssets.Dispose);
}
// ConsoleErrorLogger surfaces WB's silently-caught exceptions
// (ObjectMeshManager.PrepareMeshData try/catch at line ~589).
meshManager = new ObjectMeshManager(
graphicsDevice,
dats,
resolvedPreparedAssets,
new ConsoleErrorLogger<ObjectMeshManager>());
resources.Add("WB object mesh manager", meshManager.Dispose);
resources.TransferAll();
@ -163,6 +205,9 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_graphicsDevice = graphicsDevice;
_meshManager = meshManager;
_ownedPreparedAssets = ownsPreparedAssets
? resolvedPreparedAssets
: null;
}
/// <summary>
@ -213,13 +258,6 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
/// underlying mesh manager. Public methods are all no-ops.</summary>
public static WbMeshAdapter CreateUninitialized() => new();
/// <summary>
/// The surface metadata side-table populated on each first
/// <see cref="IncrementRefCount"/>. Queried by the draw dispatcher
/// to determine translucency, luminosity, and fog behavior per batch.
/// </summary>
public AcSurfaceMetadataTable MetadataTable => _metadataTable;
/// <summary>
/// Phase A8 (2026-05-28): exposes the underlying <see cref="ObjectMeshManager"/>
/// so <c>EnvCellRenderer</c> can share the same global mesh buffer (VAO/VBO/IBO).
@ -265,13 +303,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
if (_isUninitialized || _meshManager is null) return;
_meshManager.IncrementRefCount(id);
bool metadataClaimed = false;
try
{
metadataClaimed = _metadataPopulated.Add(id);
if (metadataClaimed)
PopulateMetadata(id);
// WB's IncrementRefCount alone only bumps a usage counter; it does
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
// so the background workers actually decode the GfxObj. The result
@ -281,9 +314,8 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// skips the entity — standard streaming flicker.
//
// #128 (2026-06-11): Prepare must RE-ARM whenever the id has no render
// data — NOT only on the first-ever registration. The old
// first-ever-only gate (`if (_metadataPopulated.Add(id))`) permanently
// lost any id whose initial decode was cancelled before completing
// data — NOT only on the first-ever registration. A first-only gate
// permanently lost any id whose initial read was cancelled before completing
// (landblock unload → CancelStagedUploads during login/teleport
// churn) or whose upload was later LRU-evicted: every subsequent
// registration skipped Prepare, so the mesh stayed invisible for the
@ -299,19 +331,13 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused.
if (metadataClaimed || _meshManager.TryGetRenderData(id) is null)
if (_meshManager.TryGetRenderData(id) is null)
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
}
catch (Exception acquireFailure)
{
if (metadataClaimed)
{
_metadataPopulated.Remove(id);
_metadataTable.Remove(id);
}
// IncrementRefCount is a public ownership boundary. Metadata/DAT
// preparation happens after ObjectMeshManager commits its count,
// IncrementRefCount is a public ownership boundary. Prepared-data
// acquisition happens after ObjectMeshManager commits its count,
// so compensate before reporting failure. ObjectMeshManager's
// decrement has a strong exception guarantee; if compensation
// itself fails, expose that the increment remains committed so a
@ -612,21 +638,6 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
$" (arraysBefore={before.ArraysWithPending})");
}
private void PopulateMetadata(ulong id)
{
if (_dats is null) return;
if (!_dats.Portal.TryGet<GfxObj>((uint)id, out var gfxObj)) return;
var subMeshes = GfxObjMesh.Build(gfxObj, _dats);
for (int i = 0; i < subMeshes.Count; i++)
{
var sm = subMeshes[i];
_metadataTable.Add(id, i, new AcSurfaceMetadata(
sm.Translucency, sm.Luminosity, sm.Diffuse,
sm.SurfOpacity, sm.NeedsUvRepeat, sm.DisableFog));
}
}
/// <inheritdoc/>
public void Dispose()
{
@ -646,6 +657,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
frameFlights.WaitForSubmittedWork();
},
() => _meshManager?.Dispose(),
() => _ownedPreparedAssets?.Dispose(),
() => DrainGraphicsQueue("publishing mesh resource retirements"),
() =>
{

View file

@ -128,7 +128,7 @@ public sealed class LandblockBuildFactory
// Hydrate the stabs: same logic as the old OnLoad preload. Each stab
// entity from LandblockLoader carries a SourceGfxObjOrSetupId that we
// expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build.
// expand into per-part MeshRefs via SetupMesh.Flatten.
// GPU upload happens later through the render-thread presentation
// pipeline, never in this worker-side build.
var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
@ -364,9 +364,6 @@ public sealed class LandblockBuildFactory
if (gfx is not null)
{
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx);
// Sub-meshes are CPU-built here; the presentation pipeline
// defers upload to the render thread.
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value);
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat));
@ -384,7 +381,6 @@ public sealed class LandblockBuildFactory
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
// Compose: part's own transform, then the spawn's scale.
var partXf = mr.PartTransform * scaleMat;
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
@ -692,7 +688,6 @@ public sealed class LandblockBuildFactory
if (gfx is not null)
{
_physicsDataCache.CacheGfxObj(stab.Id, gfx);
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
@ -733,7 +728,6 @@ public sealed class LandblockBuildFactory
continue;
}
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value);
meshRefs.Add(mr);

View file

@ -1,4 +1,5 @@
using AcDream.Core.Rendering.Wb;
using AcDream.Core.Meshing;
using AcDream.Content.Vfx;
using BCnEncoder.Decoder;
using BCnEncoder.ImageSharp;
@ -569,6 +570,9 @@ public sealed class MeshExtractor {
TextureData = textureData!,
UploadPixelFormat = uploadPixelFormat,
UploadPixelType = uploadPixelType,
Translucency =
TranslucencyKindExtensions.FromSurfaceType(
surface.Type),
IsTransparent = isTransparent,
IsAdditive = isAdditive
};
@ -919,6 +923,9 @@ public sealed class MeshExtractor {
TextureData = textureData!,
UploadPixelFormat = uploadPixelFormat,
UploadPixelType = uploadPixelType,
Translucency =
TranslucencyKindExtensions.FromSurfaceType(
surface.Type),
IsTransparent = isTransparent,
IsAdditive = isAdditive
};

View file

@ -1,5 +1,6 @@
using Chorizite.Core.Lib;
using Chorizite.Core.Render.Enums;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using System;
@ -149,6 +150,8 @@ public class TextureBatchData {
public UploadPixelType? UploadPixelType { get; set; }
public List<ushort> Indices { get; set; } = new();
public DatReaderWriter.Enums.CullMode CullMode { get; set; }
public TranslucencyKind Translucency { get; set; } =
TranslucencyKind.Opaque;
public bool IsTransparent { get; set; }
public bool IsAdditive { get; set; }
public bool HasWrappingUVs { get; set; }

View file

@ -168,6 +168,7 @@ public static class ObjectMeshDataSerializer {
WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0);
WriteUInt16List(w, batch.Indices);
w.Write((int)batch.CullMode);
w.Write((int)batch.Translucency);
w.Write(batch.IsTransparent);
w.Write(batch.IsAdditive);
w.Write(batch.HasWrappingUVs);
@ -182,6 +183,8 @@ public static class ObjectMeshDataSerializer {
batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v);
batch.Indices = ReadUInt16List(r);
batch.CullMode = (CullMode)r.ReadInt32();
batch.Translucency =
(AcDream.Core.Meshing.TranslucencyKind)r.ReadInt32();
batch.IsTransparent = r.ReadBoolean();
batch.IsAdditive = r.ReadBoolean();
batch.HasWrappingUVs = r.ReadBoolean();

View file

@ -18,10 +18,12 @@ public static class PakFormat {
/// <summary>
/// Identity of the bake algorithm that produced the payloads. Version 2
/// introduces content-identity EnvCell blobs with ordinary TOC aliases.
/// introduced content-identity EnvCell blobs with ordinary TOC aliases;
/// version 3 embeds exact render-pass translucency in each texture batch
/// so production never rebuilds surface metadata from live DAT.
/// The binary format remains version 1.
/// </summary>
public const uint CurrentBakeToolVersion = 2;
public const uint CurrentBakeToolVersion = 3;
}
/// <summary>