Detach old-world spatial ownership atomically, prioritize destination retirement dependencies, and reveal the viewport at the retail transition edge. Give private paperdoll views independent mesh ownership and retain dormant ACE entities so portal revisits preserve server objects without extending active GPU lifetimes.
737 lines
33 KiB
C#
737 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.
|
|
/// </summary>
|
|
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current
|
|
/// thread (construction runs GL queries; call from OnLoad).</param>
|
|
/// <param name="dats">acdream's shared runtime DAT facade. Tooling uses it
|
|
/// through an explicitly owned <see cref="DatPreparedAssetSource"/>.</param>
|
|
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses
|
|
/// NullLogger internally.</param>
|
|
public WbMeshAdapter(GL gl, IDatReaderWriter dats, ILogger<WbMeshAdapter> logger)
|
|
: this(
|
|
gl,
|
|
dats,
|
|
preparedAssets: null,
|
|
logger,
|
|
AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance,
|
|
ownsPreparedAssets: true,
|
|
ResidencyBudgetOptions.Default)
|
|
{
|
|
}
|
|
|
|
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,
|
|
ResidencyBudgetOptions.Default);
|
|
|
|
internal WbMeshAdapter(
|
|
GL gl,
|
|
IDatReaderWriter dats,
|
|
IPreparedAssetSource preparedAssets,
|
|
ILogger<WbMeshAdapter> logger,
|
|
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
|
|
ResidencyBudgetOptions? budgets = null)
|
|
: this(
|
|
gl,
|
|
dats,
|
|
preparedAssets,
|
|
logger,
|
|
resourceRetirement,
|
|
ownsPreparedAssets: false,
|
|
budgets ?? ResidencyBudgetOptions.Default)
|
|
{
|
|
}
|
|
|
|
private WbMeshAdapter(
|
|
GL gl,
|
|
IDatReaderWriter dats,
|
|
IPreparedAssetSource? preparedAssets,
|
|
ILogger<WbMeshAdapter> logger,
|
|
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
|
|
bool ownsPreparedAssets,
|
|
ResidencyBudgetOptions budgets)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(gl);
|
|
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,
|
|
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.");
|
|
}
|
|
}
|
|
}
|