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

@ -2,7 +2,14 @@
**Date:** 2026-07-24 **Date:** 2026-07-24
**Status:** active **Status:** active — C1 and C2 landed; C3 next
**Checkpoint ledger:**
- C1 typed prepared-source boundary — `c42f93b3`
- C2 production renderer injection — implementation complete and gated
- C3 activation probes and lookup precedence — next
- C4 connected equivalence and performance closeout — pending
**Parent:** [`2026-07-24-modern-runtime-architecture.md`](2026-07-24-modern-runtime-architecture.md), **Parent:** [`2026-07-24-modern-runtime-architecture.md`](2026-07-24-modern-runtime-architecture.md),
Slice C / MP1c Slice C / MP1c
@ -198,6 +205,9 @@ Implement:
- `WorldRenderComposition` and `WbMeshAdapter` injection; - `WorldRenderComposition` and `WbMeshAdapter` injection;
- `ObjectMeshManager` worker reads through `IPreparedAssetSource`; - `ObjectMeshManager` worker reads through `IPreparedAssetSource`;
- source Cell DID propagation for EnvCell aliases; - source Cell DID propagation for EnvCell aliases;
- exact `TranslucencyKind` persisted per prepared texture batch, removing the
render-thread `GfxObjMesh.Build` metadata reconstruction (bake-tool identity
advances to version 3; the pak container remains format version 1);
- exact terminal-failure and cancellation semantics; - exact terminal-failure and cancellation semantics;
- current CPU cache, staging, upload, retry, ownership, and shutdown behavior - current CPU cache, staging, upload, retry, ownership, and shutdown behavior
retained; retained;

View file

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

View file

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

View file

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

View file

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

View file

@ -171,7 +171,11 @@ public static class RenderBootstrap
// --- WbMeshAdapter (GameWindow ~2286-2287) --- // --- WbMeshAdapter (GameWindow ~2286-2287) ---
var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance; 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) --- // --- SequencerFactory (GameWindow ~2306-2334) ---
var capturedDats = dats; 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; continue;
_ = meshManager.PrepareEnvCellGeomMeshDataAsync( _ = meshManager.PrepareEnvCellGeomMeshDataAsync(
shell.GeometryId, shell.GeometryId,
shell.CellId,
shell.EnvironmentId, shell.EnvironmentId,
shell.CellStructure, shell.CellStructure,
new List<ushort>(shell.Surfaces)); new List<ushort>(shell.Surfaces));

View file

@ -84,6 +84,7 @@ namespace AcDream.App.Rendering.Wb
public uint SurfaceId { get; set; } public uint SurfaceId { get; set; }
public TextureKey Key { get; set; } public TextureKey Key { get; set; }
public DatReaderWriter.Enums.CullMode CullMode { get; set; } public DatReaderWriter.Enums.CullMode CullMode { get; set; }
public AcDream.Core.Meshing.TranslucencyKind Translucency { get; set; }
public bool IsTransparent { get; set; } public bool IsTransparent { get; set; }
public bool IsAdditive { get; set; } public bool IsAdditive { get; set; }
public bool HasWrappingUVs { get; set; } public bool HasWrappingUVs { get; set; }
@ -102,18 +103,15 @@ namespace AcDream.App.Rendering.Wb
public class ObjectMeshManager : IDisposable public class ObjectMeshManager : IDisposable
{ {
private readonly OpenGLGraphicsDevice _graphicsDevice; private readonly OpenGLGraphicsDevice _graphicsDevice;
private readonly IDatReaderWriter _dats; private readonly IPreparedAssetSource _preparedAssets;
private readonly ILogger _logger; private readonly ILogger _logger;
/// <summary> /// <summary>
/// MP1a (2026-07-05): the GL-free CPU extraction half, verbatim-moved to /// The immutable prepared-payload source is injected by composition.
/// AcDream.Content so the MP1b bake tool can run it without a GL context. /// Production uses the validated pak; UI Studio explicitly supplies the
/// Owns the dat read → mesh build → inline texture decode pipeline; this /// live-DAT tooling adapter. This class owns queue/worker lifecycle,
/// class keeps the queue/worker lifecycle and all GL upload. /// bounded CPU staging, and all GL upload.
/// </summary> /// </summary>
private readonly MeshExtractor _extractor;
internal IDatReaderWriter Dats => _dats;
public bool IsDisposed { get; private set; } public bool IsDisposed { get; private set; }
private readonly object _disposeGate = new(); private readonly object _disposeGate = new();
@ -141,7 +139,6 @@ namespace AcDream.App.Rendering.Wb
private readonly Dictionary<ulong, RetryableResourceReleaseLedger> _uploadRollbacks = new(); private readonly Dictionary<ulong, RetryableResourceReleaseLedger> _uploadRollbacks = new();
private readonly Queue<ulong> _uploadRollbackQueue = new(); private readonly Queue<ulong> _uploadRollbackQueue = new();
private readonly MeshOwnershipCounter _ownership = new(); private readonly MeshOwnershipCounter _ownership = new();
private readonly ConcurrentDictionary<ulong, (Vector3 Min, Vector3 Max)?> _boundsCache = new();
private readonly ConcurrentDictionary<ulong, Task<ObjectMeshData?>> _preparationTasks = new(); private readonly ConcurrentDictionary<ulong, Task<ObjectMeshData?>> _preparationTasks = new();
// LRU Cache for Unused objects // LRU Cache for Unused objects
@ -280,12 +277,11 @@ namespace AcDream.App.Rendering.Wb
internal CacheStats CpuMeshCacheStats => _cpuMeshCache.Stats; internal CacheStats CpuMeshCacheStats => _cpuMeshCache.Stats;
/// <summary>Decoded-texture cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).</summary> /// <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( private sealed class PreparationRequest(
ulong id, PreparedAssetRequest asset,
bool isSetup,
EnvCellGeomRequest? envCell,
ObjectMeshData? cachedData, ObjectMeshData? cachedData,
TaskCompletionSource<ObjectMeshData?> completion, TaskCompletionSource<ObjectMeshData?> completion,
CancellationTokenSource cancellation) CancellationTokenSource cancellation)
@ -296,9 +292,8 @@ namespace AcDream.App.Rendering.Wb
private bool _disposeRequested; private bool _disposeRequested;
private bool _cancellationDisposed; private bool _cancellationDisposed;
public ulong Id { get; } = id; public PreparedAssetRequest Asset { get; } = asset;
public bool IsSetup { get; } = isSetup; public ulong Id => Asset.RuntimeObjectId;
public EnvCellGeomRequest? EnvCell { get; } = envCell;
public ObjectMeshData? CachedData { get; } = cachedData; public ObjectMeshData? CachedData { get; } = cachedData;
public TaskCompletionSource<ObjectMeshData?> Completion { get; } = completion; public TaskCompletionSource<ObjectMeshData?> Completion { get; } = completion;
public CancellationTokenSource Cancellation { get; } = cancellation; public CancellationTokenSource Cancellation { get; } = cancellation;
@ -402,26 +397,18 @@ namespace AcDream.App.Rendering.Wb
public bool IsQueued { get; set; } 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; _graphicsDevice = graphicsDevice
_dats = dats; ?? throw new ArgumentNullException(nameof(graphicsDevice));
_logger = logger; _preparedAssets = preparedAssets
// Side-stage sink: particle-preload meshes staged mid-extraction go ?? throw new ArgumentNullException(nameof(preparedAssets));
// straight onto the staged-upload queue, exactly as the pre-MP1a code _logger = logger
// did — immediate enqueue, surviving a later throw in the same ?? throw new ArgumentNullException(nameof(logger));
// Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the
// extractor's up-to-4 concurrent decode workers.
_cpuMeshCache = new CpuMeshUploadCache(_maxCpuCacheSize); _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; _useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless;
if (_useModernRendering) if (_useModernRendering)
{ {
@ -809,6 +796,7 @@ namespace AcDream.App.Rendering.Wb
public struct EnvCellGeomRequest public struct EnvCellGeomRequest
{ {
public uint SourceCellId;
public uint EnvironmentId; public uint EnvironmentId;
public ushort CellStructure; public ushort CellStructure;
public List<ushort> Surfaces; public List<ushort> Surfaces;
@ -817,12 +805,19 @@ namespace AcDream.App.Rendering.Wb
/// <summary> /// <summary>
/// Phase 1 (Background Thread): Prepare CPU-side mesh data for deduplicated EnvCell geometry. /// Phase 1 (Background Thread): Prepare CPU-side mesh data for deduplicated EnvCell geometry.
/// </summary> /// </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); if (IsDisposed || HasRenderData(geomId)) return Task.FromResult<ObjectMeshData?>(null);
var envCell = new EnvCellGeomRequest var envCell = new EnvCellGeomRequest
{ {
SourceCellId = sourceCellId,
EnvironmentId = environmentId, EnvironmentId = environmentId,
CellStructure = cellStructure, CellStructure = cellStructure,
Surfaces = surfaces Surfaces = surfaces
@ -874,9 +869,12 @@ namespace AcDream.App.Rendering.Wb
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct);
_preparationTasks[geomId] = task; _preparationTasks[geomId] = task;
var request = new PreparationRequest( var request = new PreparationRequest(
geomId, PreparedAssetRequest.EnvCellGeometry(
false, sourceCellId,
envCell, geomId,
environmentId,
cellStructure,
surfaces),
deferredCachedData, deferredCachedData,
tcs, tcs,
cancellation); cancellation);
@ -933,10 +931,18 @@ namespace AcDream.App.Rendering.Wb
EnvCellGeomRequest? envCell = _envCellDescriptors.TryGetValue(id, out EnvCellGeomRequest descriptor) EnvCellGeomRequest? envCell = _envCellDescriptors.TryGetValue(id, out EnvCellGeomRequest descriptor)
? descriptor ? descriptor
: null; : 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( var request = new PreparationRequest(
id, asset,
isSetup,
envCell,
deferredCachedData, deferredCachedData,
tcs, tcs,
cancellation); cancellation);
@ -956,7 +962,7 @@ namespace AcDream.App.Rendering.Wb
lock (_pendingRequests) lock (_pendingRequests)
{ {
// IsDisposed re-check lets Dispose cancel and join every // 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: // Exit WITHOUT resetting the shared manual-reset event:
// Dispose sets it once to wake all four persistent workers. // Dispose sets it once to wake all four persistent workers.
// If the first worker reset it, the remaining three slept // If the first worker reset it, the remaining three slept
@ -991,7 +997,6 @@ namespace AcDream.App.Rendering.Wb
} }
ulong id = request.Id; ulong id = request.Id;
bool isSetup = request.IsSetup;
TaskCompletionSource<ObjectMeshData?> tcs = request.Completion; TaskCompletionSource<ObjectMeshData?> tcs = request.Completion;
CancellationToken ct = request.Cancellation.Token; CancellationToken ct = request.Cancellation.Token;
@ -1010,38 +1015,21 @@ namespace AcDream.App.Rendering.Wb
{ {
ObjectMeshData? data = request.CachedData; ObjectMeshData? data = request.CachedData;
if (data is null && request.EnvCell is { } req) if (data is null)
{ {
uint envId = 0x0D000000u | req.EnvironmentId; PreparedAssetReadResult read =
if (_dats.Portal.TryGet<DatReaderWriter.DBObjs.Environment>(envId, out var environment)) _preparedAssets.Read(request.Asset, ct);
data = read.Data;
if (read.Status != PreparedAssetReadStatus.Loaded)
{ {
if (environment.Cells.TryGetValue(req.CellStructure, out var cellStruct)) _logger.LogError(
{ "Prepared mesh {Status} for {Type} source " +
data = _extractor.PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, ct); "0x{SourceId:X8}, runtime 0x{RuntimeId:X10}",
// TEMP diagnostic #105 (strip with fix): a null prep here means read.Status,
// this deduplicated cell geometry will NEVER render anywhere. request.Asset.Type,
if (data == null) request.Asset.SourceFileId,
Console.WriteLine($"[geom-null] prepare-null geom=0x{id:X10} env=0x{envId:X8} cs=0x{req.CellStructure:X4}"); request.Asset.RuntimeObjectId);
}
else
{
Console.WriteLine($"[geom-null] cellstruct-missing geom=0x{id:X10} env=0x{envId:X8} cs=0x{req.CellStructure:X4}");
}
} }
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) if (ct.IsCancellationRequested)
{ {
@ -1168,7 +1156,7 @@ namespace AcDream.App.Rendering.Wb
/// Readiness barrier used by the streaming publisher. Missing owned /// Readiness barrier used by the streaming publisher. Missing owned
/// data is re-armed through its retained schema, so synthetic EnvCell /// data is re-armed through its retained schema, so synthetic EnvCell
/// IDs can never fall into generic GfxObj decoding after cancellation. /// 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. /// ownership schedule instead of being retried every render frame.
/// </summary> /// </summary>
internal bool EnsureRenderDataReady(ulong id) internal bool EnsureRenderDataReady(ulong id)
@ -1194,6 +1182,7 @@ namespace AcDream.App.Rendering.Wb
{ {
_ = PrepareEnvCellGeomMeshDataAsync( _ = PrepareEnvCellGeomMeshDataAsync(
id, id,
req.SourceCellId,
req.EnvironmentId, req.EnvironmentId,
req.CellStructure, req.CellStructure,
req.Surfaces); req.Surfaces);
@ -1206,16 +1195,16 @@ namespace AcDream.App.Rendering.Wb
} }
/// <summary> /// <summary>
/// Phase 1 (Background Thread): Prepare CPU-side mesh data from DAT. /// Synchronously reads an immutable prepared payload without creating
/// This loads vertices, indices, and texture data but creates NO GPU resources. /// GPU resources. The production source is the validated pak; explicit
/// Thread-safe: only reads from DAT files. /// tooling adapters may use live DAT extraction.
///
/// MP1a (2026-07-05): delegates to <see cref="MeshExtractor.PrepareMeshData"/>,
/// the verbatim-moved GL-free extraction dispatcher. See <see cref="_extractor"/>.
/// </summary> /// </summary>
public ObjectMeshData? PrepareMeshData(ulong id, bool isSetup, CancellationToken ct = default) 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> /// <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 #region Private: Background Preparation
/// <summary> /// <summary>
@ -1981,6 +1890,7 @@ namespace AcDream.App.Rendering.Wb
TextureIndex = textureIndex, TextureIndex = textureIndex,
TextureSize = (format.Width, format.Height), TextureSize = (format.Width, format.Height),
TextureFormat = format.Format, TextureFormat = format.Format,
Translucency = batch.Translucency,
IsTransparent = batch.IsTransparent, IsTransparent = batch.IsTransparent,
IsAdditive = batch.IsAdditive, IsAdditive = batch.IsAdditive,
HasWrappingUVs = batch.HasWrappingUVs, HasWrappingUVs = batch.HasWrappingUVs,

View file

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

View file

@ -1,10 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Meshing;
using AcDream.Core.Rendering; using AcDream.Core.Rendering;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Silk.NET.OpenGL; using Silk.NET.OpenGL;
@ -48,9 +46,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
private readonly OpenGLGraphicsDevice? _graphicsDevice; private readonly OpenGLGraphicsDevice? _graphicsDevice;
private readonly ObjectMeshManager? _meshManager; private readonly ObjectMeshManager? _meshManager;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement; private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement;
private readonly IDatReaderWriter? _dats; private readonly IPreparedAssetSource? _ownedPreparedAssets;
private readonly AcSurfaceMetadataTable _metadataTable = new();
private readonly HashSet<ulong> _metadataPopulated = new();
private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits( private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits(
MaximumUploadsPerFrame, MaximumUploadsPerFrame,
MaximumUploadBytesPerFrame, MaximumUploadBytesPerFrame,
@ -95,35 +91,72 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_meshManager?.CpuCacheDiagnostics ?? default; _meshManager?.CpuCacheDiagnostics ?? default;
/// <summary> /// <summary>
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → shared bounded /// Constructs the UI-Studio/tooling WB pipeline. Production composition
/// DAT facade → ObjectMeshManager. /// supplies the validated pak source through the internal overload below;
/// this explicit tooling seam retains live-DAT extraction.
/// </summary> /// </summary>
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current /// <param name="gl">Active Silk.NET GL context. Must be bound to the current
/// thread (construction runs GL queries; call from OnLoad).</param> /// thread (construction runs GL queries; call from OnLoad).</param>
/// <param name="dats">acdream's shared runtime DAT facade, used to populate the surface /// <param name="dats">acdream's shared runtime DAT facade. Tooling uses it
/// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with /// through an explicitly owned <see cref="DatPreparedAssetSource"/>.</param>
/// the rest of the client; read-only access from the render thread.</param>
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses /// <param name="logger">Logger for the adapter; ObjectMeshManager uses
/// NullLogger internally.</param> /// NullLogger internally.</param>
public WbMeshAdapter(GL gl, IDatReaderWriter dats, ILogger<WbMeshAdapter> logger) 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( internal WbMeshAdapter(
GL gl, GL gl,
IDatReaderWriter dats, IDatReaderWriter dats,
IPreparedAssetSource preparedAssets,
ILogger<WbMeshAdapter> logger, ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement) 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(gl);
ArgumentNullException.ThrowIfNull(dats); ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(logger);
_dats = dats;
_resourceRetirement = resourceRetirement; _resourceRetirement = resourceRetirement;
var resources = new AcDream.App.Rendering.ResourceCleanupGroup(); var resources = new AcDream.App.Rendering.ResourceCleanupGroup();
OpenGLGraphicsDevice? graphicsDevice = null; OpenGLGraphicsDevice? graphicsDevice = null;
IPreparedAssetSource? resolvedPreparedAssets = preparedAssets;
ObjectMeshManager? meshManager = null; ObjectMeshManager? meshManager = null;
try try
{ {
@ -145,11 +178,20 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
} }
}); });
resources.Add("WB graphics device", graphicsDeviceRelease.Run); 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 // ConsoleErrorLogger surfaces WB's silently-caught exceptions
// (ObjectMeshManager.PrepareMeshData try/catch at line ~589). // (ObjectMeshManager.PrepareMeshData try/catch at line ~589).
meshManager = new ObjectMeshManager( meshManager = new ObjectMeshManager(
graphicsDevice, graphicsDevice,
dats, resolvedPreparedAssets,
new ConsoleErrorLogger<ObjectMeshManager>()); new ConsoleErrorLogger<ObjectMeshManager>());
resources.Add("WB object mesh manager", meshManager.Dispose); resources.Add("WB object mesh manager", meshManager.Dispose);
resources.TransferAll(); resources.TransferAll();
@ -163,6 +205,9 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_graphicsDevice = graphicsDevice; _graphicsDevice = graphicsDevice;
_meshManager = meshManager; _meshManager = meshManager;
_ownedPreparedAssets = ownsPreparedAssets
? resolvedPreparedAssets
: null;
} }
/// <summary> /// <summary>
@ -213,13 +258,6 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
/// underlying mesh manager. Public methods are all no-ops.</summary> /// underlying mesh manager. Public methods are all no-ops.</summary>
public static WbMeshAdapter CreateUninitialized() => new(); 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> /// <summary>
/// Phase A8 (2026-05-28): exposes the underlying <see cref="ObjectMeshManager"/> /// 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). /// 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; if (_isUninitialized || _meshManager is null) return;
_meshManager.IncrementRefCount(id); _meshManager.IncrementRefCount(id);
bool metadataClaimed = false;
try try
{ {
metadataClaimed = _metadataPopulated.Add(id);
if (metadataClaimed)
PopulateMetadata(id);
// WB's IncrementRefCount alone only bumps a usage counter; it does // WB's IncrementRefCount alone only bumps a usage counter; it does
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync // NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
// so the background workers actually decode the GfxObj. The result // 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. // skips the entity — standard streaming flicker.
// //
// #128 (2026-06-11): Prepare must RE-ARM whenever the id has no render // #128 (2026-06-11): Prepare must RE-ARM whenever the id has no render
// data — NOT only on the first-ever registration. The old // data — NOT only on the first-ever registration. A first-only gate
// first-ever-only gate (`if (_metadataPopulated.Add(id))`) permanently // permanently lost any id whose initial read was cancelled before completing
// lost any id whose initial decode was cancelled before completing
// (landblock unload → CancelStagedUploads during login/teleport // (landblock unload → CancelStagedUploads during login/teleport
// churn) or whose upload was later LRU-evicted: every subsequent // churn) or whose upload was later LRU-evicted: every subsequent
// registration skipped Prepare, so the mesh stayed invisible for the // 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 // isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is // per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused. // unused.
if (metadataClaimed || _meshManager.TryGetRenderData(id) is null) if (_meshManager.TryGetRenderData(id) is null)
_meshManager.PrepareMeshDataAsync(id, isSetup: false); _meshManager.PrepareMeshDataAsync(id, isSetup: false);
} }
catch (Exception acquireFailure) catch (Exception acquireFailure)
{ {
if (metadataClaimed) // IncrementRefCount is a public ownership boundary. Prepared-data
{ // acquisition happens after ObjectMeshManager commits its count,
_metadataPopulated.Remove(id);
_metadataTable.Remove(id);
}
// IncrementRefCount is a public ownership boundary. Metadata/DAT
// preparation happens after ObjectMeshManager commits its count,
// so compensate before reporting failure. ObjectMeshManager's // so compensate before reporting failure. ObjectMeshManager's
// decrement has a strong exception guarantee; if compensation // decrement has a strong exception guarantee; if compensation
// itself fails, expose that the increment remains committed so a // itself fails, expose that the increment remains committed so a
@ -612,21 +638,6 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
$" (arraysBefore={before.ArraysWithPending})"); $" (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/> /// <inheritdoc/>
public void Dispose() public void Dispose()
{ {
@ -646,6 +657,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
frameFlights.WaitForSubmittedWork(); frameFlights.WaitForSubmittedWork();
}, },
() => _meshManager?.Dispose(), () => _meshManager?.Dispose(),
() => _ownedPreparedAssets?.Dispose(),
() => DrainGraphicsQueue("publishing mesh resource retirements"), () => 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 // Hydrate the stabs: same logic as the old OnLoad preload. Each stab
// entity from LandblockLoader carries a SourceGfxObjOrSetupId that we // 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 // GPU upload happens later through the render-thread presentation
// pipeline, never in this worker-side build. // pipeline, never in this worker-side build.
var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count); var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
@ -364,9 +364,6 @@ public sealed class LandblockBuildFactory
if (gfx is not null) if (gfx is not null)
{ {
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx); _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); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value); if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value);
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat)); 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); var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue; if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
// Compose: part's own transform, then the spawn's scale. // Compose: part's own transform, then the spawn's scale.
var partXf = mr.PartTransform * scaleMat; var partXf = mr.PartTransform * scaleMat;
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
@ -692,7 +688,6 @@ public sealed class LandblockBuildFactory
if (gfx is not null) if (gfx is not null)
{ {
_physicsDataCache.CacheGfxObj(stab.Id, gfx); _physicsDataCache.CacheGfxObj(stab.Id, gfx);
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); 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)); meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
@ -733,7 +728,6 @@ public sealed class LandblockBuildFactory
continue; continue;
} }
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value); if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value);
meshRefs.Add(mr); meshRefs.Add(mr);

View file

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

View file

@ -1,5 +1,6 @@
using Chorizite.Core.Lib; using Chorizite.Core.Lib;
using Chorizite.Core.Render.Enums; using Chorizite.Core.Render.Enums;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using System; using System;
@ -149,6 +150,8 @@ public class TextureBatchData {
public UploadPixelType? UploadPixelType { get; set; } public UploadPixelType? UploadPixelType { get; set; }
public List<ushort> Indices { get; set; } = new(); public List<ushort> Indices { get; set; } = new();
public DatReaderWriter.Enums.CullMode CullMode { get; set; } public DatReaderWriter.Enums.CullMode CullMode { get; set; }
public TranslucencyKind Translucency { get; set; } =
TranslucencyKind.Opaque;
public bool IsTransparent { get; set; } public bool IsTransparent { get; set; }
public bool IsAdditive { get; set; } public bool IsAdditive { get; set; }
public bool HasWrappingUVs { 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); WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0);
WriteUInt16List(w, batch.Indices); WriteUInt16List(w, batch.Indices);
w.Write((int)batch.CullMode); w.Write((int)batch.CullMode);
w.Write((int)batch.Translucency);
w.Write(batch.IsTransparent); w.Write(batch.IsTransparent);
w.Write(batch.IsAdditive); w.Write(batch.IsAdditive);
w.Write(batch.HasWrappingUVs); w.Write(batch.HasWrappingUVs);
@ -182,6 +183,8 @@ public static class ObjectMeshDataSerializer {
batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v); batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v);
batch.Indices = ReadUInt16List(r); batch.Indices = ReadUInt16List(r);
batch.CullMode = (CullMode)r.ReadInt32(); batch.CullMode = (CullMode)r.ReadInt32();
batch.Translucency =
(AcDream.Core.Meshing.TranslucencyKind)r.ReadInt32();
batch.IsTransparent = r.ReadBoolean(); batch.IsTransparent = r.ReadBoolean();
batch.IsAdditive = r.ReadBoolean(); batch.IsAdditive = r.ReadBoolean();
batch.HasWrappingUVs = r.ReadBoolean(); batch.HasWrappingUVs = r.ReadBoolean();

View file

@ -18,10 +18,12 @@ public static class PakFormat {
/// <summary> /// <summary>
/// Identity of the bake algorithm that produced the payloads. Version 2 /// 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. /// The binary format remains version 1.
/// </summary> /// </summary>
public const uint CurrentBakeToolVersion = 2; public const uint CurrentBakeToolVersion = 3;
} }
/// <summary> /// <summary>

View file

@ -36,6 +36,9 @@ public sealed class ContentEffectsAudioCompositionTests
Enum.GetValues<ContentEffectsAudioCompositionPoint>(), Enum.GetValues<ContentEffectsAudioCompositionPoint>(),
fixture.Points); fixture.Points);
Assert.Same(fixture.Publication.Dats, result.Dats); Assert.Same(fixture.Publication.Dats, result.Dats);
Assert.Same(fixture.Publication.PreparedAssets, result.PreparedAssets);
Assert.Equal("test.pak", fixture.Factory.PreparedAssetPath);
Assert.Same(result.Dats, fixture.Factory.PreparedAssetDats);
Assert.Same(fixture.Publication.Magic, result.MagicCatalog); Assert.Same(fixture.Publication.Magic, result.MagicCatalog);
Assert.Same(fixture.Publication.Animations, result.AnimationLoader); Assert.Same(fixture.Publication.Animations, result.AnimationLoader);
Assert.Same(fixture.Publication.Particles, result.ParticleSystem); Assert.Same(fixture.Publication.Particles, result.ParticleSystem);
@ -167,6 +170,70 @@ public sealed class ContentEffectsAudioCompositionTests
StringComparison.Ordinal); StringComparison.Ordinal);
} }
[Fact]
public void ProductionRendererConsumesOnlyThePublishedPreparedAssetSource()
{
string root = FindRepoRoot();
string contentPhase = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"ContentEffectsAudioComposition.cs"));
string worldPhase = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Composition",
"WorldRenderComposition.cs"));
string manager = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"Wb",
"ObjectMeshManager.cs"));
string adapter = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"Wb",
"WbMeshAdapter.cs"));
string lifetime = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"Rendering",
"GameWindowLifetime.cs"));
Assert.Contains("new PakPreparedAssetSource(path, dats, diagnostic)",
contentPhase, StringComparison.Ordinal);
Assert.Contains("content.PreparedAssets", worldPhase,
StringComparison.Ordinal);
Assert.Contains("_preparedAssets.Read(request.Asset, ct)", manager,
StringComparison.Ordinal);
Assert.DoesNotContain("MeshExtractor", manager,
StringComparison.Ordinal);
Assert.DoesNotContain("IDatReaderWriter", manager,
StringComparison.Ordinal);
Assert.DoesNotContain("GfxObjMesh.Build", adapter,
StringComparison.Ordinal);
int meshStage = lifetime.IndexOf(
"new ResourceShutdownStage(\"mesh adapter\"",
StringComparison.Ordinal);
int preparedRelease = lifetime.IndexOf(
"Hard(\"prepared asset source\"",
StringComparison.Ordinal);
int datRelease = lifetime.IndexOf(
"Hard(\"DAT collection\"",
StringComparison.Ordinal);
Assert.True(meshStage >= 0);
Assert.True(preparedRelease > meshStage);
Assert.True(datRelease > preparedRelease);
}
private sealed class Fixture : IDisposable private sealed class Fixture : IDisposable
{ {
private readonly ContentEffectsAudioCompositionPoint? _failurePoint; private readonly ContentEffectsAudioCompositionPoint? _failurePoint;
@ -185,6 +252,7 @@ public sealed class ContentEffectsAudioCompositionTests
typeof(HostInputCameraResult)); typeof(HostInputCameraResult));
Dependencies = new ContentEffectsAudioDependencies( Dependencies = new ContentEffectsAudioDependencies(
"test-dat", "test-dat",
"test.pak",
new PhysicsDataCache(), new PhysicsDataCache(),
false, false,
new Spellbook(), new Spellbook(),
@ -227,6 +295,7 @@ public sealed class ContentEffectsAudioCompositionTests
IDisposable IDisposable
{ {
public IDatReaderWriter? Dats { get; private set; } public IDatReaderWriter? Dats { get; private set; }
public IPreparedAssetSource? PreparedAssets { get; private set; }
public MagicCatalog? Magic { get; private set; } public MagicCatalog? Magic { get; private set; }
public IAnimationLoader? Animations { get; private set; } public IAnimationLoader? Animations { get; private set; }
public LiveEntityCollisionBuilder? Collision { get; private set; } public LiveEntityCollisionBuilder? Collision { get; private set; }
@ -242,6 +311,8 @@ public sealed class ContentEffectsAudioCompositionTests
public ContentAudioGraph? Audio { get; private set; } public ContentAudioGraph? Audio { get; private set; }
public void PublishDatCollection(IDatReaderWriter value) => Dats = Once(Dats, value); public void PublishDatCollection(IDatReaderWriter value) => Dats = Once(Dats, value);
public void PublishPreparedAssetSource(IPreparedAssetSource value) =>
PreparedAssets = Once(PreparedAssets, value);
public void PublishMagicCatalog(MagicCatalog value) => Magic = Once(Magic, value); public void PublishMagicCatalog(MagicCatalog value) => Magic = Once(Magic, value);
public void PublishAnimationLoader(IAnimationLoader value) => public void PublishAnimationLoader(IAnimationLoader value) =>
Animations = Once(Animations, value); Animations = Once(Animations, value);
@ -273,6 +344,8 @@ public sealed class ContentEffectsAudioCompositionTests
Registrations = null; Registrations = null;
Audio?.Engine.Dispose(); Audio?.Engine.Dispose();
Audio = null; Audio = null;
PreparedAssets?.Dispose();
PreparedAssets = null;
Dats?.Dispose(); Dats?.Dispose();
Dats = null; Dats = null;
} }
@ -292,11 +365,24 @@ public sealed class ContentEffectsAudioCompositionTests
private readonly MagicCatalog _magic = private readonly MagicCatalog _magic =
(MagicCatalog)RuntimeHelpers.GetUninitializedObject(typeof(MagicCatalog)); (MagicCatalog)RuntimeHelpers.GetUninitializedObject(typeof(MagicCatalog));
private readonly IAnimationLoader _animations = new NullAnimationLoader(); private readonly IAnimationLoader _animations = new NullAnimationLoader();
private readonly IPreparedAssetSource _preparedAssets =
new NullPreparedAssetSource();
public int AudioFactoryCalls { get; private set; } public int AudioFactoryCalls { get; private set; }
public OpenAlAudioEngine? LastAudioEngine { get; private set; } public OpenAlAudioEngine? LastAudioEngine { get; private set; }
public string? PreparedAssetPath { get; private set; }
public IDatReaderWriter? PreparedAssetDats { get; private set; }
public IDatReaderWriter OpenDatCollection(string datDirectory) => _dats; public IDatReaderWriter OpenDatCollection(string datDirectory) => _dats;
public IPreparedAssetSource OpenPreparedAssetSource(
string path,
IDatReaderWriter dats,
Action<string> diagnostic)
{
PreparedAssetPath = path;
PreparedAssetDats = dats;
return _preparedAssets;
}
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic; public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => _magic;
public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) { } public void InstallSpellMetadata(Spellbook spellBook, MagicCatalog catalog) { }
public int GetSpellCount(MagicCatalog catalog) => 0; public int GetSpellCount(MagicCatalog catalog) => 0;
@ -369,6 +455,26 @@ public sealed class ContentEffectsAudioCompositionTests
public Animation? LoadAnimation(uint id) => null; public Animation? LoadAnimation(uint id) => null;
} }
private sealed class NullPreparedAssetSource : IPreparedAssetSource
{
public PreparedAssetSourceStats Stats => default;
public CacheStats DecodedTextureCacheStats => default;
public PreparedAssetPresence Probe(
AcDream.Content.Pak.PakAssetType type,
uint sourceFileId) =>
PreparedAssetPresence.Missing;
public PreparedAssetReadResult Read(
in PreparedAssetRequest request,
CancellationToken cancellationToken = default) =>
PreparedAssetReadResult.Missing;
public void Dispose()
{
}
}
private sealed class AudioApiFactory(IOpenAlResourceApi api) private sealed class AudioApiFactory(IOpenAlResourceApi api)
: IOpenAlResourceApiFactory : IOpenAlResourceApiFactory
{ {

View file

@ -265,6 +265,7 @@ public sealed class WorldRenderCompositionTests
public WbMeshAdapter CreateMeshAdapter( public WbMeshAdapter CreateMeshAdapter(
GL gl, GL gl,
IDatReaderWriter dats, IDatReaderWriter dats,
IPreparedAssetSource preparedAssets,
IGpuResourceRetirementQueue retirement) => IGpuResourceRetirementQueue retirement) =>
Resource<WbMeshAdapter>("WB mesh adapter"); Resource<WbMeshAdapter>("WB mesh adapter");

View file

@ -95,6 +95,8 @@ public static class ObjectMeshDataEquality {
Assert.True(expected.Indices.SequenceEqual(actual.Indices), Assert.True(expected.Indices.SequenceEqual(actual.Indices),
$"{path}.Indices: expected [{string.Join(",", expected.Indices)}], got [{string.Join(",", actual.Indices)}]"); $"{path}.Indices: expected [{string.Join(",", expected.Indices)}], got [{string.Join(",", actual.Indices)}]");
Assert.True(expected.CullMode == actual.CullMode, $"{path}.CullMode: expected {expected.CullMode}, got {actual.CullMode}"); Assert.True(expected.CullMode == actual.CullMode, $"{path}.CullMode: expected {expected.CullMode}, got {actual.CullMode}");
Assert.True(expected.Translucency == actual.Translucency,
$"{path}.Translucency: expected {expected.Translucency}, got {actual.Translucency}");
Assert.True(expected.IsTransparent == actual.IsTransparent, $"{path}.IsTransparent: expected {expected.IsTransparent}, got {actual.IsTransparent}"); Assert.True(expected.IsTransparent == actual.IsTransparent, $"{path}.IsTransparent: expected {expected.IsTransparent}, got {actual.IsTransparent}");
Assert.True(expected.IsAdditive == actual.IsAdditive, $"{path}.IsAdditive: expected {expected.IsAdditive}, got {actual.IsAdditive}"); Assert.True(expected.IsAdditive == actual.IsAdditive, $"{path}.IsAdditive: expected {expected.IsAdditive}, got {actual.IsAdditive}");
Assert.True(expected.HasWrappingUVs == actual.HasWrappingUVs, $"{path}.HasWrappingUVs: expected {expected.HasWrappingUVs}, got {actual.HasWrappingUVs}"); Assert.True(expected.HasWrappingUVs == actual.HasWrappingUVs, $"{path}.HasWrappingUVs: expected {expected.HasWrappingUVs}, got {actual.HasWrappingUVs}");

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Numerics; using System.Numerics;
using AcDream.Content.Pak; using AcDream.Content.Pak;
using AcDream.Core.Meshing;
using Chorizite.Core.Lib; using Chorizite.Core.Lib;
using Chorizite.Core.Render.Enums; using Chorizite.Core.Render.Enums;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
@ -59,6 +60,7 @@ public class ObjectMeshDataSerializerTests {
UploadPixelType = AcDream.Content.UploadPixelType.UnsignedByte, UploadPixelType = AcDream.Content.UploadPixelType.UnsignedByte,
Indices = new List<ushort> { 0, 1, 2, 2, 3, 0 }, Indices = new List<ushort> { 0, 1, 2, 2, 3, 0 },
CullMode = CullMode.CounterClockwise, CullMode = CullMode.CounterClockwise,
Translucency = TranslucencyKind.InvAlpha,
IsTransparent = true, IsTransparent = true,
IsAdditive = false, IsAdditive = false,
HasWrappingUVs = true, HasWrappingUVs = true,

View file

@ -1,72 +0,0 @@
using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing;
namespace AcDream.Core.Tests.Rendering.Wb;
public sealed class AcSurfaceMetadataTableTests
{
[Fact]
public void Add_ThenLookup_RoundTripsSameMetadata()
{
var table = new AcSurfaceMetadataTable();
var meta = new AcSurfaceMetadata(
Translucency: TranslucencyKind.AlphaBlend,
Luminosity: 0.5f,
Diffuse: 0.8f,
SurfOpacity: 0.7f,
NeedsUvRepeat: true,
DisableFog: false);
table.Add(gfxObjId: 0x01000123ul, surfaceIdx: 2, meta);
Assert.True(table.TryLookup(0x01000123ul, 2, out var got));
Assert.Equal(meta, got);
}
[Fact]
public void Lookup_MissingKey_ReturnsFalse()
{
var table = new AcSurfaceMetadataTable();
Assert.False(table.TryLookup(0xDEADBEEFul, 0, out _));
}
[Fact]
public void Add_OverwritesPreviousMetadata()
{
var table = new AcSurfaceMetadataTable();
var first = new AcSurfaceMetadata(TranslucencyKind.Opaque, 0f, 1f, 1f, false, false);
var second = new AcSurfaceMetadata(TranslucencyKind.Additive, 1f, 1f, 1f, false, true);
table.Add(0xAAAA, 0, first);
table.Add(0xAAAA, 0, second);
Assert.True(table.TryLookup(0xAAAA, 0, out var got));
Assert.Equal(second, got);
}
[Fact]
public void Add_FromMultipleThreads_IsThreadSafe()
{
var table = new AcSurfaceMetadataTable();
var threads = new System.Threading.Tasks.Task[8];
for (int t = 0; t < 8; t++)
{
int threadIdx = t;
threads[t] = System.Threading.Tasks.Task.Run(() =>
{
for (int i = 0; i < 1000; i++)
{
ulong key = (ulong)(threadIdx * 1000 + i);
table.Add(key, 0, new AcSurfaceMetadata(
TranslucencyKind.Opaque, 0f, 1f, 1f, false, false));
}
});
}
System.Threading.Tasks.Task.WaitAll(threads);
// 8000 entries should be present.
for (int t = 0; t < 8; t++)
for (int i = 0; i < 1000; i++)
Assert.True(table.TryLookup((ulong)(t * 1000 + i), 0, out _));
}
}