The shared vertex/index arena is the largest single GPU allocation acdream
makes (384 MiB + 128 MiB) and the one the Vulkan backend has the most specific
plan for (campaign doc section 4.3). This slice swaps the resource handle type
underneath it and changes nothing else: the reclaimable-range allocator, the
growth quanta, the budgeted incremental grow-and-copy, the retirement-ledger
gating, the abort ticket, the LRU that drives eviction, and the 896 MiB
dual-generation physical ceiling are all untouched. That is deliberate - those
are the semantics section 4.3 says the Vulkan arena must mirror exactly, so
preserving them is the point of the slice rather than an incidental constraint.
What moved:
- GlobalMeshBuffer's two GL buffer objects became IGpuBuffer, allocated through
IGpuDevice.CreateBuffer with DeviceLocal residency and Vertex-or-Index plus
both transfer usages (the arena is simultaneously a draw source and both ends
of its own migration, which is exactly why GpuBufferUsage is a flags enum).
- UploadMesh's two hand-rolled BufferSubData sites became IGpuBuffer.Upload.
The old code staged indices through GL_COPY_WRITE_BUFFER specifically so an
upload could not mutate whichever VAO a preceding render pass left bound;
Upload stages through a neutral binding point of the backend's choosing, so
that property now comes for free instead of by hand.
- AdvanceMigration's CopyBufferSubData became IGpuBuffer.CopyTo - a device-side
copy, which the Vulkan backend will record as vkCmdCopyBuffer. The live
prefix still never round-trips through system memory.
- BeginMigration/CommitMigration/AbortMigration/Dispose now carry IGpuBuffer in
the migration record and the abort ticket instead of raw uint names, so the
ticket's identity check is a resource identity rather than a number that goes
stale the moment the buffer is deleted.
What deliberately did not move. A VAO has no RHI verb - Vulkan bakes vertex
input into the pipeline - and WbDrawDispatcher, EnvCellRenderer and
ParticleRenderer still bind VAO/VBO/IBO with raw GL until V4c hands them the
pass encoder. So GlobalMeshBuffer keeps its GL handle for the vertex array and
its attribute layout, and VBO/IBO became computed properties that publish the
backing GL name of the buffer the arena now owns as an IGpuBuffer. One private
RequireGlBuffer helper is the single place that reaches through the interface,
and it disappears with those consumers. ObjectMeshManager therefore needed no
upload-path change at all - it reads those same three properties.
Two decisions worth recording.
First, arena deletes do not route through IGpuBuffer.Dispose. The arena already
gates every delete behind its own GpuRetirementLedger and decrements its
physical-capacity accounting in the same retirement stage; Dispose would defer
the physical free through the device queue a second time, so the accounting
would run ahead of real GPU residency and could admit a migration that breaches
the 896 MiB ceiling. GlGpuBuffer gains DeleteRetired for callers that have
already proved flight safety, and GlobalMeshBuffer composes it into a release
whose four stages match TrackedGlResource.CreateRetryableBufferDeletion exactly
- precondition, mutation-with-validation, byte accounting, resource-count
accounting - so a driver failure re-issues only the delete and never
double-counts.
Second, two corrections in the GL backend, both required to keep this port
behaviour-preserving rather than merely compiling. GlGpuBuffer's glBufferData
usage hint now follows residency (DeviceLocal -> StaticDraw), which is what the
arena has always requested; the host-writable rings and texture table keep
DynamicDraw and are unaffected. And a failed allocation now releases the GL
name it had already created - GL_OUT_OF_MEMORY is a real outcome for a 384 MiB
growth destination, and the previous code leaked the name on that path.
Plumbing: the device reaches the arena through WbMeshAdapter and
ObjectMeshManager. Their constructors became internal because IGpuDevice is an
internal type by the pinned contract, matching what V4a did for BitmapFont,
DebugLineRenderer and TextRenderer; both classes stay public and every caller
already lives inside AcDream.App or its InternalsVisibleTo test assemblies. The
unused public GlobalMeshBuffer(GL) convenience constructor is gone - it could
not supply a device and had no callers.
Gates. Release build green with TreatWarningsAsErrors. App tests 3,843 passed /
3 skipped, exactly the slice baseline; complete Release suite 8,906 passed / 5
skipped. Offline pixel gate against 79ee2361: 25 differing pixels of 563,200
(fraction 4.44e-05), against a same-commit control captured immediately
afterwards of 24 - the change is indistinguishable from capture noise and sits
40x under the 0.001 threshold. An earlier gate run was discarded rather than
interpreted: its client log showed real ScrollUp/ScrollDown input reaching the
offline window, which zoomed the camera, and a camera-motion difference is not
a rendering result.
No divergence-register row: this slice changes no retail-facing behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
756 lines
33 KiB
C#
756 lines
33 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using AcDream.Content;
|
|
using AcDream.App.Rendering.Residency;
|
|
using AcDream.Core.Rendering;
|
|
using DatReaderWriter;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Rendering.Wb;
|
|
|
|
/// <summary>
|
|
/// Single seam between acdream and WB's render pipeline. Owns the
|
|
/// <c>ObjectMeshManager</c> instance and exposes a stable acdream-shaped API
|
|
/// so the rest of the renderer doesn't need to know about WB's types directly.
|
|
///
|
|
/// <para>
|
|
/// As of Phase O-T7, all DAT I/O routes through the runtime-owned shared
|
|
/// <see cref="IDatReaderWriter"/> facade — the separate
|
|
/// <c>DefaultDatReaderWriter</c> file-handle set has been removed.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class WbMeshAdapter
|
|
: IDisposable,
|
|
IWbMeshAdapter
|
|
{
|
|
internal const int MaximumUploadsPerFrame = 8;
|
|
internal const int DestinationRevealMaximumUploadsPerFrame = 64;
|
|
internal const long MaximumUploadBytesPerFrame = 8L * 1024 * 1024;
|
|
internal const long MaximumArrayAllocationBytesPerFrame = 8L * 1024 * 1024;
|
|
internal const long MaximumMipmapBytesPerFrame = 8L * 1024 * 1024;
|
|
internal const int MaximumNewArraysPerFrame = 1;
|
|
internal const long MaximumBufferUploadBytesPerFrame = 8L * 1024 * 1024;
|
|
// A GL buffer store is indivisible. The active vertex arena's explicit
|
|
// supported ceiling is therefore the truthful one-allocation bound; only
|
|
// its prefix copy is staged across frames.
|
|
internal const long MaximumBufferAllocationBytesPerFrame =
|
|
GlobalMeshBuffer.MaximumVertexBufferBytes;
|
|
internal const long MaximumBufferCopyBytesPerFrame = 32L * 1024 * 1024;
|
|
internal const int MaximumNewBuffersPerFrame = 1;
|
|
internal const long MaximumSingleUploadBytes = 128L * 1024 * 1024;
|
|
internal const long MaximumSingleArrayAllocationBytes = 128L * 1024 * 1024;
|
|
internal const long MaximumSingleMipmapBytes = 128L * 1024 * 1024;
|
|
internal const int MaximumSingleNewArrays = 16;
|
|
internal const long MaximumSingleBufferUploadBytes = 32L * 1024 * 1024;
|
|
internal const int MaximumReclaimedMeshesPerFrame = MaximumUploadsPerFrame;
|
|
internal const long MaximumReclaimedMeshBytesPerFrame = 64L * 1024 * 1024;
|
|
internal const int MaximumStaleDiscardsPerFrame = 64;
|
|
private readonly OpenGLGraphicsDevice? _graphicsDevice;
|
|
private readonly ObjectMeshManager? _meshManager;
|
|
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement;
|
|
private readonly IPreparedAssetSource? _ownedPreparedAssets;
|
|
private readonly MeshUploadFrameBudget _ordinaryUploadBudget =
|
|
CreateUploadBudget(MaximumUploadsPerFrame);
|
|
private readonly MeshUploadFrameBudget _destinationRevealUploadBudget =
|
|
CreateUploadBudget(DestinationRevealMaximumUploadsPerFrame);
|
|
private readonly HashSet<TextureAtlasManager> _mipmapsBudgeted = new();
|
|
private bool _destinationRevealUploadPriority;
|
|
|
|
/// <summary>
|
|
/// True when this instance was created via <see cref="CreateUninitialized"/>;
|
|
/// all public methods no-op when uninitialized.
|
|
/// </summary>
|
|
private readonly bool _isUninitialized;
|
|
|
|
private bool _disposed;
|
|
private AcDream.App.Rendering.OrderedResourceTeardown? _teardown;
|
|
internal int LastUploadCount { get; private set; }
|
|
internal long LastUploadBytes { get; private set; }
|
|
internal int LastStaleDiscardCount { get; private set; }
|
|
internal long LastArrayAllocationBytes { get; private set; }
|
|
internal long LastPlannedMipmapBytes { get; private set; }
|
|
internal int LastNewArrayCount { get; private set; }
|
|
internal long LastBufferUploadBytes { get; private set; }
|
|
internal long LastBufferAllocationBytes { get; private set; }
|
|
internal long LastBufferCopyBytes { get; private set; }
|
|
internal int LastNewBufferCount { get; private set; }
|
|
internal int LastMipmapArrayCount { get; private set; }
|
|
internal long LastMipmapBytes { get; private set; }
|
|
internal int StagedUploadBacklog => _meshManager?.StagedMeshCount ?? 0;
|
|
internal long StagedUploadBytes => _meshManager?.StagedMeshBytes ?? 0;
|
|
internal bool StagingAtHighWater => _meshManager?.StagingAtHighWater ?? false;
|
|
internal (int Count, long Bytes) CpuMeshCacheDiagnostics =>
|
|
_meshManager?.CpuCacheDiagnostics ?? default;
|
|
|
|
internal void SetDestinationRevealUploadPriority(bool enabled) =>
|
|
_destinationRevealUploadPriority = enabled;
|
|
|
|
private static MeshUploadFrameBudget CreateUploadBudget(
|
|
int maximumObjects) =>
|
|
new(new MeshUploadBudgetLimits(
|
|
maximumObjects,
|
|
MaximumUploadBytesPerFrame,
|
|
MaximumArrayAllocationBytesPerFrame,
|
|
MaximumMipmapBytesPerFrame,
|
|
MaximumNewArraysPerFrame,
|
|
MaximumBufferUploadBytesPerFrame,
|
|
MaximumBufferAllocationBytesPerFrame,
|
|
MaximumBufferCopyBytesPerFrame,
|
|
MaximumNewBuffersPerFrame,
|
|
MaximumSingleUploadBytes,
|
|
MaximumSingleArrayAllocationBytes,
|
|
MaximumSingleMipmapBytes,
|
|
MaximumSingleNewArrays,
|
|
MaximumSingleBufferUploadBytes));
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// internal, not public, since Campaign V slice V4b: the shared mesh arena
|
|
/// allocates its backing stores from <c>IGpuDevice</c>, which is an internal
|
|
/// type by the pinned RHI contract. Every caller already lives inside
|
|
/// AcDream.App or its InternalsVisibleTo test assemblies.
|
|
/// </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="gpuDevice">The one process RHI device. Supplies the mesh
|
|
/// arena's vertex/index buffers.</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>
|
|
internal WbMeshAdapter(
|
|
GL gl,
|
|
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
|
|
IDatReaderWriter dats,
|
|
ILogger<WbMeshAdapter> logger)
|
|
: this(
|
|
gl,
|
|
gpuDevice,
|
|
dats,
|
|
preparedAssets: null,
|
|
logger,
|
|
AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance,
|
|
ownsPreparedAssets: true,
|
|
ResidencyBudgetOptions.Default)
|
|
{
|
|
}
|
|
|
|
internal static WbMeshAdapter CreateWithLiveDatPreparedAssets(
|
|
GL gl,
|
|
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
|
|
IDatReaderWriter dats,
|
|
ILogger<WbMeshAdapter> logger,
|
|
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement) =>
|
|
new(
|
|
gl,
|
|
gpuDevice,
|
|
dats,
|
|
preparedAssets: null,
|
|
logger,
|
|
resourceRetirement,
|
|
ownsPreparedAssets: true,
|
|
ResidencyBudgetOptions.Default);
|
|
|
|
internal WbMeshAdapter(
|
|
GL gl,
|
|
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
|
|
IDatReaderWriter dats,
|
|
IPreparedAssetSource preparedAssets,
|
|
ILogger<WbMeshAdapter> logger,
|
|
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
|
|
ResidencyBudgetOptions? budgets = null)
|
|
: this(
|
|
gl,
|
|
gpuDevice,
|
|
dats,
|
|
preparedAssets,
|
|
logger,
|
|
resourceRetirement,
|
|
ownsPreparedAssets: false,
|
|
budgets ?? ResidencyBudgetOptions.Default)
|
|
{
|
|
}
|
|
|
|
private WbMeshAdapter(
|
|
GL gl,
|
|
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
|
|
IDatReaderWriter dats,
|
|
IPreparedAssetSource? preparedAssets,
|
|
ILogger<WbMeshAdapter> logger,
|
|
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
|
|
bool ownsPreparedAssets,
|
|
ResidencyBudgetOptions budgets)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(gl);
|
|
ArgumentNullException.ThrowIfNull(gpuDevice);
|
|
ArgumentNullException.ThrowIfNull(dats);
|
|
ArgumentNullException.ThrowIfNull(logger);
|
|
ArgumentNullException.ThrowIfNull(budgets);
|
|
|
|
_resourceRetirement = resourceRetirement;
|
|
var resources = new AcDream.App.Rendering.ResourceCleanupGroup();
|
|
OpenGLGraphicsDevice? graphicsDevice = null;
|
|
IPreparedAssetSource? resolvedPreparedAssets = preparedAssets;
|
|
ObjectMeshManager? meshManager = null;
|
|
try
|
|
{
|
|
graphicsDevice = new OpenGLGraphicsDevice(
|
|
gl,
|
|
logger,
|
|
new DebugRenderSettings(),
|
|
resourceRetirement);
|
|
OpenGLGraphicsDevice ownedGraphicsDevice = graphicsDevice;
|
|
var graphicsDeviceRelease = new RetryableGpuResourceRelease(
|
|
ownedGraphicsDevice.Dispose,
|
|
() =>
|
|
{
|
|
ownedGraphicsDevice.ProcessGLQueue();
|
|
if (ownedGraphicsDevice.HasPendingGLWork)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"WB graphics-device construction cleanup still has queued GL work.");
|
|
}
|
|
});
|
|
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,
|
|
gpuDevice,
|
|
resolvedPreparedAssets,
|
|
new ConsoleErrorLogger<ObjectMeshManager>(),
|
|
budgets);
|
|
resources.Add("WB object mesh manager", meshManager.Dispose);
|
|
resources.TransferAll();
|
|
}
|
|
catch (Exception constructionFailure)
|
|
{
|
|
resources.RollbackConstructionAndThrow(
|
|
"WbMeshAdapter construction failed and its WB/GL prefix did not cleanly roll back.",
|
|
constructionFailure);
|
|
}
|
|
|
|
_graphicsDevice = graphicsDevice;
|
|
_meshManager = meshManager;
|
|
_ownedPreparedAssets = ownsPreparedAssets
|
|
? resolvedPreparedAssets
|
|
: null;
|
|
}
|
|
|
|
internal void RegisterResidencySources(ResidencyManager manager)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(manager);
|
|
ObjectMeshManager meshManager = _meshManager
|
|
?? throw new InvalidOperationException(
|
|
"An initialized mesh adapter is required for residency diagnostics.");
|
|
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
|
|
ResidencyDomain.ObjectMeshes,
|
|
meshManager.CaptureObjectMeshResidency));
|
|
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
|
|
ResidencyDomain.PreparedMeshCpu,
|
|
meshManager.CapturePreparedMeshResidency));
|
|
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
|
|
ResidencyDomain.MeshStaging,
|
|
meshManager.CaptureStagingResidency));
|
|
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
|
|
ResidencyDomain.GlobalMeshArena,
|
|
meshManager.CaptureGlobalArenaResidency));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal Console-backed logger that fires only on
|
|
/// <see cref="LogLevel.Error"/> and above. Format:
|
|
/// <code>[wb-error] <message>
|
|
/// [wb-error] <ExceptionType>: <ExceptionMessage>
|
|
/// [wb-error] at <frame> (up to 5 frames)</code>
|
|
/// Used to surface WB's silently-caught exceptions in
|
|
/// <c>ObjectMeshManager.PrepareMeshData</c>.
|
|
/// </summary>
|
|
private sealed class ConsoleErrorLogger<T> : ILogger<T>
|
|
{
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
|
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error;
|
|
public void Log<TState>(
|
|
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
if (!IsEnabled(logLevel)) return;
|
|
var message = formatter(state, exception);
|
|
Console.WriteLine($"[wb-error] {message}");
|
|
if (exception is not null)
|
|
{
|
|
Console.WriteLine($"[wb-error] {exception.GetType().Name}: {exception.Message}");
|
|
var stack = (exception.StackTrace ?? "")
|
|
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
|
.Take(5);
|
|
foreach (var s in stack) Console.WriteLine($"[wb-error] {s.Trim()}");
|
|
}
|
|
}
|
|
|
|
private sealed class NullScope : IDisposable
|
|
{
|
|
public static readonly NullScope Instance = new();
|
|
public void Dispose() { }
|
|
}
|
|
}
|
|
|
|
private WbMeshAdapter()
|
|
{
|
|
// Uninitialized constructor — only for tests / flag-off cases where
|
|
// the caller wants a Dispose-safe no-op instance.
|
|
_isUninitialized = true;
|
|
}
|
|
|
|
/// <summary>Test/init helper — produces a Dispose-safe instance with no
|
|
/// underlying mesh manager. Public methods are all no-ops.</summary>
|
|
public static WbMeshAdapter CreateUninitialized() => new();
|
|
|
|
/// <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).
|
|
/// Returns null when the adapter is uninitialized.
|
|
/// </summary>
|
|
public ObjectMeshManager? MeshManager => _meshManager;
|
|
|
|
/// <summary>
|
|
/// Returns the WB render data for <paramref name="id"/>, or null if not
|
|
/// yet uploaded or if this adapter is uninitialized. Increments WB's
|
|
/// internal usage counter — use <see cref="TryGetRenderData"/> for
|
|
/// render-loop lookups that should not affect lifecycle.
|
|
/// </summary>
|
|
public ObjectRenderData? GetRenderData(ulong id)
|
|
{
|
|
if (_isUninitialized || _meshManager is null) return null;
|
|
return _meshManager.GetRenderData(id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the WB render data for <paramref name="id"/> without
|
|
/// modifying reference counts. Returns null if the mesh is not yet
|
|
/// uploaded. Safe for render-loop lookups.
|
|
/// </summary>
|
|
public ObjectRenderData? TryGetRenderData(ulong id)
|
|
{
|
|
if (_isUninitialized || _meshManager is null) return null;
|
|
return _meshManager.TryGetRenderData(id);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public bool IsRenderDataReady(ulong id)
|
|
{
|
|
// An uninitialized adapter owns no render pipeline, so it must not
|
|
// manufacture a readiness wait that can never complete. The modern
|
|
// production path is always initialized at startup.
|
|
return _isUninitialized || (_meshManager?.EnsureRenderDataReady(id) ?? false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void IncrementRefCount(ulong id)
|
|
{
|
|
if (_isUninitialized || _meshManager is null) return;
|
|
_meshManager.IncrementRefCount(id);
|
|
|
|
try
|
|
{
|
|
// 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
|
|
// auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
|
|
// which Tick() drains onto the GPU. Until that completes,
|
|
// TryGetRenderData(id) returns null and the dispatcher silently
|
|
// 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. 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
|
|
// session with zero log output — the dispatcher's slow path just
|
|
// counted meshMissing forever (issue #55's 1.45M/5s mountain was this
|
|
// bug's heartbeat). User-visible: the AAB3 tower staircase rendering
|
|
// partially or not at all depending on the session's landblock
|
|
// load/unload interleaving (#119/#128 "broken stairs"). Safe to call
|
|
// unconditionally when data is absent: PrepareMeshDataAsync early-outs
|
|
// on existing render data, returns the in-flight task when already
|
|
// pending, and dedups via _preparationTasks.
|
|
//
|
|
// isSetup: false — acdream's MeshRefs already carry expanded
|
|
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
|
|
// unused.
|
|
if (_meshManager.TryGetRenderData(id) is null)
|
|
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
|
|
}
|
|
catch (Exception acquireFailure)
|
|
{
|
|
// 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
|
|
// higher-level transactional owner can retain its held marker.
|
|
try
|
|
{
|
|
_meshManager.DecrementRefCount(id);
|
|
}
|
|
catch (Exception rollbackFailure)
|
|
{
|
|
throw new MeshReferenceMutationException(
|
|
$"Mesh 0x{id:X10} reference acquisition failed and its committed increment could not be rolled back.",
|
|
mutationCommitted: true,
|
|
new AggregateException(acquireFailure, rollbackFailure));
|
|
}
|
|
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void DecrementRefCount(ulong id)
|
|
{
|
|
if (_isUninitialized || _meshManager is null) return;
|
|
_meshManager.DecrementRefCount(id);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void PinPreparedRenderData(ulong id)
|
|
{
|
|
if (_isUninitialized || _meshManager is null) return;
|
|
// EnvCell geometry has its own schema-aware preparation request.
|
|
// Only establish lifecycle ownership here; IncrementRefCount's normal
|
|
// generic GfxObj preparation would decode the synthetic id incorrectly.
|
|
_meshManager.IncrementRefCount(id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #128 self-heal (2026-06-11): re-request a mesh load at the POINT OF
|
|
/// USE. Registration-time re-arming was insufficient — a preparation
|
|
/// cancelled by landblock churn AFTER the last registration event
|
|
/// (running across blocks loads/unloads them repeatedly) left the mesh
|
|
/// permanently unloadable with no later event to re-fire it. The draw
|
|
/// dispatcher touches every missing-but-referenced mesh every frame (the
|
|
/// meshMissing slow path) — that is the one place a retry can never be
|
|
/// missed. Cheap and idempotent: PrepareMeshDataAsync early-outs on
|
|
/// existing render data and returns the in-flight task when pending.
|
|
/// Retail-equivalence: retail loads content synchronously — geometry is
|
|
/// never permanently absent; this converges our async pipeline to the
|
|
/// same guarantee.
|
|
/// </summary>
|
|
public void EnsureLoaded(ulong id)
|
|
{
|
|
if (_isUninitialized || _meshManager is null) return;
|
|
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-frame drain of the WB pipeline's main-thread work queues. MUST be
|
|
/// called once per frame from the render thread. Without this, the staged
|
|
/// mesh data queue grows unbounded (memory leak) and queued GL actions
|
|
/// never execute.
|
|
///
|
|
/// <para>
|
|
/// Order matters: <c>ProcessGLQueue</c> runs first to apply any pending GL
|
|
/// state changes (e.g., texture uploads queued by background workers
|
|
/// during mesh prep). Then we drain staged mesh data, calling
|
|
/// <c>UploadMeshData</c> on each item to materialize the actual GL VAO /
|
|
/// VBO / IBO resources. After Tick, <c>GetRenderData</c> for any id
|
|
/// previously passed to <c>IncrementRefCount</c> may return non-null.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// No-op when the adapter is uninitialized (e.g., flag is off and the
|
|
/// adapter was constructed via <c>CreateUninitialized</c>).
|
|
/// </para>
|
|
/// </summary>
|
|
public void Tick()
|
|
{
|
|
if (_isUninitialized) return;
|
|
if (_disposed) return;
|
|
|
|
ObjectMeshManager meshManager = _meshManager!;
|
|
_graphicsDevice!.ProcessGLQueue();
|
|
// #125: drain staged uploads; a FAILED upload (UploadMeshData returned
|
|
// null from its catch) is re-staged for a LATER frame, not dropped. The
|
|
// re-stages are collected and re-enqueued AFTER the loop — re-enqueuing
|
|
// inside the while would let a deterministic failure spin the queue in a
|
|
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
|
|
// a genuine defect surfaces loudly instead of the old silent sticky drop.
|
|
List<MeshUploadQueueItem>? requeue = null;
|
|
MeshUploadFrameBudget uploadBudget =
|
|
_destinationRevealUploadPriority
|
|
? _destinationRevealUploadBudget
|
|
: _ordinaryUploadBudget;
|
|
int maximumUploads =
|
|
_destinationRevealUploadPriority
|
|
? DestinationRevealMaximumUploadsPerFrame
|
|
: MaximumUploadsPerFrame;
|
|
uploadBudget.Reset();
|
|
_mipmapsBudgeted.Clear();
|
|
int staleDiscardCount = 0;
|
|
bool arenaBackpressured = false;
|
|
GlobalMeshBuffer? globalBuffer = meshManager.GlobalBuffer;
|
|
|
|
// A backing-store migration is real GL work, not a cost estimate. One
|
|
// bounded copy chunk runs per frame while the old VAO remains active.
|
|
// Uploads stay queued until the atomic final swap has completed.
|
|
if (globalBuffer?.IsMigrationInProgress == true)
|
|
{
|
|
GlobalMeshMaintenanceStep maintenance =
|
|
globalBuffer.AdvanceMigration(MaximumBufferCopyBytesPerFrame);
|
|
uploadBudget.RecordBufferMaintenance(
|
|
maintenance.AllocationBytes,
|
|
maintenance.CopyBytes,
|
|
maintenance.NewBufferCount);
|
|
arenaBackpressured = !maintenance.Completed;
|
|
}
|
|
|
|
while (globalBuffer?.IsMigrationInProgress != true
|
|
&& uploadBudget.BufferCopyBytes == 0
|
|
&& uploadBudget.ObjectCount < maximumUploads)
|
|
{
|
|
staleDiscardCount += meshManager.DiscardUnownedStagedPrefix(
|
|
MaximumStaleDiscardsPerFrame - staleDiscardCount);
|
|
if (staleDiscardCount >= MaximumStaleDiscardsPerFrame)
|
|
break;
|
|
if (!meshManager.TryPeekStagedMeshData(out MeshUploadQueueItem next))
|
|
break;
|
|
|
|
GlobalMeshCapacityResult capacity;
|
|
GlobalMeshMaintenanceStep maintenance;
|
|
try
|
|
{
|
|
capacity = meshManager.EnsureGlobalBufferCapacity(next, out maintenance);
|
|
}
|
|
catch (NotSupportedException error)
|
|
{
|
|
RejectUnsupportedHead(meshManager, next, error);
|
|
throw;
|
|
}
|
|
if (capacity == GlobalMeshCapacityResult.MigrationStarted)
|
|
{
|
|
uploadBudget.RecordBufferMaintenance(
|
|
maintenance.AllocationBytes,
|
|
maintenance.CopyBytes,
|
|
maintenance.NewBufferCount);
|
|
arenaBackpressured = true;
|
|
break;
|
|
}
|
|
if (capacity == GlobalMeshCapacityResult.MigrationInProgress)
|
|
{
|
|
arenaBackpressured = true;
|
|
break;
|
|
}
|
|
if (capacity == GlobalMeshCapacityResult.NeedsReclamation)
|
|
{
|
|
// Do not evict another batch while the frame-fence queue is
|
|
// already returning ranges or retiring an old backing store.
|
|
// Waiting for real allocator state prevents three frames of
|
|
// duplicate eviction before the first release becomes reusable.
|
|
if (globalBuffer?.HasPendingReclamation != true)
|
|
{
|
|
var reclaimed = meshManager.ReclaimUnusedResources(
|
|
MaximumReclaimedMeshesPerFrame,
|
|
MaximumReclaimedMeshBytesPerFrame,
|
|
forceArenaReclamation: true);
|
|
if (reclaimed.Count == 0)
|
|
{
|
|
throw new NotSupportedException(
|
|
$"The live global-mesh working set cannot fit the supported "
|
|
+ $"{GlobalMeshBuffer.MaximumVertexBufferBytes + GlobalMeshBuffer.MaximumIndexBufferBytes:N0}-byte arena "
|
|
+ $"(blocked staging generation {next.Generation}, object 0x{next.Data.ObjectId:X10}).");
|
|
}
|
|
}
|
|
arenaBackpressured = true;
|
|
break;
|
|
}
|
|
|
|
MeshUploadCost cost;
|
|
try
|
|
{
|
|
cost = meshManager.PlanUploadCost(
|
|
next.Data,
|
|
_mipmapsBudgeted,
|
|
next.Generation);
|
|
if (!uploadBudget.TryAdmit(cost))
|
|
break;
|
|
}
|
|
catch (NotSupportedException error)
|
|
{
|
|
RejectUnsupportedHead(meshManager, next, error);
|
|
throw;
|
|
}
|
|
|
|
if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem meshData))
|
|
break;
|
|
if (meshData.Generation != next.Generation)
|
|
throw new InvalidOperationException("The staged mesh FIFO head changed during render-thread admission.");
|
|
if (meshManager.UploadOrRequeue(meshData))
|
|
(requeue ??= new()).Add(meshData);
|
|
meshManager.AddDirtyAtlasesTo(_mipmapsBudgeted);
|
|
}
|
|
if (requeue is not null)
|
|
foreach (var m in requeue)
|
|
meshManager.RequeueStagedMeshData(m);
|
|
meshManager.SetArenaBackpressure(arenaBackpressured);
|
|
|
|
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
|
|
var pendingBefore = texProbe
|
|
? meshManager.GetPendingTextureUpdateStats()
|
|
: default;
|
|
|
|
// #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES
|
|
// immutable decoded payloads in ManagedGLTextureArray._pendingUpdates — the
|
|
// actual TexSubImage3D copies + mipmap regeneration happen in
|
|
// ProcessDirtyUpdates, which WB drives ONCE PER FRAME from its render loop
|
|
// (WB GameScene.cs:975 `_meshManager?.GenerateMipmaps()`, just before the
|
|
// opaque pass). That call site lived in the GameScene file the N.4/O-T4
|
|
// extraction replaced with GameWindow, so the driver was silently dropped:
|
|
// staged updates never reached TexSubImage3D, leaving undefined
|
|
// TexStorage3D content behind valid resident bindless handles — the
|
|
// intermittent white indoor walls (#105). Pre-fix evidence: 126 updates
|
|
// stuck across 34/34 arrays at standstill (texflush-prefix.log). Tick()
|
|
// runs before all draw passes (GameWindow OnRender), so this is the exact
|
|
// WB-equivalent position.
|
|
(LastMipmapArrayCount, LastMipmapBytes) = meshManager.GenerateMipmaps();
|
|
if (globalBuffer?.HasPendingReclamation != true)
|
|
{
|
|
meshManager.ReclaimUnusedResources(
|
|
MaximumReclaimedMeshesPerFrame,
|
|
MaximumReclaimedMeshBytesPerFrame);
|
|
}
|
|
meshManager.EvictOneEmptyAtlas();
|
|
|
|
// Arena maintenance is capacity-driven and uses genuinely idle upload
|
|
// frames. It never competes with destination materialization, and the
|
|
// GlobalMeshBuffer policy retains enough hysteresis that portal-route
|
|
// revisits do not alternate shrink/grow every frame.
|
|
if (uploadBudget.ObjectCount == 0
|
|
&& LastMipmapArrayCount == 0
|
|
&& meshManager.StagedMeshCount == 0
|
|
&& globalBuffer?.IsMigrationInProgress == false
|
|
&& globalBuffer.TryTrimUnusedTail(out GlobalMeshMaintenanceStep trim))
|
|
{
|
|
uploadBudget.RecordBufferMaintenance(
|
|
trim.AllocationBytes,
|
|
trim.CopyBytes,
|
|
trim.NewBufferCount);
|
|
meshManager.SetArenaBackpressure(true);
|
|
}
|
|
|
|
LastUploadCount = uploadBudget.ObjectCount;
|
|
LastUploadBytes = uploadBudget.SourceBytes;
|
|
LastStaleDiscardCount = staleDiscardCount;
|
|
LastArrayAllocationBytes = uploadBudget.ArrayAllocationBytes;
|
|
LastPlannedMipmapBytes = uploadBudget.MipmapBytes;
|
|
LastNewArrayCount = uploadBudget.NewArrayCount;
|
|
LastBufferUploadBytes = uploadBudget.BufferUploadBytes;
|
|
LastBufferAllocationBytes = uploadBudget.BufferAllocationBytes;
|
|
LastBufferCopyBytes = uploadBudget.BufferCopyBytes;
|
|
LastNewBufferCount = uploadBudget.NewBufferCount;
|
|
|
|
if (texProbe)
|
|
EmitTexFlushProbe(pendingBefore);
|
|
}
|
|
|
|
private static void RejectUnsupportedHead(
|
|
ObjectMeshManager meshManager,
|
|
MeshUploadQueueItem expected,
|
|
NotSupportedException error)
|
|
{
|
|
if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem rejected)
|
|
|| rejected.Generation != expected.Generation)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The staged mesh FIFO head changed while rejecting an unsupported generation.",
|
|
error);
|
|
}
|
|
meshManager.RejectUnsupportedStagedUpload(rejected, error);
|
|
}
|
|
|
|
// #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled.
|
|
private int _lastTexFlushBefore = -1;
|
|
private int _texFlushHeartbeat;
|
|
|
|
/// <summary>
|
|
/// #105 apparatus: one <c>[tex-flush]</c> line on change of the staged-texture
|
|
/// pending picture (plus a ~10 s heartbeat while anything is stuck). A healthy
|
|
/// frame ends with <c>after=0</c>; <c>before==after>0</c> persisting at
|
|
/// standstill is the white-walls mechanism live (staged uploads never applied).
|
|
/// </summary>
|
|
private void EmitTexFlushProbe((int PendingUpdates, int ArraysWithPending, int TotalArrays) before)
|
|
{
|
|
var after = _meshManager!.GetPendingTextureUpdateStats();
|
|
bool changed = before.PendingUpdates != _lastTexFlushBefore;
|
|
bool flushed = after.PendingUpdates != before.PendingUpdates;
|
|
bool heartbeat = after.PendingUpdates > 0 && ++_texFlushHeartbeat >= 600;
|
|
if (!changed && !flushed && !heartbeat) return;
|
|
|
|
_texFlushHeartbeat = 0;
|
|
_lastTexFlushBefore = before.PendingUpdates;
|
|
Console.WriteLine(
|
|
$"[tex-flush] before={before.PendingUpdates} after={after.PendingUpdates}" +
|
|
$" arrays={after.ArraysWithPending}/{after.TotalArrays}" +
|
|
$" (arraysBefore={before.ArraysWithPending})");
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
// These are dependency edges, not merely a best-effort cleanup list.
|
|
// In particular, the sampler objects owned by the device must remain
|
|
// alive until every resident texture-array handle has been retired.
|
|
_teardown ??= new AcDream.App.Rendering.OrderedResourceTeardown(
|
|
() =>
|
|
{
|
|
// The current global arena is still directly owned by the mesh
|
|
// manager. Fence every submitted draw before manager teardown
|
|
// can delete that arena's VAO/VBO/IBO.
|
|
if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights)
|
|
frameFlights.WaitForSubmittedWork();
|
|
},
|
|
() => _meshManager?.Dispose(),
|
|
() => _ownedPreparedAssets?.Dispose(),
|
|
() => DrainGraphicsQueue("publishing mesh resource retirements"),
|
|
() =>
|
|
{
|
|
if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights)
|
|
frameFlights.WaitForSubmittedWork();
|
|
},
|
|
() => DrainGraphicsQueue("releasing retired mesh resources"),
|
|
() => _graphicsDevice?.Dispose(),
|
|
() => DrainGraphicsQueue("deleting mesh graphics-device resources"));
|
|
|
|
_teardown.Advance();
|
|
_disposed = _teardown.IsComplete;
|
|
}
|
|
|
|
private void DrainGraphicsQueue(string operation)
|
|
{
|
|
if (_graphicsDevice is null)
|
|
return;
|
|
|
|
_graphicsDevice.ProcessGLQueue();
|
|
if (_graphicsDevice.HasPendingGLWork)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"OpenGL work remains pending after {operation}; retry adapter disposal to continue the exact stage.");
|
|
}
|
|
}
|
|
}
|