Commit 2 deleted the GL rendering backend's implementations; this step removes the package references and shader vocabulary they leave behind, so nothing in the App project still spells Silk.NET.OpenGL. Silk.NET.OpenGL and Silk.NET.OpenGL.Extensions.ARB are dropped from AcDream.App.csproj. Chorizite.Core stays — the audit is NOT clean: its Render.Enums (TextureFormat, BufferUsage) and Lib.BoundingBox types are used directly and extensively across the Wb texture/mesh pipeline, independent of the deleted GL IUniformBuffer implementers the package comment used to cite. The stale comment is corrected in place. IMeshPipelineDevice.Gl is removed along with the GL? gl parameter threaded through WbMeshAdapter's four constructors, WorldRenderComposition's CreateMeshAdapter, and VulkanMeshPipelineDevice's Gl => null implementation — nothing read any of them once the legacy per-mesh upload bodies were gone (confirmed by grep: the sole non-doc-comment hit was a test assertion). While in WbMeshAdapter.Dispose(), found and fixed a real bug along the way: its teardown still pattern-matched the deleted GL GpuFrameFlightController to decide whether to wait for submitted work, which VulkanFrameFlightController replaced at slice V6a without this site being updated — so the wait had been silently dead on every Vulkan run since then. Retargeted to VulkanFrameFlightController, which carries the same WaitForSubmittedWork(). The GL pixel-format vocabulary (Silk.NET.OpenGL.PixelFormat/PixelType) that WorldTextureArray/TextureFormatExtensions/TextureAtlasManager used for upload validation is replaced by AcDream.Content's existing Silk.NET-free UploadPixelFormat/UploadPixelType enums (added at MP1a to keep the bake tool GL-free); two new members (Rgb, Red, Float) extend that enum with their GL ABI constants to cover the full vocabulary WorldTextureArray needs, since MP1a's original set only covered what the extractor itself emits. ObjectMeshManager's App-boundary cast `(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` becomes a direct pass-through now that both sides share the type. GpuBindingModel.StorageTextureTable (the GL-only binding=9 emulation of the Vulkan texture table) is deleted and StorageBindingCount drops from 10 to 9; the descriptor-set-layout code that builds from that count (VulkanPipelineLayouts, VulkanFrameBindings) is untouched and just allocates one fewer always-dummy-seeded, always-unused binding. Several fully dead GL-only classes came along for the ride, confirmed by zero construction sites: SilkFramebufferViewportTarget (NullFramebufferViewportTarget is the sole production IFramebufferViewportTarget), SilkRenderGlStateReader (NullRenderGlStateReader.Instance is the sole IRenderGlStateReader), RuntimeRenderFrameClearPhase (VulkanRenderFrameClearPhase is the sole IRenderFrameClearPhase, expressing the same atmosphere-clear logic as a pass load-op instead), and GpuFrameTimer plus FrameProfiler's GL-owning FrameBoundary(GL) overload and BeginGpuFrame/EndGpuFrame bracket (RecordGpuSample is the only GPU-timing path any backend uses now — the ACDREAM_WB_DIAG nested-query exclusion these existed for no longer applies, since WbDrawDispatcher's own diagnostic GPU sampling already moved to the device's Vulkan timer pool). GpuFrameFlightController itself stays (never constructed with a real fence API in production, but its retirement-ledger/serial-ring logic is backend-neutral and still covered by its own unit tests) — only its GL-specific parts (the public GL constructor overload, SilkGpuFenceApi) are deleted, since removing the whole class would mean restructuring the frozen Slice-8 composition shape's GpuFrameFlightController? threading, which is out of this commit's scope. TextureParameters.cs and BufferUsageExtensions.cs (zero callers each) are deleted outright. common.glsl is deleted: nothing in the actual Vulkan .spv build reads it. tools/ShaderCompiler/Program.cs compiles each .vert/.frag pair directly and tools/ShaderCompiler/VulkanGlslPreamble.cs injects its own complete self-contained preamble per file; common.glsl's textual concatenation was exclusively Shader.cs's GL-only mechanism, deleted at Commit 2. The five shader files that named it in comments (mesh_modern.vert, particle.vert, particle.frag, sky.frag, terrain_modern.frag) are corrected to point at VulkanGlslPreamble.cs instead. mesh.vert/mesh.frag — the pre-N.5 legacy shader pair the mandatory modern path already made unreachable, with zero C# consumers and no compiled .spv — are deleted too. Regenerated via tools/compile-shaders.ps1: 9/9 remaining shader pairs compile (previously 9/10, with mesh the sole failure — the VulkanShaderManifestTests doc comment's "nine of ten are not Vulkan-expressible" was already stale before this commit). Test fallout: dead-subject test methods/files are deleted rather than patched (TextRendererFailureSafetyTests.cs, ClipFrameUploadTests.cs, GpuResourceRetirementTransactionTests.cs's GL queue tests, one WorldRenderDiagnosticsTests source-order test, one RenderFrameResourceControllerTests clear-phase-order test); tests whose subject moved or was renamed are updated in place rather than deleted (GpuContractTests, VulkanCapabilityGateTests, MeshPipelineDeviceSeamTests' pinned seven-member surface now reads six, ParticleBindlessInstanceTests' cross-dialect check now covers the one surviving dialect, WbMeshAdapterTests' misleadingly-named null-gl test — gpuDevice was always the parameter that actually threw). Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors, with the Silk.NET.OpenGL/.Extensions.ARB package references physically removed from the csproj (not just unreferenced in code). Tests: full-solution `dotnet test` green across every project. Zero remaining `using Silk.NET.OpenGL` anywhere in src/ or tests/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
472 lines
15 KiB
C#
472 lines
15 KiB
C#
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// Bounds how far the CPU may submit OpenGL work ahead of the GPU.
|
|
/// Dynamic buffers are intentionally reused from frame to frame; without a
|
|
/// frames-in-flight bound, an uncapped render loop can make the driver retain
|
|
/// an unbounded chain of renamed backing stores while older draws are pending.
|
|
/// </summary>
|
|
internal interface IGpuResourceRetirementQueue
|
|
{
|
|
void Retire(Action release);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A physical GPU-resource release split into independently committed stages.
|
|
/// A retirement callback may be retried after a driver or accounting failure;
|
|
/// stages which already returned successfully are never executed twice.
|
|
/// </summary>
|
|
internal sealed class RetryableGpuResourceRelease
|
|
{
|
|
private readonly Action[] _stages;
|
|
private int _nextStage;
|
|
private bool _running;
|
|
|
|
public RetryableGpuResourceRelease(params Action[] stages)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(stages);
|
|
if (stages.Length == 0)
|
|
throw new ArgumentException("At least one release stage is required.", nameof(stages));
|
|
if (Array.Exists(stages, static stage => stage is null))
|
|
throw new ArgumentException("Release stages cannot contain null.", nameof(stages));
|
|
_stages = stages;
|
|
}
|
|
|
|
public int CompletedStageCount => _nextStage;
|
|
public bool IsComplete => _nextStage == _stages.Length;
|
|
|
|
public void Run()
|
|
{
|
|
// A release stage is allowed to call code which drains retirement
|
|
// work. Treat that nested drain as observing the active transaction,
|
|
// not as permission to execute the same physical mutation twice.
|
|
if (_running)
|
|
return;
|
|
|
|
_running = true;
|
|
try
|
|
{
|
|
while (_nextStage < _stages.Length)
|
|
{
|
|
_stages[_nextStage]();
|
|
_nextStage++;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_running = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reports whether a throwable GPU operation changed physical ownership
|
|
/// before surfacing its error. Stateful resource owners use this for backends
|
|
/// or observers which can explicitly prove that ownership changed before an
|
|
/// exception. OpenGL error validation remains in the same retryable stage as
|
|
/// its command because a GL error means that command did not commit.
|
|
/// </summary>
|
|
internal sealed class GpuResourceMutationException : InvalidOperationException
|
|
{
|
|
public GpuResourceMutationException(
|
|
string message,
|
|
bool mutationCommitted,
|
|
Exception innerException)
|
|
: base(message, innerException) => MutationCommitted = mutationCommitted;
|
|
|
|
public bool MutationCommitted { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retains release ownership until a callback has either run immediately or
|
|
/// has been accepted by the frame-flight queue. Queue insertion failure can
|
|
/// therefore be retried without losing the only references to old GL names.
|
|
/// </summary>
|
|
internal sealed class GpuRetirementLedger
|
|
{
|
|
private readonly IGpuResourceRetirementQueue _queue;
|
|
private readonly List<RetryableGpuResourceRelease> _awaitingPublication = [];
|
|
private readonly HashSet<RetryableGpuResourceRelease> _publishing =
|
|
new(ReferenceEqualityComparer.Instance);
|
|
|
|
public GpuRetirementLedger(IGpuResourceRetirementQueue queue) =>
|
|
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
|
|
|
public int AwaitingPublicationCount => _awaitingPublication.Count;
|
|
|
|
public void Retire(RetryableGpuResourceRelease release)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(release);
|
|
_awaitingPublication.Add(release);
|
|
PublishAt(_awaitingPublication.Count - 1);
|
|
}
|
|
|
|
public void RetireMany(IEnumerable<RetryableGpuResourceRelease> releases)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(releases);
|
|
RetryableGpuResourceRelease[] batch = releases.ToArray();
|
|
if (batch.Length == 0)
|
|
return;
|
|
if (Array.Exists(batch, static release => release is null))
|
|
throw new ArgumentException("Retirement batches cannot contain null.", nameof(releases));
|
|
|
|
// Establish ownership for the complete physical set before the first
|
|
// queue call. If publication N fails, later resources remain reachable
|
|
// and the next maintenance pass can publish every independent member.
|
|
_awaitingPublication.AddRange(batch);
|
|
List<Exception>? failures = null;
|
|
for (int i = 0; i < batch.Length; i++)
|
|
{
|
|
try { Publish(batch[i]); }
|
|
catch (Exception error) { (failures ??= []).Add(error); }
|
|
}
|
|
if (failures is not null)
|
|
throw new AggregateException(
|
|
"One or more GPU retirement callbacks could not be published.",
|
|
failures);
|
|
}
|
|
|
|
public void RetryPendingPublications()
|
|
{
|
|
RetryableGpuResourceRelease[] pending = _awaitingPublication.ToArray();
|
|
List<Exception>? failures = null;
|
|
for (int i = 0; i < pending.Length; i++)
|
|
{
|
|
RetryableGpuResourceRelease release = pending[i];
|
|
if (!_awaitingPublication.Contains(release, ReferenceEqualityComparer.Instance)
|
|
|| _publishing.Contains(release))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
Publish(release);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= []).Add(error);
|
|
}
|
|
}
|
|
|
|
if (failures is not null)
|
|
throw new AggregateException(
|
|
"One or more GPU retirement callbacks could not be published.",
|
|
failures);
|
|
}
|
|
|
|
public void RetryPendingPublication(RetryableGpuResourceRelease release)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(release);
|
|
if (!_awaitingPublication.Contains(release, ReferenceEqualityComparer.Instance)
|
|
|| _publishing.Contains(release))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Publish(release);
|
|
}
|
|
|
|
private void PublishAt(int index)
|
|
{
|
|
RetryableGpuResourceRelease release = _awaitingPublication[index];
|
|
Publish(release);
|
|
}
|
|
|
|
private void Publish(RetryableGpuResourceRelease release)
|
|
{
|
|
if (!_publishing.Add(release))
|
|
return;
|
|
try
|
|
{
|
|
_queue.Retire(release.Run);
|
|
_awaitingPublication.Remove(release);
|
|
}
|
|
catch
|
|
{
|
|
// An immediate queue can throw from the callback itself. If every
|
|
// stage committed before a later wrapper failed, no retry remains.
|
|
if (release.IsComplete)
|
|
_awaitingPublication.Remove(release);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
_publishing.Remove(release);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal sealed class ImmediateGpuResourceRetirementQueue : IGpuResourceRetirementQueue
|
|
{
|
|
public static ImmediateGpuResourceRetirementQueue Instance { get; } = new();
|
|
|
|
private ImmediateGpuResourceRetirementQueue()
|
|
{
|
|
}
|
|
|
|
public void Retire(Action release)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(release);
|
|
release();
|
|
}
|
|
}
|
|
|
|
internal sealed class GpuFrameFlightController :
|
|
IGpuResourceRetirementQueue,
|
|
IRenderFrameLifetime,
|
|
IRenderFrameSlotSource,
|
|
IDisposable
|
|
{
|
|
internal const int DefaultMaximumFramesInFlight = 3;
|
|
private const ulong WaitSliceNanoseconds = 1_000_000;
|
|
|
|
private readonly IGpuFenceApi _fenceApi;
|
|
private readonly nint[] _fences;
|
|
private readonly long[] _fenceSerials;
|
|
private readonly SortedDictionary<long, List<Action>> _retirements = new();
|
|
private int _slot;
|
|
private long _lastSubmittedSerial;
|
|
private bool _frameOpen;
|
|
private bool _disposed;
|
|
|
|
public int CurrentSlot => _slot;
|
|
public int SlotCount => _fences.Length;
|
|
internal int PendingRetirementCount => _retirements.Sum(entry => entry.Value.Count);
|
|
|
|
// Campaign V slice V11 deleted the public GL gl overload constructor and
|
|
// SilkGpuFenceApi, its concrete IGpuFenceApi implementation — every
|
|
// production caller went through VulkanFrameFlightController /
|
|
// GpuDeviceFrameLifetime instead (this class is never constructed with a
|
|
// real fence API in production; only its own unit tests exercise it, via
|
|
// a fake IGpuFenceApi). The internal fenceApi-shaped constructor stays: the
|
|
// class's own retirement-ledger/serial-ring logic is backend-neutral and
|
|
// is what those tests protect.
|
|
internal GpuFrameFlightController(
|
|
IGpuFenceApi fenceApi,
|
|
int maximumFramesInFlight = DefaultMaximumFramesInFlight)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(fenceApi);
|
|
ArgumentOutOfRangeException.ThrowIfLessThan(maximumFramesInFlight, 1);
|
|
|
|
_fenceApi = fenceApi;
|
|
_fences = new nint[maximumFramesInFlight];
|
|
_fenceSerials = new long[maximumFramesInFlight];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Waits for the frame currently occupying the next ring slot. The first
|
|
/// wait flushes submitted commands so the fence can make progress; later
|
|
/// one-millisecond slices avoid an unbounded native blocking call.
|
|
/// </summary>
|
|
public void BeginFrame()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (_frameOpen)
|
|
throw new InvalidOperationException("EndFrame must close the current frame before BeginFrame is called again.");
|
|
|
|
nint fence = _fences[_slot];
|
|
if (fence != 0)
|
|
RetireFence(_slot);
|
|
|
|
_frameOpen = true;
|
|
}
|
|
|
|
private void RetireFence(int slot)
|
|
{
|
|
nint fence = _fences[slot];
|
|
if (fence == 0)
|
|
return;
|
|
|
|
bool flushCommands = true;
|
|
while (true)
|
|
{
|
|
GpuFenceWaitResult result = _fenceApi.Wait(
|
|
fence,
|
|
flushCommands,
|
|
WaitSliceNanoseconds);
|
|
flushCommands = false;
|
|
|
|
if (result == GpuFenceWaitResult.Timeout)
|
|
continue;
|
|
if (result == GpuFenceWaitResult.Failed)
|
|
throw new InvalidOperationException("OpenGL failed while waiting for an in-flight frame fence.");
|
|
|
|
break;
|
|
}
|
|
|
|
_fenceApi.Delete(fence);
|
|
_fences[slot] = 0;
|
|
long completedSerial = _fenceSerials[slot];
|
|
_fenceSerials[slot] = 0;
|
|
RunRetirementsThrough(completedSerial);
|
|
}
|
|
|
|
/// <summary>Marks every GL command submitted by the current frame.</summary>
|
|
public void EndFrame()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (!_frameOpen)
|
|
throw new InvalidOperationException("BeginFrame must be called before EndFrame.");
|
|
if (_fences[_slot] != 0)
|
|
throw new InvalidOperationException("BeginFrame must retire the current frame slot before EndFrame.");
|
|
|
|
nint fence = _fenceApi.Insert();
|
|
if (fence == 0)
|
|
throw new InvalidOperationException("OpenGL did not create an in-flight frame fence.");
|
|
|
|
_fences[_slot] = fence;
|
|
_fenceSerials[_slot] = ++_lastSubmittedSerial;
|
|
_frameOpen = false;
|
|
_slot = (_slot + 1) % _fences.Length;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Defers destruction until the fence covering every draw that could still
|
|
/// reference the resource has signaled. Calls made during a render frame
|
|
/// include that frame; update-thread calls cover the last submitted frame.
|
|
/// </summary>
|
|
public void Retire(Action release)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(release);
|
|
|
|
long targetSerial = _frameOpen
|
|
? _lastSubmittedSerial + 1
|
|
: _lastSubmittedSerial;
|
|
if (targetSerial == 0)
|
|
{
|
|
release();
|
|
return;
|
|
}
|
|
|
|
if (!_retirements.TryGetValue(targetSerial, out List<Action>? releases))
|
|
{
|
|
releases = [];
|
|
_retirements.Add(targetSerial, releases);
|
|
}
|
|
|
|
releases.Add(release);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Waits for all submitted GL work and runs every resource retirement it
|
|
/// protects. Used before orderly renderer teardown while the context lives.
|
|
/// </summary>
|
|
public void WaitForSubmittedWork()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (_frameOpen)
|
|
throw new InvalidOperationException("Cannot drain submitted work while a render frame is open.");
|
|
|
|
List<Exception>? failures = null;
|
|
for (int i = 0; i < _fences.Length; i++)
|
|
{
|
|
int slot = (_slot + i) % _fences.Length;
|
|
try
|
|
{
|
|
RetireFence(slot);
|
|
}
|
|
catch (AggregateException ex)
|
|
{
|
|
(failures ??= []).AddRange(ex.InnerExceptions);
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
RunRetirementsThrough(_lastSubmittedSerial);
|
|
}
|
|
catch (AggregateException ex)
|
|
{
|
|
(failures ??= []).AddRange(ex.InnerExceptions);
|
|
}
|
|
|
|
if (failures is not null)
|
|
throw new AggregateException("One or more GPU resource retirements failed.", failures);
|
|
}
|
|
|
|
private void RunRetirementsThrough(long completedSerial)
|
|
{
|
|
List<Exception>? failures = null;
|
|
List<(long Serial, Action Release)>? retry = null;
|
|
while (_retirements.Count != 0)
|
|
{
|
|
KeyValuePair<long, List<Action>> first;
|
|
using (IEnumerator<KeyValuePair<long, List<Action>>> enumerator = _retirements.GetEnumerator())
|
|
{
|
|
if (!enumerator.MoveNext() || enumerator.Current.Key > completedSerial)
|
|
break;
|
|
first = enumerator.Current;
|
|
}
|
|
|
|
if (!_retirements.Remove(first.Key))
|
|
break;
|
|
|
|
List<Action> releases = first.Value;
|
|
for (int i = 0; i < releases.Count; i++)
|
|
{
|
|
try
|
|
{
|
|
releases[i]();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
(failures ??= []).Add(ex);
|
|
(retry ??= []).Add((first.Key, releases[i]));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (retry is not null)
|
|
{
|
|
for (int i = 0; i < retry.Count; i++)
|
|
{
|
|
(long serial, Action release) = retry[i];
|
|
if (!_retirements.TryGetValue(serial, out List<Action>? releases))
|
|
{
|
|
releases = [];
|
|
_retirements.Add(serial, releases);
|
|
}
|
|
releases.Add(release);
|
|
}
|
|
}
|
|
|
|
if (failures is not null)
|
|
throw new AggregateException("One or more GPU resource retirements failed.", failures);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
if (_frameOpen)
|
|
EndFrame();
|
|
WaitForSubmittedWork();
|
|
|
|
Array.Clear(_fences);
|
|
Array.Clear(_fenceSerials);
|
|
_disposed = true;
|
|
}
|
|
}
|
|
|
|
internal enum GpuFenceWaitResult
|
|
{
|
|
Signaled,
|
|
Timeout,
|
|
Failed,
|
|
}
|
|
|
|
internal interface IGpuFenceApi
|
|
{
|
|
nint Insert();
|
|
GpuFenceWaitResult Wait(nint fence, bool flushCommands, ulong timeoutNanoseconds);
|
|
void Delete(nint fence);
|
|
}
|
|
|
|
// Campaign V slice V11 deleted SilkGpuFenceApi, the GL-backed IGpuFenceApi
|
|
// implementation (glFenceSync/glClientWaitSync/glDeleteSync) — it was the
|
|
// sole reason this file needed Silk.NET.OpenGL, and had no remaining
|
|
// production caller once GpuFrameFlightController's own GL constructor
|
|
// overload above was deleted alongside it.
|