refactor(lifetime): own render resource prefixes

Give terrain, sky, retained UI, portal preparation, and the update/render frame pair explicit single owners. Make shader, texture, text, bindless, and GL construction prefixes checked and retryable so partial failure cannot lose or replay resource ownership.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 14:42:14 +02:00
parent fec0d94148
commit c87b15303d
41 changed files with 3768 additions and 355 deletions

View file

@ -0,0 +1,68 @@
using System.Runtime.ExceptionServices;
namespace AcDream.App.Rendering;
/// <summary>
/// Temporarily removes bindless residency around texture-state mutation while
/// retaining the obligation to restore the pair across failed mutation or
/// failed reacquisition attempts.
/// </summary>
internal sealed class BindlessTextureMutationGuard(BindlessTexturePair pair)
{
private bool _restoreRequired;
private bool _operationActive;
internal bool RestoreRequired => _restoreRequired;
public void Execute(Action mutation)
{
ArgumentNullException.ThrowIfNull(mutation);
if (_operationActive)
throw new InvalidOperationException("A bindless texture mutation is already active.");
_operationActive = true;
Exception? mutationFailure = null;
Exception? restoreFailure = null;
try
{
_restoreRequired |= pair.HasAnyResident;
if (pair.HasAnyResident)
pair.Release();
mutation();
}
catch (Exception failure)
{
mutationFailure = failure;
}
finally
{
if (_restoreRequired)
{
try
{
_ = pair.Acquire();
_restoreRequired = false;
}
catch (Exception failure)
{
restoreFailure = failure;
}
}
_operationActive = false;
}
if (mutationFailure is not null && restoreFailure is not null)
{
throw new AggregateException(
"Texture-state mutation failed and bindless residency could not be restored.",
mutationFailure,
restoreFailure);
}
if (mutationFailure is not null)
ExceptionDispatchInfo.Capture(mutationFailure).Throw();
if (restoreFailure is not null)
ExceptionDispatchInfo.Capture(restoreFailure).Throw();
}
}

View file

@ -0,0 +1,94 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Owns the two independent residency edges used by TerrainAtlas. Acquisition
/// and release publish each successful edge immediately, so a partial failure
/// retries only work that is still pending.
/// </summary>
internal sealed class BindlessTexturePair(
uint firstTexture,
uint secondTexture,
Func<uint, ulong> acquire,
Action<ulong> release)
{
private ulong _firstHandle;
private ulong _secondHandle;
private bool _firstResident;
private bool _secondResident;
public bool IsFullyResident => _firstResident && _secondResident;
public bool HasAnyResident => _firstResident || _secondResident;
public (ulong First, ulong Second) Acquire()
{
if (!_firstResident)
{
_firstHandle = acquire(firstTexture);
_firstResident = true;
}
if (!_secondResident)
{
try
{
_secondHandle = acquire(secondTexture);
_secondResident = true;
}
catch (Exception acquisitionFailure)
{
try
{
Release();
}
catch (Exception cleanupFailure)
{
throw new AggregateException(
"Second bindless texture acquisition failed and the resident prefix did not cleanly roll back.",
acquisitionFailure,
cleanupFailure);
}
throw;
}
}
return (_firstHandle, _secondHandle);
}
public void Release()
{
List<Exception>? failures = null;
if (_firstResident)
{
try
{
release(_firstHandle);
_firstResident = false;
_firstHandle = 0;
}
catch (Exception failure)
{
(failures ??= []).Add(failure);
}
}
if (_secondResident)
{
try
{
release(_secondHandle);
_secondResident = false;
_secondHandle = 0;
}
catch (Exception failure)
{
(failures ??= []).Add(failure);
}
}
if (failures is not null)
throw new AggregateException(
"One or more bindless texture handles could not be made non-resident.",
failures);
}
}

View file

@ -0,0 +1,67 @@
using AcDream.App.Update;
namespace AcDream.App.Rendering;
internal interface IGameUpdateFrameRoot
{
void Tick(UpdateFrameInput input);
}
internal interface IGameRenderFrameRoot
{
RenderFrameOutcome Render(RenderFrameInput input);
}
/// <summary>
/// Sole atomic publication point for the current update/render frame pair.
/// Native callbacks are deliberately inert before publication and after
/// withdrawal so a partial load or converging shutdown cannot enter half a
/// frame graph.
/// </summary>
internal sealed class GameFrameGraphSlot
{
private sealed record FrameGraphPair(
IGameUpdateFrameRoot Update,
IGameRenderFrameRoot Render);
private FrameGraphPair? _pair;
public bool IsPublished => Volatile.Read(ref _pair) is not null;
public void Publish(IGameUpdateFrameRoot update, IGameRenderFrameRoot render)
{
ArgumentNullException.ThrowIfNull(update);
ArgumentNullException.ThrowIfNull(render);
var pair = new FrameGraphPair(update, render);
if (Interlocked.CompareExchange(ref _pair, pair, null) is not null)
throw new InvalidOperationException("A game frame graph is already published.");
}
public bool Tick(UpdateFrameInput input)
{
FrameGraphPair? pair = Volatile.Read(ref _pair);
if (pair is null)
return false;
pair.Update.Tick(input);
return true;
}
public bool Render(RenderFrameInput input, out RenderFrameOutcome outcome)
{
FrameGraphPair? pair = Volatile.Read(ref _pair);
if (pair is null)
{
outcome = default;
return false;
}
outcome = pair.Render.Render(input);
return true;
}
public void Withdraw()
{
Interlocked.Exchange(ref _pair, null);
}
}

View file

@ -0,0 +1,21 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Sole lifetime owner for render resources that are borrowed by, but not
/// owned by, their renderers.
/// </summary>
internal sealed class GameRenderResourceLifetime
{
private readonly OwnedResourceSlot<TerrainAtlas> _terrainAtlas = new();
private readonly OwnedResourceSlot<Shader> _skyShader = new();
public TerrainAtlas AcquireTerrainAtlas(Func<TerrainAtlas> factory) =>
_terrainAtlas.Acquire(factory);
public Shader AcquireSkyShader(Func<Shader> factory) =>
_skyShader.Acquire(factory);
public void ReleaseTerrainAtlas() => _terrainAtlas.Release();
public void ReleaseSkyShader() => _skyShader.Release();
}

View file

@ -92,8 +92,11 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
private AcDream.App.Rendering.RenderFrameOrchestrator?
_renderFrameOrchestrator;
private readonly AcDream.App.Rendering.GameFrameGraphSlot _frameGraphs = new();
private readonly AcDream.App.Rendering.GameRenderResourceLifetime
_renderResourceLifetime = new();
private readonly AcDream.App.Rendering.GlConstructionCleanupLedger
_glConstructionCleanup = new();
private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment =
new(Console.WriteLine);
private ResourceShutdownTransaction? _shutdown;
@ -148,7 +151,6 @@ public sealed class GameWindow : IDisposable
private readonly AcDream.App.Input.MovementTruthDiagnosticController
_movementTruthDiagnostics;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
private AcDream.App.Update.UpdateFrameOrchestrator _updateFrameOrchestrator = null!;
private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new();
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map;
@ -202,7 +204,8 @@ public sealed class GameWindow : IDisposable
// R1 (render redesign): portal-view draw owners are retained transitively
// by the frame orchestrator rather than duplicated as GameWindow roots.
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
private readonly AcDream.App.Rendering.TransferableResourceSlot<
AcDream.App.Rendering.PortalTunnelPresentation> _portalTunnelFallback = new();
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
// UBO). In U.3 a single ClipFrame.NoClip() instance is created lazily (??=) and
@ -342,6 +345,7 @@ public sealed class GameWindow : IDisposable
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost;
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
private readonly AcDream.App.UI.RetailUiRuntimeLease _retailUiLease = new();
private readonly AcDream.App.Combat.CombatAttackOperationsSlot
_combatAttackOperations = new();
private readonly AcDream.App.Combat.CombatFeedbackSlot
@ -638,7 +642,15 @@ public sealed class GameWindow : IDisposable
_displayFramePacing,
_hostQuiescence);
_windowCallbacks.Attach();
_window.Run();
try
{
_window.Run();
}
catch (Exception failure)
{
_glConstructionCleanup.RetainFrom(failure);
throw;
}
}
private void OnLoad()
@ -1100,7 +1112,11 @@ public sealed class GameWindow : IDisposable
// Build the terrain atlas once from the Region dat. Phase N.5b: the
// atlas exposes bindless handles for the modern terrain path, so
// BindlessSupport is threaded through.
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats, _bindlessSupport);
var terrainAtlas = _renderResourceLifetime.AcquireTerrainAtlas(
() => AcDream.App.Rendering.TerrainAtlas.Build(
_gl,
_dats,
_bindlessSupport));
// A.5 T22.5: apply anisotropic level from quality preset. Build()
// hard-codes 16x; override here to match the resolved quality so Low
// (4x) and Medium (8x) actually take effect.
@ -1230,11 +1246,12 @@ public sealed class GameWindow : IDisposable
if (_options.RetailUi)
{
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(
_gl,
shadersDir,
_debugFont,
_hostQuiescence);
_uiHost = _retailUiLease.AcquireHost(
() => new AcDream.App.UI.UiHost(
_gl,
shadersDir,
_debugFont,
_hostQuiescence));
_retainedInputCapture.Root = _uiHost.Root;
_uiHost.Root.UiLocked = _runtimeSettings.Gameplay.LockUI;
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
@ -1360,8 +1377,7 @@ public sealed class GameWindow : IDisposable
UiProbeLog);
}
_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(
new AcDream.App.UI.RetailUiRuntimeBindings(
var retailUiBindings = new AcDream.App.UI.RetailUiRuntimeBindings(
Host: _uiHost,
Assets: new AcDream.App.UI.RetailUiAssets(
_dats!, _datLock, ResolveChrome, ResolveDatFont,
@ -1511,7 +1527,10 @@ public sealed class GameWindow : IDisposable
action => _inputDispatcher?.TryInvokeAutomationAction(action) == true,
(action, held) =>
_inputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
_worldLifecycleAutomation)));
_worldLifecycleAutomation));
_retailUiRuntime = _retailUiLease.Mount(
() => AcDream.App.UI.RetailUiRuntime.CreateUninitialized(
retailUiBindings));
}
@ -1966,26 +1985,29 @@ public sealed class GameWindow : IDisposable
// T1: invisible portal depth writes (seal/punch) — retail
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
_portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired(
_gl,
_dats!,
_animLoader!,
_hookRouter,
_wbDrawDispatcher,
_sceneLightingUbo!,
_wbMeshAdapter!);
_portalTunnelFallback.AcquirePrepared(
() => AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired(
_gl,
_dats!,
_animLoader!,
_hookRouter,
_wbDrawDispatcher,
_sceneLightingUbo!,
_wbMeshAdapter!),
static portalTunnel => portalTunnel.PrepareResources());
// Publish the presentation before touching the mesh-reference
// backend. Partial acquisition remains reachable by staged
// shutdown instead of becoming constructor-local leaked state.
_portalTunnel.PrepareResources();
}
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
// with depth writes off + far plane 1e6 so celestial meshes
// never clip. Shares the TextureCache with the static pipeline.
var skyShader = new Shader(_gl,
Path.Combine(shadersDir, "sky.vert"),
Path.Combine(shadersDir, "sky.frag"));
var skyShader = _renderResourceLifetime.AcquireSkyShader(
() => new Shader(
_gl,
Path.Combine(shadersDir, "sky.vert"),
Path.Combine(shadersDir, "sky.frag")));
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
_gl, _dats, skyShader, _textureCache!, _samplerCache);
@ -2422,13 +2444,8 @@ public sealed class GameWindow : IDisposable
_localPlayerMode,
_playerModeController));
_playerModeController.BindAutoEntry(_playerModeAutoEntry);
var localTeleportPresentation =
new AcDream.App.Streaming.LocalPlayerTeleportPresentation(
_portalTunnel
?? throw new InvalidOperationException(
"Portal presentation was not composed before local teleport."));
_localPlayerTeleport =
new AcDream.App.Streaming.LocalPlayerTeleportController(
_localPlayerTeleport = _portalTunnelFallback.Transfer(
portalTunnel => new AcDream.App.Streaming.LocalPlayerTeleportController(
new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority(
_liveEntities,
_localPlayerIdentity),
@ -2451,11 +2468,8 @@ public sealed class GameWindow : IDisposable
liveSpatialReconciler),
new AcDream.App.Streaming.LocalPlayerTeleportSession(
_liveSessionController),
localTeleportPresentation);
// Ownership transferred to LocalPlayerTeleportController. This field
// remains only as the staged-startup fallback used by shutdown when
// composition fails before the transfer.
_portalTunnel = null;
new AcDream.App.Streaming.LocalPlayerTeleportPresentation(
portalTunnel)));
_localPlayerTeleportSink.Bind(_localPlayerTeleport);
var teleportRenderState =
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
@ -2652,7 +2666,7 @@ public sealed class GameWindow : IDisposable
renderFrameResources,
_devToolsFramePresenter,
renderWeatherFrame);
_renderFrameOrchestrator =
var renderFrameOrchestrator =
new AcDream.App.Rendering.RenderFrameOrchestrator(
_gpuFrameFlights!,
framePreparation,
@ -2681,7 +2695,7 @@ public sealed class GameWindow : IDisposable
Combat,
_selection,
_worldSelectionQuery!));
_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(
var updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(
new AcDream.App.Update.LiveEntityTeardownFramePhase(
_liveEntities),
new AcDream.App.Update.ConsoleUpdateFrameFailureSink(),
@ -2697,6 +2711,7 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
_playerModeAutoEntry),
cameraFrame);
_frameGraphs.Publish(updateFrameOrchestrator, renderFrameOrchestrator);
_liveSessionHost = CreateLiveSessionHost();
AcDream.UI.Abstractions.Panels.Debug.DebugVM? debugVm = _debugVm;
@ -3055,8 +3070,7 @@ public sealed class GameWindow : IDisposable
{
using var _updStage = _frameProfiler.BeginStage(
AcDream.App.Diagnostics.FrameStage.Update);
_updateFrameOrchestrator.Tick(
new AcDream.App.Update.UpdateFrameInput(dt));
_frameGraphs.Tick(new AcDream.App.Update.UpdateFrameInput(dt));
}
// Performance overlay state — updated every ~0.5s and written to the
@ -3064,11 +3078,12 @@ public sealed class GameWindow : IDisposable
private void OnRender(double deltaSeconds)
{
Vector2D<int> size = _window!.Size;
_renderFrameOrchestrator!.Render(
_frameGraphs.Render(
new AcDream.App.Rendering.RenderFrameInput(
deltaSeconds,
size.X,
size.Y));
size.Y),
out _);
}
private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot
@ -3336,7 +3351,7 @@ public sealed class GameWindow : IDisposable
new("dispatcher", () => _inputDispatcher?.Deactivate()),
new("mouse source", () => _mouseSource?.Deactivate()),
new("keyboard source", () => _kbSource?.Deactivate()),
new("retained UI input", () => _uiHost?.QuiesceInput()),
new("retained UI input", _retailUiLease.QuiesceInput),
new("developer tools input", () => _devToolsInputContext?.Deactivate()),
]),
// Live-session reset retires the complete streaming window. Every
@ -3390,7 +3405,7 @@ public sealed class GameWindow : IDisposable
"Gameplay action callback removal remains pending.");
_gameplayInputActions = null;
}),
new("retained UI input", () => _uiHost?.DeactivateInput()),
new("retained UI input", _retailUiLease.DeactivateInput),
new("developer tools input", () => _devToolsInputContext?.Dispose()),
new("camera pointer", () =>
{
@ -3444,7 +3459,7 @@ public sealed class GameWindow : IDisposable
new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets),
new("world frame composition", () =>
{
_renderFrameOrchestrator = null;
_frameGraphs.Withdraw();
}),
]),
new ResourceShutdownStage("session dependents",
@ -3461,7 +3476,10 @@ public sealed class GameWindow : IDisposable
}),
new("retail UI", () =>
{
_retailUiRuntime?.Dispose();
_retailUiLease.Dispose();
if (!_retailUiLease.IsDisposalComplete)
throw new InvalidOperationException(
"The retained UI ownership lease did not complete disposal.");
_retailUiRuntime = null;
_retainedInputCapture.Root = null;
_uiHost = null;
@ -3544,8 +3562,7 @@ public sealed class GameWindow : IDisposable
{
_localPlayerTeleport?.Dispose();
_localPlayerTeleport = null;
_portalTunnel?.Dispose();
_portalTunnel = null;
_portalTunnelFallback.ReleaseFallback();
}),
new("paperdoll viewport", () =>
{
@ -3589,6 +3606,15 @@ public sealed class GameWindow : IDisposable
new("frame pacing", _displayFramePacing.Dispose),
new("frame profiler", _frameProfiler.Dispose),
]),
new ResourceShutdownStage("dedicated render resources",
[
new("sky shader", _renderResourceLifetime.ReleaseSkyShader),
new("terrain atlas", _renderResourceLifetime.ReleaseTerrainAtlas),
]),
new ResourceShutdownStage("failed render construction cleanup",
[
new("GL construction ledger", _glConstructionCleanup.Dispose),
]),
new ResourceShutdownStage("frame flight owner",
[
new("frame flights", () =>

View file

@ -0,0 +1,102 @@
namespace AcDream.App.Rendering;
internal interface IRetryableResourceCleanup
{
bool IsCleanupComplete { get; }
void RetryCleanup();
}
internal sealed class GlResourceConstructionException : AggregateException,
IRetryableResourceCleanup
{
private readonly IRetryableResourceCleanup _cleanup;
public GlResourceConstructionException(
string message,
IRetryableResourceCleanup cleanup,
IEnumerable<Exception> failures)
: base(message, failures)
{
_cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup));
}
public bool IsCleanupComplete => _cleanup.IsCleanupComplete;
public void RetryCleanup() => _cleanup.RetryCleanup();
}
/// <summary>
/// Lifetime root for cleanup work that could not finish before a throwing GL
/// factory returned control. The original exception remains the retry owner;
/// this ledger prevents it and its exact pending names from becoming local-only.
/// </summary>
internal sealed class GlConstructionCleanupLedger : IDisposable
{
private readonly List<IRetryableResourceCleanup> _pending = [];
private bool _disposing;
public bool IsComplete => _pending.Count == 0;
public bool RetainFrom(Exception failure)
{
ArgumentNullException.ThrowIfNull(failure);
bool retained = false;
Visit(failure);
return retained;
void Visit(Exception current)
{
if (current is IRetryableResourceCleanup cleanup)
{
if (!cleanup.IsCleanupComplete && !_pending.Contains(cleanup))
_pending.Add(cleanup);
retained = true;
}
if (current is AggregateException aggregate)
{
foreach (Exception inner in aggregate.InnerExceptions)
Visit(inner);
}
else if (current.InnerException is { } inner)
{
Visit(inner);
}
}
}
public void Dispose()
{
if (_disposing || _pending.Count == 0)
return;
_disposing = true;
List<Exception>? failures = null;
try
{
for (int i = _pending.Count - 1; i >= 0; i--)
{
IRetryableResourceCleanup cleanup = _pending[i];
try
{
cleanup.RetryCleanup();
if (cleanup.IsCleanupComplete)
_pending.RemoveAt(i);
}
catch (Exception failure)
{
(failures ??= []).Add(failure);
}
}
}
finally
{
_disposing = false;
}
if (failures is not null)
throw new AggregateException(
"One or more failed GL construction transactions remain pending.",
failures);
}
}

View file

@ -0,0 +1,152 @@
using System.Runtime.ExceptionServices;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Always-on commit boundary for GL resource commands. OpenGL reports ordinary
/// command failures through its error flag, so an ownership state machine may
/// advance only after the post-command check succeeds.
/// </summary>
internal static class GlResourceCommand
{
public static void Execute(GL gl, string context, Action command)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentException.ThrowIfNullOrWhiteSpace(context);
ArgumentNullException.ThrowIfNull(command);
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
command();
GLHelpers.ThrowOnResourceError(gl, context);
}
public static T Execute<T>(GL gl, string context, Func<T> command)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentException.ThrowIfNullOrWhiteSpace(context);
ArgumentNullException.ThrowIfNull(command);
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
T result = command();
GLHelpers.ThrowOnResourceError(gl, context);
return result;
}
public static uint CreateName(
GL gl,
string resourceName,
Func<uint> create,
Action<uint> delete)
{
ArgumentException.ThrowIfNullOrWhiteSpace(resourceName);
ArgumentNullException.ThrowIfNull(create);
ArgumentNullException.ThrowIfNull(delete);
return CreateNameCore(
resourceName,
() => GLHelpers.ThrowOnResourceError(
gl,
$"create {resourceName} (precondition)"),
create,
() => GLHelpers.ThrowOnResourceError(gl, $"create {resourceName}"),
ownedName => Execute(
gl,
$"rollback {resourceName} name {ownedName}",
() => delete(ownedName)));
}
internal static uint CreateNameCore(
string resourceName,
Action precondition,
Func<uint> create,
Action postcondition,
Action<uint> deleteChecked)
{
ArgumentException.ThrowIfNullOrWhiteSpace(resourceName);
ArgumentNullException.ThrowIfNull(precondition);
ArgumentNullException.ThrowIfNull(create);
ArgumentNullException.ThrowIfNull(postcondition);
ArgumentNullException.ThrowIfNull(deleteChecked);
uint name = 0;
Exception? creationFailure = null;
try
{
precondition();
name = create();
if (name == 0)
throw new InvalidOperationException($"OpenGL returned no {resourceName} name.");
postcondition();
return name;
}
catch (Exception failure)
{
creationFailure = failure;
}
if (name != 0)
{
var cleanup = new SingleNameCleanup(name, deleteChecked);
try
{
cleanup.RetryCleanup();
}
catch (Exception cleanupFailure)
{
throw new GlResourceConstructionException(
$"Creating {resourceName} failed and its returned GL name could not be released.",
cleanup,
[creationFailure, cleanupFailure]);
}
}
ExceptionDispatchInfo.Capture(creationFailure).Throw();
throw new InvalidOperationException("Unreachable GL resource-creation path.");
}
public static uint CreateTexture(GL gl, string context) =>
CreateName(gl, context, gl.GenTexture, gl.DeleteTexture);
public static void DeleteTexture(GL gl, uint texture, string context) =>
Execute(gl, context, () => gl.DeleteTexture(texture));
public static void DeleteShader(GL gl, uint shader, string context) =>
Execute(gl, context, () => gl.DeleteShader(shader));
public static void DeleteProgram(GL gl, uint program, string context) =>
Execute(gl, context, () => gl.DeleteProgram(program));
public static void DeleteBuffer(GL gl, uint buffer, string context) =>
Execute(gl, context, () => gl.DeleteBuffer(buffer));
public static void DeleteVertexArray(GL gl, uint vertexArray, string context) =>
Execute(gl, context, () => gl.DeleteVertexArray(vertexArray));
private sealed class SingleNameCleanup(uint name, Action<uint> delete)
: IRetryableResourceCleanup
{
private uint _name = name;
private bool _running;
public bool IsCleanupComplete => _name == 0;
public void RetryCleanup()
{
if (_running || _name == 0)
return;
_running = true;
try
{
delete(_name);
_name = 0;
}
finally
{
_running = false;
}
}
}
}

View file

@ -0,0 +1,85 @@
namespace AcDream.App.Rendering;
internal interface IGlTextureNameApi
{
uint GenTexture();
void DeleteTexture(uint texture);
}
internal sealed class GlTextureNameApi(Silk.NET.OpenGL.GL gl) : IGlTextureNameApi
{
public uint GenTexture() =>
GlResourceCommand.CreateTexture(gl, "terrain construction texture");
public void DeleteTexture(uint texture) =>
GlResourceCommand.DeleteTexture(
gl,
texture,
$"delete terrain construction texture {texture}");
}
/// <summary>
/// Retains every texture name immediately after allocation until the complete
/// aggregate owner has been constructed. A failed factory rolls names back in
/// reverse acquisition order and attempts every deletion.
/// </summary>
internal sealed class GlTextureConstructionTransaction(IGlTextureNameApi api)
: IRetryableResourceCleanup
{
private readonly List<uint> _ownedNames = [];
private bool _finished;
public bool IsCleanupComplete => _finished;
public uint Allocate()
{
if (_finished)
throw new InvalidOperationException("The texture construction transaction is finished.");
uint texture = api.GenTexture();
if (texture == 0)
throw new InvalidOperationException("OpenGL returned no texture name.");
_ownedNames.Add(texture);
return texture;
}
public void Commit()
{
if (_finished)
throw new InvalidOperationException("The texture construction transaction is finished.");
_ownedNames.Clear();
_finished = true;
}
public void Rollback()
{
if (_finished)
return;
List<Exception>? failures = null;
for (int i = _ownedNames.Count - 1; i >= 0; i--)
{
uint texture = _ownedNames[i];
try
{
api.DeleteTexture(texture);
_ownedNames.RemoveAt(i);
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"Texture construction rollback could not delete OpenGL name {texture}.",
failure));
}
}
if (failures is not null)
throw new AggregateException(
"Texture construction rollback did not release every allocated OpenGL name.",
failures);
_finished = true;
}
public void RetryCleanup() => Rollback();
}

View file

@ -95,7 +95,7 @@ internal sealed class NullRenderFrameFailureRecovery : IRenderFrameFailureRecove
/// and close failure are reported together, while either failure alone
/// propagates directly.
/// </remarks>
internal sealed class RenderFrameOrchestrator
internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
{
private readonly IRenderFrameLifetime _lifetime;
private readonly IRenderFrameResourcePhase _resources;

View file

@ -0,0 +1,63 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Reverse-order, all-attempted cleanup owner used while a composite resource
/// is still under construction and after it becomes the aggregate owner.
/// </summary>
internal sealed class ResourceCleanupGroup : IRetryableResourceCleanup
{
private sealed record Entry(string Name, Action Release)
{
public bool Complete { get; set; }
}
private readonly List<Entry> _entries = [];
private bool _running;
public bool IsCleanupComplete => _entries.All(static entry => entry.Complete);
public void Add(string name, Action release)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(release);
if (_running || IsCleanupComplete && _entries.Count != 0)
throw new InvalidOperationException("The resource cleanup group is no longer accepting ownership.");
_entries.Add(new Entry(name, release));
}
public void RetryCleanup()
{
if (_running || IsCleanupComplete)
return;
_running = true;
List<Exception>? failures = null;
try
{
for (int i = _entries.Count - 1; i >= 0; i--)
{
Entry entry = _entries[i];
if (entry.Complete)
continue;
try
{
entry.Release();
entry.Complete = true;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"Resource cleanup operation '{entry.Name}' failed.",
failure));
}
}
}
finally
{
_running = false;
}
if (failures is not null)
throw new AggregateException("Composite resource cleanup remains incomplete.", failures);
}
}

View file

@ -0,0 +1,83 @@
using System.Runtime.ExceptionServices;
namespace AcDream.App.Rendering;
/// <summary>
/// Runs a texture mutation with the caller's prior binding restored on every
/// exit path. The concrete GL adapter supplies checked commands; tests can
/// fault each edge without requiring a graphics context.
/// </summary>
internal sealed class RestoredTextureBindingMutation
{
private uint? _pendingRestore;
private bool _operationActive;
internal bool HasPendingRestore => _pendingRestore.HasValue;
public void Execute(
Func<uint> readBinding,
Action<uint> bind,
uint mutationBinding,
Action mutation)
{
ArgumentNullException.ThrowIfNull(readBinding);
ArgumentNullException.ThrowIfNull(bind);
ArgumentNullException.ThrowIfNull(mutation);
if (_operationActive)
throw new InvalidOperationException("A texture-binding mutation is already active.");
_operationActive = true;
try
{
ExecuteCore(readBinding, bind, mutationBinding, mutation);
}
finally
{
_operationActive = false;
}
}
private void ExecuteCore(
Func<uint> readBinding,
Action<uint> bind,
uint mutationBinding,
Action mutation)
{
if (_pendingRestore is uint pending)
{
bind(pending);
_pendingRestore = null;
}
uint previousBinding = readBinding();
_pendingRestore = previousBinding;
Exception? mutationFailure = null;
try
{
bind(mutationBinding);
mutation();
}
catch (Exception failure)
{
mutationFailure = failure;
}
try
{
bind(previousBinding);
_pendingRestore = null;
}
catch (Exception restoreFailure)
{
if (mutationFailure is not null)
throw new AggregateException(
"Texture mutation and binding restoration both failed.",
mutationFailure,
restoreFailure);
throw;
}
if (mutationFailure is not null)
ExceptionDispatchInfo.Capture(mutationFailure).Throw();
}
}

View file

@ -0,0 +1,171 @@
namespace AcDream.App.Rendering;
/// <summary>Retryable one-owner slot for a disposable runtime resource.</summary>
internal sealed class OwnedResourceSlot<T>
where T : class, IDisposable
{
private T? _resource;
private bool _operationActive;
public bool HasResource => _resource is not null;
public T Acquire(Func<T> factory)
{
ArgumentNullException.ThrowIfNull(factory);
if (_operationActive || _resource is not null)
throw new InvalidOperationException($"A {typeof(T).Name} is already owned.");
_operationActive = true;
try
{
T resource = factory()
?? throw new InvalidOperationException($"The {typeof(T).Name} factory returned null.");
_resource = resource;
return resource;
}
finally
{
_operationActive = false;
}
}
public T Borrow()
{
if (_operationActive)
throw new InvalidOperationException($"The {typeof(T).Name} owner is changing state.");
return _resource
?? throw new InvalidOperationException($"No {typeof(T).Name} is currently owned.");
}
public void Release()
{
T? resource = _resource;
if (resource is null)
return;
if (_operationActive)
throw new InvalidOperationException($"The {typeof(T).Name} owner is changing state.");
_operationActive = true;
try
{
resource.Dispose();
if (ReferenceEquals(_resource, resource))
_resource = null;
}
finally
{
_operationActive = false;
}
}
internal void TransferOwnership(T expected)
{
ArgumentNullException.ThrowIfNull(expected);
if (_operationActive)
throw new InvalidOperationException($"The {typeof(T).Name} owner is changing state.");
if (!ReferenceEquals(_resource, expected))
throw new InvalidOperationException(
$"The expected {typeof(T).Name} is not owned by this slot.");
_resource = null;
}
}
/// <summary>
/// Owns a fallback resource until a complete destination owner factory
/// returns. A throwing factory leaves fallback ownership unchanged.
/// </summary>
internal sealed class TransferableResourceSlot<T>
where T : class, IDisposable
{
private readonly OwnedResourceSlot<T> _fallback = new();
private bool _operationActive;
private bool _prepared;
public bool HasFallback => _fallback.HasResource;
public T Acquire(Func<T> factory) => Execute(() =>
{
T resource = _fallback.Acquire(factory);
_prepared = true;
return resource;
});
public T AcquirePrepared(Func<T> factory, Action<T> prepare) => Execute(() =>
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(prepare);
T resource;
if (_fallback.HasResource)
{
resource = _fallback.Borrow();
}
else
{
resource = _fallback.Acquire(factory);
_prepared = false;
}
if (!_prepared)
{
prepare(resource);
_prepared = true;
}
return resource;
});
public T Borrow()
{
if (_operationActive)
throw new InvalidOperationException("The transferable resource owner is changing state.");
return _fallback.Borrow();
}
public TOwner Transfer<TOwner>(Func<T, TOwner> destinationFactory)
where TOwner : class
{
return Execute(() =>
{
ArgumentNullException.ThrowIfNull(destinationFactory);
if (!_prepared)
throw new InvalidOperationException(
"The transferable resource has not completed preparation.");
T resource = _fallback.Borrow();
TOwner owner = destinationFactory(resource)
?? throw new InvalidOperationException("The destination owner factory returned null.");
// The destination factory contract transfers ownership of resource.
// Clear without disposing only after the complete owner exists.
_fallback.TransferOwnership(resource);
_prepared = false;
return owner;
});
}
public void ReleaseFallback() => Execute(
() =>
{
_fallback.Release();
if (!_fallback.HasResource)
_prepared = false;
return true;
});
private TResult Execute<TResult>(Func<TResult> operation)
{
ArgumentNullException.ThrowIfNull(operation);
if (_operationActive)
throw new InvalidOperationException("The transferable resource owner is changing state.");
_operationActive = true;
try
{
return operation();
}
finally
{
_operationActive = false;
}
}
}

View file

@ -7,36 +7,17 @@ public sealed class Shader : IDisposable
{
private readonly GL _gl;
private readonly Dictionary<string, int> _uniformLocations = new(StringComparer.Ordinal);
public uint Program { get; }
public uint Program { get; private set; }
public Shader(GL gl, string vertexPath, string fragmentPath)
{
_gl = gl;
uint vert = Compile(File.ReadAllText(vertexPath), ShaderType.VertexShader);
uint frag = Compile(File.ReadAllText(fragmentPath), ShaderType.FragmentShader);
Program = _gl.CreateProgram();
_gl.AttachShader(Program, vert);
_gl.AttachShader(Program, frag);
_gl.LinkProgram(Program);
_gl.GetProgram(Program, ProgramPropertyARB.LinkStatus, out int linked);
if (linked == 0)
throw new Exception("program link failed: " + _gl.GetProgramInfoLog(Program));
_gl.DetachShader(Program, vert);
_gl.DetachShader(Program, frag);
_gl.DeleteShader(vert);
_gl.DeleteShader(frag);
}
private uint Compile(string source, ShaderType type)
{
uint id = _gl.CreateShader(type);
_gl.ShaderSource(id, source);
_gl.CompileShader(id);
_gl.GetShader(id, ShaderParameterName.CompileStatus, out int ok);
if (ok == 0)
throw new Exception($"{type} compile failed: " + _gl.GetShaderInfoLog(id));
return id;
string vertexSource = File.ReadAllText(vertexPath);
string fragmentSource = File.ReadAllText(fragmentPath);
Program = ShaderProgramConstruction.Build(
new GlShaderProgramBuildApi(gl),
vertexSource,
fragmentSource);
}
public void Use() => _gl.UseProgram(Program);
@ -90,7 +71,12 @@ public sealed class Shader : IDisposable
public void Dispose()
{
uint program = Program;
if (program == 0)
return;
_uniformLocations.Clear();
_gl.DeleteProgram(Program);
GlResourceCommand.DeleteProgram(_gl, program, $"delete Shader program {program}");
Program = 0;
}
}

View file

@ -0,0 +1,325 @@
using System.Runtime.ExceptionServices;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
internal interface IShaderProgramBuildApi
{
uint CreateShader(ShaderType type);
void ShaderSource(uint shader, string source);
void CompileShader(uint shader);
int GetShaderCompileStatus(uint shader);
string GetShaderInfoLog(uint shader);
uint CreateProgram();
void AttachShader(uint program, uint shader);
void LinkProgram(uint program);
int GetProgramLinkStatus(uint program);
string GetProgramInfoLog(uint program);
void DetachShader(uint program, uint shader);
void DeleteShader(uint shader);
void DeleteProgram(uint program);
}
internal sealed class GlShaderProgramBuildApi(GL gl) : IShaderProgramBuildApi
{
public uint CreateShader(ShaderType type) =>
GlResourceCommand.CreateName(
gl,
$"{type} shader",
() => gl.CreateShader(type),
gl.DeleteShader);
public void ShaderSource(uint shader, string source) =>
GlResourceCommand.Execute(
gl,
$"upload shader source {shader}",
() => gl.ShaderSource(shader, source));
public void CompileShader(uint shader) =>
GlResourceCommand.Execute(
gl,
$"compile shader {shader}",
() => gl.CompileShader(shader));
public void GetShader(uint shader, ShaderParameterName name, out int value) =>
gl.GetShader(shader, name, out value);
public int GetShaderCompileStatus(uint shader)
{
return GlResourceCommand.Execute(
gl,
$"read shader {shader} compile status",
() =>
{
gl.GetShader(shader, ShaderParameterName.CompileStatus, out int value);
return value;
});
}
public string GetShaderInfoLog(uint shader) =>
GlResourceCommand.Execute(
gl,
$"read shader {shader} info log",
() => gl.GetShaderInfoLog(shader));
public uint CreateProgram() =>
GlResourceCommand.CreateName(
gl,
"shader program",
gl.CreateProgram,
gl.DeleteProgram);
public void AttachShader(uint program, uint shader) =>
GlResourceCommand.Execute(
gl,
$"attach shader {shader} to program {program}",
() => gl.AttachShader(program, shader));
public void LinkProgram(uint program) =>
GlResourceCommand.Execute(
gl,
$"link shader program {program}",
() => gl.LinkProgram(program));
public int GetProgramLinkStatus(uint program)
{
return GlResourceCommand.Execute(
gl,
$"read shader program {program} link status",
() =>
{
gl.GetProgram(program, ProgramPropertyARB.LinkStatus, out int value);
return value;
});
}
public string GetProgramInfoLog(uint program) =>
GlResourceCommand.Execute(
gl,
$"read shader program {program} info log",
() => gl.GetProgramInfoLog(program));
public void DetachShader(uint program, uint shader) =>
GlResourceCommand.Execute(
gl,
$"detach shader {shader} from program {program}",
() => gl.DetachShader(program, shader));
public void DeleteShader(uint shader) =>
GlResourceCommand.DeleteShader(gl, shader, $"delete shader {shader}");
public void DeleteProgram(uint program) =>
GlResourceCommand.DeleteProgram(gl, program, $"delete shader program {program}");
}
internal sealed class ShaderProgramCleanup(IShaderProgramBuildApi api)
: IRetryableResourceCleanup
{
public uint Vertex { get; set; }
public uint Fragment { get; set; }
public uint Program { get; set; }
public bool VertexAttached { get; set; }
public bool FragmentAttached { get; set; }
public bool IsCleanupComplete => Vertex == 0 && Fragment == 0 && Program == 0;
public uint TransferProgram()
{
if (Vertex != 0 || Fragment != 0 || Program == 0)
throw new InvalidOperationException("The linked program is not ready for ownership transfer.");
uint program = Program;
Program = 0;
return program;
}
public List<Exception> Release(bool includeProgram)
{
var failures = new List<Exception>();
Try(
() =>
{
if (Program != 0 && Vertex != 0 && VertexAttached)
{
api.DetachShader(Program, Vertex);
VertexAttached = false;
}
},
failures);
Try(
() =>
{
if (Program != 0 && Fragment != 0 && FragmentAttached)
{
api.DetachShader(Program, Fragment);
FragmentAttached = false;
}
},
failures);
Try(
() =>
{
if (Vertex != 0)
{
api.DeleteShader(Vertex);
Vertex = 0;
VertexAttached = false;
}
},
failures);
Try(
() =>
{
if (Fragment != 0)
{
api.DeleteShader(Fragment);
Fragment = 0;
FragmentAttached = false;
}
},
failures);
if (includeProgram)
{
Try(
() =>
{
if (Program != 0)
{
api.DeleteProgram(Program);
Program = 0;
VertexAttached = false;
FragmentAttached = false;
}
},
failures);
}
return failures;
}
public List<Exception> ReleaseProgramOnly()
{
var failures = new List<Exception>();
Try(
() =>
{
if (Program != 0)
{
api.DeleteProgram(Program);
Program = 0;
VertexAttached = false;
FragmentAttached = false;
}
},
failures);
return failures;
}
public void RetryCleanup()
{
List<Exception> failures = Release(includeProgram: true);
if (!IsCleanupComplete)
{
throw new AggregateException(
"Shader program cleanup remains incomplete.",
failures.Count == 0
? [new InvalidOperationException("Shader cleanup retained names without reporting an operation failure.")]
: failures);
}
}
private static void Try(Action operation, List<Exception> failures)
{
try
{
operation();
}
catch (Exception failure)
{
failures.Add(failure);
}
}
}
internal static class ShaderProgramConstruction
{
public static uint Build(
IShaderProgramBuildApi api,
string vertexSource,
string fragmentSource)
{
ArgumentNullException.ThrowIfNull(api);
ArgumentNullException.ThrowIfNull(vertexSource);
ArgumentNullException.ThrowIfNull(fragmentSource);
var cleanup = new ShaderProgramCleanup(api);
Exception? constructionFailure = null;
try
{
cleanup.Vertex = CreateShader(api, ShaderType.VertexShader);
Compile(api, cleanup.Vertex, ShaderType.VertexShader, vertexSource);
cleanup.Fragment = CreateShader(api, ShaderType.FragmentShader);
Compile(api, cleanup.Fragment, ShaderType.FragmentShader, fragmentSource);
cleanup.Program = api.CreateProgram();
if (cleanup.Program == 0)
throw new InvalidOperationException("OpenGL returned no shader program name.");
api.AttachShader(cleanup.Program, cleanup.Vertex);
cleanup.VertexAttached = true;
api.AttachShader(cleanup.Program, cleanup.Fragment);
cleanup.FragmentAttached = true;
api.LinkProgram(cleanup.Program);
if (api.GetProgramLinkStatus(cleanup.Program) == 0)
throw new InvalidOperationException(
"program link failed: " + api.GetProgramInfoLog(cleanup.Program));
}
catch (Exception failure)
{
constructionFailure = failure;
}
List<Exception> cleanupFailures = cleanup.Release(includeProgram: false);
if (constructionFailure is null && cleanupFailures.Count == 0)
return cleanup.TransferProgram();
cleanupFailures.AddRange(cleanup.ReleaseProgramOnly());
if (constructionFailure is not null && cleanupFailures.Count == 0)
ExceptionDispatchInfo.Capture(constructionFailure).Throw();
var failures = new List<Exception>(cleanupFailures.Count + 1);
if (constructionFailure is not null)
failures.Add(constructionFailure);
failures.AddRange(cleanupFailures);
const string message =
"Shader program construction failed and one or more temporary OpenGL names could not be cleanly released.";
if (!cleanup.IsCleanupComplete)
throw new GlResourceConstructionException(message, cleanup, failures);
throw new AggregateException(message, failures);
}
private static uint CreateShader(
IShaderProgramBuildApi api,
ShaderType type)
{
uint shader = api.CreateShader(type);
if (shader == 0)
throw new InvalidOperationException($"OpenGL returned no {type} name.");
return shader;
}
private static void Compile(
IShaderProgramBuildApi api,
uint shader,
ShaderType type,
string source)
{
api.ShaderSource(shader, source);
api.CompileShader(shader);
if (api.GetShaderCompileStatus(shader) == 0)
throw new InvalidOperationException(
$"{type} compile failed: " + api.GetShaderInfoLog(shader));
}
}

View file

@ -61,12 +61,10 @@ public sealed unsafe class TerrainAtlas : IDisposable
public IReadOnlyList<uint> RoadAlphaRCodes { get; }
private readonly Wb.BindlessSupport? _bindless;
// Cached bindless handles. Generated lazily on first GetBindlessHandles() call;
// reused for the lifetime of the atlas.
private ulong _terrainHandle;
private ulong _alphaHandle;
private bool _handlesGenerated;
private readonly BindlessTexturePair? _bindlessHandles;
private readonly BindlessTextureMutationGuard? _bindlessMutation;
private readonly RestoredTextureBindingMutation _anisotropyBindingMutation = new();
private ResourceShutdownTransaction? _shutdown;
/// <summary>
/// Get 64-bit bindless handles for the terrain + alpha texture arrays.
@ -80,13 +78,8 @@ public sealed unsafe class TerrainAtlas : IDisposable
if (_bindless is null)
throw new InvalidOperationException(
"TerrainAtlas was constructed without BindlessSupport; cannot return bindless handles.");
if (!_handlesGenerated)
{
_terrainHandle = _bindless.GetResidentHandle(GlTexture);
_alphaHandle = _bindless.GetResidentHandle(GlAlphaTexture);
_handlesGenerated = true;
}
return (_terrainHandle, _alphaHandle);
(ulong terrain, ulong alpha) = _bindlessHandles!.Acquire();
return (terrain, alpha);
}
private TerrainAtlas(
@ -112,6 +105,15 @@ public sealed unsafe class TerrainAtlas : IDisposable
CornerAlphaTCodes = cornerTCodes;
SideAlphaTCodes = sideTCodes;
RoadAlphaRCodes = roadRCodes;
if (bindless is not null)
{
_bindlessHandles = new BindlessTexturePair(
glTexture,
glAlphaTexture,
bindless.GetResidentHandle,
bindless.MakeNonResident);
_bindlessMutation = new BindlessTextureMutationGuard(_bindlessHandles);
}
}
/// <summary>
@ -120,6 +122,37 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
/// </summary>
public static TerrainAtlas Build(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
{
var textures = new GlTextureConstructionTransaction(new GlTextureNameApi(gl));
try
{
TerrainAtlas atlas = BuildCore(gl, dats, bindless, textures);
textures.Commit();
return atlas;
}
catch (Exception constructionFailure)
{
try
{
textures.Rollback();
}
catch (Exception cleanupFailure)
{
throw new GlResourceConstructionException(
"TerrainAtlas construction failed and its allocated OpenGL texture names did not cleanly roll back.",
textures,
[constructionFailure, cleanupFailure]);
}
throw;
}
}
private static TerrainAtlas BuildCore(
GL gl,
IDatReaderWriter dats,
Wb.BindlessSupport? bindless,
GlTextureConstructionTransaction textures)
{
var region = dats.Get<Region>(0x13000000u)
?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
@ -129,7 +162,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
if (terrainDesc is null || terrainDesc.Count == 0)
{
Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
return BuildFallback(gl, bindless);
return BuildFallback(gl, bindless, textures);
}
// ---- Terrain atlas (unchanged Phase 2b logic) ----
@ -179,29 +212,45 @@ public sealed unsafe class TerrainAtlas : IDisposable
}
int layerCount = decodedByType.Count;
uint tex = gl.GenTexture();
gl.BindTexture(TextureTarget.Texture2DArray, tex);
gl.TexImage3D(
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
(uint)maxW, (uint)maxH, (uint)layerCount,
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
var map = new Dictionary<uint, uint>();
int layerIdx = 0;
foreach (var kvp in decodedByType)
uint tex = TrackedTextureConstruction.Create(
textures,
gl,
"upload terrain atlas texture array",
texture =>
{
byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH);
fixed (byte* p = buffer)
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
(uint)maxW, (uint)maxH, (uint)layerCount,
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
int layerIdx = 0;
foreach (var kvp in decodedByType)
{
gl.TexSubImage3D(
TextureTarget.Texture2DArray, 0,
0, 0, layerIdx,
(uint)maxW, (uint)maxH, 1,
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH);
fixed (byte* p = buffer)
{
gl.TexSubImage3D(
TextureTarget.Texture2DArray, 0,
0, 0, layerIdx,
(uint)maxW, (uint)maxH, 1,
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
}
map[kvp.Key] = (uint)layerIdx;
layerIdx++;
}
map[kvp.Key] = (uint)layerIdx;
layerIdx++;
}
// A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality.
gl.GenerateMipmap(TextureTarget.Texture2DArray);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE (GL_EXT_texture_filter_anisotropic / ARB_texture_filter_anisotropic).
gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, 16.0f);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
var tilingByLayer = TerrainTextureTilingTable.Build(
map.Select(entry =>
@ -209,22 +258,12 @@ public sealed unsafe class TerrainAtlas : IDisposable
? repeatCount
: 1u)));
// A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality.
gl.GenerateMipmap(TextureTarget.Texture2DArray);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE (GL_EXT_texture_filter_anisotropic / ARB_texture_filter_anisotropic).
gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, 16.0f);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} (mipmaps+aniso16x)");
// ---- Alpha atlas (new in Phase 3c.2) ----
// texMerge is guaranteed non-null here: the early return above exited
// if texMerge?.TerrainDesc was null.
var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!);
var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!, textures);
return new TerrainAtlas(
gl,
@ -252,7 +291,10 @@ public sealed unsafe class TerrainAtlas : IDisposable
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes);
private static AlphaAtlasBuildResult BuildAlphaAtlas(
GL gl, IDatReaderWriter dats, DatReaderWriter.Types.TexMerge texMerge)
GL gl,
IDatReaderWriter dats,
DatReaderWriter.Types.TexMerge texMerge,
GlTextureConstructionTransaction textures)
{
var decoded = new List<DecodedTexture>();
var cornerLayers = new List<byte>();
@ -305,15 +347,21 @@ public sealed unsafe class TerrainAtlas : IDisposable
if (decoded.Count == 0)
{
Console.WriteLine("WARN: no alpha maps loaded; alpha atlas will be a 1x1 white fallback");
uint fallbackAlpha = gl.GenTexture();
gl.BindTexture(TextureTarget.Texture2DArray, fallbackAlpha);
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0,
GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
fixed (byte* p = white)
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1,
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
uint fallbackAlpha = TrackedTextureConstruction.Create(
textures,
gl,
"upload fallback terrain alpha texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0,
GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
fixed (byte* p = white)
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1,
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
return new AlphaAtlasBuildResult(
fallbackAlpha, 1,
cornerLayers, sideLayers, roadLayers,
@ -329,31 +377,37 @@ public sealed unsafe class TerrainAtlas : IDisposable
if (d.Height > aMaxH) aMaxH = d.Height;
}
uint glAlpha = gl.GenTexture();
gl.BindTexture(TextureTarget.Texture2DArray, glAlpha);
gl.TexImage3D(
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
(uint)aMaxW, (uint)aMaxH, (uint)decoded.Count,
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
for (int i = 0; i < decoded.Count; i++)
uint glAlpha = TrackedTextureConstruction.Create(
textures,
gl,
"upload terrain alpha texture array",
texture =>
{
var buffer = ResizeRgba8Nearest(decoded[i], aMaxW, aMaxH);
fixed (byte* p = buffer)
{
gl.TexSubImage3D(
TextureTarget.Texture2DArray, 0,
0, 0, i,
(uint)aMaxW, (uint)aMaxH, 1,
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
}
}
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
(uint)aMaxW, (uint)aMaxH, (uint)decoded.Count,
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
for (int i = 0; i < decoded.Count; i++)
{
var buffer = ResizeRgba8Nearest(decoded[i], aMaxW, aMaxH);
fixed (byte* p = buffer)
{
gl.TexSubImage3D(
TextureTarget.Texture2DArray, 0,
0, 0, i,
(uint)aMaxW, (uint)aMaxH, 1,
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
}
}
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
Console.WriteLine(
$"AlphaAtlas: {decoded.Count} layers at {aMaxW}x{aMaxH} "
@ -412,25 +466,40 @@ public sealed unsafe class TerrainAtlas : IDisposable
return dst;
}
private static TerrainAtlas BuildFallback(GL gl, Wb.BindlessSupport? bindless = null)
private static TerrainAtlas BuildFallback(
GL gl,
Wb.BindlessSupport? bindless,
GlTextureConstructionTransaction textures)
{
uint tex = gl.GenTexture();
gl.BindTexture(TextureTarget.Texture2DArray, tex);
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
fixed (byte* p = white)
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
uint tex = TrackedTextureConstruction.Create(
textures,
gl,
"upload fallback terrain texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
fixed (byte* p = white)
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
// Fallback alpha atlas: 1x1 white, no layers tracked
uint alphaTex = gl.GenTexture();
gl.BindTexture(TextureTarget.Texture2DArray, alphaTex);
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
fixed (byte* p = white)
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
uint alphaTex = TrackedTextureConstruction.Create(
textures,
gl,
"upload fallback alpha texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
fixed (byte* p = white)
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
return new TerrainAtlas(
gl,
@ -453,46 +522,65 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// </summary>
public void SetAnisotropic(int level)
{
// If bindless handles are live we must make them non-resident before
// mutating texture state, then re-resident after.
bool wasResident = _handlesGenerated && _bindless is not null;
if (wasResident)
void Mutate()
{
_bindless!.MakeNonResident(_terrainHandle);
// Alpha texture is not affected by anisotropic but we must keep
// residency symmetric — re-generate both handles after.
_bindless.MakeNonResident(_alphaHandle);
_handlesGenerated = false;
_anisotropyBindingMutation.Execute(
() => unchecked((uint)GlResourceCommand.Execute(
_gl,
"read terrain-array binding before anisotropy mutation",
() => _gl.GetInteger(GetPName.TextureBinding2DArray))),
binding => GlResourceCommand.Execute(
_gl,
"set terrain-array binding for anisotropy mutation",
() => _gl.BindTexture(TextureTarget.Texture2DArray, binding)),
GlTexture,
() => GlResourceCommand.Execute(
_gl,
"set terrain atlas anisotropy",
() =>
{
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE
_gl.TexParameter(
TextureTarget.Texture2DArray,
(TextureParameterName)0x84FE,
(float)level);
}));
}
_gl.BindTexture(TextureTarget.Texture2DArray, GlTexture);
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE
_gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, (float)level);
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
// Re-generate bindless handles if they were live before.
if (wasResident)
{
// GetBindlessHandles regenerates and makes resident.
_ = GetBindlessHandles();
}
if (_bindlessMutation is not null)
_bindlessMutation.Execute(Mutate);
else
Mutate();
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
}
public void Dispose()
{
// Phase 1: release bindless residency BEFORE deleting textures.
// ARB_bindless_texture requires this ordering; interleaving is UB.
if (_handlesGenerated && _bindless is not null)
{
_bindless.MakeNonResident(_terrainHandle);
_bindless.MakeNonResident(_alphaHandle);
_handlesGenerated = false;
}
// Phase 2: delete the underlying GL textures.
_gl.DeleteTexture(GlTexture);
_gl.DeleteTexture(GlAlphaTexture);
_shutdown ??= new ResourceShutdownTransaction(
new ResourceShutdownStage(
"terrain atlas bindless residency",
[
new ResourceShutdownOperation(
"release terrain and alpha handles",
() => _bindlessHandles?.Release()),
]),
new ResourceShutdownStage(
"terrain atlas textures",
[
new ResourceShutdownOperation(
"delete terrain texture",
() => GlResourceCommand.DeleteTexture(
_gl,
GlTexture,
$"delete terrain atlas texture {GlTexture}")),
new ResourceShutdownOperation(
"delete alpha texture",
() => GlResourceCommand.DeleteTexture(
_gl,
GlAlphaTexture,
$"delete terrain alpha texture {GlAlphaTexture}")),
]));
_shutdown.CompleteOrThrow();
}
}

View file

@ -26,6 +26,7 @@ public sealed unsafe class TextRenderer : IDisposable
private readonly GL _gl;
private readonly ITextRenderGlStateApi _glState;
private readonly Shader _shader;
private readonly ResourceCleanupGroup _resources;
private uint _vao;
private uint _vbo;
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
@ -39,7 +40,7 @@ public sealed unsafe class TextRenderer : IDisposable
public int UsedBytes;
}
private readonly FrameBufferSet[] _frameBuffers = new FrameBufferSet[3];
private readonly FrameBufferSet[] _frameBuffers;
private FrameBufferSet? _activeFrameBuffer;
internal long DynamicBufferCapacityBytes =>
@ -80,26 +81,70 @@ public sealed unsafe class TextRenderer : IDisposable
{
_gl = gl;
_glState = new SilkTextRenderGlStateApi(gl);
_shader = new Shader(gl,
Path.Combine(shaderDir, "ui_text.vert"),
Path.Combine(shaderDir, "ui_text.frag"));
var resources = new ResourceCleanupGroup();
Shader? shader = null;
var frameBuffers = new FrameBufferSet[3];
uint whiteTexture = 0;
try
{
shader = new Shader(gl,
Path.Combine(shaderDir, "ui_text.vert"),
Path.Combine(shaderDir, "ui_text.frag"));
resources.Add("text shader", shader.Dispose);
for (int i = 0; i < _frameBuffers.Length; i++)
_frameBuffers[i] = CreateFrameBufferSet();
for (int i = 0; i < frameBuffers.Length; i++)
frameBuffers[i] = CreateFrameBufferSet(resources);
// 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE
// bucket (the shader multiplies texel×color → white×color = color). Lets a panel
// background draw UNDER its text in painter order, which DrawRect's separate
// bucket cannot (it always composites after all sprites).
_whiteTex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, _whiteTex);
Span<byte> whitePixel = stackalloc byte[] { 255, 255, 255, 255 };
fixed (byte* wp = whitePixel)
_gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0,
PixelFormat.Rgba, PixelType.UnsignedByte, wp);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest);
_gl.BindTexture(TextureTarget.Texture2D, 0);
// 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE
// bucket (the shader multiplies texel×color → white×color = color). Lets a panel
// background draw UNDER its text in painter order, which DrawRect's separate
// bucket cannot (it always composites after all sprites).
whiteTexture = GlResourceCommand.CreateTexture(
_gl,
"TextRenderer white texture");
uint ownedWhiteTexture = whiteTexture;
resources.Add(
"white texture",
() => GlResourceCommand.DeleteTexture(
_gl,
ownedWhiteTexture,
$"delete TextRenderer white texture {ownedWhiteTexture}"));
GlResourceCommand.Execute(
_gl,
"initialize TextRenderer white texture",
() =>
{
_gl.BindTexture(TextureTarget.Texture2D, whiteTexture);
Span<byte> whitePixel = stackalloc byte[] { 255, 255, 255, 255 };
fixed (byte* wp = whitePixel)
_gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0,
PixelFormat.Rgba, PixelType.UnsignedByte, wp);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest);
_gl.BindTexture(TextureTarget.Texture2D, 0);
});
}
catch (Exception constructionFailure)
{
try
{
resources.RetryCleanup();
}
catch (Exception cleanupFailure)
{
throw new GlResourceConstructionException(
"TextRenderer construction failed and its published GL resources did not cleanly roll back.",
resources,
[constructionFailure, cleanupFailure]);
}
throw;
}
_resources = resources;
_shader = shader;
_frameBuffers = frameBuffers;
_whiteTex = whiteTexture;
}
/// <summary>
@ -120,43 +165,54 @@ public sealed unsafe class TextRenderer : IDisposable
_vboCapacityBytes = set.CapacityBytes;
}
private FrameBufferSet CreateFrameBufferSet()
private FrameBufferSet CreateFrameBufferSet(ResourceCleanupGroup resources)
{
uint vao = 0;
uint vbo = 0;
try
{
vao = TrackedGlResource.CreateVertexArray(
uint vao = TrackedGlResource.CreateVertexArray(
_gl,
"TextRenderer frame VAO creation");
RetryableGpuResourceRelease vaoRelease =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
"TextRenderer frame VAO creation");
vbo = TrackedGlResource.CreateBuffer(
_gl,
"TextRenderer frame VBO creation");
var set = new FrameBufferSet { Vao = vao, Vbo = vbo };
vao,
"TextRenderer frame VAO disposal");
resources.Add("frame VAO", vaoRelease.Run);
var set = new FrameBufferSet { Vao = vao };
_gl.BindVertexArray(set.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo);
uint stride = FloatsPerVertex * sizeof(float);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float)));
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(0);
return set;
}
catch
{
if (vbo != 0)
TrackedGlResource.DeleteBuffer(
_gl, vbo, 0, "TextRenderer frame VBO rollback");
if (vao != 0)
TrackedGlResource.DeleteVertexArray(
_gl, vao, "TextRenderer frame VAO rollback");
throw;
}
uint vbo = TrackedGlResource.CreateBuffer(
_gl,
"TextRenderer frame VBO creation");
set.Vbo = vbo;
RetryableGpuResourceRelease? vboRelease = null;
resources.Add(
"frame VBO",
() =>
{
vboRelease ??= TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
vbo,
set.CapacityBytes,
"TextRenderer frame VBO disposal");
vboRelease.Run();
});
GlResourceCommand.Execute(
_gl,
"initialize TextRenderer frame VAO and VBO",
() =>
{
_gl.BindVertexArray(set.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo);
uint stride = FloatsPerVertex * sizeof(float);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float)));
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(0);
});
return set;
}
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
@ -471,19 +527,6 @@ public sealed unsafe class TextRenderer : IDisposable
public void Dispose()
{
_gl.DeleteTexture(_whiteTex);
foreach (FrameBufferSet set in _frameBuffers)
{
TrackedGlResource.DeleteBuffer(
_gl,
set.Vbo,
set.CapacityBytes,
"TextRenderer frame VBO disposal");
TrackedGlResource.DeleteVertexArray(
_gl,
set.Vao,
"TextRenderer frame VAO disposal");
}
_shader.Dispose();
_resources.RetryCleanup();
}
}

View file

@ -0,0 +1,31 @@
namespace AcDream.App.Rendering;
internal static class TrackedTextureConstruction
{
public static uint Create(
GlTextureConstructionTransaction transaction,
Action<uint> initialize)
{
ArgumentNullException.ThrowIfNull(transaction);
ArgumentNullException.ThrowIfNull(initialize);
uint texture = transaction.Allocate();
initialize(texture);
return texture;
}
public static uint Create(
GlTextureConstructionTransaction transaction,
Silk.NET.OpenGL.GL gl,
string context,
Action<uint> initialize)
{
ArgumentNullException.ThrowIfNull(transaction);
ArgumentNullException.ThrowIfNull(gl);
ArgumentException.ThrowIfNullOrWhiteSpace(context);
ArgumentNullException.ThrowIfNull(initialize);
uint texture = transaction.Allocate();
GlResourceCommand.Execute(gl, context, () => initialize(texture));
return texture;
}
}

View file

@ -1,5 +1,6 @@
using Silk.NET.OpenGL;
using Silk.NET.OpenGL.Extensions.ARB;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb;
@ -10,10 +11,12 @@ namespace AcDream.App.Rendering.Wb;
/// </summary>
public sealed class BindlessSupport
{
private readonly GL _gl;
private readonly ArbBindlessTexture _ext;
private BindlessSupport(ArbBindlessTexture extension)
private BindlessSupport(GL gl, ArbBindlessTexture extension)
{
_gl = gl;
_ext = extension;
}
@ -21,7 +24,7 @@ public sealed class BindlessSupport
{
if (gl.TryGetExtension<ArbBindlessTexture>(out var ext))
{
support = new BindlessSupport(ext);
support = new BindlessSupport(gl, ext);
return true;
}
support = null;
@ -32,17 +35,42 @@ public sealed class BindlessSupport
/// Idempotent: handle is the same for a given texture name.</summary>
public ulong GetResidentHandle(uint textureName)
{
ulong h = _ext.GetTextureHandle(textureName);
if (!_ext.IsTextureHandleResident(h))
_ext.MakeTextureHandleResident(h);
ulong h = GlResourceCommand.Execute(
_gl,
$"get bindless handle for texture {textureName}",
() => _ext.GetTextureHandle(textureName));
if (h == 0)
throw new InvalidOperationException(
$"OpenGL returned no bindless handle for texture {textureName}.");
bool resident = GlResourceCommand.Execute(
_gl,
$"query bindless handle {h} residency",
() => _ext.IsTextureHandleResident(h));
if (!resident)
{
GlResourceCommand.Execute(
_gl,
$"make bindless handle {h} resident",
() => _ext.MakeTextureHandleResident(h));
}
return h;
}
/// <summary>Release residency for a handle. Call before deleting the underlying texture.</summary>
public void MakeNonResident(ulong handle)
{
if (_ext.IsTextureHandleResident(handle))
_ext.MakeTextureHandleNonResident(handle);
bool resident = GlResourceCommand.Execute(
_gl,
$"query bindless handle {handle} residency before release",
() => _ext.IsTextureHandleResident(handle));
if (!resident)
return;
GlResourceCommand.Execute(
_gl,
$"make bindless handle {handle} non-resident",
() => _ext.MakeTextureHandleNonResident(handle));
}
// Phase N.5b note: a `SetSamplerHandleUniform` wrapper was added in T6

View file

@ -1,5 +1,6 @@
using Silk.NET.OpenGL;
using AcDream.App.Rendering;
using System.Runtime.ExceptionServices;
namespace AcDream.App.Rendering.Wb;
@ -96,41 +97,68 @@ internal static unsafe class TrackedGlResource
public static uint CreateBuffer(GL gl, string context)
{
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
uint name = gl.GenBuffer();
try
{
if (name == 0)
throw new InvalidOperationException($"OpenGL returned buffer name zero. Context: {context}");
GLHelpers.ThrowOnResourceError(gl, context);
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
return name;
}
catch
{
if (name != 0)
gl.DeleteBuffer(name);
throw;
}
return CreateTrackedName(
gl,
"buffer",
context,
gl.GenBuffer,
name => GlResourceCommand.DeleteBuffer(
gl,
name,
$"rollback buffer {name} after failed {context}"),
() => GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer));
}
public static uint CreateVertexArray(GL gl, string context)
{
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
uint name = gl.GenVertexArray();
return CreateTrackedName(
gl,
"vertex-array",
context,
gl.GenVertexArray,
name => GlResourceCommand.DeleteVertexArray(
gl,
name,
$"rollback vertex array {name} after failed {context}"),
() => GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO));
}
private static uint CreateTrackedName(
GL gl,
string resourceName,
string context,
Func<uint> create,
Action<uint> rollback,
Action publishAccounting)
{
uint name = GlResourceCommand.CreateName(
gl,
$"{resourceName} for {context}",
create,
rollback);
var cleanup = new ResourceCleanupGroup();
cleanup.Add($"{resourceName} name {name}", () => rollback(name));
try
{
if (name == 0)
throw new InvalidOperationException($"OpenGL returned vertex-array name zero. Context: {context}");
GLHelpers.ThrowOnResourceError(gl, context);
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO);
publishAccounting();
return name;
}
catch
catch (Exception publicationFailure)
{
if (name != 0)
gl.DeleteVertexArray(name);
throw;
try
{
cleanup.RetryCleanup();
}
catch (Exception cleanupFailure)
{
throw new GlResourceConstructionException(
$"Publishing {resourceName} accounting failed and GL name {name} could not be released.",
cleanup,
[publicationFailure, cleanupFailure]);
}
ExceptionDispatchInfo.Capture(publicationFailure).Throw();
throw new InvalidOperationException("Unreachable resource-publication path.");
}
}

View file

@ -202,6 +202,8 @@ public sealed class RetailUiRuntime : IDisposable
private ResourceShutdownTransaction? _shutdown;
private bool _disposed;
internal bool IsDisposalComplete => _disposed;
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
{
_bindings = bindings;
@ -211,6 +213,15 @@ public sealed class RetailUiRuntime : IDisposable
bindings.Host.HideWindow);
}
internal static RetailUiRuntime CreateUninitialized(
RetailUiRuntimeBindings bindings)
{
ArgumentNullException.ThrowIfNull(bindings);
return new RetailUiRuntime(bindings);
}
internal void InitializeForLease() => Initialize();
private void Initialize()
{
RetailUiRuntimeBindings bindings = _bindings;
@ -298,7 +309,7 @@ public sealed class RetailUiRuntime : IDisposable
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
ArgumentNullException.ThrowIfNull(bindings);
var runtime = new RetailUiRuntime(bindings);
var runtime = CreateUninitialized(bindings);
try
{
runtime.Initialize();

View file

@ -0,0 +1,263 @@
namespace AcDream.App.UI;
/// <summary>
/// Retains one UiHost/runtime ownership chain across partial construction,
/// initialization cleanup, lifetime retry, and explicit terminal abandonment.
/// </summary>
internal sealed class RetailUiRuntimeLease : IDisposable
{
private object? _host;
private Action? _quiesceInput;
private Action? _deactivateInput;
private Action? _disposeHost;
private Func<bool>? _hostDisposalComplete;
private object? _runtime;
private Action? _disposeRuntime;
private Func<bool>? _runtimeDisposalComplete;
private bool _disposalFailed;
private bool _inputQuiesced;
private bool _inputDeactivated;
private bool _operationActive;
private bool _disposed;
private bool _abandoned;
public bool IsDisposalComplete => _disposed;
public bool IsAbandoned => _abandoned;
internal bool RetainsResources => _host is not null || _runtime is not null;
public UiHost AcquireHost(Func<UiHost> factory) => AcquireHostCore(
factory,
static host => host.QuiesceInput(),
static host => host.DeactivateInput(),
static host => host.Dispose(),
static host => host.IsDisposalComplete);
internal T AcquireHostCore<T>(
Func<T> factory,
Action<T> quiesceInput,
Action<T> deactivateInput,
Action<T> dispose,
Func<T, bool> disposalComplete)
where T : class
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(quiesceInput);
ArgumentNullException.ThrowIfNull(deactivateInput);
ArgumentNullException.ThrowIfNull(dispose);
ArgumentNullException.ThrowIfNull(disposalComplete);
ThrowIfUnavailable();
if (_operationActive || _host is not null)
throw new InvalidOperationException("A retained UI host is already owned.");
_operationActive = true;
try
{
T host = factory()
?? throw new InvalidOperationException("The retained UI host factory returned null.");
_host = host;
_quiesceInput = () => quiesceInput(host);
_deactivateInput = () => deactivateInput(host);
_disposeHost = () => dispose(host);
_hostDisposalComplete = () => disposalComplete(host);
return host;
}
finally
{
_operationActive = false;
}
}
public RetailUiRuntime Mount(Func<RetailUiRuntime> factory) => MountCore(
factory,
static runtime => runtime.InitializeForLease(),
static runtime => runtime.Dispose(),
static runtime => runtime.IsDisposalComplete);
internal T MountCore<T>(
Func<T> factory,
Action<T> initialize,
Action<T> dispose,
Func<T, bool> disposalComplete)
where T : class
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(initialize);
ArgumentNullException.ThrowIfNull(dispose);
ArgumentNullException.ThrowIfNull(disposalComplete);
ThrowIfUnavailable();
if (_operationActive)
throw new InvalidOperationException("The retained UI lease is changing state.");
if (_host is null)
throw new InvalidOperationException("The retained UI host must be acquired first.");
if (_runtime is not null)
throw new InvalidOperationException("A retained UI runtime is already owned.");
// Construction may throw while the lease still owns the host directly.
_operationActive = true;
T runtime;
try
{
runtime = factory()
?? throw new InvalidOperationException("The retained UI runtime factory returned null.");
}
finally
{
_operationActive = false;
}
// Publish the partial runtime before any Initialize side effect.
_runtime = runtime;
_disposeRuntime = () => dispose(runtime);
_runtimeDisposalComplete = () => disposalComplete(runtime);
_operationActive = true;
try
{
initialize(runtime);
_operationActive = false;
return runtime;
}
catch (Exception initializationFailure)
{
_operationActive = false;
try
{
Dispose();
}
catch (Exception cleanupFailure)
{
throw new AggregateException(
"Retail UI initialization failed and its published partial runtime did not cleanly retire.",
initializationFailure,
cleanupFailure);
}
throw;
}
}
public void QuiesceInput()
{
if (_disposed || _abandoned || _inputQuiesced)
return;
if (_operationActive)
throw new InvalidOperationException("The retained UI lease is changing state.");
_operationActive = true;
_inputQuiesced = true;
try
{
_quiesceInput?.Invoke();
}
catch
{
_inputQuiesced = false;
throw;
}
finally
{
_operationActive = false;
}
}
public void DeactivateInput()
{
if (_disposed || _abandoned || _inputDeactivated)
return;
if (_operationActive)
throw new InvalidOperationException("The retained UI lease is changing state.");
_operationActive = true;
_inputDeactivated = true;
try
{
_deactivateInput?.Invoke();
}
catch
{
_inputDeactivated = false;
throw;
}
finally
{
_operationActive = false;
}
}
public void Dispose()
{
if (_disposed || _abandoned)
return;
if (_operationActive)
throw new InvalidOperationException("The retained UI lease is changing state.");
_operationActive = true;
try
{
if (_runtime is not null)
{
_disposeRuntime!();
if (_runtimeDisposalComplete?.Invoke() != true)
throw new InvalidOperationException(
"The retained UI runtime returned without completing disposal.");
if (_hostDisposalComplete?.Invoke() != true)
throw new InvalidOperationException(
"The retained UI runtime completed without retiring its host.");
}
else if (_host is not null)
{
_disposeHost!();
if (_hostDisposalComplete?.Invoke() != true)
throw new InvalidOperationException(
"The retained UI host returned without completing disposal.");
}
_runtime = null;
_disposeRuntime = null;
_runtimeDisposalComplete = null;
_host = null;
_quiesceInput = null;
_deactivateInput = null;
_disposeHost = null;
_hostDisposalComplete = null;
_disposalFailed = false;
_disposed = true;
}
catch
{
_disposalFailed = true;
throw;
}
finally
{
_operationActive = false;
}
}
public void AbandonAfterTerminalFailure()
{
if (_disposed || _abandoned)
return;
if (_operationActive)
throw new InvalidOperationException("The retained UI lease is changing state.");
if (!_disposalFailed)
throw new InvalidOperationException(
"Retained UI resources may be abandoned only after disposal failed.");
// Keep the exact references rooted. Checkpoint J owns the final native
// fallback/reporting policy; abandonment must not turn this into an
// unowned local resource.
_abandoned = true;
}
private void ThrowIfUnavailable()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_abandoned)
throw new InvalidOperationException("The retained UI lease was abandoned.");
}
}

View file

@ -56,6 +56,8 @@ public sealed class UiHost : System.IDisposable
private bool _disposeRequested;
private bool _disposed;
internal bool IsDisposalComplete => _disposed;
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
: this(gl, shaderDir, defaultFont, new HostQuiescenceGate())
{
@ -67,9 +69,9 @@ public sealed class UiHost : System.IDisposable
BitmapFont? defaultFont,
HostQuiescenceGate quiescence)
{
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
TextRenderer = new TextRenderer(gl, shaderDir);
DefaultFont = defaultFont;
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
}
// ── Per-frame ──────────────────────────────────────────────────────

View file

@ -109,7 +109,7 @@ internal interface ICameraFramePhase
/// <see cref="IRetailLiveFramePhase"/>. This host order is the accepted TS-53
/// adaptation and is not claimed to be the exact retail Client::UseTime order.
/// </remarks>
internal sealed class UpdateFrameOrchestrator
internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdateFrameRoot
{
private readonly IUpdateFrameTeardownPhase _teardown;
private readonly IUpdateFrameFailureSink _failureSink;