fix(rendering): bound portal resource lifetime

Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -0,0 +1,121 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Least-recently-used set for resources which currently have no logical
/// owner but remain resident for inexpensive reuse. The byte budget is a
/// cache policy, not a lifetime fence: callers remove one key here and then
/// retire its physical GPU storage through <see cref="IGpuResourceRetirementQueue"/>.
/// Render-thread only.
/// </summary>
internal sealed class BoundedUnownedResourceCache<TKey> where TKey : notnull
{
private readonly long _budgetBytes;
private readonly int _maximumCount;
private readonly LinkedList<TKey> _lru = new();
private readonly Dictionary<TKey, Entry> _entries = new();
private long _residentBytes;
private readonly record struct Entry(long Bytes, LinkedListNode<TKey> Node);
public BoundedUnownedResourceCache(long budgetBytes, int maximumCount = int.MaxValue)
{
ArgumentOutOfRangeException.ThrowIfNegative(budgetBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1);
_budgetBytes = budgetBytes;
_maximumCount = maximumCount;
}
public int Count => _entries.Count;
public long ResidentBytes => _residentBytes;
public long BudgetBytes => _budgetBytes;
public int MaximumCount => _maximumCount;
public bool Contains(TKey key) => _entries.ContainsKey(key);
public void MarkUnowned(TKey key, long bytes)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes);
if (_entries.TryGetValue(key, out Entry existing))
{
if (existing.Bytes != bytes)
throw new InvalidOperationException(
$"Cached resource {key} changed size from {existing.Bytes} to {bytes} bytes.");
_lru.Remove(existing.Node);
_lru.AddLast(existing.Node);
return;
}
LinkedListNode<TKey> node = _lru.AddLast(key);
_entries.Add(key, new Entry(bytes, node));
_residentBytes = checked(_residentBytes + bytes);
}
public bool MarkOwned(TKey key)
{
if (!_entries.Remove(key, out Entry entry))
return false;
_lru.Remove(entry.Node);
_residentBytes -= entry.Bytes;
return true;
}
/// <summary>
/// Removes the oldest unowned key only while the cache is above budget.
/// Calling once per frame gives the GPU driver explicit destruction
/// backpressure instead of turning a portal transition into one large
/// resource-release burst.
/// </summary>
public bool TryTakeOldestOverBudget(out TKey key)
{
if ((_residentBytes <= _budgetBytes && _entries.Count <= _maximumCount)
|| _lru.First is null)
{
key = default!;
return false;
}
LinkedListNode<TKey> node = _lru.First;
key = node.Value;
Entry entry = _entries[key];
_lru.RemoveFirst();
_entries.Remove(key);
_residentBytes -= entry.Bytes;
return true;
}
public bool TryTakeOldest(out TKey key)
{
if (_lru.First is null)
{
key = default!;
return false;
}
LinkedListNode<TKey> node = _lru.First;
key = node.Value;
Entry entry = _entries[key];
_lru.RemoveFirst();
_entries.Remove(key);
_residentBytes -= entry.Bytes;
return true;
}
public bool TryTake(TKey key)
{
if (!_entries.Remove(key, out Entry entry))
return false;
_lru.Remove(entry.Node);
_residentBytes -= entry.Bytes;
return true;
}
public void Clear()
{
_lru.Clear();
_entries.Clear();
_residentBytes = 0;
}
}

View file

@ -22,12 +22,13 @@
// terrain OutsideView planes, then points each renderer's per-instance slot
// buffer at the right slots.
//
// Pure CPU byte-packing + a thin GL upload. NO GL types appear except in
// UploadShared. The byte layout is asserted by ClipFrameLayoutTests so a silent
// Pure CPU byte-packing + a thin GL upload. The byte layout is asserted by
// ClipFrameLayoutTests so a silent
// std430/std140 drift can't reach the GPU.
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -78,16 +79,54 @@ public sealed class ClipFrame : IDisposable
// Packed std140 bytes for the terrain UBO (always TerrainUboBytes long).
private readonly byte[] _terrainBytes = new byte[TerrainUboBytes];
// ---- GL-side state (lazily created on first UploadShared) ----------------
// ---- GL-side state (lazily created on first upload) ----------------------
private uint _regionSsbo;
private uint _terrainUbo;
private bool _glInitialized;
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposing;
private bool _disposed;
// GL reference captured on the first UploadShared so Dispose can delete the two
// buffers. ClipFrame is long-lived in U.3 (GameWindow holds one via ??= NoClip()
// and reuses it every frame), so we DO own buffer teardown — see Dispose.
internal const int FrameSlotCount = GpuFrameFlightController.DefaultMaximumFramesInFlight;
private sealed class RegionBuffer
{
public uint Name;
public int CapacityBytes;
public ClipBufferCapacityPolicy CapacityPolicy;
}
private sealed class TerrainBufferArena
{
public uint Name;
public int CapacityBytes;
public ClipBufferCapacityPolicy CapacityPolicy;
}
// A full clip-region table is immutable once ClipFrameAssembler has packed
// the frame. Keep exactly one SSBO per GPU-fenced frame slot. Terrain clip
// state changes between outside-view slices, so each frame slot owns one
// UBO arena and each draw binds a distinct aligned range within that arena.
// This bounds native object retention at six buffers total instead of one
// region/terrain pair per outside-view slice.
private readonly ClipFrameResourceRing<RegionBuffer> _regionBuffers =
new(FrameSlotCount);
private readonly ClipFrameResourceRing<TerrainBufferArena> _terrainBuffers =
new(FrameSlotCount);
private readonly ClipFrameUploadState _uploadState = new();
private int _dynamicFrameSlot;
private bool _dynamicFrameStarted;
private int _terrainRecordStrideBytes;
private TerrainClipBufferBinding _terrainBinding;
internal int DynamicBufferSetCount =>
Math.Max(_regionBuffers.Count, _terrainBuffers.Count);
internal int RegionBufferCount => _regionBuffers.Count;
internal int TerrainBufferCount => _terrainBuffers.Count;
internal int TerrainRecordStrideBytes => _terrainRecordStrideBytes;
// GL reference captured on the first upload so Dispose can delete each
// frame-slot buffer. ClipFrame is long-lived and owns their teardown.
private GL? _gl;
private ClipFrame(byte[] regionBytes, int slotCount)
@ -119,9 +158,7 @@ public sealed class ClipFrame : IDisposable
/// (no-clip, count 0) and a terrain count of 0 — WITHOUT allocating a new
/// frame or new GL buffers. The single long-lived <c>_clipFrame</c> in
/// GameWindow is reset + re-packed every frame by <see cref="ClipFrameAssembler"/>,
/// then re-uploaded via <see cref="UploadShared"/> (which reuses the same SSBO /
/// UBO ids). This keeps the per-frame cost at one BufferData per buffer instead
/// of leaking a fresh pair of GL buffers each frame.
/// then uploaded through one SSBO and one terrain arena per fenced frame slot.
/// </summary>
public void Reset()
{
@ -139,16 +176,36 @@ public sealed class ClipFrame : IDisposable
}
/// <summary>The shared mesh-clip SSBO id, or 0 before the first
/// <see cref="UploadShared"/>. Renderers may bind this directly if they don't
/// receive it via a parameter; <see cref="UploadShared"/> already binds it to
/// <see cref="UploadRegions"/>. Renderers may bind this directly if they don't
/// receive it via a parameter; <see cref="UploadRegions"/> already binds it to
/// <see cref="MeshClipSsboBinding"/>.</summary>
public uint RegionSsbo => _regionSsbo;
/// <summary>The terrain-clip UBO id, or 0 before the first
/// <see cref="UploadShared"/>. Handed to <see cref="TerrainModernRenderer"/>
/// so it can re-bind binding=2 (UBO namespace) before its draw.</summary>
/// <see cref="UploadTerrainClip"/>. The buffer alone does not identify the
/// active range; consumers should use <see cref="TerrainBinding"/>.</summary>
public uint TerrainUbo => _terrainUbo;
/// <summary>The currently installed terrain-clip range. The buffer name is
/// stable for the current GPU-fenced frame slot; the offset advances once
/// for every outside-view draw.</summary>
public TerrainClipBufferBinding TerrainBinding => _terrainBinding;
/// <summary>
/// Selects one GPU-fenced frame slot. Its region SSBO and terrain arena are
/// safe to reuse because the frame-flight controller already waited for the
/// slot's preceding submission.
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= FrameSlotCount)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_dynamicFrameSlot = frameSlot;
_dynamicFrameStarted = true;
_terrainBinding = default;
_uploadState.BeginFrame();
}
/// <summary>
/// Append one clip region (becomes the next slot index) from a
/// <see cref="ClipPlaneSet"/>. Only the convex-plane case is supported in
@ -215,55 +272,265 @@ public sealed class ClipFrame : IDisposable
}
/// <summary>
/// Upload the shared mesh-clip SSBO (binding=2) and the terrain-clip UBO
/// (binding=2, UBO namespace) and bind both to their binding points. Idempotent
/// to call once per frame. Creates the GL buffers lazily on first call.
/// Compatibility entry point for one-region/one-terrain callers. Complex
/// PView frames should reserve their complete terrain sequence, upload the
/// regions once, then call <see cref="UploadTerrainClip"/> per slice.
/// </summary>
public unsafe void UploadShared(GL gl)
{
ArgumentNullException.ThrowIfNull(gl);
ObjectDisposedException.ThrowIf(_disposed, this);
ReserveTerrainUploads(gl, 1);
UploadRegions(gl);
UploadTerrainClip(gl);
}
if (!_glInitialized)
/// <summary>
/// Allocates the current frame slot's terrain arena before any draw uses it.
/// The arena has one alignment-safe range per subsequent
/// <see cref="UploadTerrainClip"/> call, so later slices never overwrite
/// uniform data referenced by earlier GPU commands.
/// </summary>
public void ReserveTerrainUploads(GL gl, int uploadCount)
{
ValidateGlContext(gl);
ArgumentOutOfRangeException.ThrowIfLessThan(uploadCount, 1);
_uploadState.ValidateTerrainReservation(uploadCount);
if (_terrainRecordStrideBytes == 0)
{
_gl = gl; // captured for Dispose (single context for the frame's lifetime)
_regionSsbo = gl.GenBuffer();
_terrainUbo = gl.GenBuffer();
_glInitialized = true;
GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query (precondition)");
gl.GetInteger(GetPName.UniformBufferOffsetAlignment, out int alignment);
GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query");
_terrainRecordStrideBytes = ClipFrameArenaLayout.RecordStride(alignment);
}
TerrainBufferArena arena = GetOrCreateTerrainArena(gl);
int requiredBytes = ClipFrameArenaLayout.RequiredBytes(
uploadCount,
_terrainRecordStrideBytes);
int targetCapacity = arena.CapacityPolicy.SelectCapacity(
arena.CapacityBytes,
requiredBytes);
if (targetCapacity != arena.CapacityBytes)
{
ClipBufferCapacityTransaction.Resize(
ref arena.CapacityBytes,
targetCapacity,
(previousBytes, newBytes) =>
TrackedGlResource.AllocateBufferStorage(
gl,
GLEnum.UniformBuffer,
arena.Name,
previousBytes,
newBytes,
GLEnum.DynamicDraw,
"ClipFrame terrain UBO arena resize"));
}
_terrainUbo = arena.Name;
_uploadState.CommitTerrainReservation(uploadCount);
}
/// <summary>Uploads and binds the immutable clip-region table once for the
/// assembled frame.</summary>
public unsafe void UploadRegions(GL gl)
{
ValidateGlContext(gl);
_uploadState.ValidateRegionsNotUploaded();
RegionBuffer region = GetOrCreateRegionBuffer(gl);
int regionByteCount = checked(_slotCount * CellClipStrideBytes);
int targetCapacity = region.CapacityPolicy.SelectCapacity(
region.CapacityBytes,
regionByteCount);
if (targetCapacity != region.CapacityBytes)
{
ClipBufferCapacityTransaction.Resize(
ref region.CapacityBytes,
targetCapacity,
(previousBytes, newBytes) =>
TrackedGlResource.AllocateBufferStorage(
gl,
GLEnum.ShaderStorageBuffer,
region.Name,
previousBytes,
newBytes,
GLEnum.DynamicDraw,
"ClipFrame region SSBO resize"));
}
int regionByteCount = _slotCount * CellClipStrideBytes;
gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, _regionSsbo);
fixed (byte* p = _regionBytes)
{
gl.BufferData(BufferTargetARB.ShaderStorageBuffer,
(nuint)regionByteCount, p, BufferUsageARB.DynamicDraw);
TrackedGlResource.UpdateBufferSubData(
gl,
GLEnum.ShaderStorageBuffer,
region.Name,
0,
regionByteCount,
p,
"ClipFrame region SSBO update");
}
gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, MeshClipSsboBinding, _regionSsbo);
gl.BindBufferBase(
BufferTargetARB.ShaderStorageBuffer,
MeshClipSsboBinding,
region.Name);
GLHelpers.ThrowOnResourceError(gl, "ClipFrame region SSBO binding");
_regionSsbo = region.Name;
_uploadState.MarkRegionsUploaded();
}
/// <summary>Uploads the current terrain clip bytes into the next unique
/// aligned arena record and installs that range at UBO binding 2.</summary>
public unsafe TerrainClipBufferBinding UploadTerrainClip(GL gl)
{
ValidateGlContext(gl);
int recordIndex = _uploadState.NextTerrainRecord();
TerrainBufferArena arena = _terrainBuffers.GetRequired(_dynamicFrameSlot);
int offsetBytes = ClipFrameArenaLayout.RecordOffset(
recordIndex,
_terrainRecordStrideBytes);
gl.BindBuffer(BufferTargetARB.UniformBuffer, _terrainUbo);
fixed (byte* p = _terrainBytes)
{
gl.BufferData(BufferTargetARB.UniformBuffer,
(nuint)TerrainUboBytes, p, BufferUsageARB.DynamicDraw);
TrackedGlResource.UpdateBufferSubData(
gl,
GLEnum.UniformBuffer,
arena.Name,
offsetBytes,
TerrainUboBytes,
p,
"ClipFrame terrain UBO range update");
}
gl.BindBufferBase(BufferTargetARB.UniformBuffer, TerrainClipUboBinding, _terrainUbo);
_terrainBinding = new TerrainClipBufferBinding(
arena.Name,
offsetBytes,
TerrainUboBytes);
_terrainBinding.Bind(gl);
_terrainUbo = arena.Name;
return _terrainBinding;
}
public void BindTerrainClip(GL gl)
{
ValidateGlContext(gl);
if (!_terrainBinding.IsValid)
throw new InvalidOperationException("No terrain clip range has been uploaded for this frame.");
_terrainBinding.Bind(gl);
}
private RegionBuffer GetOrCreateRegionBuffer(GL gl)
{
if (_regionBuffers.TryGet(_dynamicFrameSlot, out RegionBuffer? existing))
return existing!;
uint name = TrackedGlResource.CreateBuffer(gl, "ClipFrame region SSBO creation");
var created = new RegionBuffer { Name = name };
try
{
_regionBuffers.Set(_dynamicFrameSlot, created);
return created;
}
catch
{
TrackedGlResource.DeleteBuffer(gl, name, 0, "ClipFrame region SSBO rollback");
throw;
}
}
private TerrainBufferArena GetOrCreateTerrainArena(GL gl)
{
if (_terrainBuffers.TryGet(_dynamicFrameSlot, out TerrainBufferArena? existing))
return existing!;
uint name = TrackedGlResource.CreateBuffer(gl, "ClipFrame terrain UBO creation");
var created = new TerrainBufferArena { Name = name };
try
{
_terrainBuffers.Set(_dynamicFrameSlot, created);
return created;
}
catch
{
TrackedGlResource.DeleteBuffer(gl, name, 0, "ClipFrame terrain UBO rollback");
throw;
}
}
private void ValidateGlContext(GL gl)
{
ArgumentNullException.ThrowIfNull(gl);
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before uploading clip data.");
if (_gl is null)
_gl = gl;
else if (!ReferenceEquals(_gl, gl))
throw new InvalidOperationException("ClipFrame cannot span OpenGL contexts.");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// ClipFrame is long-lived in U.3 (GameWindow holds one via ??= NoClip() and
// reuses it every frame), so we own the two GL buffers and delete them here.
// _glInitialized guards the case where UploadShared never ran (no buffers to
// delete, and _gl was never captured).
if (_glInitialized && _gl is not null)
if (_disposed || _disposing) return;
_disposing = true;
try
{
_gl.DeleteBuffer(_regionSsbo);
_gl.DeleteBuffer(_terrainUbo);
if (_disposeResources is null)
{
var releases = new List<(string Name, Action Release)>();
if (_gl is not null)
{
int regionIndex = 0;
foreach (RegionBuffer set in _regionBuffers.Values)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
set.Name,
set.CapacityBytes,
"ClipFrame region SSBO disposal");
releases.Add(($"region-ssbo-{regionIndex++}", release.Run));
}
int terrainIndex = 0;
foreach (TerrainBufferArena arena in _terrainBuffers.Values)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
arena.Name,
arena.CapacityBytes,
"ClipFrame terrain UBO disposal");
releases.Add(($"terrain-ubo-{terrainIndex++}", release.Run));
}
}
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more ClipFrame GPU buffers could not be released.");
}
_regionSsbo = 0;
_terrainUbo = 0;
_terrainBinding = default;
_dynamicFrameStarted = false;
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"ClipFrame GPU buffers released with exceptional committed outcomes.");
}
}
finally
{
_disposing = false;
}
}
@ -310,3 +577,230 @@ public sealed class ClipFrame : IDisposable
/// <summary>Test seam: the packed std140 terrain UBO bytes.</summary>
internal ReadOnlySpan<byte> TerrainBytesForTest => _terrainBytes;
}
/// <summary>A single std140 terrain-clip record within a frame-slot UBO arena.</summary>
public readonly record struct TerrainClipBufferBinding(
uint Buffer,
int OffsetBytes,
int SizeBytes)
{
public bool IsValid => Buffer != 0 && OffsetBytes >= 0 && SizeBytes > 0;
public void Bind(GL gl)
{
ArgumentNullException.ThrowIfNull(gl);
if (!IsValid)
throw new InvalidOperationException("Cannot bind an empty terrain clip range.");
gl.BindBufferRange(
BufferTargetARB.UniformBuffer,
ClipFrame.TerrainClipUboBinding,
Buffer,
(nint)OffsetBytes,
(nuint)SizeBytes);
GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain UBO range binding");
}
}
internal static class ClipFrameArenaLayout
{
public static int RecordStride(int uniformBufferOffsetAlignment)
{
ArgumentOutOfRangeException.ThrowIfLessThan(uniformBufferOffsetAlignment, 1);
long stride = ((long)ClipFrame.TerrainUboBytes + uniformBufferOffsetAlignment - 1L)
/ uniformBufferOffsetAlignment
* uniformBufferOffsetAlignment;
if (stride > int.MaxValue)
throw new OverflowException("Terrain clip UBO record stride exceeds Int32.MaxValue.");
return (int)stride;
}
public static int RecordOffset(int recordIndex, int recordStrideBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(recordIndex);
ArgumentOutOfRangeException.ThrowIfLessThan(recordStrideBytes, ClipFrame.TerrainUboBytes);
return checked(recordIndex * recordStrideBytes);
}
public static int RequiredBytes(int recordCount, int recordStrideBytes)
{
ArgumentOutOfRangeException.ThrowIfLessThan(recordCount, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(recordStrideBytes, ClipFrame.TerrainUboBytes);
return checked(recordCount * recordStrideBytes);
}
}
internal static class ClipBufferCapacityTransaction
{
/// <summary>Publishes capacity only after BufferData succeeds, and before
/// any later upload/bind operation can throw.</summary>
public static void Resize(
ref int publishedCapacityBytes,
int targetCapacityBytes,
Action<int, int> allocate)
{
ArgumentOutOfRangeException.ThrowIfNegative(publishedCapacityBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(targetCapacityBytes, 1);
ArgumentNullException.ThrowIfNull(allocate);
int previousCapacityBytes = publishedCapacityBytes;
allocate(previousCapacityBytes, targetCapacityBytes);
publishedCapacityBytes = targetCapacityBytes;
}
}
/// <summary>
/// Growth is immediate; shrinking requires repeated severe under-use. This
/// avoids reallocating during ordinary portal-count jitter while ensuring a
/// one-off pathological flood does not permanently pin its peak GPU storage.
/// </summary>
internal struct ClipBufferCapacityPolicy
{
internal const int ShrinkAfterUnderusedFrames = 3;
internal const int ShrinkUtilizationDivisor = 4;
private int _underusedFrames;
public int SelectCapacity(int currentBytes, int requiredBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(currentBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(requiredBytes, 1);
if (requiredBytes > currentBytes)
{
_underusedFrames = 0;
return DynamicBufferCapacity.Grow(currentBytes, requiredBytes);
}
if ((long)requiredBytes * ShrinkUtilizationDivisor <= currentBytes)
{
_underusedFrames++;
if (_underusedFrames >= ShrinkAfterUnderusedFrames)
{
_underusedFrames = 0;
return DynamicBufferCapacity.Grow(0, requiredBytes);
}
}
else
{
_underusedFrames = 0;
}
return currentBytes;
}
}
/// <summary>Fixed-cardinality resource ownership indexed by the same frame
/// slots protected by <see cref="GpuFrameFlightController"/>.</summary>
internal sealed class ClipFrameResourceRing<T>(int slotCount) where T : class
{
private readonly T?[] _slots = new T?[slotCount > 0
? slotCount
: throw new ArgumentOutOfRangeException(nameof(slotCount))];
public int Count { get; private set; }
public bool TryGet(int slot, out T? resource)
{
ValidateSlot(slot);
resource = _slots[slot];
return resource is not null;
}
public T GetRequired(int slot)
{
ValidateSlot(slot);
return _slots[slot]
?? throw new InvalidOperationException($"Frame slot {slot} has no resource.");
}
public void Set(int slot, T resource)
{
ValidateSlot(slot);
ArgumentNullException.ThrowIfNull(resource);
if (_slots[slot] is not null)
throw new InvalidOperationException($"Frame slot {slot} already owns a resource.");
_slots[slot] = resource;
Count++;
}
public IEnumerable<T> Values
{
get
{
for (int i = 0; i < _slots.Length; i++)
if (_slots[i] is T value)
yield return value;
}
}
private void ValidateSlot(int slot)
{
if ((uint)slot >= (uint)_slots.Length)
throw new ArgumentOutOfRangeException(nameof(slot));
}
}
/// <summary>Pure sequencing state for one ClipFrame render submission.</summary>
internal sealed class ClipFrameUploadState
{
private bool _frameStarted;
private bool _regionsUploaded;
private int _terrainReserved;
private int _terrainCursor;
public int TerrainReserved => _terrainReserved;
public int TerrainUploaded => _terrainCursor;
public bool RegionsUploaded => _regionsUploaded;
public void BeginFrame()
{
_frameStarted = true;
_regionsUploaded = false;
_terrainReserved = 0;
_terrainCursor = 0;
}
public void ValidateRegionsNotUploaded()
{
EnsureFrameStarted();
if (_regionsUploaded)
throw new InvalidOperationException("Clip regions may be uploaded only once per frame.");
}
public void MarkRegionsUploaded()
{
ValidateRegionsNotUploaded();
_regionsUploaded = true;
}
public void ValidateTerrainReservation(int uploadCount)
{
EnsureFrameStarted();
ArgumentOutOfRangeException.ThrowIfLessThan(uploadCount, 1);
if (_terrainReserved != 0)
throw new InvalidOperationException("Terrain uploads have already been reserved for this frame.");
}
public void CommitTerrainReservation(int uploadCount)
{
ValidateTerrainReservation(uploadCount);
_terrainReserved = uploadCount;
}
public int NextTerrainRecord()
{
EnsureFrameStarted();
if (_terrainReserved == 0)
throw new InvalidOperationException("ReserveTerrainUploads must run before terrain clip uploads.");
if (_terrainCursor >= _terrainReserved)
throw new InvalidOperationException("Terrain clip uploads exceeded the reserved arena record count.");
return _terrainCursor++;
}
private void EnsureFrameStarted()
{
if (!_frameStarted)
throw new InvalidOperationException("BeginFrame must be called before uploading clip data.");
}
}

View file

@ -45,47 +45,220 @@ public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[]
/// </summary>
public sealed class ClipFrameAssembly
{
public required ClipFrame Frame { get; init; }
public ClipFrame Frame { get; private set; } = null!;
/// <summary>First drawable slice slot per visible cell. Compatibility map
/// for renderer APIs that can accept only one slot at a time.</summary>
public required Dictionary<uint, int> CellIdToSlot { get; init; }
public Dictionary<uint, int> CellIdToSlot { get; } = new();
/// <summary>Slot-only cell slices, retained for older renderer APIs.</summary>
public required Dictionary<uint, int[]> CellIdToViewSlots { get; init; }
public Dictionary<uint, int[]> CellIdToViewSlots { get; } = new();
/// <summary>Full retail portal_view slices per visible cell.</summary>
public required Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; init; }
public Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; } = new();
/// <summary>Full retail outside_view slices.</summary>
public required ClipViewSlice[] OutsideViewSlices { get; init; }
public ClipViewSlice[] OutsideViewSlices { get; private set; } = System.Array.Empty<ClipViewSlice>();
public required int OutdoorSlot { get; init; }
public required bool OutdoorVisible { get; init; }
public required TerrainClipMode TerrainMode { get; init; }
public required Vector4 TerrainScissorNdcAabb { get; init; }
public required bool HasOutsideView { get; init; }
public required Vector4 OutsideViewNdcAabb { get; init; }
public int OutdoorSlot { get; internal set; }
public bool OutdoorVisible { get; internal set; }
public TerrainClipMode TerrainMode { get; internal set; }
public Vector4 TerrainScissorNdcAabb { get; internal set; }
public bool HasOutsideView { get; internal set; }
public Vector4 OutsideViewNdcAabb { get; internal set; }
// Probe data.
public required int OutsidePlaneCount { get; init; }
public required Dictionary<uint, int> PerCellPlaneCounts { get; init; }
public required int ScissorFallbacks { get; init; }
public int OutsidePlaneCount { get; internal set; }
public Dictionary<uint, int> PerCellPlaneCounts { get; } = new();
public int ScissorFallbacks { get; internal set; }
// The assembly is frame-scoped. An owner that passes it back to Assemble may reuse the
// dictionaries, per-cell arrays, and construction list after the prior frame is consumed.
// Exact-length pools keep the public array API honest: Length is always the live slice count.
private readonly Dictionary<int, Stack<ClipViewSlice[]>> _sliceArraysByLength = new();
private readonly Dictionary<int, Stack<int[]>> _slotArraysByLength = new();
internal List<ClipViewSlice> SliceScratch { get; } = new();
internal int SliceArrayAllocationCount { get; private set; }
internal int SlotArrayAllocationCount { get; private set; }
internal const int MaxRetainedSliceItems = 4096;
internal const int MaxRetainedSlotItems = 8192;
internal const int MaxRetainedArraysPerPool = 128;
internal int RetainedSliceItems { get; private set; }
internal int RetainedSlotItems { get; private set; }
internal int RetainedSliceArrays { get; private set; }
internal int RetainedSlotArrays { get; private set; }
internal void Reset(ClipFrame frame)
{
Frame = frame;
foreach (ClipViewSlice[] slices in CellIdToViewSlices.Values)
ReturnSlices(slices);
foreach (int[] slots in CellIdToViewSlots.Values)
ReturnSlots(slots);
if (OutsideViewSlices.Length != 0)
ReturnSlices(OutsideViewSlices);
CellIdToSlot.Clear();
CellIdToViewSlots.Clear();
CellIdToViewSlices.Clear();
PerCellPlaneCounts.Clear();
OutsideViewSlices = System.Array.Empty<ClipViewSlice>();
SliceScratch.Clear();
}
internal ClipViewSlice[] CopySlices(List<ClipViewSlice> source)
{
if (source.Count == 0)
return System.Array.Empty<ClipViewSlice>();
ClipViewSlice[] result = RentSlices(source.Count);
source.CopyTo(result, 0);
return result;
}
internal int[] CopySlots(ClipViewSlice[] slices)
{
if (slices.Length == 0)
return System.Array.Empty<int>();
int[] result = RentSlots(slices.Length);
for (int i = 0; i < slices.Length; i++)
result[i] = slices[i].Slot;
return result;
}
internal void SetOutsideViewSlices(ClipViewSlice[] slices) => OutsideViewSlices = slices;
private ClipViewSlice[] RentSlices(int length)
{
if (_sliceArraysByLength.TryGetValue(length, out Stack<ClipViewSlice[]>? pool)
&& pool.Count != 0)
{
ClipViewSlice[] result = pool.Pop();
RetainedSliceItems -= result.Length;
RetainedSliceArrays--;
if (pool.Count == 0)
_sliceArraysByLength.Remove(length);
return result;
}
SliceArrayAllocationCount++;
return new ClipViewSlice[length];
}
private int[] RentSlots(int length)
{
if (_slotArraysByLength.TryGetValue(length, out Stack<int[]>? pool)
&& pool.Count != 0)
{
int[] result = pool.Pop();
RetainedSlotItems -= result.Length;
RetainedSlotArrays--;
if (pool.Count == 0)
_slotArraysByLength.Remove(length);
return result;
}
SlotArrayAllocationCount++;
return new int[length];
}
private void ReturnSlices(ClipViewSlice[] array)
{
// ClipViewSlice contains a Vector4[] reference. Clear before either retaining or dropping
// the array so a bounded cache cannot accidentally extend plane-payload lifetimes.
System.Array.Clear(array);
if (array.Length > MaxRetainedSliceItems)
return;
while (RetainedSliceItems + array.Length > MaxRetainedSliceItems
|| RetainedSliceArrays >= MaxRetainedArraysPerPool)
{
if (!EvictOneSliceArray())
break;
}
if (!_sliceArraysByLength.TryGetValue(array.Length, out Stack<ClipViewSlice[]>? pool))
{
pool = new Stack<ClipViewSlice[]>();
_sliceArraysByLength.Add(array.Length, pool);
}
pool.Push(array);
RetainedSliceItems += array.Length;
RetainedSliceArrays++;
}
private void ReturnSlots(int[] array)
{
if (array.Length > MaxRetainedSlotItems)
return;
while (RetainedSlotItems + array.Length > MaxRetainedSlotItems
|| RetainedSlotArrays >= MaxRetainedArraysPerPool)
{
if (!EvictOneSlotArray())
break;
}
if (!_slotArraysByLength.TryGetValue(array.Length, out Stack<int[]>? pool))
{
pool = new Stack<int[]>();
_slotArraysByLength.Add(array.Length, pool);
}
pool.Push(array);
RetainedSlotItems += array.Length;
RetainedSlotArrays++;
}
private bool EvictOneSliceArray()
{
int selectedLength = -1;
foreach ((int length, Stack<ClipViewSlice[]> pool) in _sliceArraysByLength)
{
if (pool.Count != 0 && length > selectedLength)
selectedLength = length;
}
if (selectedLength < 0)
return false;
Stack<ClipViewSlice[]> selected = _sliceArraysByLength[selectedLength];
ClipViewSlice[] evicted = selected.Pop();
RetainedSliceItems -= evicted.Length;
RetainedSliceArrays--;
if (selected.Count == 0)
_sliceArraysByLength.Remove(selectedLength);
return true;
}
private bool EvictOneSlotArray()
{
int selectedLength = -1;
foreach ((int length, Stack<int[]> pool) in _slotArraysByLength)
{
if (pool.Count != 0 && length > selectedLength)
selectedLength = length;
}
if (selectedLength < 0)
return false;
Stack<int[]> selected = _slotArraysByLength[selectedLength];
int[] evicted = selected.Pop();
RetainedSlotItems -= evicted.Length;
RetainedSlotArrays--;
if (selected.Count == 0)
_slotArraysByLength.Remove(selectedLength);
return true;
}
}
public static class ClipFrameAssembler
{
public static ClipFrameAssembly Assemble(ClipFrame frame, PortalVisibilityFrame pvFrame)
public static ClipFrameAssembly Assemble(
ClipFrame frame,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly? reuseAssembly = null)
{
System.ArgumentNullException.ThrowIfNull(frame);
System.ArgumentNullException.ThrowIfNull(pvFrame);
frame.Reset();
ClipFrameAssembly assembly = reuseAssembly ?? new ClipFrameAssembly();
assembly.Reset(frame);
var cellIdToSlot = new Dictionary<uint, int>();
var cellIdToViewSlots = new Dictionary<uint, int[]>();
var cellIdToViewSlices = new Dictionary<uint, ClipViewSlice[]>();
var perCellPlaneCounts = new Dictionary<uint, int>();
Dictionary<uint, int> cellIdToSlot = assembly.CellIdToSlot;
Dictionary<uint, int[]> cellIdToViewSlots = assembly.CellIdToViewSlots;
Dictionary<uint, ClipViewSlice[]> cellIdToViewSlices = assembly.CellIdToViewSlices;
Dictionary<uint, int> perCellPlaneCounts = assembly.PerCellPlaneCounts;
int scissorFallbacks = 0;
foreach (uint cellId in pvFrame.OrderedVisibleCells)
@ -93,12 +266,13 @@ public static class ClipFrameAssembler
if (!pvFrame.CellViews.TryGetValue(cellId, out var view))
continue;
var slices = new List<ClipViewSlice>(view.Polygons.Count);
List<ClipViewSlice> slices = assembly.SliceScratch;
slices.Clear();
int maxPlaneCount = 0;
foreach (var poly in view.Polygons)
{
var cps = ClipPlaneSet.From(ViewOf(poly));
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
@ -106,7 +280,7 @@ public static class ClipFrameAssembler
Vector4[] planes;
if (cps.Count > 0)
{
planes = ToPlaneSpan(cps);
planes = cps.PlaneArray;
slot = frame.AppendSlot(planes);
if (cps.Count > maxPlaneCount)
maxPlaneCount = cps.Count;
@ -124,20 +298,21 @@ public static class ClipFrameAssembler
if (slices.Count == 0)
continue;
var sliceArray = slices.ToArray();
ClipViewSlice[] sliceArray = assembly.CopySlices(slices);
cellIdToViewSlices[cellId] = sliceArray;
cellIdToViewSlots[cellId] = ToSlots(sliceArray);
cellIdToViewSlots[cellId] = assembly.CopySlots(sliceArray);
cellIdToSlot[cellId] = sliceArray[0].Slot;
perCellPlaneCounts[cellId] = maxPlaneCount;
}
var outsideSlicesList = new List<ClipViewSlice>(pvFrame.OutsideView.Polygons.Count);
List<ClipViewSlice> outsideSlicesList = assembly.SliceScratch;
outsideSlicesList.Clear();
int outsideMaxPlaneCount = 0;
bool outsideHasScissorFallback = false;
foreach (var poly in pvFrame.OutsideView.Polygons)
{
var cps = ClipPlaneSet.From(ViewOf(poly));
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
@ -145,7 +320,7 @@ public static class ClipFrameAssembler
Vector4[] planes;
if (cps.Count > 0)
{
planes = ToPlaneSpan(cps);
planes = cps.PlaneArray;
slot = frame.AppendSlot(planes);
if (cps.Count > outsideMaxPlaneCount)
outsideMaxPlaneCount = cps.Count;
@ -161,7 +336,7 @@ public static class ClipFrameAssembler
outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
}
var outsideViewSlices = outsideSlicesList.ToArray();
ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList);
bool outdoorVisible = outsideViewSlices.Length > 0;
int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
TerrainClipMode terrainMode = !outdoorVisible
@ -176,49 +351,19 @@ public static class ClipFrameAssembler
? outsideViewNdcAabb
: Vector4.Zero;
return new ClipFrameAssembly
{
Frame = frame,
CellIdToSlot = cellIdToSlot,
CellIdToViewSlots = cellIdToViewSlots,
CellIdToViewSlices = cellIdToViewSlices,
OutsideViewSlices = outsideViewSlices,
OutdoorSlot = outdoorSlot,
OutdoorVisible = outdoorVisible,
TerrainMode = terrainMode,
TerrainScissorNdcAabb = terrainScissor,
HasOutsideView = outdoorVisible,
OutsideViewNdcAabb = outsideViewNdcAabb,
OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0,
PerCellPlaneCounts = perCellPlaneCounts,
ScissorFallbacks = scissorFallbacks,
};
}
private static CellView ViewOf(ViewPolygon poly)
{
var view = new CellView();
view.Add(poly);
return view;
assembly.SetOutsideViewSlices(outsideViewSlices);
assembly.OutdoorSlot = outdoorSlot;
assembly.OutdoorVisible = outdoorVisible;
assembly.TerrainMode = terrainMode;
assembly.TerrainScissorNdcAabb = terrainScissor;
assembly.HasOutsideView = outdoorVisible;
assembly.OutsideViewNdcAabb = outsideViewNdcAabb;
assembly.OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0;
assembly.ScissorFallbacks = scissorFallbacks;
return assembly;
}
private static Vector4 AabbOf(ViewPolygon poly) =>
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);
private static int[] ToSlots(ClipViewSlice[] slices)
{
var slots = new int[slices.Length];
for (int i = 0; i < slices.Length; i++)
slots[i] = slices[i].Slot;
return slots;
}
private static Vector4[] ToPlaneSpan(ClipPlaneSet set)
{
int n = set.Count;
var planes = new Vector4[n];
for (int i = 0; i < n; i++)
planes[i] = set.Planes[i];
return planes;
}
}

View file

@ -38,6 +38,7 @@
// pass-all" slot 0 is constructed by the consumer directly, not via From().)
// When Count > 0, Planes carries the convex gate and the scissor fields are unused.
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Numerics;
@ -84,6 +85,10 @@ public readonly struct ClipPlaneSet
/// A clip-space point <c>clip</c> is inside iff <c>Vector4.Dot(plane, clip) &gt;= 0</c> for every plane.</summary>
public IReadOnlyList<Vector4> Planes => _planes ?? (IReadOnlyList<Vector4>)Array.Empty<Vector4>();
// The set is immutable after construction. ClipFrameAssembler transfers this exact array into
// its frame-scoped slice instead of cloning every plane payload a second time.
internal Vector4[] PlaneArray => _planes ?? Array.Empty<Vector4>();
/// <summary>True ⇒ the convex-plane budget was exceeded; gate on <see cref="ScissorNdcAabb"/>
/// instead (draw the box). Always false when <see cref="Count"/> &gt; 0 or when the region is empty.</summary>
public bool UseScissorFallback { get; }
@ -119,34 +124,68 @@ public readonly struct ClipPlaneSet
if (region.Polygons.Count > 1)
return Scissor(region.MinX, region.MinY, region.MaxX, region.MaxY);
// Exactly one polygon: normalize winding to CCW, merge collinear edges, then decide.
Vector2[] verts = NormalizeAndMerge(region.Polygons[0].Vertices);
return From(region.Polygons[0]);
}
// Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no
// meaningful AABB to over-include (a zero-area region), so treat it as nothing visible.
if (verts.Length < 3)
/// <summary>
/// Reduce one convex view polygon without wrapping it in a temporary <see cref="CellView"/>.
/// The clip-frame assembler already iterates individual retail <c>view_poly</c> slices, so
/// constructing a CellView there needlessly ran the flood's canonical-key/dedup machinery for
/// every slice on every frame. This overload is behavior-identical to the one-polygon branch
/// of <see cref="From(CellView)"/> and avoids that unrelated allocation path.
/// </summary>
public static ClipPlaneSet From(in ViewPolygon polygon)
{
if (polygon.IsEmpty)
return Empty;
// A single convex polygon with too many edges to fit the hardware budget ⇒ scissor
// on ITS own AABB (still a superset of the polygon → over-include, safe).
if (verts.Length > MaxPlanes)
return Scissor(verts);
// Exactly one polygon: normalize winding to CCW and merge collinear edges in reusable
// scratch. The surviving vertices are consumed immediately to build the actual plane
// payload; there is no reason to allocate a second exact-sized polygon array per slice.
Vector2[] input = polygon.Vertices;
Vector2[]? rented = null;
Span<Vector2> verts = input.Length <= 32
? stackalloc Vector2[input.Length]
: (rented = ArrayPool<Vector2>.Shared.Rent(input.Length)).AsSpan(0, input.Length);
// 3..8 edges: emit one inward half-space plane per edge (CCW formula).
var planes = new Vector4[verts.Length];
for (int i = 0; i < verts.Length; i++)
try
{
Vector2 p = verts[i];
Vector2 q = verts[(i + 1) % verts.Length];
Vector2 dir = q - p;
// Inward normal for CCW winding: perp(dir) = (-dir.y, dir.x) points to the polygon's
// interior (the "left" side of the directed edge p→q).
Vector2 n = Vector2.Normalize(new Vector2(-dir.Y, dir.X));
// Plane: n·x + d >= 0 inside, with d = -(n·p). In clip space with NDC x = clip.x/clip.w:
// dist = n.x*clip.x + n.y*clip.y + 0*clip.z + (-(n·p))*clip.w (>= 0 ⇒ keep)
planes[i] = new Vector4(n.X, n.Y, 0f, -Vector2.Dot(n, p));
int count = NormalizeAndMerge(input, verts);
// Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no
// meaningful AABB to over-include (a zero-area region), so treat it as nothing visible.
if (count < 3)
return Empty;
ReadOnlySpan<Vector2> normalized = verts[..count];
// A single convex polygon with too many edges to fit the hardware budget ⇒ scissor
// on ITS own AABB (still a superset of the polygon → over-include, safe).
if (count > MaxPlanes)
return Scissor(normalized);
// 3..8 edges: emit one inward half-space plane per edge (CCW formula). This array is
// the retained GPU-routing payload and therefore the one necessary allocation.
var planes = new Vector4[count];
for (int i = 0; i < count; i++)
{
Vector2 p = normalized[i];
Vector2 q = normalized[(i + 1) % count];
Vector2 dir = q - p;
// Inward normal for CCW winding: perp(dir) = (-dir.y, dir.x) points to the polygon's
// interior (the "left" side of the directed edge p→q).
Vector2 n = Vector2.Normalize(new Vector2(-dir.Y, dir.X));
// Plane: n·x + d >= 0 inside, with d = -(n·p). In clip space with NDC x = clip.x/clip.w:
// dist = n.x*clip.x + n.y*clip.y + 0*clip.z + (-(n·p))*clip.w (>= 0 ⇒ keep)
planes[i] = new Vector4(n.X, n.Y, 0f, -Vector2.Dot(n, p));
}
return new ClipPlaneSet(planes, useScissorFallback: false, isNothingVisible: false, scissorNdcAabb: DegenerateAabb);
}
finally
{
if (rented is not null)
ArrayPool<Vector2>.Shared.Return(rented);
}
return new ClipPlaneSet(planes, useScissorFallback: false, isNothingVisible: false, scissorNdcAabb: DegenerateAabb);
}
private static ClipPlaneSet Scissor(float minX, float minY, float maxX, float maxY) =>
@ -171,41 +210,41 @@ public readonly struct ClipPlaneSet
/// already EnsureCcw's its output, but From() is a public entry point that must be robust to
/// either winding (e.g. a hand-built CellView), so we normalize here too.
/// </summary>
private static Vector2[] NormalizeAndMerge(Vector2[] input)
private static int NormalizeAndMerge(ReadOnlySpan<Vector2> input, Span<Vector2> points)
{
if (input is null || input.Length < 3)
return Array.Empty<Vector2>();
if (input.Length < 3)
return 0;
// 1) Drop exact/near-duplicate consecutive points first so edge directions are well-defined.
var pts = new List<Vector2>(input.Length);
foreach (var v in input)
int count = 0;
foreach (Vector2 vertex in input)
{
if (pts.Count == 0 || (v - pts[^1]).LengthSquared() > DegenerateEdgeLen * DegenerateEdgeLen)
pts.Add(v);
if (count == 0 || (vertex - points[count - 1]).LengthSquared() > DegenerateEdgeLen * DegenerateEdgeLen)
points[count++] = vertex;
}
// Wrap-around duplicate (last == first).
if (pts.Count >= 2 && (pts[^1] - pts[0]).LengthSquared() <= DegenerateEdgeLen * DegenerateEdgeLen)
pts.RemoveAt(pts.Count - 1);
if (pts.Count < 3)
return Array.Empty<Vector2>();
if (count >= 2 && (points[count - 1] - points[0]).LengthSquared() <= DegenerateEdgeLen * DegenerateEdgeLen)
count--;
if (count < 3)
return 0;
// 2) Force CCW winding (positive signed area). perp(dir)=(-y,x) is the inward normal only
// for CCW; if the caller handed us CW, reverse so the plane signs come out inside-positive.
if (SignedArea2(pts) < 0f)
pts.Reverse();
if (SignedArea2(points[..count]) < 0f)
points[..count].Reverse();
// 3) Merge collinear edges: drop vertex i when edge (i-1→i) and edge (i→i+1) point the same
// way (turn angle < ~0.5°). Iterate until stable — removing one vertex can expose a new
// collinear triple. |cross(a,b)| of unit dirs = |sin θ|; dot>0 rules out a 180° reversal.
bool changed = true;
while (changed && pts.Count >= 3)
while (changed && count >= 3)
{
changed = false;
for (int i = 0; i < pts.Count; i++)
for (int i = 0; i < count; i++)
{
Vector2 prev = pts[(i - 1 + pts.Count) % pts.Count];
Vector2 cur = pts[i];
Vector2 next = pts[(i + 1) % pts.Count];
Vector2 prev = points[(i - 1 + count) % count];
Vector2 cur = points[i];
Vector2 next = points[(i + 1) % count];
Vector2 d0 = cur - prev;
Vector2 d1 = next - cur;
@ -213,7 +252,8 @@ public readonly struct ClipPlaneSet
float l1 = d1.Length();
if (l0 < DegenerateEdgeLen || l1 < DegenerateEdgeLen)
{
pts.RemoveAt(i);
points[(i + 1)..count].CopyTo(points[i..]);
count--;
changed = true;
break;
}
@ -224,34 +264,35 @@ public readonly struct ClipPlaneSet
float dot = d0.X * d1.X + d0.Y * d1.Y; // cos θ
if (dot > 0f && MathF.Abs(cross) < CollinearSinEps)
{
pts.RemoveAt(i); // cur lies on the straight line prev→next
points[(i + 1)..count].CopyTo(points[i..]);
count--; // cur lies on the straight line prev→next
changed = true;
break;
}
}
}
if (pts.Count < 3)
return Array.Empty<Vector2>();
if (count < 3)
return 0;
// Final degeneracy gate: a polygon with negligible area is a line/point even if it still
// has >= 3 distinct vertices (e.g. an edge-on portal, or a near-collinear triple the 0.5°
// merge didn't quite collapse). Emitting its planes would yield an empty half-space
// intersection that silently gates out everything; report it honestly as nothing-visible.
if (MathF.Abs(SignedArea2(pts)) * 0.5f < MinPolygonArea)
return Array.Empty<Vector2>();
if (MathF.Abs(SignedArea2(points[..count])) * 0.5f < MinPolygonArea)
return 0;
return pts.ToArray();
return count;
}
// Twice the signed area (the "shoelace" sum). > 0 ⇒ CCW, < 0 ⇒ CW.
private static float SignedArea2(List<Vector2> poly)
private static float SignedArea2(ReadOnlySpan<Vector2> poly)
{
float a = 0f;
for (int i = 0; i < poly.Count; i++)
for (int i = 0; i < poly.Length; i++)
{
Vector2 p = poly[i];
Vector2 q = poly[(i + 1) % poly.Count];
Vector2 q = poly[(i + 1) % poly.Length];
a += p.X * q.Y - q.X * p.Y;
}
return a;

View file

@ -0,0 +1,933 @@
using AcDream.Core.Textures;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Location of one decoded entity-material composite in a resident bindless
/// texture array. The modern mesh shader consumes this exact pair.
/// </summary>
public readonly record struct BindlessTextureLocation(ulong Handle, uint Layer);
internal enum CompositeTextureKind : byte
{
OriginalTextureOverride,
PaletteComposite,
}
/// <summary>
/// Structural palette identity. The precomputed hash accelerates dictionary
/// bucketing, but equality still compares every server-supplied range so a
/// hash collision can never share the wrong material pixels.
/// </summary>
internal readonly struct PaletteCompositeIdentity : IEquatable<PaletteCompositeIdentity>
{
private readonly IReadOnlyList<PaletteOverride.SubPaletteRange>? _ranges;
public PaletteCompositeIdentity(PaletteOverride palette, ulong hash)
{
ArgumentNullException.ThrowIfNull(palette);
BasePaletteId = palette.BasePaletteId;
Hash = hash;
_ranges = palette.SubPalettes;
}
public uint BasePaletteId { get; }
public ulong Hash { get; }
public int RangeCount => _ranges?.Count ?? 0;
public bool Equals(PaletteCompositeIdentity other)
{
if (Hash != other.Hash
|| BasePaletteId != other.BasePaletteId
|| RangeCount != other.RangeCount)
{
return false;
}
for (int i = 0; i < RangeCount; i++)
if (_ranges![i] != other._ranges![i])
return false;
return true;
}
public override bool Equals(object? obj) =>
obj is PaletteCompositeIdentity other && Equals(other);
public override int GetHashCode() => HashCode.Combine(BasePaletteId, Hash, RangeCount);
public static bool operator ==(PaletteCompositeIdentity left, PaletteCompositeIdentity right) =>
left.Equals(right);
public static bool operator !=(PaletteCompositeIdentity left, PaletteCompositeIdentity right) =>
!left.Equals(right);
}
internal readonly record struct CompositeTextureKey(
CompositeTextureKind Kind,
uint SurfaceId,
uint OrigTextureOverride,
PaletteCompositeIdentity Palette);
internal sealed class CompositeTextureArrayResource
{
public required uint Name { get; init; }
public required ulong Handle { get; init; }
public required int Width { get; init; }
public required int Height { get; init; }
public required int Capacity { get; init; }
public required long Bytes { get; init; }
}
internal interface ICompositeTextureArrayBackend
{
int MaximumArrayLayers { get; }
CompositeTextureArrayResource Create(int width, int height, int capacity);
void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba);
void MakeNonResident(CompositeTextureArrayResource resource);
void Delete(CompositeTextureArrayResource resource);
}
/// <summary>
/// Narrow GL backend for composite arrays. Unlike ManagedGLTextureArray it
/// deliberately has one mip level, no PBO, and one resident handle: those are
/// the semantics of the standalone composite textures this pool replaces.
/// </summary>
internal sealed unsafe class GlCompositeTextureArrayBackend : ICompositeTextureArrayBackend
{
private readonly GL _gl;
private readonly Wb.BindlessSupport _bindless;
public GlCompositeTextureArrayBackend(GL gl, Wb.BindlessSupport bindless)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
_gl.GetInteger(GetPName.MaxArrayTextureLayers, out int maximumLayers);
MaximumArrayLayers = Math.Max(1, maximumLayers);
}
public int MaximumArrayLayers { get; }
public CompositeTextureArrayResource Create(int width, int height, int capacity)
{
uint name = _gl.GenTexture();
if (name == 0)
throw new InvalidOperationException("OpenGL did not create a composite texture array.");
bool resident = false;
ulong handle = 0;
long bytes = 0;
bool tracked = false;
try
{
// Composite creation/upload runs in the render thread's pre-draw
// preparation phase. Normalize that phase to texture unit zero
// instead of synchronously reading driver binding state.
_gl.ActiveTexture(TextureUnit.Texture0);
Wb.RenderStateCache.CurrentAtlas = 0;
_gl.BindTexture(TextureTarget.Texture2DArray, name);
_gl.TexStorage3D(
TextureTarget.Texture2DArray,
levels: 1,
SizedInternalFormat.Rgba8,
checked((uint)width),
checked((uint)height),
checked((uint)capacity));
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureBaseLevel, 0);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMaxLevel, 0);
_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.Repeat);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
handle = _bindless.GetResidentHandle(name);
resident = true;
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"creating composite texture array {width}x{height}x{capacity}");
bytes = checked((long)width * height * 4L * capacity);
Wb.GpuMemoryTracker.TrackResourceAllocation(Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackAllocation(bytes, Wb.GpuResourceType.Texture);
tracked = true;
return new CompositeTextureArrayResource
{
Name = name,
Handle = handle,
Width = width,
Height = height,
Capacity = capacity,
Bytes = bytes,
};
}
catch (Exception creationFailure)
{
List<Exception>? cleanupFailures = null;
void Attempt(Action cleanup)
{
try { cleanup(); }
catch (Exception ex) { (cleanupFailures ??= []).Add(ex); }
}
bool residencyReleased = !resident;
if (resident)
{
Attempt(() =>
{
_bindless.MakeNonResident(handle);
Wb.GLHelpers.ThrowOnResourceError(_gl, "rolling back composite texture residency");
residencyReleased = true;
});
}
if (residencyReleased)
{
Attempt(() =>
{
_gl.DeleteTexture(name);
Wb.GLHelpers.ThrowOnResourceError(_gl, "rolling back composite texture array");
if (tracked)
{
Wb.GpuMemoryTracker.TrackDeallocation(bytes, Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture);
}
});
}
if (cleanupFailures is not null)
{
cleanupFailures.Insert(0, creationFailure);
throw new AggregateException(
"Composite texture-array construction and rollback both failed.",
cleanupFailures);
}
throw;
}
finally
{
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
_gl.ActiveTexture(TextureUnit.Texture0);
}
}
public void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba)
{
_gl.ActiveTexture(TextureUnit.Texture0);
Wb.RenderStateCache.CurrentAtlas = 0;
try
{
_gl.BindBuffer(BufferTargetARB.PixelUnpackBuffer, 0);
_gl.BindTexture(TextureTarget.Texture2DArray, resource.Name);
fixed (byte* pixels = rgba)
{
_gl.TexSubImage3D(
TextureTarget.Texture2DArray,
level: 0,
xoffset: 0,
yoffset: 0,
zoffset: layer,
checked((uint)resource.Width),
checked((uint)resource.Height),
depth: 1,
PixelFormat.Rgba,
PixelType.UnsignedByte,
pixels);
}
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"uploading composite texture layer {layer} ({resource.Width}x{resource.Height})");
}
finally
{
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
_gl.BindBuffer(BufferTargetARB.PixelUnpackBuffer, 0);
_gl.ActiveTexture(TextureUnit.Texture0);
}
}
public void MakeNonResident(CompositeTextureArrayResource resource)
{
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"releasing composite texture handle {resource.Handle} (precondition)");
_bindless.MakeNonResident(resource.Handle);
Wb.GLHelpers.ThrowOnResourceError(_gl, $"releasing composite texture handle {resource.Handle}");
}
public void Delete(CompositeTextureArrayResource resource)
{
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"deleting composite texture array {resource.Name} (precondition)");
_gl.DeleteTexture(resource.Name);
Wb.GLHelpers.ThrowOnResourceError(_gl, $"deleting composite texture array {resource.Name}");
Wb.GpuMemoryTracker.TrackDeallocation(resource.Bytes, Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture);
}
}
/// <summary>
/// Pools per-entity material composites into dimension-compatible texture
/// arrays. Retail releases the owning CSurface reference immediately. This
/// modern adaptation preserves that logical boundary while retaining recent
/// same-pixel layers under a bounded LRU; layer reuse waits for a GPU fence.
/// </summary>
internal sealed class CompositeTextureArrayCache : IDisposable
{
internal const long DefaultUnownedBudgetBytes = 64L * 1024 * 1024;
internal const long DefaultPhysicalBudgetBytes = 128L * 1024 * 1024;
internal const long TargetArrayBytes = 4L * 1024 * 1024;
internal const int MaximumLayersPerArray = 64;
internal const int DefaultMaximumUploadsPerFrame = 16;
internal const long DefaultMaximumUploadBytesPerFrame = 8L * 1024 * 1024;
internal const int MaximumLogicalEvictionsPerFrame = 16;
internal const int MaximumAtlasCreationsPerFrame = 1;
private readonly ICompositeTextureArrayBackend _backend;
private readonly GpuRetirementLedger _retirementLedger;
private readonly OwnerScopedResourceRegistry<CompositeTextureKey> _owners = new();
private readonly BoundedUnownedResourceCache<CompositeTextureKey> _unowned;
private readonly long _physicalBudgetBytes;
private readonly int _maximumArrayLayers;
private readonly int _maximumUploadsPerFrame;
private readonly long _maximumUploadBytesPerFrame;
private readonly Dictionary<CompositeTextureKey, Entry> _entries = new();
private readonly Dictionary<(int Width, int Height), List<Atlas>> _atlasesBySize = new();
private readonly List<Atlas> _atlases = new();
private long _allocatedBytes;
private long _useSequence;
private int _frameUploadCount;
private long _frameUploadBytes;
private int _frameAtlasCreationCount;
private bool _uploadBudgetBlocked;
private int _pendingAtlasWidth;
private int _pendingAtlasHeight;
private long _pendingAtlasAllocationBytes;
private readonly List<CompositeTextureKey> _evictionScratch = new(MaximumLogicalEvictionsPerFrame);
private bool _disposeRequested;
private bool _disposed;
private sealed class Entry
{
public required Atlas Atlas { get; init; }
public required int Layer { get; init; }
public required long Bytes { get; init; }
}
private sealed class Atlas
{
public required CompositeTextureArrayResource Resource { get; init; }
public required Wb.TextureAtlasSlotAllocator Slots { get; init; }
public int EntryCount { get; set; }
public int PendingRetirements { get; set; }
public long LastUseSequence { get; set; }
public bool ReleaseRequested { get; set; }
public AtlasReleaseStage ReleaseStage { get; set; }
public int AvailableLayers => Slots.AvailableCount;
public bool IsGpuSafeEmpty => EntryCount == 0 && PendingRetirements == 0;
public bool IsReusable => !ReleaseRequested && ReleaseStage == AtlasReleaseStage.Resident;
public bool Deleted => ReleaseStage >= AtlasReleaseStage.Deleted;
}
private enum AtlasReleaseStage : byte
{
Resident,
NonResident,
Deleted,
Accounted,
}
public CompositeTextureArrayCache(
GL gl,
Wb.BindlessSupport bindless,
IGpuResourceRetirementQueue retirementQueue,
long unownedBudgetBytes = DefaultUnownedBudgetBytes,
long physicalBudgetBytes = DefaultPhysicalBudgetBytes,
int maximumUploadsPerFrame = DefaultMaximumUploadsPerFrame,
long maximumUploadBytesPerFrame = DefaultMaximumUploadBytesPerFrame)
: this(
new GlCompositeTextureArrayBackend(gl, bindless),
retirementQueue,
unownedBudgetBytes,
physicalBudgetBytes,
maximumUploadsPerFrame,
maximumUploadBytesPerFrame)
{
}
internal CompositeTextureArrayCache(
ICompositeTextureArrayBackend backend,
IGpuResourceRetirementQueue retirementQueue,
long unownedBudgetBytes = DefaultUnownedBudgetBytes,
long physicalBudgetBytes = DefaultPhysicalBudgetBytes,
int maximumUploadsPerFrame = DefaultMaximumUploadsPerFrame,
long maximumUploadBytesPerFrame = DefaultMaximumUploadBytesPerFrame)
{
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
ArgumentNullException.ThrowIfNull(retirementQueue);
_retirementLedger = new GpuRetirementLedger(retirementQueue);
ArgumentOutOfRangeException.ThrowIfNegative(physicalBudgetBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumUploadsPerFrame, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumUploadBytesPerFrame, 1);
_unowned = new BoundedUnownedResourceCache<CompositeTextureKey>(unownedBudgetBytes);
_physicalBudgetBytes = physicalBudgetBytes;
_maximumUploadsPerFrame = maximumUploadsPerFrame;
_maximumUploadBytesPerFrame = maximumUploadBytesPerFrame;
_maximumArrayLayers = Math.Max(
1,
Math.Min(backend.MaximumArrayLayers, MaximumLayersPerArray));
}
internal int ActiveResourceCount => _owners.ResourceCount;
internal int OwnerCount => _owners.OwnerCount;
internal int CachedEntryCount => _entries.Count;
internal int UnownedEntryCount => _unowned.Count;
internal long UnownedBytes => _unowned.ResidentBytes;
internal int AtlasCount => _atlases.Count;
internal long AllocatedBytes => _allocatedBytes;
internal int FrameUploadCount => _frameUploadCount;
internal long FrameUploadBytes => _frameUploadBytes;
internal bool CanStartUpload =>
!_uploadBudgetBlocked
&& _frameUploadCount < _maximumUploadsPerFrame
&& (_frameUploadCount == 0 || _frameUploadBytes < _maximumUploadBytesPerFrame);
internal bool CanUpload(long bytes)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bytes);
if (_frameUploadCount >= _maximumUploadsPerFrame)
return false;
// Always allow one item so a texture larger than the normal frame
// budget cannot permanently stall portal readiness. Every later item
// must fit completely inside the advertised byte budget.
return _frameUploadCount == 0
|| bytes <= _maximumUploadBytesPerFrame - _frameUploadBytes;
}
internal bool CanPrepareUpload(int width, int height)
{
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
long layerBytes = checked((long)width * height * 4L);
if (!CanUpload(layerBytes))
{
_uploadBudgetBlocked = true;
return false;
}
if (_atlasesBySize.TryGetValue((width, height), out List<Atlas>? compatible))
{
for (int i = 0; i < compatible.Count; i++)
if (compatible[i].IsReusable && compatible[i].AvailableLayers != 0)
return true;
}
int capacity = CalculateLayerCapacity(width, height, _maximumArrayLayers);
long requestedBytes = checked(layerBytes * capacity);
if (_frameAtlasCreationCount < MaximumAtlasCreationsPerFrame
&& CanAllocateAtlas(requestedBytes))
return true;
SetPendingAllocation(width, height, requestedBytes);
_uploadBudgetBlocked = true;
return false;
}
public void BeginFrame()
{
ThrowIfUnavailable();
_retirementLedger.RetryPendingPublications();
_frameUploadCount = 0;
_frameUploadBytes = 0;
_frameAtlasCreationCount = 0;
_uploadBudgetBlocked = false;
}
public bool TryAcquire(
uint ownerLocalId,
CompositeTextureKey key,
out BindlessTextureLocation location)
{
ThrowIfUnavailable();
if (!_entries.TryGetValue(key, out Entry? entry))
{
location = default;
return false;
}
_owners.Acquire(ownerLocalId, key);
_unowned.MarkOwned(key);
entry.Atlas.LastUseSequence = ++_useSequence;
location = new BindlessTextureLocation(
entry.Atlas.Resource.Handle,
checked((uint)entry.Layer));
return true;
}
public bool TryAddAndAcquire(
uint ownerLocalId,
CompositeTextureKey key,
DecodedTexture decoded,
out BindlessTextureLocation location)
{
ThrowIfUnavailable();
if (TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing))
{
location = existing;
return true;
}
ValidateDecodedTexture(decoded);
long bytes = checked((long)decoded.Width * decoded.Height * 4L);
if (!CanUpload(bytes))
{
// Dimensions are only known after DAT decode. Once one candidate
// does not fit, stop all later decodes this frame rather than
// repeatedly allocating RGBA buffers that cannot be uploaded.
_uploadBudgetBlocked = true;
location = default;
return false;
}
if (!TryFindOrCreateAtlas(decoded.Width, decoded.Height, out Atlas atlas))
{
// As with a byte-budget rejection, stop subsequent DAT decodes in
// this frame. Tick advances compatible reclamation before the next
// frame retries the same logical composite.
_uploadBudgetBlocked = true;
location = default;
return false;
}
int layer = atlas.Slots.Rent();
try
{
_backend.Upload(atlas.Resource, layer, decoded.Rgba8);
}
catch (Exception uploadFailure)
{
atlas.Slots.Return(layer);
if (atlas.IsGpuSafeEmpty)
{
try { DeleteAtlas(atlas); }
catch (Exception releaseFailure)
{
throw new AggregateException(
"Composite upload and empty-atlas rollback both failed.",
uploadFailure,
releaseFailure);
}
}
throw;
}
var entry = new Entry { Atlas = atlas, Layer = layer, Bytes = bytes };
_entries.Add(key, entry);
atlas.EntryCount++;
atlas.LastUseSequence = ++_useSequence;
_owners.Acquire(ownerLocalId, key);
_frameUploadCount++;
_frameUploadBytes = checked(_frameUploadBytes + bytes);
location = new BindlessTextureLocation(atlas.Resource.Handle, checked((uint)layer));
return true;
}
public void ReleaseOwner(uint ownerLocalId)
{
ThrowIfUnavailable();
IReadOnlyList<CompositeTextureKey> unowned = _owners.ReleaseOwner(ownerLocalId);
for (int i = 0; i < unowned.Count; i++)
{
CompositeTextureKey key = unowned[i];
if (_entries.TryGetValue(key, out Entry? entry))
_unowned.MarkUnowned(key, entry.Bytes);
}
}
/// <summary>
/// Logical layer eviction is a bounded CPU-only batch; its fenced callback
/// merely returns integer slots. At most one already-empty GL array becomes
/// non-resident and is deleted per frame, so a portal unload cannot become
/// one large driver destruction batch.
/// </summary>
public void Tick()
{
ThrowIfUnavailable();
_retirementLedger.RetryPendingPublications();
bool allocationPressure = HasPendingAllocationPressure();
bool physicalOverBudget = _allocatedBytes > _physicalBudgetBytes;
bool needsPhysicalRelief = allocationPressure || physicalOverBudget;
// Finish an already-started release before selecting another array.
// This keeps driver-visible destruction bounded to one array per tick
// even when a prior non-resident/delete stage had to be retried.
bool servicedPendingRelease = CompleteOnePendingAtlasRelease();
// A fence may have made an array safe since the prior frame. Free one
// first; this is the only driver-visible destruction operation here.
if (needsPhysicalRelief && !servicedPendingRelease)
DeleteOneGpuSafeEmptyAtlas(
_pendingAtlasAllocationBytes == 0 ? null : (_pendingAtlasWidth, _pendingAtlasHeight));
allocationPressure = HasPendingAllocationPressure();
physicalOverBudget = _allocatedBytes > _physicalBudgetBytes;
needsPhysicalRelief = allocationPressure || physicalOverBudget;
int evicted = 0;
if (needsPhysicalRelief && _pendingAtlasAllocationBytes != 0)
{
evicted += EvictCompatibleUnowned(
_pendingAtlasWidth,
_pendingAtlasHeight,
MaximumLogicalEvictionsPerFrame);
}
while (evicted < MaximumLogicalEvictionsPerFrame)
{
bool take = needsPhysicalRelief
? _unowned.TryTakeOldest(out CompositeTextureKey key)
: _unowned.TryTakeOldestOverBudget(out key);
if (!take)
break;
EvictEntry(key);
evicted++;
}
// Allocation pressure is a one-frame demand signal. The requesting
// entity will set it again later this frame if it is still relevant;
// stale portal destinations must not keep evicting unrelated storage.
_pendingAtlasWidth = 0;
_pendingAtlasHeight = 0;
_pendingAtlasAllocationBytes = 0;
}
internal void VisitEntries(Action<uint, int, int> visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
foreach ((CompositeTextureKey key, Entry entry) in _entries)
visitor(key.SurfaceId, entry.Atlas.Resource.Width, entry.Atlas.Resource.Height);
}
internal static int CalculateLayerCapacity(int width, int height, int driverMaximumLayers)
{
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(driverMaximumLayers, 1);
long layerBytes = checked((long)width * height * 4L);
long targetLayers = Math.Max(1L, TargetArrayBytes / layerBytes);
return checked((int)Math.Min(
targetLayers,
Math.Min(driverMaximumLayers, MaximumLayersPerArray)));
}
private bool TryFindOrCreateAtlas(int width, int height, out Atlas atlas)
{
var size = (width, height);
if (_atlasesBySize.TryGetValue(size, out List<Atlas>? compatible))
{
for (int i = 0; i < compatible.Count; i++)
{
Atlas candidate = compatible[i];
if (candidate.IsReusable && candidate.AvailableLayers != 0)
{
ClearPendingAllocation(width, height);
atlas = candidate;
return true;
}
}
}
int capacity = CalculateLayerCapacity(width, height, _maximumArrayLayers);
long requestedBytes = checked((long)width * height * 4L * capacity);
if (_frameAtlasCreationCount >= MaximumAtlasCreationsPerFrame
|| !CanAllocateAtlas(requestedBytes))
{
SetPendingAllocation(width, height, requestedBytes);
atlas = null!;
return false;
}
CompositeTextureArrayResource resource = _backend.Create(width, height, capacity);
atlas = new Atlas
{
Resource = resource,
Slots = new Wb.TextureAtlasSlotAllocator(capacity),
};
if (compatible is null)
{
compatible = new List<Atlas>();
_atlasesBySize.Add(size, compatible);
}
compatible.Add(atlas);
_atlases.Add(atlas);
_allocatedBytes = checked(_allocatedBytes + resource.Bytes);
_frameAtlasCreationCount++;
ClearPendingAllocation(width, height);
return true;
}
private bool CanAllocateAtlas(long requestedBytes)
{
if (FitsWithinPhysicalBudget(requestedBytes))
return true;
// The budget bounds reusable/cache storage, not required live scene
// content. Wait while stale or retiring storage can make room; if the
// entire resident set is live, permit one new atlas this frame so an
// unusually large destination cannot deadlock portal readiness.
return !HasReclaimableStorage();
}
private bool FitsWithinPhysicalBudget(long requestedBytes)
{
if (requestedBytes > _physicalBudgetBytes)
return _allocatedBytes == 0;
return _allocatedBytes <= _physicalBudgetBytes - requestedBytes;
}
private bool HasPendingAllocationPressure() =>
_pendingAtlasAllocationBytes != 0
&& !FitsWithinPhysicalBudget(_pendingAtlasAllocationBytes);
private bool HasReclaimableStorage()
{
if (_unowned.Count != 0)
return true;
for (int i = 0; i < _atlases.Count; i++)
{
Atlas candidate = _atlases[i];
if (!candidate.Deleted
&& (candidate.ReleaseRequested
|| candidate.PendingRetirements != 0
|| candidate.IsGpuSafeEmpty))
{
return true;
}
}
return false;
}
private void SetPendingAllocation(int width, int height, long bytes)
{
_pendingAtlasWidth = width;
_pendingAtlasHeight = height;
_pendingAtlasAllocationBytes = bytes;
}
private void ClearPendingAllocation(int width, int height)
{
if (_pendingAtlasWidth != width || _pendingAtlasHeight != height)
return;
_pendingAtlasWidth = 0;
_pendingAtlasHeight = 0;
_pendingAtlasAllocationBytes = 0;
}
private int EvictCompatibleUnowned(int width, int height, int maximum)
{
_evictionScratch.Clear();
foreach ((CompositeTextureKey key, Entry entry) in _entries)
{
if (_evictionScratch.Count == maximum)
break;
if (entry.Atlas.Resource.Width == width
&& entry.Atlas.Resource.Height == height
&& _unowned.Contains(key))
{
_evictionScratch.Add(key);
}
}
int evicted = 0;
for (int i = 0; i < _evictionScratch.Count; i++)
{
CompositeTextureKey key = _evictionScratch[i];
if (!_unowned.TryTake(key))
continue;
EvictEntry(key);
evicted++;
}
return evicted;
}
private void EvictEntry(CompositeTextureKey key)
{
if (!_entries.Remove(key, out Entry? entry))
return;
Atlas atlas = entry.Atlas;
atlas.EntryCount--;
atlas.PendingRetirements++;
int layer = entry.Layer;
_retirementLedger.Retire(new RetryableGpuResourceRelease(
() => atlas.Slots.Return(layer),
() => atlas.PendingRetirements--));
}
private bool CompleteOnePendingAtlasRelease()
{
for (int i = 0; i < _atlases.Count; i++)
{
Atlas atlas = _atlases[i];
if (!atlas.ReleaseRequested)
continue;
DeleteAtlas(atlas);
return true;
}
return false;
}
private void DeleteOneGpuSafeEmptyAtlas((int Width, int Height)? preserveSize = null)
{
Atlas? oldest = null;
for (int i = 0; i < _atlases.Count; i++)
{
Atlas candidate = _atlases[i];
if (preserveSize is { } preserve
&& candidate.Resource.Width == preserve.Width
&& candidate.Resource.Height == preserve.Height)
{
continue;
}
if (candidate.IsReusable
&& candidate.IsGpuSafeEmpty
&& (oldest is null || candidate.LastUseSequence < oldest.LastUseSequence))
{
oldest = candidate;
}
}
if (oldest is not null)
DeleteAtlas(oldest);
}
private void DeleteAtlas(Atlas atlas, bool requireGpuSafeEmpty = true)
{
if (atlas.ReleaseStage == AtlasReleaseStage.Accounted)
return;
if (requireGpuSafeEmpty && !atlas.IsGpuSafeEmpty)
throw new InvalidOperationException("Cannot delete a composite array while a layer is live or retiring.");
atlas.ReleaseRequested = true;
if (atlas.ReleaseStage == AtlasReleaseStage.Resident)
{
try
{
_backend.MakeNonResident(atlas.Resource);
atlas.ReleaseStage = AtlasReleaseStage.NonResident;
RemoveFromReusableAtlasIndex(atlas);
}
catch (GpuResourceMutationException error) when (error.MutationCommitted)
{
atlas.ReleaseStage = AtlasReleaseStage.NonResident;
RemoveFromReusableAtlasIndex(atlas);
throw;
}
}
if (atlas.ReleaseStage == AtlasReleaseStage.NonResident)
{
try
{
_backend.Delete(atlas.Resource);
atlas.ReleaseStage = AtlasReleaseStage.Deleted;
}
catch (GpuResourceMutationException error) when (error.MutationCommitted)
{
atlas.ReleaseStage = AtlasReleaseStage.Deleted;
throw;
}
}
if (atlas.ReleaseStage == AtlasReleaseStage.Deleted)
{
_allocatedBytes = checked(_allocatedBytes - atlas.Resource.Bytes);
_atlases.Remove(atlas);
atlas.ReleaseStage = AtlasReleaseStage.Accounted;
}
}
private void RevokeAtlasResidencyForDispose(Atlas atlas)
{
atlas.ReleaseRequested = true;
if (atlas.ReleaseStage != AtlasReleaseStage.Resident)
return;
try
{
_backend.MakeNonResident(atlas.Resource);
atlas.ReleaseStage = AtlasReleaseStage.NonResident;
RemoveFromReusableAtlasIndex(atlas);
}
catch (GpuResourceMutationException error) when (error.MutationCommitted)
{
atlas.ReleaseStage = AtlasReleaseStage.NonResident;
RemoveFromReusableAtlasIndex(atlas);
throw;
}
}
private void RemoveFromReusableAtlasIndex(Atlas atlas)
{
var size = (atlas.Resource.Width, atlas.Resource.Height);
if (!_atlasesBySize.TryGetValue(size, out List<Atlas>? compatible))
return;
compatible.Remove(atlas);
if (compatible.Count == 0)
_atlasesBySize.Remove(size);
}
private static void ValidateDecodedTexture(DecodedTexture decoded)
{
ArgumentOutOfRangeException.ThrowIfLessThan(decoded.Width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(decoded.Height, 1);
long expected = checked((long)decoded.Width * decoded.Height * 4L);
if (decoded.Rgba8.LongLength != expected)
throw new ArgumentException(
$"Decoded RGBA texture has {decoded.Rgba8.LongLength} bytes; expected {expected}.",
nameof(decoded));
}
public void Dispose()
{
if (_disposed)
return;
_disposeRequested = true;
_retirementLedger.RetryPendingPublications();
// GameWindow drains frame-flight fences before TextureCache teardown.
// Release every handle first, then delete any backing array. A failed
// stage leaves its exact atlas/stage reachable for a later Dispose.
List<Exception>? failures = null;
for (int i = 0; i < _atlases.Count; i++)
{
Atlas atlas = _atlases[i];
try { RevokeAtlasResidencyForDispose(atlas); }
catch (Exception ex) { (failures ??= []).Add(ex); }
}
if (failures is not null)
throw new AggregateException("One or more composite-array residency releases failed.", failures);
// DeleteAtlas removes completed entries, so walk a stable snapshot.
Atlas[] atlases = _atlases.ToArray();
for (int i = 0; i < atlases.Length; i++)
{
try { DeleteAtlas(atlases[i], requireGpuSafeEmpty: false); }
catch (Exception ex) { (failures ??= []).Add(ex); }
}
if (failures is not null)
throw new AggregateException("One or more composite-array deletions failed.", failures);
_entries.Clear();
_owners.Clear();
_unowned.Clear();
_atlasesBySize.Clear();
_atlases.Clear();
_allocatedBytes = 0;
_pendingAtlasAllocationBytes = 0;
_disposed = true;
}
private void ThrowIfUnavailable() =>
ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
}

View file

@ -0,0 +1,30 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Capacity policy for render-thread-owned streaming GPU buffers. Dynamic
/// buffers keep one backing allocation and update its active prefix; they do
/// not orphan a new driver allocation on every draw call.
/// </summary>
internal static class DynamicBufferCapacity
{
internal const int DefaultAlignment = 4096;
public static int Grow(int currentBytes, int requiredBytes, int alignment = DefaultAlignment)
{
if (currentBytes < 0)
throw new ArgumentOutOfRangeException(nameof(currentBytes));
if (requiredBytes < 0)
throw new ArgumentOutOfRangeException(nameof(requiredBytes));
if (alignment <= 0)
throw new ArgumentOutOfRangeException(nameof(alignment));
if (requiredBytes <= currentBytes)
return currentBytes;
long doubled = currentBytes == 0 ? alignment : (long)currentBytes * 2L;
long target = Math.Max(requiredBytes, doubled);
long aligned = ((target + alignment - 1L) / alignment) * alignment;
if (aligned > int.MaxValue)
throw new OverflowException("Dynamic GPU buffer capacity exceeds Int32.MaxValue.");
return (int)aligned;
}
}

View file

@ -8,6 +8,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
@ -23,7 +24,7 @@ namespace AcDream.App.Rendering;
/// </summary>
public sealed class EquippedChildRenderController : IDisposable
{
private readonly DatCollection _dats;
private readonly IDatReaderWriter _dats;
private readonly object _datLock;
private readonly ClientObjectTable _objects;
private readonly LiveEntityRuntime _liveEntities;
@ -53,7 +54,7 @@ public sealed class EquippedChildRenderController : IDisposable
}
public EquippedChildRenderController(
DatCollection dats,
IDatReaderWriter dats,
object datLock,
ClientObjectTable objects,
LiveEntityRuntime liveEntities,

View file

@ -0,0 +1,31 @@
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering;
/// <summary>
/// Balances one synthetic render owner's texture lifetime across replacement.
/// The next draw reacquires only the replacement entity's current materials.
/// </summary>
internal sealed class FixedEntityTextureOwnerLease : IDisposable
{
private readonly IEntityTextureLifetime _lifetime;
private readonly uint _ownerLocalId;
private bool _active;
public FixedEntityTextureOwnerLease(IEntityTextureLifetime lifetime, uint ownerLocalId)
{
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
if (ownerLocalId == 0)
throw new ArgumentOutOfRangeException(nameof(ownerLocalId));
_ownerLocalId = ownerLocalId;
}
public void Replace(bool hasReplacement)
{
if (_active)
_lifetime.ReleaseOwner(_ownerLocalId);
_active = hasReplacement;
}
public void Dispose() => Replace(hasReplacement: false);
}

View file

@ -0,0 +1,155 @@
using System.Diagnostics;
namespace AcDream.App.Rendering;
/// <summary>
/// Deadline-based software frame pacer used only when the user disables
/// VSync. VSync-on presentation waits in the driver's buffer swap instead.
/// </summary>
/// <remarks>
/// Silk.NET's <c>FramesPerSecond</c> gate still runs its outer loop as a busy
/// poll. This owner performs a real thread wait, bounding both render work and
/// CPU use. A missed deadline is rebased from the current time so one slow
/// frame cannot trigger a burst of catch-up frames.
/// </remarks>
internal sealed class FramePacingController
{
private readonly IFramePacingClock _clock;
private readonly IFramePacingWaiter _waiter;
private FramePacingPolicy _policy;
private long _periodTicks;
private long _nextDeadline;
private bool _hasDeadline;
public FramePacingController()
: this(StopwatchFramePacingClock.Instance, ThreadFramePacingWaiter.Instance)
{
}
internal FramePacingController(
IFramePacingClock clock,
IFramePacingWaiter waiter)
{
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_waiter = waiter ?? throw new ArgumentNullException(nameof(waiter));
if (_clock.Frequency <= 0)
throw new ArgumentOutOfRangeException(nameof(clock), "Clock frequency must be positive.");
}
internal FramePacingPolicy Policy => _policy;
public void Apply(FramePacingPolicy policy)
{
if (_policy == policy)
return;
_policy = policy;
if (policy.UseVSync
|| policy.SoftwareLimitHz is not { } limitHz
|| !double.IsFinite(limitHz)
|| limitHz <= 0d)
{
_periodTicks = 0;
_nextDeadline = 0;
_hasDeadline = false;
return;
}
_periodTicks = Math.Max(
1L,
checked((long)Math.Round(_clock.Frequency / limitHz)));
_nextDeadline = AddSaturating(_clock.GetTimestamp(), _periodTicks);
_hasDeadline = true;
}
/// <summary>
/// Wait until the current frame's presentation deadline. GameWindow wires
/// this after its render callback and before Silk.NET's automatic swap.
/// </summary>
public void CompleteFrame()
{
if (!_hasDeadline || _periodTicks <= 0)
return;
long deadline = _nextDeadline;
long now = _clock.GetTimestamp();
if (now < deadline)
{
do
{
long remaining = deadline - now;
_waiter.Wait(remaining, _clock.Frequency);
now = _clock.GetTimestamp();
}
while (now < deadline);
long followingDeadline = AddSaturating(deadline, _periodTicks);
_nextDeadline = followingDeadline > now
? followingDeadline
: AddSaturating(now, _periodTicks);
return;
}
// The frame reached or missed its deadline without waiting. Rebase
// from now instead of advancing through old deadlines: there is never
// an immediate catch-up frame after a stall or long portal load.
_nextDeadline = AddSaturating(now, _periodTicks);
}
private static long AddSaturating(long value, long increment)
=> value > long.MaxValue - increment
? long.MaxValue
: value + increment;
}
internal interface IFramePacingClock
{
long Frequency { get; }
long GetTimestamp();
}
internal interface IFramePacingWaiter
{
void Wait(long durationTicks, long clockFrequency);
}
internal sealed class StopwatchFramePacingClock : IFramePacingClock
{
public static StopwatchFramePacingClock Instance { get; } = new();
private StopwatchFramePacingClock()
{
}
public long Frequency => Stopwatch.Frequency;
public long GetTimestamp() => Stopwatch.GetTimestamp();
}
internal sealed class ThreadFramePacingWaiter : IFramePacingWaiter
{
public static ThreadFramePacingWaiter Instance { get; } = new();
private ThreadFramePacingWaiter()
{
}
public void Wait(long durationTicks, long clockFrequency)
{
if (durationTicks <= 0 || clockFrequency <= 0)
return;
double milliseconds = durationTicks * 1000d / clockFrequency;
// A kernel sleep is intentionally preferred over a final spin. It can
// overshoot a presentation deadline slightly, but it keeps normal CPU
// use low—the central reason this fallback exists.
int sleepMilliseconds = Math.Max(
1,
(int)Math.Ceiling(Math.Min(milliseconds, int.MaxValue)));
Thread.Sleep(sleepMilliseconds);
}
}

View file

@ -0,0 +1,31 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Resolves the user's presentation preference into a bounded render policy.
/// A normal client is never uncapped: disabling VSync selects a software
/// ceiling at the active monitor's refresh rate. Only the explicit startup
/// diagnostic may disable both mechanisms.
/// </summary>
internal readonly record struct FramePacingPolicy(
bool UseVSync,
double? SoftwareLimitHz)
{
internal const double FallbackRefreshHz = 60d;
public static FramePacingPolicy Resolve(
bool requestedVSync,
bool uncappedRendering,
int? monitorRefreshHz)
{
if (uncappedRendering)
return new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: null);
if (requestedVSync)
return new FramePacingPolicy(UseVSync: true, SoftwareLimitHz: null);
double refreshHz = monitorRefreshHz is > 0
? monitorRefreshHz.Value
: FallbackRefreshHz;
return new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: refreshHz);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,486 @@
using Silk.NET.OpenGL;
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, 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);
public GpuFrameFlightController(GL gl, int maximumFramesInFlight = DefaultMaximumFramesInFlight)
: this(new SilkGpuFenceApi(gl), maximumFramesInFlight)
{
}
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);
}
internal sealed class SilkGpuFenceApi(GL gl) : IGpuFenceApi
{
private readonly GL _gl = gl ?? throw new ArgumentNullException(nameof(gl));
public nint Insert() =>
_gl.FenceSync(SyncCondition.SyncGpuCommandsComplete, SyncBehaviorFlags.None);
public GpuFenceWaitResult Wait(nint fence, bool flushCommands, ulong timeoutNanoseconds)
{
SyncObjectMask flags = flushCommands
? SyncObjectMask.Bit
: 0;
return _gl.ClientWaitSync(fence, flags, timeoutNanoseconds) switch
{
GLEnum.AlreadySignaled or GLEnum.ConditionSatisfied => GpuFenceWaitResult.Signaled,
GLEnum.TimeoutExpired => GpuFenceWaitResult.Timeout,
GLEnum.WaitFailed => GpuFenceWaitResult.Failed,
GLEnum value => throw new InvalidOperationException(
$"OpenGL returned unexpected fence wait status {value} (0x{(uint)value:X})."),
};
}
public void Delete(nint fence) => _gl.DeleteSync(fence);
}

View file

@ -0,0 +1,89 @@
using AcDream.Core.Terrain;
namespace AcDream.App.Rendering;
/// <summary>
/// Adds GPU-frame retirement to the terrain renderer's CPU slot allocator.
/// Removing a landblock stops future draws immediately, but its VBO/EBO slot
/// is not returned for overwrite until older submitted draws have completed.
/// </summary>
internal sealed class GpuRetiredTerrainSlotAllocator
{
private readonly TerrainSlotAllocator _allocator;
private readonly GpuRetirementLedger _retirementLedger;
private readonly Dictionary<int, RetryableGpuResourceRelease> _pendingReleases = [];
public GpuRetiredTerrainSlotAllocator(
int initialCapacity,
IGpuResourceRetirementQueue retirement)
{
_allocator = new TerrainSlotAllocator(initialCapacity);
_retirementLedger = new GpuRetirementLedger(
retirement ?? throw new ArgumentNullException(nameof(retirement)));
}
public int Capacity => _allocator.Capacity;
public int LoadedCount => _allocator.LoadedCount;
internal int PendingReleaseCount => _pendingReleases.Count;
public int Allocate(out bool needsGrow) => _allocator.Allocate(out needsGrow);
public void GrowTo(int newCapacity) => _allocator.GrowTo(newCapacity);
/// <summary>
/// Returns a slot whose contents were never published to a GPU draw. No
/// retirement fence is required because no submitted command can refer to
/// it.
/// </summary>
public void ReleaseUnsubmitted(int slot)
{
if (_pendingReleases.ContainsKey(slot))
{
throw new InvalidOperationException(
"A GPU-submitted terrain slot cannot be released as unsubmitted.");
}
_allocator.Free(slot);
}
public void FreeAfterGpuUse(int slot)
{
if (_pendingReleases.TryGetValue(slot, out RetryableGpuResourceRelease? pending))
{
try
{
_retirementLedger.RetryPendingPublication(pending);
}
catch when (pending.IsComplete)
{
// Completion is stronger than publication acknowledgement.
}
return;
}
var release = new RetryableGpuResourceRelease(
() => _allocator.Free(slot),
() =>
{
if (!_pendingReleases.Remove(slot))
{
throw new InvalidOperationException(
"Terrain-slot retirement lost its ownership record.");
}
});
_pendingReleases.Add(slot, release);
try
{
_retirementLedger.Retire(release);
}
catch when (release.IsComplete)
{
// An immediate retirement queue can complete before surfacing a
// wrapper failure. The slot is already safely reusable.
}
}
public void RetryPendingPublications() =>
_retirementLedger.RetryPendingPublications();
}

View file

@ -0,0 +1,36 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Runs dependency-ordered resource teardown one stage at a time. A failed
/// stage remains current, so a later <see cref="Advance"/> retries exactly
/// that owner before any dependent resource can be destroyed.
/// </summary>
internal sealed class OrderedResourceTeardown(params Action[] stages)
{
private readonly Action[] _stages = stages ?? throw new ArgumentNullException(nameof(stages));
private int _nextStage;
private bool _advancing;
public bool IsComplete => _nextStage == _stages.Length;
internal int NextStage => _nextStage;
public void Advance()
{
if (_advancing || IsComplete)
return;
_advancing = true;
try
{
while (_nextStage < _stages.Length)
{
_stages[_nextStage]();
_nextStage++;
}
}
finally
{
_advancing = false;
}
}
}

View file

@ -0,0 +1,61 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Tracks shared resources by logical owner. Repeated use of the same key by
/// one owner is idempotent; a key is returned for destruction only after its
/// final owner leaves. Render-thread only.
/// </summary>
internal sealed class OwnerScopedResourceRegistry<TKey> where TKey : notnull
{
private readonly Dictionary<uint, HashSet<TKey>> _keysByOwner = new();
private readonly Dictionary<TKey, int> _ownerCountByKey = new();
public int OwnerCount => _keysByOwner.Count;
public int ResourceCount => _ownerCountByKey.Count;
public bool Acquire(uint ownerId, TKey key)
{
if (ownerId == 0)
throw new ArgumentOutOfRangeException(nameof(ownerId));
if (!_keysByOwner.TryGetValue(ownerId, out HashSet<TKey>? keys))
{
keys = new HashSet<TKey>();
_keysByOwner.Add(ownerId, keys);
}
if (!keys.Add(key))
return false;
_ownerCountByKey[key] = _ownerCountByKey.GetValueOrDefault(key) + 1;
return true;
}
public IReadOnlyList<TKey> ReleaseOwner(uint ownerId)
{
if (!_keysByOwner.Remove(ownerId, out HashSet<TKey>? keys))
return Array.Empty<TKey>();
List<TKey>? unowned = null;
foreach (TKey key in keys)
{
int remaining = _ownerCountByKey[key] - 1;
if (remaining > 0)
{
_ownerCountByKey[key] = remaining;
continue;
}
_ownerCountByKey.Remove(key);
(unowned ??= new List<TKey>()).Add(key);
}
return unowned is null ? Array.Empty<TKey>() : unowned;
}
public void Clear()
{
_keysByOwner.Clear();
_ownerCountByKey.Clear();
}
}

View file

@ -32,6 +32,7 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis
private readonly GL _gl;
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
private readonly DollCamera _camera = new();
// Off-screen target (lazily (re)created on size change).
@ -55,15 +56,28 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis
// AND what lets the idle animation (later slice, TickAnimations rebuilds MeshRefs) show.
private static readonly HashSet<uint> _dollAnimatedIds = new() { DollEntityBuilder.DollRenderId };
public PaperdollViewportRenderer(GL gl, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo)
public PaperdollViewportRenderer(
GL gl,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IEntityTextureLifetime textureLifetime)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
_textureOwnerLease = new FixedEntityTextureOwnerLease(
textureLifetime,
DollEntityBuilder.DollRenderId);
}
/// <summary>Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player.</summary>
public void SetDoll(WorldEntity? doll) => _doll = doll;
public void SetDoll(WorldEntity? doll)
{
if (ReferenceEquals(_doll, doll))
return;
_textureOwnerLease.Replace(doll is not null);
_doll = doll;
}
/// <inheritdoc/>
public uint Render(int width, int height)
@ -189,5 +203,10 @@ public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDis
_fbW = _fbH = 0;
}
public void Dispose() => DeleteFramebuffer();
public void Dispose()
{
_doll = null;
_textureOwnerLease.Dispose();
DeleteFramebuffer();
}
}

View file

@ -0,0 +1,139 @@
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering;
/// <summary>
/// Durable, per-emitter teardown ledger. Emitter death removes simulation
/// state first, so renderer-owned resources must retain their own retryable
/// obligations instead of relying on the death event being raised again.
/// </summary>
internal sealed class ParticleEmitterRetirementTracker
{
private sealed class RetirementState
{
public bool MeshReleased;
public bool GfxInfoRemoved;
public bool TextureOwnerReleased;
public bool FailureReported;
}
private readonly Action<int> _releaseMesh;
private readonly Action<int> _removeGfxInfo;
private readonly Action<int> _releaseTextureOwner;
private readonly Action<Exception>? _reportFailure;
private readonly Dictionary<int, RetirementState> _pending = [];
private readonly HashSet<int> _advancingHandles = [];
public ParticleEmitterRetirementTracker(
Action<int> releaseMesh,
Action<int> removeGfxInfo,
Action<int> releaseTextureOwner,
Action<Exception>? reportFailure = null)
{
_releaseMesh = releaseMesh ?? throw new ArgumentNullException(nameof(releaseMesh));
_removeGfxInfo = removeGfxInfo ?? throw new ArgumentNullException(nameof(removeGfxInfo));
_releaseTextureOwner = releaseTextureOwner ?? throw new ArgumentNullException(nameof(releaseTextureOwner));
_reportFailure = reportFailure;
}
internal int PendingCount => _pending.Count;
public void BeginRetirement(int emitterHandle)
{
if (_advancingHandles.Contains(emitterHandle))
return;
if (!_pending.TryGetValue(emitterHandle, out RetirementState? state))
{
state = new RetirementState();
_pending.Add(emitterHandle, state);
}
Advance(emitterHandle, state);
}
public void RetryPending()
{
if (_pending.Count == 0)
return;
int[] handles = [.. _pending.Keys];
for (int i = 0; i < handles.Length; i++)
{
if (_pending.TryGetValue(handles[i], out RetirementState? state))
Advance(handles[i], state);
}
}
public void CompleteOrThrow()
{
RetryPending();
if (_pending.Count != 0)
throw new InvalidOperationException(
$"{_pending.Count} particle-emitter teardown obligation(s) remain incomplete.");
}
private void Advance(int emitterHandle, RetirementState state)
{
if (!_advancingHandles.Add(emitterHandle))
return;
List<Exception>? failures = null;
try
{
if (!state.MeshReleased)
{
try
{
_releaseMesh(emitterHandle);
state.MeshReleased = true;
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
state.MeshReleased = true;
(failures ??= []).Add(error);
}
}
if (!state.GfxInfoRemoved)
{
try
{
_removeGfxInfo(emitterHandle);
state.GfxInfoRemoved = true;
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (!state.TextureOwnerReleased)
{
try
{
_releaseTextureOwner(emitterHandle);
state.TextureOwnerReleased = true;
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (state.MeshReleased && state.GfxInfoRemoved && state.TextureOwnerReleased)
_pending.Remove(emitterHandle);
if (failures is not null && !state.FailureReported)
{
state.FailureReported = true;
_reportFailure?.Invoke(new AggregateException(
$"Particle emitter {emitterHandle} teardown will be retried.",
failures));
}
}
finally
{
_advancingHandles.Remove(emitterHandle);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering;
@ -42,9 +43,18 @@ internal static class ParticleSubmissionOrdering
/// </summary>
internal sealed class ParticleMeshReferenceTracker : IDisposable
{
private sealed class ReferenceState
{
public required uint GfxObjId { get; init; }
public bool Desired { get; set; }
public bool Held { get; set; }
public bool Reconciling { get; set; }
}
private readonly Action<uint> _increment;
private readonly Action<uint> _decrement;
private readonly Dictionary<int, uint> _gfxByEmitter = new();
private readonly Dictionary<int, ReferenceState> _referencesByEmitter = new();
private bool _disposeRequested;
private bool _disposed;
public ParticleMeshReferenceTracker(Action<uint> increment, Action<uint> decrement)
@ -55,28 +65,137 @@ internal sealed class ParticleMeshReferenceTracker : IDisposable
public void Register(int emitterHandle, uint gfxObjId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_gfxByEmitter.ContainsKey(emitterHandle))
return;
ObjectDisposedException.ThrowIf(_disposeRequested, this);
if (!_referencesByEmitter.TryGetValue(emitterHandle, out ReferenceState? state))
{
state = new ReferenceState { GfxObjId = gfxObjId };
_referencesByEmitter.Add(emitterHandle, state);
}
else if (state.GfxObjId != gfxObjId)
{
throw new InvalidOperationException(
$"Particle emitter {emitterHandle} is already associated with " +
$"GfxObj 0x{state.GfxObjId:X8}, not 0x{gfxObjId:X8}.");
}
_gfxByEmitter.Add(emitterHandle, gfxObjId);
_increment(gfxObjId);
state.Desired = true;
Reconcile(emitterHandle, state);
}
public void Release(int emitterHandle)
{
if (_disposed || !_gfxByEmitter.Remove(emitterHandle, out uint gfxObjId))
if (_disposed || !_referencesByEmitter.TryGetValue(emitterHandle, out ReferenceState? state))
return;
_decrement(gfxObjId);
state.Desired = false;
Reconcile(emitterHandle, state);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (uint gfxObjId in _gfxByEmitter.Values)
_decrement(gfxObjId);
_gfxByEmitter.Clear();
_disposeRequested = true;
List<Exception>? failures = null;
int[] emitterHandles = [.. _referencesByEmitter.Keys];
for (int i = 0; i < emitterHandles.Length; i++)
{
int emitterHandle = emitterHandles[i];
if (!_referencesByEmitter.TryGetValue(emitterHandle, out ReferenceState? state))
continue;
state.Desired = false;
try
{
Reconcile(emitterHandle, state);
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (_referencesByEmitter.Count == 0)
_disposed = true;
if (failures is not null)
throw new AggregateException(
"One or more particle mesh references failed to release.",
failures);
}
private void Reconcile(int emitterHandle, ReferenceState state)
{
if (state.Reconciling)
return;
state.Reconciling = true;
Exception? failure = null;
try
{
while (state.Desired != state.Held)
{
if (state.Desired)
{
try
{
_increment(state.GfxObjId);
state.Held = true;
}
catch (MeshReferenceMutationException error)
{
if (error.MutationCommitted)
{
state.Held = true;
failure ??= error;
continue;
}
failure = error;
break;
}
catch (Exception error)
{
failure = error;
break;
}
}
else
{
try
{
_decrement(state.GfxObjId);
state.Held = false;
}
catch (MeshReferenceMutationException error)
{
if (error.MutationCommitted)
{
state.Held = false;
failure ??= error;
continue;
}
failure = error;
break;
}
catch (Exception error)
{
failure = error;
break;
}
}
}
}
finally
{
state.Reconciling = false;
if (!state.Desired && !state.Held)
_referencesByEmitter.Remove(emitterHandle);
if (_disposeRequested && _referencesByEmitter.Count == 0)
_disposed = true;
}
if (failure is not null)
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw();
}
}

View file

@ -1,5 +1,7 @@
using System;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -80,8 +82,6 @@ void main() { } // depth-only: color writes are masked off by the caller state
private readonly GL _gl;
private readonly uint _program;
private readonly uint _vao;
private readonly uint _vbo;
private readonly int _locViewProjection;
private readonly int _locPlaneCount;
private readonly int _locPlanes;
@ -92,6 +92,20 @@ void main() { } // depth-only: color writes are masked off by the caller state
private const int MaxFanVerts = 32;
private readonly float[] _scratch = new float[MaxFanVerts * 3];
private sealed class FrameBufferSet
{
public uint Vao;
public uint Vbo;
public int CapacityBytes;
public int UsedVertices;
}
private readonly FrameBufferSet[] _frameBuffers = new FrameBufferSet[3];
private FrameBufferSet? _activeFrameBuffer;
internal long DynamicBufferCapacityBytes =>
_frameBuffers.Sum(set => (long)set.CapacityBytes);
public PortalDepthMaskRenderer(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
@ -115,19 +129,57 @@ void main() { } // depth-only: color writes are masked off by the caller state
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
_locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN");
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
unsafe
for (int i = 0; i < _frameBuffers.Length; i++)
_frameBuffers[i] = CreateFrameBufferSet();
}
/// <summary>
/// Selects the GPU-fenced frame slot and resets its append cursor. Portal
/// fans written during one frame occupy distinct ranges, so later portal
/// slices never overwrite vertices still referenced by earlier draws.
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_frameBuffers.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_activeFrameBuffer = _frameBuffers[frameSlot];
_activeFrameBuffer.UsedVertices = 0;
}
private FrameBufferSet CreateFrameBufferSet()
{
uint vao = 0;
uint vbo = 0;
try
{
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(MaxFanVerts * 3 * sizeof(float)), null, BufferUsageARB.DynamicDraw);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), (void*)0);
vao = TrackedGlResource.CreateVertexArray(
_gl,
"PortalDepthMask frame VAO creation");
vbo = TrackedGlResource.CreateBuffer(
_gl,
"PortalDepthMask frame VBO creation");
var set = new FrameBufferSet { Vao = vao, Vbo = vbo };
_gl.BindVertexArray(set.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo);
unsafe
{
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 3 * sizeof(float), (void*)0);
}
_gl.EnableVertexAttribArray(0);
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
return set;
}
catch
{
if (vbo != 0)
TrackedGlResource.DeleteBuffer(
_gl, vbo, 0, "PortalDepthMask frame VBO rollback");
if (vao != 0)
TrackedGlResource.DeleteVertexArray(
_gl, vao, "PortalDepthMask frame VAO rollback");
throw;
}
_gl.EnableVertexAttribArray(0);
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
}
private uint Compile(ShaderType type, string src)
@ -210,8 +262,12 @@ void main() { } // depth-only: color writes are masked off by the caller state
{
if (worldVerts.Length < 3)
return;
FrameBufferSet frameBuffer = _activeFrameBuffer
?? throw new InvalidOperationException("BeginFrame must be called before drawing portal depth masks.");
int n = Math.Min(worldVerts.Length, MaxFanVerts);
int planeCount = Math.Min(planes.Length, 8);
int firstVertex = frameBuffer.UsedVertices;
int requiredBytes = checked((firstVertex + n) * 3 * sizeof(float));
for (int i = 0; i < n; i++)
{
@ -249,10 +305,30 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.Uniform4(_locPlanes, (uint)planeCount, pp);
}
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
_gl.BindVertexArray(frameBuffer.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, frameBuffer.Vbo);
if (frameBuffer.CapacityBytes < requiredBytes)
{
int newCapacity = DynamicBufferCapacity.Grow(
frameBuffer.CapacityBytes,
requiredBytes);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ArrayBuffer,
frameBuffer.Vbo,
frameBuffer.CapacityBytes,
newCapacity,
GLEnum.DynamicDraw,
"PortalDepthMask frame VBO growth");
frameBuffer.CapacityBytes = newCapacity;
}
fixed (float* v = _scratch)
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(n * 3 * sizeof(float)), v);
_gl.BufferSubData(
BufferTargetARB.ArrayBuffer,
(nint)(firstVertex * 3 * sizeof(float)),
(nuint)(n * 3 * sizeof(float)),
v);
frameBuffer.UsedVertices += n;
if (!forceFarZ)
{
@ -261,7 +337,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.DepthMask(true);
_gl.Uniform1(_locForceFarZ, 0);
_gl.Uniform1(_locDepthBias, 0f);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
_gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n);
}
else
{
@ -276,7 +352,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
_gl.Uniform1(_locDepthBiasEyeCapN,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
_gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n);
// ── PUNCH pass B: far-Z write on marked pixels only;
// zero the stencil as we go (self-cleaning) ──
@ -286,7 +362,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.DepthMask(true);
_gl.Uniform1(_locForceFarZ, 1);
_gl.Uniform1(_locDepthBias, 0f);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
_gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n);
_gl.Disable(EnableCap.StencilTest);
}
@ -310,7 +386,17 @@ void main() { } // depth-only: color writes are masked off by the caller state
public void Dispose()
{
_gl.DeleteProgram(_program);
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
foreach (FrameBufferSet set in _frameBuffers)
{
TrackedGlResource.DeleteVertexArray(
_gl,
set.Vao,
"PortalDepthMask frame VAO disposal");
TrackedGlResource.DeleteBuffer(
_gl,
set.Vbo,
set.CapacityBytes,
"PortalDepthMask frame VBO disposal");
}
}
}

View file

@ -0,0 +1,154 @@
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering;
/// <summary>
/// Owns the synthetic portal creature's mesh references. Acquisition happens
/// only after the presentation has been published to its runtime owner, so a
/// partial backend failure can never strand an unreachable constructor-local
/// reference. Per-reference physical state makes both acquisition and teardown
/// resumable without replaying successful mutations.
/// </summary>
internal sealed class PortalMeshReferenceOwner : IDisposable
{
private sealed class ReferenceState(ulong gfxObjId)
{
public ulong GfxObjId { get; } = gfxObjId;
public bool Held { get; set; }
}
private readonly IWbMeshAdapter _meshAdapter;
private readonly ReferenceState[] _references;
private bool _desired;
private bool _disposeRequested;
private bool _disposed;
private bool _reconciling;
private bool _reconcileAgain;
public PortalMeshReferenceOwner(
IWbMeshAdapter meshAdapter,
IEnumerable<ulong> gfxObjIds)
{
_meshAdapter = meshAdapter ?? throw new ArgumentNullException(nameof(meshAdapter));
ArgumentNullException.ThrowIfNull(gfxObjIds);
_references = gfxObjIds
.Where(static id => id != 0u)
.Distinct()
.Select(static id => new ReferenceState(id))
.ToArray();
}
public bool IsDisposed => _disposed;
public void Acquire()
{
ObjectDisposedException.ThrowIf(_disposeRequested, this);
_desired = true;
try
{
Reconcile();
ObjectDisposedException.ThrowIf(_disposeRequested, this);
}
catch (Exception acquisitionFailure)
{
// Acquisition is transactional even though the owner is already
// reachable. Roll back every committed sibling; a failed rollback
// remains represented by Held=true so Dispose can resume it.
_desired = false;
try
{
Reconcile();
}
catch (Exception rollbackFailure)
{
throw new AggregateException(
"Portal mesh acquisition failed and its rollback did not fully converge.",
acquisitionFailure,
rollbackFailure);
}
System.Runtime.ExceptionServices.ExceptionDispatchInfo
.Capture(acquisitionFailure)
.Throw();
throw new InvalidOperationException("Unreachable exception dispatch path.");
}
}
public void Dispose()
{
if (_disposed)
return;
_disposeRequested = true;
_desired = false;
if (_reconciling)
{
_reconcileAgain = true;
return;
}
Reconcile();
}
private void Reconcile()
{
if (_reconciling)
{
_reconcileAgain = true;
return;
}
_reconciling = true;
List<Exception>? failures = null;
try
{
do
{
_reconcileAgain = false;
for (int i = 0; i < _references.Length; i++)
{
ReferenceState reference = _references[i];
bool targetHeld = _desired;
if (reference.Held == targetHeld)
continue;
try
{
if (targetHeld)
_meshAdapter.IncrementRefCount(reference.GfxObjId);
else
_meshAdapter.DecrementRefCount(reference.GfxObjId);
reference.Held = targetHeld;
}
catch (Exception error)
{
if (error is MeshReferenceMutationException
{
MutationCommitted: true,
})
{
reference.Held = targetHeld;
}
string operation = targetHeld ? "acquisition" : "release";
(failures ??= []).Add(new InvalidOperationException(
$"Portal mesh 0x{reference.GfxObjId:X10} reference {operation} failed.",
error));
}
}
}
while (_reconcileAgain);
}
finally
{
_reconciling = false;
if (_disposeRequested && _references.All(static reference => !reference.Held))
_disposed = true;
}
if (failures is not null)
throw new AggregateException(
"One or more portal mesh references failed to reconcile.",
failures);
}
}

View file

@ -17,6 +17,7 @@
// (w > MinW) keeps a portal you're standing in (it covers the screen) so the cell behind stays
// visible. Retail PView::GetClip / ConstructView(CBldPortal) (decomp:432344 / 433832) near-clip the
// portal poly likewise before projecting.
using System.Buffers;
using System.Collections.Generic;
using System.Numerics;
@ -24,21 +25,97 @@ namespace AcDream.App.Rendering;
public static class PortalProjection
{
internal ref struct ClipPolygonLease
{
private readonly ArrayPool<Vector4>? _pool;
private Vector4[]? _first;
private Vector4[]? _second;
private Vector4[]? _result;
private readonly int _count;
private bool _disposed;
internal ClipPolygonLease(
ArrayPool<Vector4>? pool,
Vector4[]? first,
Vector4[]? second,
Vector4[]? result,
int count)
{
_pool = pool;
_first = first;
_second = second;
_result = result;
_count = count;
_disposed = false;
}
public int Count
{
get
{
ThrowIfDisposed();
return _count;
}
}
public ReadOnlySpan<Vector4> Span
{
get
{
ThrowIfDisposed();
return _result is null
? ReadOnlySpan<Vector4>.Empty
: _result.AsSpan(0, _count);
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
ArrayPool<Vector4>? pool = _pool;
if (_first is not null)
pool!.Return(_first);
if (_second is not null)
pool!.Return(_second);
_first = null;
_second = null;
_result = null;
}
private readonly void ThrowIfDisposed()
{
if (_disposed)
throw new ObjectDisposedException(nameof(ClipPolygonLease));
}
}
/// <summary>Project a cell-local polygon to NDC, preserving the projected winding of
/// the input (NOT normalized to CCW). The caller (PortalVisibilityBuilder) is responsible
/// for feeding camera-facing portal polygons (via the portal-side test) so the result is
/// CCW for the CCW-only <see cref="ScreenPolygonClip"/>. Returns fewer than 3 verts when
/// the polygon is entirely behind the camera / degenerate.</summary>
public static Vector2[] ProjectToNdc(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
=> ProjectToNdc(localPoly, cellToWorld, viewProj, ArrayPool<Vector4>.Shared);
internal static Vector2[] ProjectToNdc(
IReadOnlyList<Vector3> localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj,
ArrayPool<Vector4> vectorPool)
{
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector2>();
ArgumentNullException.ThrowIfNull(vectorPool);
Matrix4x4 m = cellToWorld * viewProj;
// To clip space (keep w).
var clip = new List<Vector4>(localPoly.Count);
foreach (var lp in localPoly)
clip.Add(Vector4.Transform(new Vector4(lp, 1f), m));
// A convex polygon can gain at most one vertex at each clipping plane. Keep the two
// Sutherland-Hodgman work buffers in ArrayPool instead of allocating six Lists per portal.
int capacity = checked(localPoly.Count + 5);
Vector4[] first = vectorPool.Rent(capacity);
Vector4[]? second = null;
// Homogeneous frustum clip in CLIP SPACE, before the perspective divide. First the
// in-front-of-eye half-space (w > MinW) — near-INDEPENDENT, so a portal the camera is
@ -52,22 +129,47 @@ public static class PortalProjection
// all survivors have w > 0, making the side-plane functionals (w ± x, w ± y) well defined.
// Near/far are intentionally NOT clipped (near-independence). Retail PView::GetClip
// (decomp:0x005a4320) projects + frustum-clips the portal poly likewise (research doc A §3.5).
clip = ClipPlane(clip, v => v.W - MinW); // in front of eye (near-independent)
if (clip.Count < 3) return System.Array.Empty<Vector2>();
clip = ClipPlane(clip, v => v.W + v.X); // left: x/w >= -1 <=> w + x >= 0
clip = ClipPlane(clip, v => v.W - v.X); // right: x/w <= 1 <=> w - x >= 0
clip = ClipPlane(clip, v => v.W + v.Y); // bottom: y/w >= -1 <=> w + y >= 0
clip = ClipPlane(clip, v => v.W - v.Y); // top: y/w <= 1 <=> w - y >= 0
if (clip.Count < 3) return System.Array.Empty<Vector2>();
// Perspective divide → NDC xy.
var ndc = new Vector2[clip.Count];
for (int i = 0; i < clip.Count; i++)
try
{
float w = clip[i].W;
ndc[i] = new Vector2(clip[i].X / w, clip[i].Y / w);
second = vectorPool.Rent(capacity);
int currentCount = localPoly.Count;
for (int i = 0; i < currentCount; i++)
first[i] = Vector4.Transform(new Vector4(localPoly[i], 1f), m);
Vector4[] current = first;
Vector4[] output = second;
ReadOnlySpan<HomogeneousPlane> planes =
[
HomogeneousPlane.EyeMinW,
HomogeneousPlane.Left,
HomogeneousPlane.Right,
HomogeneousPlane.Bottom,
HomogeneousPlane.Top,
];
foreach (HomogeneousPlane plane in planes)
{
currentCount = ClipHomogeneousPlane(
current.AsSpan(0, currentCount), output, plane);
if (currentCount < 3)
return System.Array.Empty<Vector2>();
(current, output) = (output, current);
}
// Perspective divide → NDC xy. This is the only result allocation.
var ndc = new Vector2[currentCount];
for (int i = 0; i < currentCount; i++)
{
float w = current[i].W;
ndc[i] = new Vector2(current[i].X / w, current[i].Y / w);
}
return ndc;
}
finally
{
vectorPool.Return(first);
if (second is not null)
vectorPool.Return(second);
}
return ndc;
}
/// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the W=0 clip of
@ -89,24 +191,83 @@ public static class PortalProjection
/// when some vertex has w &lt; 0; &lt;3 survivors → reject (empty).</para></summary>
public static Vector4[] ProjectToClip(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
{
if (localPoly == null || localPoly.Count < 3) return System.Array.Empty<Vector4>();
using ClipPolygonLease lease = ProjectToClipLease(localPoly, cellToWorld, viewProj);
return lease.Count < 3 ? System.Array.Empty<Vector4>() : lease.Span.ToArray();
}
internal static ClipPolygonLease ProjectToClipLease(
IReadOnlyList<Vector3> localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj)
=> ProjectToClipLease(
localPoly,
cellToWorld,
viewProj,
ArrayPool<Vector4>.Shared);
internal static ClipPolygonLease ProjectToClipLease(
IReadOnlyList<Vector3> localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj,
ArrayPool<Vector4> vectorPool)
{
ArgumentNullException.ThrowIfNull(vectorPool);
if (localPoly == null || localPoly.Count < 3)
return new ClipPolygonLease(null, null, null, null, 0);
Matrix4x4 m = cellToWorld * viewProj;
var clip = new List<Vector4>(localPoly.Count);
bool anyBehind = false;
foreach (var lp in localPoly)
Vector4[] transformed = vectorPool.Rent(localPoly.Count);
Vector4[]? clipped = null;
bool success = false;
try
{
var v = Vector4.Transform(new Vector4(lp, 1f), m);
if (v.W < 0f) anyBehind = true;
clip.Add(v);
}
clipped = vectorPool.Rent(checked(localPoly.Count + 1));
bool anyBehind = false;
for (int i = 0; i < localPoly.Count; i++)
{
Vector4 vertex = Vector4.Transform(new Vector4(localPoly[i], 1f), m);
if (vertex.W < 0f) anyBehind = true;
transformed[i] = vertex;
}
// polyClipFinish part 1 (0x006b6d5d): the W pass runs only when some vertex sits behind
// the eye plane (w < 0); an all-in-front polygon passes through untouched (and an
// all-behind one clips to empty inside the pass).
if (anyBehind)
clip = ClipPlane(clip, v => v.W);
return clip.Count >= 3 ? clip.ToArray() : System.Array.Empty<Vector4>();
// polyClipFinish part 1 (0x006b6d5d): the W pass runs only when some vertex sits behind
// the eye plane (w < 0); an all-in-front polygon passes through untouched (and an
// all-behind one clips to empty inside the pass).
ReadOnlySpan<Vector4> result = transformed.AsSpan(0, localPoly.Count);
if (anyBehind)
{
int count = ClipHomogeneousPlane(result, clipped, HomogeneousPlane.EyeZero);
if (count < 3)
{
success = true;
return new ClipPolygonLease(
vectorPool,
transformed,
clipped,
null,
0);
}
result = clipped.AsSpan(0, count);
}
Vector4[] resultArray = anyBehind ? clipped : transformed;
success = true;
return new ClipPolygonLease(
vectorPool,
transformed,
clipped,
resultArray,
result.Length);
}
finally
{
if (!success)
{
vectorPool.Return(transformed);
if (clipped is not null)
vectorPool.Return(clipped);
}
}
}
/// <summary>Clip a homogeneous (clip-space) portal polygon against an NDC view region
@ -120,55 +281,128 @@ public static class PortalProjection
if (subjectClip == null || regionCcwNdc == null || subjectClip.Count < 3 || regionCcwNdc.Count < 3)
return System.Array.Empty<Vector2>();
if (subjectClip is Vector4[] array)
return ClipToRegion(array.AsSpan(), regionCcwNdc);
Vector4[] rented = ArrayPool<Vector4>.Shared.Rent(subjectClip.Count);
try
{
for (int i = 0; i < subjectClip.Count; i++)
rented[i] = subjectClip[i];
return ClipToRegion(rented.AsSpan(0, subjectClip.Count), regionCcwNdc);
}
finally
{
ArrayPool<Vector4>.Shared.Return(rented);
}
}
internal static Vector2[] ClipToRegion(
ReadOnlySpan<Vector4> subjectClip,
IReadOnlyList<Vector2> regionCcwNdc)
=> ClipToRegionCore(subjectClip, regionCcwNdc, vertexStore: null);
internal static Vector2[] ClipToRegion(
ReadOnlySpan<Vector4> subjectClip,
IReadOnlyList<Vector2> regionCcwNdc,
PortalPolygonVertexStore vertexStore)
=> ClipToRegionCore(
subjectClip,
regionCcwNdc,
vertexStore,
ArrayPool<Vector4>.Shared,
ArrayPool<Vector2>.Shared);
internal static Vector2[] ClipToRegion(
ReadOnlySpan<Vector4> subjectClip,
IReadOnlyList<Vector2> regionCcwNdc,
PortalPolygonVertexStore vertexStore,
ArrayPool<Vector4> vector4Pool)
=> ClipToRegionCore(
subjectClip,
regionCcwNdc,
vertexStore,
vector4Pool,
ArrayPool<Vector2>.Shared);
private static Vector2[] ClipToRegionCore(
ReadOnlySpan<Vector4> subjectClip,
IReadOnlyList<Vector2> regionCcwNdc,
PortalPolygonVertexStore? vertexStore,
ArrayPool<Vector4>? vector4Pool = null,
ArrayPool<Vector2>? vector2Pool = null)
{
if (subjectClip.Length < 3 || regionCcwNdc == null || regionCcwNdc.Count < 3)
return System.Array.Empty<Vector2>();
vector4Pool ??= ArrayPool<Vector4>.Shared;
vector2Pool ??= ArrayPool<Vector2>.Shared;
// Homogeneous Sutherland-Hodgman: clip the (w > 0) subject against each CCW edge of the NDC
// region. f(P) below is the NDC inside test cross(edge, P_ndc - a) multiplied through P.W,
// which is > 0 after the eye-plane clip — so the sign is the NDC sign yet no near-eye vertex
// is ever divided (retail polyClipFinish, decomp 702749).
var poly = new List<Vector4>(subjectClip);
int n = regionCcwNdc.Count;
for (int e = 0; e < n; e++)
{
if (poly.Count < 3) return System.Array.Empty<Vector2>();
poly = ClipHomogeneousEdge(poly, regionCcwNdc[e], regionCcwNdc[(e + 1) % n]);
}
if (poly.Count < 3) return System.Array.Empty<Vector2>();
int regionCount = regionCcwNdc.Count;
int capacity = checked(subjectClip.Length + regionCount);
Vector4[] first = vector4Pool.Rent(capacity);
Vector4[]? second = null;
Vector2[]? ndcScratch = null;
// Divide survivors → NDC. They are inside the region now, so |x| ≤ |w| and |y| ≤ |w|: the
// divide is bounded by construction (this is why the homogeneous clip avoids the early-divide
// blow-up). Normalize to CCW so the result is a valid clip region for the next portal hop.
//
// W=0 port (2026-06-11): with ProjectToClip clipping at exactly w >= 0, a w == 0 vertex
// (a direction) cannot survive the bounded region clip above — a nonzero direction fails at
// least one edge's inside test of any bounded convex region — EXCEPT the measure-zero case
// of a direction lying exactly on a region corner with d == 0 on the adjoining edges. That
// case divides to ±Inf/NaN; treat it as the degenerate knife-edge sliver it is and return
// empty (retail's effective result for the same input: a <1 px degenerate region).
var ndc = new Vector2[poly.Count];
for (int i = 0; i < poly.Count; i++)
try
{
float w = poly[i].W;
var v = new Vector2(poly[i].X / w, poly[i].Y / w);
if (!float.IsFinite(v.X) || !float.IsFinite(v.Y))
second = vector4Pool.Rent(capacity);
int currentCount = subjectClip.Length;
subjectClip.CopyTo(first);
Vector4[] current = first;
Vector4[] output = second;
for (int edge = 0; edge < regionCount; edge++)
{
if (currentCount < 3)
return System.Array.Empty<Vector2>();
int outputCount = ClipHomogeneousEdge(
current.AsSpan(0, currentCount),
output,
regionCcwNdc[edge],
regionCcwNdc[(edge + 1) % regionCount]);
(current, output) = (output, current);
currentCount = outputCount;
}
if (currentCount < 3)
return System.Array.Empty<Vector2>();
ndc[i] = v;
// Divide survivors → NDC. They are already inside the bounded region. A w=0
// measure-zero corner remains the same empty knife-edge result as the prior path.
ndcScratch = vector2Pool.Rent(currentCount);
Span<Vector2> ndc = ndcScratch.AsSpan(0, currentCount);
for (int i = 0; i < currentCount; i++)
{
float w = current[i].W;
var vertex = new Vector2(current[i].X / w, current[i].Y / w);
if (!float.IsFinite(vertex.X) || !float.IsFinite(vertex.Y))
return System.Array.Empty<Vector2>();
ndc[i] = vertex;
}
// T2 (BR-4): retail's post-divide ~1-pixel vertex merge. Compact in place; only a
// genuinely shortened result needs a second exactly-sized output array.
int mergedCount = MergeSubPixelVertices(ndc);
if (mergedCount < 3)
return System.Array.Empty<Vector2>();
Vector2[] merged = vertexStore?.Rent(mergedCount)
?? GC.AllocateUninitializedArray<Vector2>(mergedCount);
ndc[..mergedCount].CopyTo(merged);
EnsureCcw(merged);
return merged;
}
finally
{
vector4Pool.Return(first);
if (second is not null)
vector4Pool.Return(second);
if (ndcScratch is not null)
vector2Pool.Return(ndcScratch);
}
// T2 (BR-4): retail's post-divide vertex merge — Render::copy_view
// (Ghidra 0x0054dfc0) collapses consecutive vertices closer than ~1
// PIXEL (|dx|<=1 && |dy|<=1 screen units) after the perspective divide.
// This is the flood's physical fixpoint floor: re-clipping a view can
// only insert sub-pixel sliver vertices, which this merge removes, so
// accumulated views converge instead of drifting (the drift is what
// forced the MaxReprocessPerCell=16 cap). Unit approximation: the
// builder has no viewport, so 1 px is expressed in NDC at a reference
// 1080p (2/1080 ≈ 0.00185); at higher resolutions the merge is merely
// slightly coarser than retail's, which only strengthens convergence.
var merged = MergeSubPixelVertices(ndc);
if (merged.Length < 3)
return System.Array.Empty<Vector2>();
EnsureCcw(merged);
return merged;
}
// Retail copy_view's ~1-pixel vertex merge (see ClipToRegion). Collapses
@ -178,47 +412,52 @@ public static class PortalProjection
// "<3 surviving verts → output count 0".
private const float VertexMergeEpsilonNdc = 2f / 1080f;
private static Vector2[] MergeSubPixelVertices(Vector2[] poly)
private static int MergeSubPixelVertices(Span<Vector2> poly)
{
if (poly.Length < 3) return poly;
var kept = new List<Vector2>(poly.Length);
foreach (var v in poly)
if (poly.Length < 3) return poly.Length;
int kept = 0;
for (int i = 0; i < poly.Length; i++)
{
if (kept.Count > 0)
Vector2 vertex = poly[i];
if (kept > 0)
{
var prev = kept[^1];
if (MathF.Abs(v.X - prev.X) <= VertexMergeEpsilonNdc
&& MathF.Abs(v.Y - prev.Y) <= VertexMergeEpsilonNdc)
Vector2 previous = poly[kept - 1];
if (MathF.Abs(vertex.X - previous.X) <= VertexMergeEpsilonNdc
&& MathF.Abs(vertex.Y - previous.Y) <= VertexMergeEpsilonNdc)
continue;
}
kept.Add(v);
poly[kept++] = vertex;
}
// Wrap-around: last ≈ first.
while (kept.Count >= 2)
while (kept >= 2)
{
var first = kept[0];
var last = kept[^1];
Vector2 first = poly[0];
Vector2 last = poly[kept - 1];
if (MathF.Abs(first.X - last.X) <= VertexMergeEpsilonNdc
&& MathF.Abs(first.Y - last.Y) <= VertexMergeEpsilonNdc)
kept.RemoveAt(kept.Count - 1);
kept--;
else
break;
}
return kept.Count == poly.Length ? poly : kept.ToArray();
return kept;
}
// One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside
// (left) part of a HOMOGENEOUS polygon. Inside test for vertex P (clip space): the NDC cross
// product cross(b-a, P/P.W - a) scaled by P.W (> 0): ex·(P.Y - P.W·a.Y) - ey·(P.X - P.W·a.X) ≥ 0.
// Crossings interpolate in homogeneous coords (perspective-correct), via the shared Lerp.
private static List<Vector4> ClipHomogeneousEdge(List<Vector4> poly, Vector2 a, Vector2 b)
private static int ClipHomogeneousEdge(
ReadOnlySpan<Vector4> polygon,
Span<Vector4> result,
Vector2 a,
Vector2 b)
{
var result = new List<Vector4>(poly.Count + 1);
int outputCount = 0;
float ex = b.X - a.X, ey = b.Y - a.Y;
for (int i = 0; i < poly.Count; i++)
for (int i = 0; i < polygon.Length; i++)
{
Vector4 cur = poly[i];
Vector4 prev = poly[(i + poly.Count - 1) % poly.Count];
Vector4 cur = polygon[i];
Vector4 prev = polygon[(i + polygon.Length - 1) % polygon.Length];
float dCur = ex * (cur.Y - cur.W * a.Y) - ey * (cur.X - cur.W * a.X);
float dPrev = ex * (prev.Y - prev.W * a.Y) - ey * (prev.X - prev.W * a.X);
bool curIn = dCur >= 0f;
@ -226,15 +465,15 @@ public static class PortalProjection
if (curIn)
{
if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur));
result.Add(cur);
if (!prevIn) result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
result[outputCount++] = cur;
}
else if (prevIn)
{
result.Add(Lerp(prev, cur, dPrev, dCur));
result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
}
}
return result;
return outputCount;
}
// Reverse vertex order in place if wound clockwise (signed area < 0). Mirrors the builder's
@ -257,33 +496,54 @@ public static class PortalProjection
private const float MinW = 0.05f;
// Sutherland-Hodgman against one half-space of the homogeneous view frustum, in CLIP SPACE.
// `dist` is the signed plane functional (>= 0 keeps the vertex); crossings are interpolated in
// homogeneous coords (perspective-correct). Callers apply the eye plane first so every survivor
// has w > 0, making the side-plane functionals (w ± x, w ± y) well defined.
private static List<Vector4> ClipPlane(List<Vector4> poly, System.Func<Vector4, float> dist)
// The enum avoids per-plane delegate/lambda traffic in this per-portal hot path.
private static int ClipHomogeneousPlane(
ReadOnlySpan<Vector4> polygon,
Span<Vector4> result,
HomogeneousPlane plane)
{
if (poly.Count == 0) return poly;
var result = new List<Vector4>(poly.Count + 1);
for (int i = 0; i < poly.Count; i++)
int outputCount = 0;
for (int i = 0; i < polygon.Length; i++)
{
Vector4 cur = poly[i];
Vector4 prev = poly[(i + poly.Count - 1) % poly.Count];
float dCur = dist(cur);
float dPrev = dist(prev);
Vector4 cur = polygon[i];
Vector4 prev = polygon[(i + polygon.Length - 1) % polygon.Length];
float dCur = PlaneDistance(cur, plane);
float dPrev = PlaneDistance(prev, plane);
bool curIn = dCur >= 0f;
bool prevIn = dPrev >= 0f;
if (curIn)
{
if (!prevIn) result.Add(Lerp(prev, cur, dPrev, dCur));
result.Add(cur);
if (!prevIn) result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
result[outputCount++] = cur;
}
else if (prevIn)
{
result.Add(Lerp(prev, cur, dPrev, dCur));
result[outputCount++] = Lerp(prev, cur, dPrev, dCur);
}
}
return result;
return outputCount;
}
private static float PlaneDistance(in Vector4 vertex, HomogeneousPlane plane) => plane switch
{
HomogeneousPlane.EyeMinW => vertex.W - MinW,
HomogeneousPlane.EyeZero => vertex.W,
HomogeneousPlane.Left => vertex.W + vertex.X,
HomogeneousPlane.Right => vertex.W - vertex.X,
HomogeneousPlane.Bottom => vertex.W + vertex.Y,
HomogeneousPlane.Top => vertex.W - vertex.Y,
_ => throw new System.ArgumentOutOfRangeException(nameof(plane)),
};
private enum HomogeneousPlane : byte
{
EyeMinW,
EyeZero,
Left,
Right,
Bottom,
Top,
}
private static Vector4 Lerp(Vector4 p, Vector4 q, float dp, float dq)

View file

@ -8,6 +8,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Silk.NET.OpenGL;
@ -44,7 +45,6 @@ public sealed class PortalTunnelPresentation : IDisposable
private readonly GL _gl;
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
private readonly IWbMeshAdapter _meshAdapter;
private readonly Setup _setup;
private readonly uint _setupDid;
private readonly uint _animationDid;
@ -54,7 +54,7 @@ public sealed class PortalTunnelPresentation : IDisposable
private readonly PortalTunnelCamera _camera = new();
private readonly Random _random;
private readonly Action<string>? _displayNotice;
private readonly uint[] _registeredGfxObjects;
private readonly PortalMeshReferenceOwner _meshReferences;
private bool _visible;
private double _rotationElapsed;
@ -62,6 +62,8 @@ public sealed class PortalTunnelPresentation : IDisposable
private float _rotationStartAngle;
private float _rotationEndAngle;
private float _rotationCurrentAngle;
private bool _disposeRequested;
private bool _disposing;
private bool _disposed;
private PortalTunnelPresentation(
@ -80,7 +82,6 @@ public sealed class PortalTunnelPresentation : IDisposable
_gl = gl;
_dispatcher = dispatcher;
_lightUbo = lightUbo;
_meshAdapter = meshAdapter;
_setup = setup;
_setupDid = setupDid;
_animationDid = animationDid;
@ -99,13 +100,9 @@ public sealed class PortalTunnelPresentation : IDisposable
MeshRefs = SetupMesh.Flatten(setup),
};
_registeredGfxObjects = setup.Parts
.Select(part => (uint)part)
.Where(id => id != 0u)
.Distinct()
.ToArray();
foreach (uint gfxObjId in _registeredGfxObjects)
_meshAdapter.IncrementRefCount(gfxObjId);
_meshReferences = new PortalMeshReferenceOwner(
meshAdapter,
setup.Parts.Select(part => (ulong)(uint)part));
}
/// <summary>
@ -116,7 +113,7 @@ public sealed class PortalTunnelPresentation : IDisposable
/// </summary>
public static PortalTunnelPresentation CreateRequired(
GL gl,
DatCollection dats,
IDatReaderWriter dats,
IAnimationLoader animationLoader,
IAnimationHookSink hookSink,
WbDrawDispatcher dispatcher,
@ -180,6 +177,17 @@ public sealed class PortalTunnelPresentation : IDisposable
public uint SetupDid => _setupDid;
public uint AnimationDid => _animationDid;
/// <summary>
/// Publishes the synthetic scene's mesh ownership after the presentation
/// itself has been stored by <c>GameWindow</c>. A partial acquisition stays
/// reachable and is resumed or released by the same lifetime owner.
/// </summary>
internal void PrepareResources()
{
ThrowIfDisposed();
_meshReferences.Acquire();
}
/// <summary>
/// Retail <c>set_sequence_animation(anim, clear=1, low=1, fps=40)</c>
/// on the edge where portal space becomes visible.
@ -332,7 +340,8 @@ public sealed class PortalTunnelPresentation : IDisposable
});
}
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
private void ThrowIfDisposed() =>
ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
/// <summary>
/// Retail stages animation hooks while advancing the sequence, then drains
@ -372,12 +381,23 @@ public sealed class PortalTunnelPresentation : IDisposable
public void Dispose()
{
if (_disposed)
if (_disposed || _disposing)
return;
Exit();
_animationHooks.Clear();
foreach (uint gfxObjId in _registeredGfxObjects)
_meshAdapter.DecrementRefCount(gfxObjId);
_disposed = true;
_disposeRequested = true;
_disposing = true;
try
{
_visible = false;
_animationHooks.Clear();
_sequence.ClearAnimations();
_meshReferences.Dispose();
if (_meshReferences.IsDisposed)
_disposed = true;
}
finally
{
_disposing = false;
}
}
}

View file

@ -3,9 +3,9 @@
// Phase A8.F: GL-free 2D screen-space (NDC) clip-region data model.
// Mirrors retail view_poly (acclient.h:32465) and view_type (acclient.h:32338):
// a cell's clip region is a SET of convex polygons in normalized device coords.
using System.Buffers;
using System.Collections.Generic;
using System.Numerics;
using System.Text;
namespace AcDream.App.Rendering;
@ -37,14 +37,96 @@ public readonly struct ViewPolygon
public bool IsEmpty => Vertices is null || Vertices.Length < 3;
}
/// <summary>
/// Frame-owned exact-length storage for projected portal polygons. A polygon's
/// vertex array remains immutable for the lifetime of its visibility frame, then
/// becomes reusable when that frame is reset. Exact lengths preserve the existing
/// <see cref="ViewPolygon.Vertices"/> contract and all clipping semantics.
/// </summary>
internal sealed class PortalPolygonVertexStore
{
private const int MaxRetainedArrays = 4_096;
private const int MaxRetainedVertices = 65_536;
private const int MaxRetainedPolygonVertices = 256;
private sealed class Bucket
{
public readonly List<Vector2[]> Buffers = new(4);
public int Used;
}
private readonly Dictionary<int, Bucket> _buckets = new();
private int _retainedArrays;
private int _retainedVertices;
internal int AllocationCount { get; private set; }
internal int RetainedArrayCount => _retainedArrays;
internal Vector2[] Rent(int vertexCount)
{
ArgumentOutOfRangeException.ThrowIfLessThan(vertexCount, 1);
if (_buckets.TryGetValue(vertexCount, out Bucket? bucket)
&& bucket.Used < bucket.Buffers.Count)
{
return bucket.Buffers[bucket.Used++];
}
Vector2[] result = GC.AllocateUninitializedArray<Vector2>(vertexCount);
AllocationCount++;
bool retain = vertexCount <= MaxRetainedPolygonVertices
&& _retainedArrays < MaxRetainedArrays
&& _retainedVertices + vertexCount <= MaxRetainedVertices;
if (!retain)
return result;
if (bucket is null)
{
bucket = new Bucket();
_buckets.Add(vertexCount, bucket);
}
bucket.Buffers.Add(result);
bucket.Used++;
_retainedArrays++;
_retainedVertices += vertexCount;
return result;
}
internal void ResetUsage()
{
foreach (Bucket bucket in _buckets.Values)
bucket.Used = 0;
}
}
/// <summary>A cell's accumulated clip region: a set of convex view polygons + the union bounding rect.</summary>
public sealed class CellView
{
// ViewPolygon exposes its vertex array for the renderer, so this seed must
// be owned by the CellView rather than shared globally. Pooling the
// CellView then reuses the four vertices without allowing one caller to
// corrupt every future full-screen seed.
private readonly ViewPolygon _fullScreenPolygon = new(new[]
{
new Vector2(-1f, -1f),
new Vector2(1f, -1f),
new Vector2(1f, 1f),
new Vector2(-1f, 1f),
});
public readonly List<ViewPolygon> Polygons = new();
// Canonical (snapped) keys of the polygons in <see cref="Polygons"/>, backing the drift-tolerant
// dedup in <see cref="Add"/>. One entry per stored polygon; HashSet membership IS the dedup.
private readonly HashSet<string> _polygonKeys = new();
// dedup in <see cref="Add"/>. Hash-bucket membership is the dedup; a stored key owns only its
// snapped integer coordinates while duplicate probes use stack or ArrayPool scratch.
// Hash -> head index in _polygonKeyStorage. Collision chains are integer
// links rather than one List allocation per accepted hash. Storage and
// coordinate arrays stay with this pooled CellView and are reused across
// frames; _activePolygonKeyCount marks the live prefix.
private readonly Dictionary<int, int> _polygonKeyHeads = new();
private readonly List<PolygonKey> _polygonKeyStorage = new();
private int _activePolygonKeyCount;
public float MinX { get; private set; } = float.MaxValue;
public float MinY { get; private set; } = float.MaxValue;
public float MaxX { get; private set; } = float.MinValue;
@ -52,18 +134,57 @@ public sealed class CellView
public bool IsEmpty => Polygons.Count == 0;
internal bool IsRetainable
{
get
{
if (Polygons.Capacity > 256
|| _polygonKeyHeads.EnsureCapacity(0) > 512
|| _polygonKeyStorage.Count > 512)
{
return false;
}
int retainedCoordinateInts = 0;
for (int i = 0; i < _polygonKeyStorage.Count; i++)
{
int length = _polygonKeyStorage[i].Coordinates.Length;
if (length > 256)
return false;
retainedCoordinateInts += length;
if (retainedCoordinateInts > 8192)
return false;
}
return true;
}
}
internal void Reset()
{
Polygons.Clear();
_polygonKeyHeads.Clear();
_activePolygonKeyCount = 0;
MinX = float.MaxValue;
MinY = float.MaxValue;
MaxX = float.MinValue;
MaxY = float.MinValue;
}
/// <summary>A region covering the entire NDC viewport — the camera cell's seed region
/// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814).</summary>
public static CellView FullScreen()
{
var v = new CellView();
v.Add(new ViewPolygon(new[]
{
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
}));
v.Add(v._fullScreenPolygon);
return v;
}
internal void SetFullScreen()
{
Reset();
Add(_fullScreenPolygon);
}
public bool Add(ViewPolygon p)
{
if (p.IsEmpty) return false;
@ -79,9 +200,9 @@ public sealed class CellView
// bounded and the flood is GUARANTEED to converge. The stored polygon keeps full precision (only
// the key is snapped), so downstream clip geometry is unchanged, and the grid (1e-3 NDC ~ sub-
// pixel) is far finer than the gap between genuinely distinct openings, so real regions never merge.
string? key = CanonicalKey(p.Vertices);
if (key is null) return false; // degenerate after snap (< 3 distinct vertices)
if (!_polygonKeys.Add(key)) return false; // duplicate region (drift / rotation / count tolerant)
CanonicalKeyResult keyResult = TryAddCanonicalKey(p.Vertices);
if (keyResult == CanonicalKeyResult.Degenerate) return false;
if (keyResult == CanonicalKeyResult.Duplicate) return false;
// #120 convergence (2026-06-11): reject a polygon CONTAINED in one already
// stored. The reciprocal ping-pong (eye within PortalSideEpsilon of a
@ -178,8 +299,8 @@ public sealed class CellView
// against the zero-area region). Rejecting these views dropped the whole chain behind an
// exactly-in-plane portal for the frame — the parked-eye knife-edge band (tower deck, spiral
// landings). The segment key space is finite like the area-key space, so dedup + the strict
// growth convergence invariant are unchanged. Returns null only when fewer than 2 distinct
// snapped points survive (a true sub-grid point — not a real region OR segment).
// growth convergence invariant are unchanged. Degenerate is returned only when fewer than 2
// distinct snapped points survive (a true sub-grid point — not a real region OR segment).
//
// §4 corner/doorway fix (2026-06-10) — the collinear pass: the homogeneous region clipper
// (PortalProjection.ClipToRegion, used by the forward AND — as of today — the reciprocal hop)
@ -190,89 +311,194 @@ public sealed class CellView
// exact reason the reciprocal clip was previously parked on the unstable divide-first path).
// Dropping collinear snapped points makes the key purely a function of the region's CORNERS, so
// any re-emission of the same shape — drifted, rotated, vertex-count-inflated — deduplicates.
private static string? CanonicalKey(Vector2[]? verts)
private CanonicalKeyResult TryAddCanonicalKey(Vector2[]? verts)
{
if (verts is null || verts.Length < 3) return null;
if (verts is null || verts.Length < 3)
return CanonicalKeyResult.Degenerate;
var pts = new List<(int X, int Y)>(verts.Length);
foreach (var v in verts)
SnappedPoint[]? rented = null;
Span<SnappedPoint> points = verts.Length <= 32
? stackalloc SnappedPoint[verts.Length]
: (rented = ArrayPool<SnappedPoint>.Shared.Rent(verts.Length)).AsSpan(0, verts.Length);
try
{
var q = ((int)System.MathF.Round(v.X / DedupGridNdc), (int)System.MathF.Round(v.Y / DedupGridNdc));
if (pts.Count == 0 || pts[^1] != q) pts.Add(q);
}
if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1);
// Snapshot the distinct snapped points BEFORE collinear removal — the all-collinear
// fallback keys off the segment EXTREMES of the full point set (stable across
// re-emissions regardless of the removal loop's order).
List<(int X, int Y)>? preCollinear = pts.Count >= 2 ? new List<(int, int)>(pts) : null;
// Remove collinear points: for consecutive (prev, cur, next) around the cycle, drop cur when
// cross(cur-prev, next-cur) == 0 — exact in integer grid coordinates (deltas ≤ ~4000, products
// ≤ ~1.6e7, no overflow). Loop to a fixpoint: removing one point can make its neighbour
// collinear. All-collinear inputs reduce below 3 → the segment-key fallback below.
bool removed = true;
while (removed && pts.Count >= 3)
{
removed = false;
for (int i = 0; i < pts.Count && pts.Count >= 3; i++)
int count = 0;
foreach (Vector2 vertex in verts)
{
var prev = pts[(i + pts.Count - 1) % pts.Count];
var cur = pts[i];
var next = pts[(i + 1) % pts.Count];
long cross = (long)(cur.X - prev.X) * (next.Y - cur.Y)
- (long)(cur.Y - prev.Y) * (next.X - cur.X);
if (cross == 0)
var point = new SnappedPoint(
(int)MathF.Round(vertex.X / DedupGridNdc),
(int)MathF.Round(vertex.Y / DedupGridNdc));
if (count == 0 || points[count - 1] != point)
points[count++] = point;
}
if (count >= 2 && points[count - 1] == points[0])
count--;
if (count < 2)
return CanonicalKeyResult.Degenerate;
SnappedPoint lo = points[0];
SnappedPoint hi = points[0];
for (int i = 1; i < count; i++)
{
SnappedPoint point = points[i];
if (point.X < lo.X || (point.X == lo.X && point.Y < lo.Y)) lo = point;
if (point.X > hi.X || (point.X == hi.X && point.Y > hi.Y)) hi = point;
}
bool removed = true;
while (removed && count >= 3)
{
removed = false;
for (int i = 0; i < count && count >= 3; i++)
{
pts.RemoveAt(i);
SnappedPoint previous = points[(i + count - 1) % count];
SnappedPoint current = points[i];
SnappedPoint next = points[(i + 1) % count];
long cross = (long)(current.X - previous.X) * (next.Y - current.Y)
- (long)(current.Y - previous.Y) * (next.X - current.X);
if (cross != 0)
continue;
points.Slice(i + 1, count - i - 1).CopyTo(points.Slice(i));
count--;
removed = true;
i--;
}
}
}
if (pts.Count < 3)
{
// Zero-area (all-collinear) view — key as its snapped segment so retail's
// degenerate-view propagation works (see method doc). Extremes are the
// lexicographic min/max of the full snapped point set.
if (preCollinear is null) return null;
var lo = preCollinear[0];
var hi = preCollinear[0];
foreach (var q in preCollinear)
if (count < 3)
{
if (q.X < lo.X || (q.X == lo.X && q.Y < lo.Y)) lo = q;
if (q.X > hi.X || (q.X == hi.X && q.Y > hi.Y)) hi = q;
if (lo == hi)
return CanonicalKeyResult.Degenerate;
Span<SnappedPoint> segment = stackalloc SnappedPoint[2] { lo, hi };
return AddCanonicalKey(PolygonKeyKind.Line, segment, start: 0);
}
if (lo == hi) return null; // a sub-grid point — not a region or a segment
return $"L:{lo.X},{lo.Y};{hi.X},{hi.Y};";
int best = 0;
for (int start = 1; start < count; start++)
if (RotationLess(points, start, best, count)) best = start;
return AddCanonicalKey(PolygonKeyKind.Polygon, points[..count], best);
}
int n = pts.Count;
int best = 0;
for (int s = 1; s < n; s++)
if (RotationLess(pts, s, best, n)) best = s;
var sb = new StringBuilder(n * 10);
for (int i = 0; i < n; i++)
finally
{
var q = pts[(best + i) % n];
sb.Append(q.X).Append(',').Append(q.Y).Append(';');
if (rented is not null)
ArrayPool<SnappedPoint>.Shared.Return(rented);
}
return sb.ToString();
}
// True when the rotation of `pts` starting at index a is lexicographically less than the rotation
// starting at b (compare X then Y, vertex by vertex around the cycle). Gives a unique canonical
// start even when two vertices share the minimum snapped coordinate.
private static bool RotationLess(List<(int X, int Y)> pts, int a, int b, int n)
private CanonicalKeyResult AddCanonicalKey(
PolygonKeyKind kind,
ReadOnlySpan<SnappedPoint> points,
int start)
{
for (int i = 0; i < n; i++)
int hash = ComputeHash(kind, points, start);
if (_polygonKeyHeads.TryGetValue(hash, out int keyIndex))
{
var pa = pts[(a + i) % n];
var pb = pts[(b + i) % n];
if (pa.X != pb.X) return pa.X < pb.X;
if (pa.Y != pb.Y) return pa.Y < pb.Y;
while (keyIndex >= 0)
{
PolygonKey existing = _polygonKeyStorage[keyIndex];
if (existing.Equals(kind, points, start))
return CanonicalKeyResult.Duplicate;
keyIndex = existing.Next;
}
}
int coordinateCount = points.Length * 2;
int storageIndex = FindOrCreateCoordinateStorage(coordinateCount);
int[] coordinates = _polygonKeyStorage[storageIndex].Coordinates;
for (int i = 0; i < points.Length; i++)
{
SnappedPoint point = points[(start + i) % points.Length];
coordinates[i * 2] = point.X;
coordinates[i * 2 + 1] = point.Y;
}
int next = _polygonKeyHeads.GetValueOrDefault(hash, -1);
_polygonKeyStorage[storageIndex] = new PolygonKey(kind, coordinates, next);
_polygonKeyHeads[hash] = storageIndex;
_activePolygonKeyCount++;
return CanonicalKeyResult.Added;
}
private int FindOrCreateCoordinateStorage(int coordinateCount)
{
int storageIndex = _activePolygonKeyCount;
for (int i = storageIndex; i < _polygonKeyStorage.Count; i++)
{
if (_polygonKeyStorage[i].Coordinates.Length != coordinateCount)
continue;
if (i != storageIndex)
(_polygonKeyStorage[storageIndex], _polygonKeyStorage[i]) =
(_polygonKeyStorage[i], _polygonKeyStorage[storageIndex]);
return storageIndex;
}
_polygonKeyStorage.Add(new PolygonKey(
PolygonKeyKind.Polygon,
new int[coordinateCount],
-1));
int addedIndex = _polygonKeyStorage.Count - 1;
if (addedIndex != storageIndex)
(_polygonKeyStorage[storageIndex], _polygonKeyStorage[addedIndex]) =
(_polygonKeyStorage[addedIndex], _polygonKeyStorage[storageIndex]);
return storageIndex;
}
private static int ComputeHash(
PolygonKeyKind kind,
ReadOnlySpan<SnappedPoint> points,
int start)
{
unchecked
{
uint hash = 2166136261u;
hash = (hash ^ (byte)kind) * 16777619u;
hash = (hash ^ (uint)points.Length) * 16777619u;
for (int i = 0; i < points.Length; i++)
{
SnappedPoint point = points[(start + i) % points.Length];
hash = (hash ^ (uint)point.X) * 16777619u;
hash = (hash ^ (uint)point.Y) * 16777619u;
}
return (int)hash;
}
}
private static bool RotationLess(
ReadOnlySpan<SnappedPoint> points,
int a,
int b,
int count)
{
for (int i = 0; i < count; i++)
{
SnappedPoint left = points[(a + i) % count];
SnappedPoint right = points[(b + i) % count];
if (left.X != right.X) return left.X < right.X;
if (left.Y != right.Y) return left.Y < right.Y;
}
return false;
}
private readonly record struct SnappedPoint(int X, int Y);
private readonly record struct PolygonKey(PolygonKeyKind Kind, int[] Coordinates, int Next)
{
public bool Equals(PolygonKeyKind kind, ReadOnlySpan<SnappedPoint> points, int start)
{
if (Kind != kind || Coordinates.Length != points.Length * 2)
return false;
for (int i = 0; i < points.Length; i++)
{
SnappedPoint point = points[(start + i) % points.Length];
if (Coordinates[i * 2] != point.X || Coordinates[i * 2 + 1] != point.Y)
return false;
}
return true;
}
}
private enum PolygonKeyKind : byte { Polygon, Line }
private enum CanonicalKeyResult : byte { Degenerate, Duplicate, Added }
}

View file

@ -14,9 +14,26 @@ namespace AcDream.App.Rendering;
/// <summary>Per-frame output of the portal-frame BFS.</summary>
public sealed class PortalVisibilityFrame
{
private const int MaxRetainedCellViews = 512;
internal const int MaxRetainedBuildCollectionCapacity = 512;
internal const int CapacityTrimIdleFrames = 120;
private readonly Stack<CellView> _cellViewPool = new();
private readonly PortalPolygonVertexStore _polygonVertices = new();
private int _cellViewsUnderusedFrames;
private int _crossBuildingViewsUnderusedFrames;
private int _queuedUnderusedFrames;
private int _drawListedUnderusedFrames;
private int _processedViewCountsUnderusedFrames;
private int _orderedVisibleCellsUnderusedFrames;
private int _todoUnderusedFrames;
internal PortalPolygonVertexStore PolygonVertices => _polygonVertices;
internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount;
internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount;
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
public CellView OutsideView { get; } = new();
public CellView OutsideView { get; private set; } = new();
/// <summary>Per-cell accumulated clip region, keyed by full cell id (wire-in #2).</summary>
public Dictionary<uint, CellView> CellViews { get; } = new();
@ -31,6 +48,199 @@ public sealed class PortalVisibilityFrame
/// <summary>Entry clip regions for other buildings reached through our portals, keyed by the
/// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary>
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
// Build scratch belongs to the frame so a caller that reuses a frame also reuses the large
// hash tables and the 128-entry convergence trace. This is especially important outdoors,
// where retail runs one small ConstructView flood per nearby building every frame.
internal HashSet<uint> QueuedScratch { get; } = new();
internal HashSet<uint> DrawListedScratch { get; } = new();
internal Dictionary<uint, int> ProcessedViewCountsScratch { get; } = new();
internal uint[] PropagationChainScratch { get; } = new uint[128];
internal List<(LoadedCell Cell, float Distance)> TodoScratch { get; } = new();
// A build can recurse while an outer portal's clipped region is still live, so one mutable
// singleton is unsafe. Leases reuse Lists by recursion lifetime rather than by total portal-call
// count: ordinary sequential portals need one List, while nested AdjustCellView propagation gets
// one per active depth. Dispose returns scratch on every continue/exception path.
private readonly Stack<List<ViewPolygon>> _clipRegionPool = new();
private const int MaxRetainedClipRegionLists = 132; // recursion tripwire (128) + nested side work
private const int MaxRetainedClipRegionCapacity = 256;
internal int ClipRegionScratchCount => _clipRegionPool.Count;
internal int RetainedCellViewCount => _cellViewPool.Count;
internal int CellViewMapCapacity => CellViews.EnsureCapacity(0);
internal int CrossBuildingViewMapCapacity => CrossBuildingViews.EnsureCapacity(0);
internal int QueuedCapacity => QueuedScratch.EnsureCapacity(0);
internal int DrawListedCapacity => DrawListedScratch.EnsureCapacity(0);
internal int ProcessedViewCountCapacity => ProcessedViewCountsScratch.EnsureCapacity(0);
internal ClipRegionScratchLease RentClipRegionScratch()
{
List<ViewPolygon> region = _clipRegionPool.Count != 0
? _clipRegionPool.Pop()
: new List<ViewPolygon>(2);
region.Clear();
return new ClipRegionScratchLease(this, region);
}
private void ReturnClipRegionScratch(List<ViewPolygon> region)
{
region.Clear();
if (region.Capacity <= MaxRetainedClipRegionCapacity
&& _clipRegionPool.Count < MaxRetainedClipRegionLists)
_clipRegionPool.Push(region);
}
internal readonly ref struct ClipRegionScratchLease
{
private readonly PortalVisibilityFrame _owner;
public List<ViewPolygon> Region { get; }
internal ClipRegionScratchLease(PortalVisibilityFrame owner, List<ViewPolygon> region)
{
_owner = owner;
Region = region;
}
public void Dispose() => _owner.ReturnClipRegionScratch(Region);
}
internal void ResetForBuild()
{
int cellViewCount = CellViews.Count;
int crossBuildingViewCount = CrossBuildingViews.Count;
int queuedCount = QueuedScratch.Count;
int drawListedCount = DrawListedScratch.Count;
int processedViewCount = ProcessedViewCountsScratch.Count;
int orderedVisibleCellCount = OrderedVisibleCells.Count;
int todoCount = TodoScratch.Count;
if (OutsideView.IsRetainable)
OutsideView.Reset();
else
OutsideView = new CellView();
ReturnCellViews(CellViews);
ReturnCellViews(CrossBuildingViews);
CellViews.Clear();
OrderedVisibleCells.Clear();
CrossBuildingViews.Clear();
QueuedScratch.Clear();
DrawListedScratch.Clear();
ProcessedViewCountsScratch.Clear();
TodoScratch.Clear();
_polygonVertices.ResetUsage();
// These collections live as long as RetailPViewRenderer. A fixed
// capacity cutoff is unsafe here: a legitimate large portal flood can
// exceed it every frame, causing us to discard and rebuild the same
// warmed tables forever. Retire high-water storage only after it has
// remained at least 50% underused for a sustained idle window. Once a
// collection is right-sized for its recurring workload it stays warm.
TrimIfCold(CellViews, cellViewCount, ref _cellViewsUnderusedFrames);
TrimIfCold(
CrossBuildingViews,
crossBuildingViewCount,
ref _crossBuildingViewsUnderusedFrames);
TrimIfCold(QueuedScratch, queuedCount, ref _queuedUnderusedFrames);
TrimIfCold(DrawListedScratch, drawListedCount, ref _drawListedUnderusedFrames);
TrimIfCold(
ProcessedViewCountsScratch,
processedViewCount,
ref _processedViewCountsUnderusedFrames);
TrimIfCold(
OrderedVisibleCells,
orderedVisibleCellCount,
ref _orderedVisibleCellsUnderusedFrames);
TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames);
}
internal ViewPolygon CopyPolygon(ReadOnlySpan<Vector2> vertices)
{
if (vertices.Length < 3)
return default;
Vector2[] owned = _polygonVertices.Rent(vertices.Length);
vertices.CopyTo(owned);
return new ViewPolygon(owned);
}
internal CellView RentCellView(bool fullScreen = false)
{
CellView result = _cellViewPool.Count != 0
? _cellViewPool.Pop()
: new CellView();
if (fullScreen)
result.SetFullScreen();
else
result.Reset();
return result;
}
private void ReturnCellViews(Dictionary<uint, CellView> views)
{
foreach (CellView view in views.Values)
{
view.Reset();
if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews)
_cellViewPool.Push(view);
}
}
private static void TrimIfCold<TKey, TValue>(
Dictionary<TKey, TValue> values,
int usedCount,
ref int underusedFrames)
where TKey : notnull
{
int capacity = values.EnsureCapacity(0);
if (!ShouldTrim(capacity, usedCount, ref underusedFrames))
return;
if (capacity > MaxRetainedBuildCollectionCapacity)
values.TrimExcess();
}
private static void TrimIfCold<T>(
HashSet<T> values,
int usedCount,
ref int underusedFrames)
{
int capacity = values.EnsureCapacity(0);
if (!ShouldTrim(capacity, usedCount, ref underusedFrames))
return;
if (capacity > MaxRetainedBuildCollectionCapacity)
values.TrimExcess();
}
private static void TrimIfCold<T>(
List<T> values,
int usedCount,
ref int underusedFrames)
{
int capacity = values.Capacity;
if (!ShouldTrim(capacity, usedCount, ref underusedFrames))
return;
if (capacity > MaxRetainedBuildCollectionCapacity)
values.Capacity = 0;
}
private static bool ShouldTrim(int capacity, int usedCount, ref int underusedFrames)
{
if (capacity <= MaxRetainedBuildCollectionCapacity
|| (long)usedCount * 2L > capacity)
{
underusedFrames = 0;
return false;
}
underusedFrames++;
if (underusedFrames < CapacityTrimIdleFrames)
return false;
underusedFrames = 0;
return true;
}
}
public static class PortalVisibilityBuilder
@ -121,16 +331,18 @@ public static class PortalVisibilityBuilder
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
Func<uint, bool>? buildingMembership = null,
float drawLiftZ = 0f)
float drawLiftZ = 0f,
PortalVisibilityFrame? reuseFrame = null)
{
var frame = new PortalVisibilityFrame();
var frame = reuseFrame ?? new PortalVisibilityFrame();
frame.ResetForBuild();
if (cameraCell == null) return frame;
// Interior portals never cross landblocks (same invariant as CellVisibility.GetVisibleCells);
// building-boundary crossings are handled separately via the buildingMembership escape hatch.
uint lbMask = cameraCell.CellId & 0xFFFF0000u;
frame.CellViews[cameraCell.CellId] = CellView.FullScreen();
frame.CellViews[cameraCell.CellId] = frame.RentCellView(fullScreen: true);
// Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor
// node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC
@ -142,15 +354,15 @@ public static class PortalVisibilityBuilder
// ids, so an id test would misfire. An interior root never sets this flag, so the indoor
// exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region.
if (cameraCell.IsOutdoorNode)
frame.OutsideView.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone()));
frame.OutsideView.Add(frame.CopyPolygon(FullScreenQuad));
// Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first;
// each cell carries the camera→nearest-portal-vertex distance that put it on the list
// (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The
// camera cell seeds at distance 0 (retail InsCellTodoList(this, arg2, 0f) at 433758) so it
// always pops first.
var todo = new CellTodoList();
todo.Insert(cameraCell, 0f);
List<(LoadedCell Cell, float Distance)> todo = frame.TodoScratch;
InsertTodo(todo, cameraCell, 0f);
// Fixpoint termination replacing the old MaxReprocessPerCell hard cap. This mirrors the
// retail portal_view slice offset 0x44 (last-incorporated view-poly watermark) vs 0x38
@ -162,9 +374,10 @@ public static class PortalVisibilityBuilder
// the instant a cell is popped). Enqueue-once across the cell set is the hard termination
// guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The
// camera cell is pre-marked so a portal looping back to it can never re-enqueue it.
var queued = new HashSet<uint> { cameraCell.CellId };
var drawListed = new HashSet<uint>();
var processedViewCounts = new Dictionary<uint, int>();
HashSet<uint> queued = frame.QueuedScratch;
queued.Add(cameraCell.CellId);
HashSet<uint> drawListed = frame.DrawListedScratch;
Dictionary<uint, int> processedViewCounts = frame.ProcessedViewCountsScratch;
var trace = PortalBuildTrace.Start(cameraCell, cameraPos);
// [portal-churn] apparatus (2026-06-08): when ProbePortalChurnEnabled, accumulate re-enqueue churn
@ -224,7 +437,7 @@ public static class PortalVisibilityBuilder
// reproduce the T5 firing — production-only ingredients (full lookup
// graph / real camera path) are suspected; this dump pins them on the
// next natural occurrence.
var propagationChain = new uint[RecursionTripwire];
uint[] propagationChain = frame.PropagationChainScratch;
void ProcessCellPortals(LoadedCell cell, int depth)
{
@ -251,7 +464,6 @@ public static class PortalVisibilityBuilder
}
trace?.Add($"proc cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} depth={depth}");
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
processedViewCounts[cell.CellId] = endCount;
for (int i = 0; i < cell.Portals.Count; i++)
@ -301,11 +513,18 @@ public static class PortalVisibilityBuilder
// homogeneous clip space, clip at the eye, then clip against the current
// portal_view region before the divide. Do the same here; the old early
// ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways.
var clippedRegion = ClipPortalAgainstView(
using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> clippedRegion = clippedRegionLease.Region;
ClipPortalAgainstView(
frame,
poly,
cell.WorldTransform,
viewProj,
activeViewPolygons,
currentView.Polygons,
processedCount,
endCount - processedCount,
clippedRegion,
out int clipVerts);
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
@ -339,16 +558,31 @@ public static class PortalVisibilityBuilder
// lifted space or terrain stops a lift-height short of the
// drawn lintel (#130 strip). Flood semantics keep the
// unlifted clippedRegion path above.
var outsideRegion = drawLiftZ == 0f
? clippedRegion
: ClipPortalAgainstView(
int outsideCount;
if (drawLiftZ == 0f)
{
AddRegion(frame.OutsideView, clippedRegion);
outsideCount = clippedRegion.Count;
}
else
{
using PortalVisibilityFrame.ClipRegionScratchLease outsideRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> outsideRegion = outsideRegionLease.Region;
ClipPortalAgainstView(
frame,
poly,
cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ),
viewProj,
activeViewPolygons,
currentView.Polygons,
processedCount,
endCount - processedCount,
outsideRegion,
out _);
AddRegion(frame.OutsideView, outsideRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideRegion.Count} clipVerts={clipVerts}");
AddRegion(frame.OutsideView, outsideRegion);
outsideCount = outsideRegion.Count;
}
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideCount} clipVerts={clipVerts}");
continue;
}
@ -360,7 +594,7 @@ public static class PortalVisibilityBuilder
// call inside ConstructView at decomp:433692.)
if (buildingMembership != null && !buildingMembership(neighbourId))
{
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
var xview = GetOrCreate(frame, frame.CrossBuildingViews, neighbourId);
bool grewCross = AddRegion(xview, clippedRegion);
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}");
continue;
@ -391,7 +625,8 @@ public static class PortalVisibilityBuilder
// neighbour's side; the old eye-in-opening restore was part of
// the deleted rescue.
int preReciprocalCount = clippedRegion.Count;
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
ApplyReciprocalClip(
frame, clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
if (churnProbe)
churnReciprocal!.Append(System.FormattableString.Invariant(
$" recip[0x{neighbourId:X8} {preReciprocalCount}->{clippedRegion.Count}]"));
@ -402,7 +637,7 @@ public static class PortalVisibilityBuilder
}
// Union the clipped region into the neighbour's accumulated view.
var nview = GetOrCreate(frame.CellViews, neighbourId);
var nview = GetOrCreate(frame, frame.CellViews, neighbourId);
bool grew = AddRegion(nview, clippedRegion);
bool inserted = false;
bool inPlace = false;
@ -417,7 +652,7 @@ public static class PortalVisibilityBuilder
if (queued.Add(neighbourId))
{
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
InsertTodo(todo, neighbour, dist);
inserted = true;
}
// Growth into an already-POPPED cell → retail AdjustCellView:
@ -437,7 +672,7 @@ public static class PortalVisibilityBuilder
while (todo.Count > 0)
{
var cell = todo.PopNearest();
LoadedCell cell = PopNearest(todo);
// Single pop per cell (enqueue-once) IS the cell's closest-first
// draw position (retail appends to cell_draw_list once per pop,
@ -491,13 +726,15 @@ public static class PortalVisibilityBuilder
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity,
IReadOnlyList<ViewPolygon>? seedRegion = null)
IReadOnlyList<ViewPolygon>? seedRegion = null,
PortalVisibilityFrame? reuseFrame = null)
{
var frame = new PortalVisibilityFrame();
var todo = new CellTodoList();
var queued = new HashSet<uint>();
var drawListed = new HashSet<uint>();
var processedViewCounts = new Dictionary<uint, int>();
var frame = reuseFrame ?? new PortalVisibilityFrame();
frame.ResetForBuild();
List<(LoadedCell Cell, float Distance)> todo = frame.TodoScratch;
HashSet<uint> queued = frame.QueuedScratch;
HashSet<uint> drawListed = frame.DrawListedScratch;
Dictionary<uint, int> processedViewCounts = frame.ProcessedViewCountsScratch;
foreach (var cell in candidateCells)
{
@ -534,11 +771,19 @@ public static class PortalVisibilityBuilder
if (seedDistance > maxSeedDistance)
continue;
var clippedRegion = ClipPortalAgainstView(
using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> clippedRegion = clippedRegionLease.Region;
IReadOnlyList<ViewPolygon> activeSeedRegion = seedRegion ?? FullScreenRegion;
ClipPortalAgainstView(
frame,
poly,
cell.WorldTransform,
viewProj,
seedRegion ?? FullScreenRegion,
activeSeedRegion,
0,
activeSeedRegion.Count,
clippedRegion,
out _);
// T2 (BR-4): empty clip = no seed, no exceptions (retail's
@ -547,11 +792,11 @@ public static class PortalVisibilityBuilder
if (clippedRegion.Count == 0)
continue;
var seedView = GetOrCreate(frame.CellViews, cell.CellId);
var seedView = GetOrCreate(frame, frame.CellViews, cell.CellId);
bool grew = AddRegion(seedView, clippedRegion);
if (grew && queued.Add(cell.CellId))
todo.Insert(cell, seedDistance);
InsertTodo(todo, cell, seedDistance);
}
}
@ -560,7 +805,7 @@ public static class PortalVisibilityBuilder
// re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues
// are deleted (empty clip culls, period).
const int RecursionTripwire = 128;
var propagationChain = new uint[RecursionTripwire]; // #120 self-attribution — see Build()
uint[] propagationChain = frame.PropagationChainScratch; // #120 self-attribution — see Build()
void ProcessCellPortals(LoadedCell cell, int depth)
{
@ -580,7 +825,6 @@ public static class PortalVisibilityBuilder
if (processedCount >= endCount)
return;
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
processedViewCounts[cell.CellId] = endCount;
uint lbMask = cell.CellId & 0xFFFF0000u;
@ -602,11 +846,18 @@ public static class PortalVisibilityBuilder
&& !CameraOnInteriorSide(cell, i, cameraPos))
continue;
var clippedRegion = ClipPortalAgainstView(
using PortalVisibilityFrame.ClipRegionScratchLease clippedRegionLease =
frame.RentClipRegionScratch();
List<ViewPolygon> clippedRegion = clippedRegionLease.Region;
ClipPortalAgainstView(
frame,
poly,
cell.WorldTransform,
viewProj,
activeViewPolygons,
currentView.Polygons,
processedCount,
endCount - processedCount,
clippedRegion,
out _);
if (clippedRegion.Count == 0)
@ -617,11 +868,12 @@ public static class PortalVisibilityBuilder
if (neighbour == null)
continue;
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
ApplyReciprocalClip(
frame, clippedRegion, portal.OtherPortalId, portal.Flags, neighbour, viewProj);
if (clippedRegion.Count == 0)
continue;
var nview = GetOrCreate(frame.CellViews, neighbourId);
var nview = GetOrCreate(frame, frame.CellViews, neighbourId);
bool grew = AddRegion(nview, clippedRegion);
if (grew)
@ -629,7 +881,7 @@ public static class PortalVisibilityBuilder
if (queued.Add(neighbourId))
{
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
todo.Insert(neighbour, dist);
InsertTodo(todo, neighbour, dist);
}
else if (drawListed.Contains(neighbourId))
{
@ -641,7 +893,7 @@ public static class PortalVisibilityBuilder
while (todo.Count > 0)
{
var cell = todo.PopNearest();
LoadedCell cell = PopNearest(todo);
if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId);
@ -669,8 +921,16 @@ public static class PortalVisibilityBuilder
Func<uint, LoadedCell?> lookup,
Matrix4x4 viewProj,
float maxSeedDistance = float.PositiveInfinity,
IReadOnlyList<ViewPolygon>? seedRegion = null)
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance, seedRegion);
IReadOnlyList<ViewPolygon>? seedRegion = null,
PortalVisibilityFrame? reuseFrame = null)
=> BuildFromExterior(
buildingCells,
cameraPos,
lookup,
viewProj,
maxSeedDistance,
seedRegion,
reuseFrame);
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
private static readonly Vector2[] FullScreenQuad =
@ -679,30 +939,35 @@ public static class PortalVisibilityBuilder
private static readonly ViewPolygon[] FullScreenRegion =
{ new ViewPolygon(FullScreenQuad) };
private static List<ViewPolygon> ClipPortalAgainstView(
private static void ClipPortalAgainstView(
PortalVisibilityFrame frame,
Vector3[] localPoly,
Matrix4x4 cellToWorld,
Matrix4x4 viewProj,
IReadOnlyList<ViewPolygon> viewPolygons,
int viewStart,
int viewCount,
List<ViewPolygon> clippedRegion,
out int clipVertexCount)
{
var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj);
clipVertexCount = portalClip.Length;
var clippedRegion = new List<ViewPolygon>();
if (portalClip.Length < 3)
return clippedRegion;
using PortalProjection.ClipPolygonLease portalClip =
PortalProjection.ProjectToClipLease(localPoly, cellToWorld, viewProj);
clipVertexCount = portalClip.Count;
if (portalClip.Count < 3)
return;
foreach (var vp in viewPolygons)
int viewEnd = viewStart + viewCount;
for (int i = viewStart; i < viewEnd; i++)
{
ViewPolygon vp = viewPolygons[i];
if (vp.IsEmpty)
continue;
var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices);
var clipped = PortalProjection.ClipToRegion(
portalClip.Span, vp.Vertices, frame.PolygonVertices);
if (clipped.Length >= 3)
clippedRegion.Add(new ViewPolygon(clipped));
}
return clippedRegion;
}
private const int PortalTraceEmitLimit = 160;
@ -913,6 +1178,7 @@ public static class PortalVisibilityBuilder
private const ushort PortalFlagExactMatch = 0x0001;
private static void ApplyReciprocalClip(
PortalVisibilityFrame frame,
List<ViewPolygon> clippedRegion, ushort otherPortalId, ushort portalFlags,
LoadedCell neighbour, Matrix4x4 viewProj)
{
@ -944,23 +1210,37 @@ public static class PortalVisibilityBuilder
// pins this deterministically; the glitch steps die with this change). The old path's other
// rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete:
// CanonicalKey's 1e-3-grid snap dedup (2026-06-06) absorbs re-clip drift by construction.
var reciprocalClip = PortalProjection.ProjectToClip(reciprocalPoly, neighbour.WorldTransform, viewProj);
if (reciprocalClip.Length < 3) return; // reciprocal entirely behind the eye → no constraint (over-include)
using PortalProjection.ClipPolygonLease reciprocalClip =
PortalProjection.ProjectToClipLease(
reciprocalPoly,
neighbour.WorldTransform,
viewProj);
if (reciprocalClip.Count < 3) return; // reciprocal entirely behind the eye → no constraint (over-include)
// Intersect the reciprocal opening into each near-side polygon; drop any that fall away.
// ClipToRegion(subject=homogeneous reciprocal, region=near-side NDC polygon) = the same
// region-edge homogeneous Sutherland-Hodgman the forward hop uses (polyClipFinish port).
for (int k = clippedRegion.Count - 1; k >= 0; k--)
{
var tightened = PortalProjection.ClipToRegion(reciprocalClip, clippedRegion[k].Vertices);
var tightened = PortalProjection.ClipToRegion(
reciprocalClip.Span,
clippedRegion[k].Vertices,
frame.PolygonVertices);
if (tightened.Length >= 3) clippedRegion[k] = new ViewPolygon(tightened);
else clippedRegion.RemoveAt(k);
}
}
private static CellView GetOrCreate(Dictionary<uint, CellView> map, uint key)
private static CellView GetOrCreate(
PortalVisibilityFrame frame,
Dictionary<uint, CellView> map,
uint key)
{
if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; }
if (!map.TryGetValue(key, out var v))
{
v = frame.RentCellView();
map[key] = v;
}
return v;
}
@ -998,30 +1278,22 @@ public static class PortalVisibilityBuilder
/// not-greater entry), so an equal-distance newcomer lands at the tail and pops FIRST —
/// LIFO on ties, matching retail's break-on-first-not-greater + pop-from-tail.
/// </summary>
private sealed class CellTodoList
private static void InsertTodo(
List<(LoadedCell Cell, float Distance)> items,
LoadedCell cell,
float distance)
{
private readonly List<(LoadedCell Cell, float Distance)> _items = new();
int index = items.Count;
while (index > 0 && items[index - 1].Distance < distance)
index--;
items.Insert(index, (cell, distance));
}
public int Count => _items.Count;
public void Insert(LoadedCell cell, float distance)
{
// Find the slot: scan from the tail (nearest) toward the head while existing entries are
// strictly nearer than `distance`, so the newcomer lands just ABOVE every entry that is
// farther-or-equal — i.e. nearest-at-tail order, LIFO on ties (an equal-distance
// newcomer inserts at the tail and pops first).
int idx = _items.Count;
while (idx > 0 && _items[idx - 1].Distance < distance)
idx--;
_items.Insert(idx, (cell, distance));
}
public LoadedCell PopNearest()
{
int last = _items.Count - 1;
var cell = _items[last].Cell;
_items.RemoveAt(last);
return cell;
}
private static LoadedCell PopNearest(List<(LoadedCell Cell, float Distance)> items)
{
int last = items.Count - 1;
LoadedCell cell = items[last].Cell;
items.RemoveAt(last);
return cell;
}
}

View file

@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using AcDream.Content;
using DatReaderWriter;
using Microsoft.Extensions.Logging.Abstractions;
using Silk.NET.OpenGL;
@ -13,7 +14,7 @@ namespace AcDream.App.Rendering;
/// </summary>
public sealed record RenderStack(
GL Gl,
DatReaderWriter.DatCollection Dats,
IDatReaderWriter Dats,
string ShaderDir,
Wb.BindlessSupport Bindless,
TextureCache TextureCache,
@ -26,17 +27,47 @@ public sealed record RenderStack(
AcDream.App.UI.UiDatFont? VitalsDatFont,
AcDream.App.UI.UiDatFont? LargeDatFont) : System.IDisposable
{
internal GpuFrameFlightController FrameFlights { get; init; } = null!;
private ResourceShutdownTransaction? _shutdown;
internal void BeginFrame()
{
FrameFlights.BeginFrame();
UiHost.TextRenderer.BeginFrame(FrameFlights.CurrentSlot);
}
internal void EndFrame() => FrameFlights.EndFrame();
/// <summary>Dispose the GL pieces this stack OWNS (everything created in
/// <see cref="RenderBootstrap.Create"/>). <see cref="Dats"/> + <see cref="Gl"/> are caller-owned
/// and NOT disposed here. Called once at studio teardown.</summary>
public void Dispose()
{
DrawDispatcher.Dispose();
MeshAdapter.Dispose();
TextureCache.Dispose();
MeshShader.Dispose();
LightingUbo.Dispose();
UiHost.Dispose();
_shutdown ??= new ResourceShutdownTransaction(
new ResourceShutdownStage("submitted GPU work",
[
new("frame flight drain", FrameFlights.WaitForSubmittedWork),
]),
new ResourceShutdownStage("draw frontend",
[
new("draw dispatcher", DrawDispatcher.Dispose),
]),
new ResourceShutdownStage("mesh adapter",
[
new("mesh adapter", MeshAdapter.Dispose),
]),
new ResourceShutdownStage("remaining render stack",
[
new("texture cache", TextureCache.Dispose),
new("mesh shader", MeshShader.Dispose),
new("lighting UBO", LightingUbo.Dispose),
new("UI host", UiHost.Dispose),
]),
new ResourceShutdownStage("frame flight owner",
[
new("frame flights", FrameFlights.Dispose),
]));
_shutdown.CompleteOrThrow();
}
/// <summary>
@ -108,7 +139,7 @@ public static class RenderBootstrap
/// </summary>
public static RenderStack Create(
GL gl,
DatReaderWriter.DatCollection dats,
IDatReaderWriter dats,
RenderBootstrapOptions opts)
{
// --- Bindless detection (GameWindow ~1701-1723) ---
@ -132,14 +163,15 @@ public static class RenderBootstrap
Path.Combine(shaderDir, "mesh_modern.frag"));
// --- TextureCache (GameWindow ~1774) ---
var textureCache = new TextureCache(gl, dats, bindless);
var frameFlights = new GpuFrameFlightController(gl);
var textureCache = new TextureCache(gl, dats, bindless, frameFlights);
// --- AnimLoader (GameWindow ~1240) ---
var animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(dats);
// --- WbMeshAdapter (GameWindow ~2286-2287) ---
var wbLogger = NullLogger<Wb.WbMeshAdapter>.Instance;
var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger);
var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger, frameFlights);
// --- SequencerFactory (GameWindow ~2306-2334) ---
var capturedDats = dats;
@ -216,7 +248,10 @@ public static class RenderBootstrap
LightingUbo: lightingUbo,
UiHost: uiHost,
VitalsDatFont: vitalsDatFont,
LargeDatFont: largeDatFont);
LargeDatFont: largeDatFont)
{
FrameFlights = frameFlights,
};
// Pre-seed the font cache with the two already-uploaded atlas instances
// so ResolveDatFont(0x40000000) and ResolveDatFont(0x40000001) hit the cache

View file

@ -0,0 +1,104 @@
namespace AcDream.App.Rendering;
internal readonly record struct ResourceShutdownOperation(string Name, Action Execute);
internal sealed record ResourceShutdownStage(
string Name,
ResourceShutdownOperation[] Operations);
/// <summary>
/// Owns application-level shutdown ordering. Operations within one stage are
/// independent and are all attempted; later stages remain protected until the
/// current stage has converged. Successful operations are never replayed.
/// </summary>
internal sealed class ResourceShutdownTransaction(params ResourceShutdownStage[] stages)
{
private sealed class StageState(int operationCount)
{
public bool[] Complete { get; } = new bool[operationCount];
}
private const int MaximumConsecutiveStalledPasses = 2;
private readonly ResourceShutdownStage[] _stages =
stages ?? throw new ArgumentNullException(nameof(stages));
private readonly StageState[] _states =
stages.Select(stage => new StageState(stage.Operations.Length)).ToArray();
private int _currentStage;
private bool _completing;
public bool IsComplete => _currentStage == _stages.Length;
internal int CurrentStage => _currentStage;
public void CompleteOrThrow()
{
if (_completing || IsComplete)
return;
_completing = true;
try
{
int stalledPasses = 0;
List<Exception>? latestFailures = null;
while (!IsComplete)
{
bool progressed = AdvanceCurrentStage(out List<Exception>? failures);
latestFailures = failures;
if (progressed)
{
stalledPasses = 0;
continue;
}
stalledPasses++;
if (stalledPasses < MaximumConsecutiveStalledPasses)
continue;
ResourceShutdownStage stage = _stages[_currentStage];
throw new AggregateException(
$"Shutdown stage '{stage.Name}' did not converge after retrying its pending operations.",
latestFailures ??
[new InvalidOperationException("The shutdown stage made no progress and reported no failure.")]);
}
}
finally
{
_completing = false;
}
}
private bool AdvanceCurrentStage(out List<Exception>? failures)
{
ResourceShutdownStage stage = _stages[_currentStage];
StageState state = _states[_currentStage];
bool progressed = false;
failures = null;
for (int i = 0; i < stage.Operations.Length; i++)
{
if (state.Complete[i])
continue;
ResourceShutdownOperation operation = stage.Operations[i];
try
{
operation.Execute();
state.Complete[i] = true;
progressed = true;
}
catch (Exception error)
{
(failures ??= []).Add(new InvalidOperationException(
$"Shutdown operation '{operation.Name}' failed in stage '{stage.Name}'.",
error));
}
}
if (state.Complete.All(static complete => complete))
{
_currentStage++;
progressed = true;
}
return progressed;
}
}

View file

@ -25,7 +25,13 @@ internal static class RetailAlphaOrdering
/// </summary>
internal interface IRetailAlphaDrawSource
{
void DrawAlphaBatch(ReadOnlySpan<int> tokens);
/// <summary>Uploads this source's complete payload for the current sorted
/// alpha scope exactly once. Tokens arrive in final far-to-near order.</summary>
void PrepareAlphaDraws(ReadOnlySpan<int> tokens);
/// <summary>Draws a contiguous range from the payload prepared above.</summary>
void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount);
void ResetAlphaSubmissions();
}
@ -55,6 +61,7 @@ internal sealed class RetailAlphaQueue
private readonly List<RetailAlphaSubmission> _submissions = new(256);
private readonly List<IRetailAlphaDrawSource> _sources = new(4);
private int[] _tokenScratch = new int[256];
private int[] _sourceDrawOffsets = new int[4];
private long _nextSequence;
public bool IsCollecting { get; private set; }
@ -109,6 +116,26 @@ internal sealed class RetailAlphaQueue
_submissions.Sort(CompareRetailOrder);
EnsureTokenCapacity(_submissions.Count);
EnsureSourceCapacity(_sources.Count);
Array.Clear(_sourceDrawOffsets, 0, _sources.Count);
// Prepare each renderer once for this alpha scope. The filtered
// token sequence preserves final retail order for that source, so
// every later adjacent source-run maps to one contiguous prepared
// range without another GPU upload.
for (int sourceIndex = 0; sourceIndex < _sources.Count; sourceIndex++)
{
IRetailAlphaDrawSource source = _sources[sourceIndex];
int sourceCount = 0;
for (int i = 0; i < _submissions.Count; i++)
{
RetailAlphaSubmission submission = _submissions[i];
if (ReferenceEquals(submission.Source, source))
_tokenScratch[sourceCount++] = submission.Token;
}
source.PrepareAlphaDraws(_tokenScratch.AsSpan(0, sourceCount));
}
int start = 0;
while (start < _submissions.Count)
@ -120,9 +147,10 @@ internal sealed class RetailAlphaQueue
end++;
int count = end - start;
for (int i = 0; i < count; i++)
_tokenScratch[i] = _submissions[start + i].Token;
source.DrawAlphaBatch(_tokenScratch.AsSpan(0, count));
int sourceIndex = FindSourceIndex(source);
int firstPreparedDraw = _sourceDrawOffsets[sourceIndex];
source.DrawPreparedAlphaBatch(firstPreparedDraw, count);
_sourceDrawOffsets[sourceIndex] += count;
start = end;
}
}
@ -168,4 +196,19 @@ internal sealed class RetailAlphaQueue
return;
Array.Resize(ref _tokenScratch, count + 256);
}
private void EnsureSourceCapacity(int count)
{
if (_sourceDrawOffsets.Length >= count)
return;
Array.Resize(ref _sourceDrawOffsets, count + 4);
}
private int FindSourceIndex(IRetailAlphaDrawSource source)
{
for (int i = 0; i < _sources.Count; i++)
if (ReferenceEquals(_sources[i], source))
return i;
throw new InvalidOperationException("Retail alpha source was not registered for this frame.");
}
}

View file

@ -1,6 +1,7 @@
using AcDream.App.UI;
using AcDream.Core.Textures;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using Silk.NET.Core;
using Silk.NET.Input;
@ -10,7 +11,7 @@ namespace AcDream.App.Rendering;
/// <summary>Applies retail cursor feedback to Silk using dat MediaDescCursor art when available.</summary>
public sealed class RetailCursorManager
{
private readonly DatCollection _dats;
private readonly IDatReaderWriter _dats;
private readonly object _datLock;
private readonly RetailCursorResolver _globalCursors;
private readonly Dictionary<uint, RawImage> _imagesBySurface = new();
@ -22,7 +23,7 @@ public sealed class RetailCursorManager
private UiCursorMedia _lastAppliedCursor;
private StandardCursor? _lastStandardCursor;
public RetailCursorManager(DatCollection dats, object datLock)
public RetailCursorManager(IDatReaderWriter dats, object datLock)
{
_dats = dats;
_datLock = datLock;

View file

@ -1,5 +1,6 @@
using AcDream.App.UI;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering;
@ -7,12 +8,12 @@ namespace AcDream.App.Rendering;
/// <summary>Resolves retail global cursor enum IDs through the portal EnumIDMap chain.</summary>
internal sealed class RetailCursorResolver
{
private readonly DatCollection _dats;
private readonly IDatReaderWriter _dats;
private readonly object _datLock;
private readonly Dictionary<uint, uint> _didByEnum = new();
private readonly HashSet<uint> _missingEnums = new();
public RetailCursorResolver(DatCollection dats, object datLock)
public RetailCursorResolver(IDatReaderWriter dats, object datLock)
{
_dats = dats;
_datLock = datLock;
@ -57,7 +58,7 @@ internal sealed class RetailCursorResolver
{
lock (_datLock)
{
uint masterDid = (uint)_dats.Portal.Header.MasterMapId;
uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId;
if (masterDid == 0)
return 0;

View file

@ -18,9 +18,14 @@ public sealed class RetailPViewRenderer
private readonly ClipFrame _clipFrame;
private readonly EnvCellRenderer _envCells;
private readonly WbDrawDispatcher _entities;
private readonly PortalVisibilityFrame _mainPortalFrameScratch = new();
private readonly ClipFrameAssembly _clipAssemblyScratch = new();
private readonly ViewconeCuller _viewconeScratch = new();
private readonly RetailPViewFrameResult _frameResultScratch = new();
private static readonly ClipViewSlice NoClipSlice =
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>());
private static readonly ClipViewSlice[] NoClipSlices = { NoClipSlice };
private readonly HashSet<uint> _oneCell = new(1);
// Shell-batch scratch: all of a pass's cells collected for ONE batched
@ -28,14 +33,20 @@ public sealed class RetailPViewRenderer
// frames + across look-in buildings. Spec:
// docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md
private readonly HashSet<uint> _shellBatch = new();
// Transparent shell cells retain IndoorDrawPlan's far-to-near order. The
// EnvCell renderer uploads shared instance/command data once, then issues
// range-addressed MDI calls in this exact order.
private readonly List<uint> _orderedTransparentShellCells = new();
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
private readonly Dictionary<uint, List<LoadedCell>> _buildingGroups = new();
private readonly BuildingGroupScratch _buildingGroups = new();
private readonly PortalVisibilityFrame _outdoorBuildingFrameScratch = new();
// #124: per-building look-in frames under an INTERIOR root, drawn as a
// landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the
// main frame (see DrawInside). Rebuilt each interior-root frame.
private readonly List<PortalVisibilityFrame> _lookInFrames = new();
private readonly Stack<PortalVisibilityFrame> _lookInFramePool = new();
private readonly HashSet<uint> _lookInPrepareScratch = new();
// #131/#132: the late landscape phase's scene-particle owner survivors
@ -56,6 +67,7 @@ public sealed class RetailPViewRenderer
// DrawCellObjectLists, RetailPViewFrameResult.DrawableCells) reads it
// synchronously within the same frame it was built.
private readonly HashSet<uint> _drawableCellsScratch = new();
private readonly RetailPViewScratchRetention _scratchRetention = new();
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
@ -79,6 +91,8 @@ public sealed class RetailPViewRenderer
public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx);
RecycleLookInFrames();
ResetBuildingGroups();
var pvFrame = PortalVisibilityBuilder.Build(
ctx.RootCell,
@ -86,7 +100,8 @@ public sealed class RetailPViewRenderer
ctx.CellLookup,
ctx.ViewProjection,
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ,
reuseFrame: _mainPortalFrameScratch);
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via
@ -111,14 +126,17 @@ public sealed class RetailPViewRenderer
// side). Outdoor roots keep the MergeNearbyBuildingFloods path above
// (no depth clear under outdoor roots — the merged form is equivalent
// there).
_lookInFrames.Clear();
if (!ctx.RootCell.IsOutdoorNode
&& ctx.NearbyBuildingCells is not null
&& pvFrame.OutsideView.Polygons.Count > 0)
BuildInteriorRootLookIns(ctx, pvFrame);
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
UploadClipFrame(ctx.SetTerrainClipUbo);
var clipAssembly = ClipFrameAssembler.Assemble(
_clipFrame,
pvFrame,
_clipAssemblyScratch);
int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2);
PrepareClipFrame(ctx.SetTerrainClipUbo, terrainUploadCount);
// R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the
// assembler handed a clip-slot. This feeds the Prepare filter + entity partition,
@ -161,13 +179,11 @@ public sealed class RetailPViewRenderer
InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries);
var partition = _partitionResult;
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,
ClipAssembly = clipAssembly,
DrawableCells = drawableCells,
Partition = partition,
};
RetailPViewFrameResult result = _frameResultScratch.Reset(
pvFrame,
clipAssembly,
drawableCells,
partition);
ctx.EmitDiagnostics?.Invoke(result);
@ -183,7 +199,10 @@ public sealed class RetailPViewRenderer
// T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view,
// never clipped (Ghidra 0x0054c250). Built once per frame from the
// assembled slices + this frame's view-projection.
var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection);
var viewcone = ViewconeCuller.Build(
clipAssembly,
ctx.ViewProjection,
_viewconeScratch);
// #118: stage assignment for dynamics under an INTERIOR root. Retail
// draws the OUTSIDE world's objects inside the landscape stage —
@ -224,36 +243,22 @@ public sealed class RetailPViewRenderer
// R-A2: group the nearby building cells by BuildingId and run one per-building flood per group
// (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The
// grouping dict is reused across frames; inner lists are cleared each frame so a building that left
// the near set simply contributes an empty (skipped) group.
// grouping dict contains only this frame's keys; lists are pooled across frames.
private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{
foreach (var group in _buildingGroups.Values)
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
// R-A2 seam fix: a cell without a BuildingId (unstamped, or outdoor-adjacent with an exit
// portal) must STILL flood — the pre-R-A2 node flood reached it via a reverse portal, so
// dropping it (the original `continue`) left holes at building/terrain seams. Key it by its
// own CellId → a singleton per-entrance flood: a cell with an exit portal seeds from it, a
// cell with none contributes nothing (same as before). BuildingId/CellId key collisions are
// harmless — BuildFromExterior seeds each exit-portal cell in a group independently.
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List<LoadedCell>();
_buildingGroups[groupKey] = group;
}
group.Add(cell);
}
RebuildBuildingGroups(ctx.NearbyBuildingCells!);
foreach (var group in _buildingGroups.Values)
{
if (group.Count == 0)
continue;
var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, OutdoorBuildingSeedDistance);
group,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection,
OutdoorBuildingSeedDistance,
reuseFrame: _outdoorBuildingFrameScratch);
MergeBuildingFrame(pvFrame, buildingFrame);
}
}
@ -277,15 +282,19 @@ public sealed class RetailPViewRenderer
if (!src.CellViews.TryGetValue(cellId, out var srcView))
continue;
if (target.CellViews.TryGetValue(cellId, out var existing))
if (!target.CellViews.TryGetValue(cellId, out var existing))
{
foreach (var p in srcView.Polygons)
existing.Add(p);
continue;
existing = target.RentCellView();
target.CellViews[cellId] = existing;
target.OrderedVisibleCells.Add(cellId);
}
target.CellViews[cellId] = srcView;
target.OrderedVisibleCells.Add(cellId);
// Copy the view polygons into storage owned by the target frame.
// Source building frames are reused immediately for the next
// building flood, so retaining their CellView reference would
// alias pooled scratch and mutate the merged main view.
foreach (var p in srcView.Polygons)
existing.Add(target.CopyPolygon(p.Vertices));
}
}
@ -297,32 +306,51 @@ public sealed class RetailPViewRenderer
// building self-excludes via the seed eye-side test.
private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{
foreach (var group in _buildingGroups.Values)
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List<LoadedCell>();
_buildingGroups[groupKey] = group;
}
group.Add(cell);
}
RebuildBuildingGroups(ctx.NearbyBuildingCells!);
foreach (var group in _buildingGroups.Values)
{
if (group.Count == 0)
continue;
PortalVisibilityFrame frameScratch = _lookInFramePool.Count != 0
? _lookInFramePool.Pop()
: new PortalVisibilityFrame();
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection,
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons);
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons,
reuseFrame: frameScratch);
if (frame.OrderedVisibleCells.Count > 0)
_lookInFrames.Add(frame);
else
ReturnLookInFrame(frame);
}
}
private void RecycleLookInFrames()
{
for (int i = 0; i < _lookInFrames.Count; i++)
ReturnLookInFrame(_lookInFrames[i]);
_scratchRetention.ClearFrameBuffers(
_lookInFrames,
_lookInPrepareScratch,
_drawableCellsScratch,
_shellBatch,
_orderedTransparentShellCells);
}
private void ReturnLookInFrame(PortalVisibilityFrame frame)
{
frame.ResetForBuild();
if (_lookInFramePool.Count < RetailPViewScratchRetention.MaxRetainedLookInFrames)
_lookInFramePool.Push(frame);
}
private void RebuildBuildingGroups(IReadOnlyList<LoadedCell> nearbyCells)
=> _buildingGroups.Rebuild(nearbyCells);
private void ResetBuildingGroups()
=> _buildingGroups.Reset();
// #124: draw the interior-root look-ins INSIDE the landscape stage —
// retail's placement (LScape::draw → DrawBlock → DrawSortCell →
// DrawBuilding runs as the FIRST call of DrawCells' outside-view branch,
@ -355,17 +383,15 @@ public sealed class RetailPViewRenderer
continue;
foreach (var poly in view.Polygons)
{
var single = new CellView();
single.Add(poly);
var cps = ClipPlaneSet.From(single);
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
var planes = new Vector4[cps.Count];
for (int p = 0; p < cps.Count; p++)
planes[p] = cps.Planes[p];
ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext(
cellId,
new ClipViewSlice(0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), planes),
new ClipViewSlice(
0,
new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY),
cps.PlaneArray),
Array.Empty<WorldEntity>()));
}
}
@ -454,7 +480,7 @@ public sealed class RetailPViewRenderer
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
UploadTerrainClip(ctx.SetTerrainClipUbo);
// T3 (BR-5): entities are never hard-clipped — retail viewcone-
// CHECKS each mesh's sphere against the view (Ghidra 0x0054c250)
// and draws it whole. The old per-slice entity clip routing
@ -490,7 +516,7 @@ public sealed class RetailPViewRenderer
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
UploadTerrainClip(ctx.SetTerrainClipUbo);
_entities.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors
@ -595,7 +621,7 @@ public sealed class RetailPViewRenderer
// the outside slice's slot + NDC AABB + planes (CPU side), the region-SSBO bytes decoded
// at that slot (what mesh_modern.vert reads for routed instances), the terrain-UBO head
// (what terrain/sky gate against), and the CellIdToSlot routing table. Fires AFTER
// SetTerrainClip + UploadClipFrame + SetClipRouting, BEFORE DrawLandscapeSlice — so the
// SetTerrainClip + UploadTerrainClip + SetClipRouting, BEFORE DrawLandscapeSlice — so the
// printed bytes are exactly what this slice's draws consume.
private void EmitClipRouteProbe(ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex)
{
@ -626,7 +652,7 @@ public sealed class RetailPViewRenderer
}
sb.Append('}');
// Region-SSBO content decoded at the routed slot, from the packed bytes UploadClipFrame
// Region-SSBO content decoded at the routed slot, from the packed bytes UploadRegions
// just uploaded — slot stride 144: count uint at +0, planes[8] at +16.
var rb = _clipFrame.RegionBytesForTest;
int off = slice.Slot * ClipFrame.CellClipStrideBytes;
@ -715,17 +741,17 @@ public sealed class RetailPViewRenderer
if (_shellBatch.Count > 0)
_envCells.Render(WbRenderPass.Opaque, _shellBatch);
// Transparent: far→near order matters for compositing, so keep these
// per-cell in ShellPass order — but skip cells with no transparent
// geometry (most are opaque-only walls/floors), removing the bulk of the
// per-cell transparent Render calls.
// Transparent: far-to-near order matters for compositing. The ordered
// list retains ShellPass cell boundaries while EnvCellRenderer shares
// one instance/command/light upload across the complete pass.
_orderedTransparentShellCells.Clear();
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
if (!_envCells.CellHasTransparent(entry.CellId)) continue;
_oneCell.Clear();
_oneCell.Add(entry.CellId);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
if (_envCells.CellHasTransparent(entry.CellId))
_orderedTransparentShellCells.Add(entry.CellId);
}
if (_orderedTransparentShellCells.Count > 0)
_envCells.RenderTransparentOrdered(_orderedTransparentShellCells);
}
// T1: the frame's single LAST entity pass — ALL server-spawned dynamics
@ -994,7 +1020,7 @@ public sealed class RetailPViewRenderer
&& slices.Length > 0)
return slices;
return new[] { NoClipSlice };
return NoClipSlices;
}
private void UseIndoorMembershipOnlyRouting()
@ -1026,19 +1052,25 @@ public sealed class RetailPViewRenderer
animatedEntityIds: ctx.AnimatedEntityIds);
}
private void RestoreNoClip(Action<uint> setTerrainClipUbo)
private void PrepareClipFrame(
Action<TerrainClipBufferBinding> setTerrainClipUbo,
int terrainUploadCount)
{
_clipFrame.Reset();
UploadClipFrame(setTerrainClipUbo);
UseIndoorMembershipOnlyRouting();
}
private void UploadClipFrame(Action<uint> setTerrainClipUbo)
{
_clipFrame.UploadShared(_gl);
// Allocate every terrain record before issuing the first draw. BufferData
// therefore never replaces the arena's backing store while an earlier
// slice can still reference it. The large region table is immutable for
// this assembled PView frame and is uploaded exactly once.
_clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount);
_clipFrame.UploadRegions(_gl);
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
setTerrainClipUbo(_clipFrame.TerrainUbo);
UploadTerrainClip(setTerrainClipUbo);
}
private void UploadTerrainClip(Action<TerrainClipBufferBinding> setTerrainClipUbo)
{
TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl);
setTerrainClipUbo(binding);
}
}
@ -1067,6 +1099,168 @@ public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; }
}
/// <summary>
/// Capacity policy for renderer-owned, one-frame scratch. Normal warmed frames
/// retain their storage; a pathological visibility spike is released at the
/// next frame boundary instead of becoming permanent process memory.
/// </summary>
internal sealed class RetailPViewScratchRetention
{
internal const int MaxRetainedLookInFrames = 32;
internal const int MaxRetainedCellItems = 512;
internal const int CapacityTrimIdleFrames = 120;
private int _lookInFramesUnderusedFrames;
private int _lookInPrepareUnderusedFrames;
private int _drawableCellsUnderusedFrames;
private int _shellBatchUnderusedFrames;
private int _orderedTransparentUnderusedFrames;
internal void ClearFrameBuffers(
List<PortalVisibilityFrame> lookInFrames,
HashSet<uint> lookInPrepare,
HashSet<uint> drawableCells,
HashSet<uint> shellBatch,
List<uint> orderedTransparentShellCells)
{
ClearCold(
lookInFrames,
MaxRetainedLookInFrames,
ref _lookInFramesUnderusedFrames);
ClearCold(
lookInPrepare,
MaxRetainedCellItems,
ref _lookInPrepareUnderusedFrames);
ClearCold(
drawableCells,
MaxRetainedCellItems,
ref _drawableCellsUnderusedFrames);
ClearCold(shellBatch, MaxRetainedCellItems, ref _shellBatchUnderusedFrames);
ClearCold(
orderedTransparentShellCells,
MaxRetainedCellItems,
ref _orderedTransparentUnderusedFrames);
}
private static void ClearCold<T>(
List<T> values,
int maximumRetainedCapacity,
ref int underusedFrames)
{
int usedCount = values.Count;
int capacity = values.Capacity;
values.Clear();
if (!ShouldTrim(
capacity,
usedCount,
maximumRetainedCapacity,
ref underusedFrames))
return;
if (capacity > maximumRetainedCapacity)
values.Capacity = 0;
}
private static void ClearCold<T>(
HashSet<T> values,
int maximumRetainedCapacity,
ref int underusedFrames)
{
int usedCount = values.Count;
int capacity = values.EnsureCapacity(0);
values.Clear();
if (!ShouldTrim(
capacity,
usedCount,
maximumRetainedCapacity,
ref underusedFrames))
return;
if (capacity > maximumRetainedCapacity)
values.TrimExcess();
}
private static bool ShouldTrim(
int capacity,
int usedCount,
int maximumRetainedCapacity,
ref int underusedFrames)
{
if (capacity <= maximumRetainedCapacity || (long)usedCount * 2L > capacity)
{
underusedFrames = 0;
return false;
}
underusedFrames++;
if (underusedFrames < CapacityTrimIdleFrames)
return false;
underusedFrames = 0;
return true;
}
}
/// <summary>
/// Frame-scoped grouping for retail's per-building portal floods. Active keys
/// are rebuilt in nearby-cell encounter order every frame; the value lists are
/// retained through a bounded pool so travelling through the world cannot turn
/// every historical building id into permanent memory or per-frame scan work.
/// </summary>
internal sealed class BuildingGroupScratch
{
internal const int MaxRetainedGroups = 256;
internal const int MaxRetainedCellsPerGroup = 256;
private readonly Dictionary<uint, List<LoadedCell>> _active = new();
private readonly Stack<List<LoadedCell>> _listPool = new();
internal Dictionary<uint, List<LoadedCell>>.ValueCollection Values => _active.Values;
internal IReadOnlyDictionary<uint, List<LoadedCell>> Groups => _active;
internal int ActiveGroupCount => _active.Count;
internal int RetainedListCount => _listPool.Count;
internal int MapCapacity => _active.EnsureCapacity(0);
internal void Rebuild(IReadOnlyList<LoadedCell> nearbyCells)
{
ArgumentNullException.ThrowIfNull(nearbyCells);
Reset();
for (int i = 0; i < nearbyCells.Count; i++)
{
LoadedCell cell = nearbyCells[i];
// R-A2 seam behavior: an unstamped cell still gets a singleton
// entrance flood keyed by CellId.
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_active.TryGetValue(groupKey, out List<LoadedCell>? group))
{
group = _listPool.Count != 0
? _listPool.Pop()
: new List<LoadedCell>();
_active.Add(groupKey, group);
}
group.Add(cell);
}
}
internal void Reset()
{
foreach (List<LoadedCell> group in _active.Values)
{
group.Clear();
if (group.Capacity <= MaxRetainedCellsPerGroup
&& _listPool.Count < MaxRetainedGroups)
{
_listPool.Push(group);
}
}
_active.Clear();
if (_active.EnsureCapacity(0) > MaxRetainedGroups)
_active.TrimExcess();
}
}
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
{
public required LoadedCell RootCell { get; init; }
@ -1089,7 +1283,7 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<uint> SetTerrainClipUbo { get; init; }
public required Action<TerrainClipBufferBinding> SetTerrainClipUbo { get; init; }
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
/// <summary>#131/#132: the LATE landscape phase, per slice, after the #124
@ -1115,15 +1309,38 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
/// remains open for the final world alpha scope.</summary>
public Action? FlushLandscapeAlpha { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
/// <summary>
/// Synchronous observation of the borrowed current-frame result. The
/// callback must copy anything it needs to retain beyond this invocation.
/// </summary>
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}
/// <summary>
/// Borrowed renderer scratch valid only until the next
/// <see cref="RetailPViewRenderer.DrawInside"/> call. Collections and nested
/// frame objects are deliberately reused to keep the render loop allocation
/// free; consumers must copy any state they need to retain asynchronously.
/// </summary>
public sealed class RetailPViewFrameResult
{
public required PortalVisibilityFrame PortalFrame { get; init; }
public required ClipFrameAssembly ClipAssembly { get; init; }
public required HashSet<uint> DrawableCells { get; init; }
public required InteriorEntityPartition.Result Partition { get; init; }
public PortalVisibilityFrame PortalFrame { get; private set; } = null!;
public ClipFrameAssembly ClipAssembly { get; private set; } = null!;
public HashSet<uint> DrawableCells { get; private set; } = null!;
public InteriorEntityPartition.Result Partition { get; private set; } = null!;
internal RetailPViewFrameResult Reset(
PortalVisibilityFrame portalFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
InteriorEntityPartition.Result partition)
{
PortalFrame = portalFrame;
ClipAssembly = clipAssembly;
DrawableCells = drawableCells;
Partition = partition;
return this;
}
}
public readonly record struct RetailPViewLandscapeSliceContext(

View file

@ -1,5 +1,6 @@
using System;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Lighting;
using Silk.NET.OpenGL;
@ -23,27 +24,70 @@ namespace AcDream.App.Rendering;
public sealed unsafe class SceneLightingUboBinding : IDisposable
{
private readonly GL _gl;
private readonly uint _ubo;
private uint _ubo;
private readonly List<uint>[] _buffersByFrame = [[], [], []];
private int _frameSlot;
private int _bufferCursor;
private bool _frameStarted;
private bool _disposed;
internal int DynamicBufferCount => _buffersByFrame.Sum(buffers => buffers.Count);
public SceneLightingUboBinding(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_ubo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo);
// Pre-allocate with the final size; BufferSubData each frame.
_gl.BufferData(
BufferTargetARB.UniformBuffer,
(nuint)SceneLightingUbo.SizeInBytes,
(void*)0,
BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
}
// Bind the buffer to the chosen binding point exactly once — shaders
// that declare this binding in their layout block will read from it
// on every draw without further intervention.
_gl.BindBufferBase(BufferTargetARB.UniformBuffer,
SceneLightingUbo.BindingPoint, _ubo);
/// <summary>
/// Resets the lighting-submission cursor for a GPU-fenced frame slot.
/// World, portal-space, and paperdoll draws can each upload different
/// lighting in one frame, so every upload receives distinct storage.
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_buffersByFrame.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_frameSlot = frameSlot;
_bufferCursor = 0;
_frameStarted = true;
}
private void ActivateNextBuffer()
{
if (!_frameStarted)
throw new InvalidOperationException("BeginFrame must be called before uploading scene lighting.");
List<uint> buffers = _buffersByFrame[_frameSlot];
if (_bufferCursor == buffers.Count)
{
uint buffer = TrackedGlResource.CreateBuffer(
_gl,
"SceneLighting frame UBO creation");
try
{
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.UniformBuffer,
buffer,
0,
SceneLightingUbo.SizeInBytes,
GLEnum.DynamicDraw,
"SceneLighting frame UBO allocation");
buffers.Add(buffer);
}
catch
{
TrackedGlResource.DeleteBuffer(
_gl,
buffer,
0,
"SceneLighting frame UBO rollback");
throw;
}
}
_ubo = buffers[_bufferCursor++];
}
/// <summary>
@ -52,16 +96,30 @@ public sealed unsafe class SceneLightingUboBinding : IDisposable
/// </summary>
public void Upload(SceneLightingUbo data)
{
ActivateNextBuffer();
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo);
_gl.BufferSubData(BufferTargetARB.UniformBuffer,
(nint)0, (nuint)SceneLightingUbo.SizeInBytes, &data);
_gl.BindBufferBase(BufferTargetARB.UniformBuffer,
SceneLightingUbo.BindingPoint, _ubo);
_gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
}
public void Dispose()
{
if (_disposed) return;
_gl.DeleteBuffer(_ubo);
foreach (List<uint> buffers in _buffersByFrame)
{
foreach (uint buffer in buffers)
{
TrackedGlResource.DeleteBuffer(
_gl,
buffer,
SceneLightingUbo.SizeInBytes,
"SceneLighting frame UBO disposal");
}
buffers.Clear();
}
_disposed = true;
}
}

View file

@ -1,4 +1,5 @@
using System.Numerics;
using AcDream.Content;
using AcDream.Core.Selection;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
@ -11,13 +12,13 @@ namespace AcDream.App.Rendering.Selection;
/// </summary>
internal sealed class RetailSelectionGeometryCache
{
private readonly DatCollection _dats;
private readonly IDatReaderWriter _dats;
private readonly object _datLock;
// Render-thread owned. Dictionary permits a cached null for a GfxObj which
// legitimately has no drawing BSP; ConcurrentDictionary does not.
private readonly Dictionary<uint, RetailSelectionMesh?> _cache = new();
public RetailSelectionGeometryCache(DatCollection dats, object datLock)
public RetailSelectionGeometryCache(IDatReaderWriter dats, object datLock)
{
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));

View file

@ -6,6 +6,7 @@ namespace AcDream.App.Rendering;
public sealed class Shader : IDisposable
{
private readonly GL _gl;
private readonly Dictionary<string, int> _uniformLocations = new(StringComparer.Ordinal);
public uint Program { get; }
public Shader(GL gl, string vertexPath, string fragmentPath)
@ -42,39 +43,54 @@ public sealed class Shader : IDisposable
public unsafe void SetMatrix4(string name, Matrix4x4 m)
{
int loc = _gl.GetUniformLocation(Program, name);
int loc = GetUniformLocation(name);
_gl.UniformMatrix4(loc, 1, false, (float*)&m);
}
public void SetInt(string name, int value)
{
int loc = _gl.GetUniformLocation(Program, name);
int loc = GetUniformLocation(name);
_gl.Uniform1(loc, value);
}
public void SetFloat(string name, float value)
{
int loc = _gl.GetUniformLocation(Program, name);
int loc = GetUniformLocation(name);
_gl.Uniform1(loc, value);
}
public void SetVec3(string name, Vector3 v)
{
int loc = _gl.GetUniformLocation(Program, name);
int loc = GetUniformLocation(name);
_gl.Uniform3(loc, v.X, v.Y, v.Z);
}
public void SetVec2(string name, Vector2 v)
{
int loc = _gl.GetUniformLocation(Program, name);
int loc = GetUniformLocation(name);
_gl.Uniform2(loc, v.X, v.Y);
}
public void SetVec4(string name, Vector4 v)
{
int loc = _gl.GetUniformLocation(Program, name);
int loc = GetUniformLocation(name);
_gl.Uniform4(loc, v.X, v.Y, v.Z, v.W);
}
public void Dispose() => _gl.DeleteProgram(Program);
private int GetUniformLocation(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
if (_uniformLocations.TryGetValue(name, out int location))
return location;
location = _gl.GetUniformLocation(Program, name);
_uniformLocations.Add(name, location);
return location;
}
public void Dispose()
{
_uniformLocations.Clear();
_gl.DeleteProgram(Program);
}
}

View file

@ -11,7 +11,7 @@ struct InstanceData {
struct BatchData {
uvec2 textureHandle; // bindless handle for sampler2DArray
uint textureLayer; // layer index (always 0 for per-instance composites)
uint textureLayer; // layer in the shared WB or pooled composite array
uint flags; // reserved — N.5 dispatcher owns all blend state
// (glBlendFunc per pass). If a future phase wants
// shader-side per-batch additive flag (Decision 2

View file

@ -6,6 +6,7 @@ using AcDream.Core.Meshing;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;
@ -45,7 +46,7 @@ namespace AcDream.App.Rendering.Sky;
public sealed unsafe class SkyRenderer : IDisposable
{
private readonly GL _gl;
private readonly DatCollection _dats;
private readonly IDatReaderWriter _dats;
private readonly Shader _shader;
private readonly TextureCache _textures;
private readonly SamplerCache _samplers;
@ -62,7 +63,7 @@ public sealed unsafe class SkyRenderer : IDisposable
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 1_000_000f;
public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures, SamplerCache samplers)
public SkyRenderer(GL gl, IDatReaderWriter dats, Shader shader, TextureCache textures, SamplerCache samplers)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dats = dats ?? throw new ArgumentNullException(nameof(dats));

View file

@ -0,0 +1,242 @@
namespace AcDream.App.Rendering;
/// <summary>
/// One standalone, resident bindless texture used by particle billboards.
/// Unlike entity composites, the shader always samples layer zero.
/// </summary>
internal sealed class StandaloneBindlessTextureResource
{
public required uint SurfaceId { get; init; }
public required uint Name { get; init; }
public required ulong Handle { get; init; }
public required long Bytes { get; init; }
}
internal interface IStandaloneBindlessTextureBackend
{
void MakeNonResident(StandaloneBindlessTextureResource resource);
void Delete(StandaloneBindlessTextureResource resource);
}
/// <summary>
/// Shares exact DAT-decoded particle textures between live emitter owners.
/// The final owner moves a resource into a small LRU reuse pool; exceeding
/// either cache bound logically evicts the oldest entry immediately and
/// retires its resident handle/backing texture behind the frame-flight fence.
/// Live resources are never evicted, so this policy cannot change visuals.
/// Render-thread only.
/// </summary>
internal sealed class StandaloneBindlessTextureCache : IDisposable
{
internal const long DefaultUnownedBudgetBytes = 32L * 1024 * 1024;
internal const int DefaultMaximumUnownedCount = 256;
internal const int DefaultMaximumEvictionsPerFrame = 1;
private readonly IStandaloneBindlessTextureBackend _backend;
private readonly GpuRetirementLedger _retirementLedger;
private readonly OwnerScopedResourceRegistry<uint> _owners = new();
private readonly BoundedUnownedResourceCache<uint> _unowned;
private readonly Dictionary<uint, StandaloneBindlessTextureResource> _entries = new();
private readonly HashSet<uint> _disposeResidencyReleased = [];
private readonly HashSet<uint> _disposeDeleted = [];
private bool _disposeRequested;
private bool _disposing;
private bool _disposed;
public StandaloneBindlessTextureCache(
IStandaloneBindlessTextureBackend backend,
IGpuResourceRetirementQueue retirementQueue,
long unownedBudgetBytes = DefaultUnownedBudgetBytes,
int maximumUnownedCount = DefaultMaximumUnownedCount)
{
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
ArgumentNullException.ThrowIfNull(retirementQueue);
_retirementLedger = new GpuRetirementLedger(retirementQueue);
_unowned = new BoundedUnownedResourceCache<uint>(
unownedBudgetBytes,
maximumUnownedCount);
}
internal int EntryCount => _entries.Count;
internal int ActiveResourceCount => _owners.ResourceCount;
internal int OwnerCount => _owners.OwnerCount;
internal int UnownedEntryCount => _unowned.Count;
internal long UnownedBytes => _unowned.ResidentBytes;
internal int AwaitingRetirementPublicationCount =>
_retirementLedger.AwaitingPublicationCount;
public bool TryAcquire(
uint ownerId,
uint surfaceId,
out StandaloneBindlessTextureResource resource)
{
ObjectDisposedException.ThrowIf(_disposeRequested, this);
ValidateOwnerAndSurface(ownerId, surfaceId);
if (!_entries.TryGetValue(surfaceId, out resource!))
return false;
_owners.Acquire(ownerId, surfaceId);
_unowned.MarkOwned(surfaceId);
return true;
}
public void AddAndAcquire(
uint ownerId,
StandaloneBindlessTextureResource resource)
{
ObjectDisposedException.ThrowIf(_disposeRequested, this);
ArgumentNullException.ThrowIfNull(resource);
ValidateOwnerAndSurface(ownerId, resource.SurfaceId);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Handle);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resource.Bytes);
if (!_entries.TryAdd(resource.SurfaceId, resource))
throw new InvalidOperationException(
$"Standalone particle surface 0x{resource.SurfaceId:X8} is already cached.");
_owners.Acquire(ownerId, resource.SurfaceId);
}
public void ReleaseOwner(uint ownerId)
{
ObjectDisposedException.ThrowIf(_disposeRequested, this);
if (ownerId == 0)
return;
IReadOnlyList<uint> newlyUnowned = _owners.ReleaseOwner(ownerId);
for (int i = 0; i < newlyUnowned.Count; i++)
{
uint surfaceId = newlyUnowned[i];
if (_entries.TryGetValue(surfaceId, out StandaloneBindlessTextureResource? resource))
_unowned.MarkUnowned(surfaceId, resource.Bytes);
}
}
internal void VisitEntries(Action<StandaloneBindlessTextureResource> visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
foreach (StandaloneBindlessTextureResource resource in _entries.Values)
visitor(resource);
}
/// <summary>
/// Advances logical eviction at a bounded rate. Owner release only marks
/// resources reusable; this render-frame maintenance edge prevents a
/// portal unload from submitting hundreds of bindless destruction calls
/// to the same GPU fence serial.
/// </summary>
public void Tick(int maximumEvictions = DefaultMaximumEvictionsPerFrame)
{
ObjectDisposedException.ThrowIf(_disposeRequested, this);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumEvictions);
_retirementLedger.RetryPendingPublications();
for (int i = 0; i < maximumEvictions; i++)
{
if (!_unowned.TryTakeOldestOverBudget(out uint surfaceId))
break;
if (!_entries.Remove(surfaceId, out StandaloneBindlessTextureResource? resource))
continue;
// The publication ledger owns this release from this point even
// when queue admission or an immediate callback throws. Never
// republish a texture after residency release has started.
_retirementLedger.Retire(new RetryableGpuResourceRelease(
() => _backend.MakeNonResident(resource),
() => _backend.Delete(resource)));
}
}
private static void ValidateOwnerAndSurface(uint ownerId, uint surfaceId)
{
ArgumentOutOfRangeException.ThrowIfZero(ownerId);
ArgumentOutOfRangeException.ThrowIfZero(surfaceId);
}
public void Dispose()
{
if (_disposed || _disposing)
return;
_disposeRequested = true;
_disposing = true;
try
{
// GameWindow drains the frame-flight queue before TextureCache
// teardown. Release every handle before deleting any texture so the
// ARB_bindless_texture lifetime ordering remains explicit.
List<Exception>? failures = null;
try
{
_retirementLedger.RetryPendingPublications();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
foreach (StandaloneBindlessTextureResource resource in _entries.Values)
{
if (_disposeResidencyReleased.Contains(resource.SurfaceId))
continue;
try
{
_backend.MakeNonResident(resource);
_disposeResidencyReleased.Add(resource.SurfaceId);
}
catch (Exception ex)
{
(failures ??= []).Add(ex);
}
}
foreach (StandaloneBindlessTextureResource resource in _entries.Values)
{
if (!_disposeResidencyReleased.Contains(resource.SurfaceId)
|| _disposeDeleted.Contains(resource.SurfaceId))
{
continue;
}
try
{
_backend.Delete(resource);
_disposeDeleted.Add(resource.SurfaceId);
}
catch (Exception ex)
{
(failures ??= []).Add(ex);
}
}
if (_disposeDeleted.Count != 0)
{
foreach (uint surfaceId in _disposeDeleted)
_entries.Remove(surfaceId);
}
if (_entries.Count == 0
&& _retirementLedger.AwaitingPublicationCount == 0)
{
_owners.Clear();
_unowned.Clear();
_disposeResidencyReleased.Clear();
_disposeDeleted.Clear();
_disposed = true;
}
if (failures is not null)
{
throw new AggregateException(
"One or more standalone particle textures failed to retire.",
failures);
}
}
finally
{
_disposing = false;
}
}
}

View file

@ -1,5 +1,6 @@
using AcDream.Core.Textures;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;
@ -118,7 +119,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// for the mapping from TerrainTextureType to SurfaceTexture id, decoding each
/// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
/// </summary>
public static TerrainAtlas Build(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null)
public static TerrainAtlas Build(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
{
var region = dats.Get<Region>(0x13000000u)
?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
@ -251,7 +252,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes);
private static AlphaAtlasBuildResult BuildAlphaAtlas(
GL gl, DatCollection dats, DatReaderWriter.Types.TexMerge texMerge)
GL gl, IDatReaderWriter dats, DatReaderWriter.Types.TexMerge texMerge)
{
var decoded = new List<DecodedTexture>();
var cornerLayers = new List<byte>();
@ -364,7 +365,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
cornerTCodes, sideTCodes, roadRCodes);
}
private static bool TryDecodeAlphaMap(DatCollection dats, uint surfaceTextureId, out DecodedTexture decoded)
private static bool TryDecodeAlphaMap(IDatReaderWriter dats, uint surfaceTextureId, out DecodedTexture decoded)
{
decoded = DecodedTexture.Magenta;

View file

@ -39,7 +39,10 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
/// anisotropic level mid-session via <see cref="TerrainAtlas.SetAnisotropic"/>.</summary>
public TerrainAtlas Atlas => _atlas;
private readonly TerrainSlotAllocator _alloc;
private readonly GpuRetiredTerrainSlotAllocator _alloc;
private readonly GpuRetirementLedger _retirementLedger;
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposed;
// Per-slot live data (index by slot integer; null entries are unused slots).
private SlotData?[] _slots;
@ -51,14 +54,31 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private uint _globalVao;
private uint _globalVbo;
private uint _globalEbo;
private long _globalVboCapacityBytes;
private long _globalEboCapacityBytes;
private uint _indirectBuffer;
private int _indirectCapacity;
private sealed class DynamicIndirectBuffer
{
public uint Buffer;
public int Capacity;
}
private readonly List<DynamicIndirectBuffer>[] _indirectBuffersByFrame =
[[], [], []];
private int _dynamicFrameSlot;
private int _dynamicBufferCursor;
private bool _dynamicFrameStarted;
internal int DynamicIndirectBufferCount =>
_indirectBuffersByFrame.Sum(frameBuffers => frameBuffers.Count);
// Phase U.3: terrain clip UBO (binding=2, terrain_modern.vert TerrainClip).
// The shared one is created + uploaded by the GameWindow-level ClipFrame and
// handed in via SetClipUbo. When 0, we bind a lazily-created no-clip fallback
// (count 0 = ungated) so the shader never reads an unbound UBO at binding=2.
private uint _sharedClipUbo;
private TerrainClipBufferBinding _sharedClipBinding;
private uint _fallbackClipUbo;
// Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4).
@ -91,12 +111,31 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
Shader shader,
TerrainAtlas atlas,
int initialSlotCapacity = 64)
: this(
gl,
bindless,
shader,
atlas,
ImmediateGpuResourceRetirementQueue.Instance,
initialSlotCapacity)
{
}
internal TerrainModernRenderer(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
IGpuResourceRetirementQueue resourceRetirement,
int initialSlotCapacity = 64)
{
_gl = gl;
_bindless = bindless;
_shader = shader;
_atlas = atlas;
_alloc = new TerrainSlotAllocator(initialSlotCapacity);
ArgumentNullException.ThrowIfNull(resourceRetirement);
_retirementLedger = new GpuRetirementLedger(resourceRetirement);
_alloc = new GpuRetiredTerrainSlotAllocator(initialSlotCapacity, resourceRetirement);
_slots = new SlotData?[initialSlotCapacity];
_uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle");
@ -105,22 +144,105 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
if (_uTexTilingLoc < 0)
throw new InvalidOperationException("terrain_modern.frag is missing the required uTexTiling uniform.");
_globalVao = _gl.GenVertexArray();
_globalVbo = _gl.GenBuffer();
_globalEbo = _gl.GenBuffer();
AllocateGpuBuffers(initialSlotCapacity);
ConfigureVao();
try
{
_globalVao = TrackedGlResource.CreateVertexArray(
_gl,
"creating terrain global VAO");
_globalVbo = TrackedGlResource.CreateBuffer(
_gl,
"creating terrain global vertex buffer");
_globalEbo = TrackedGlResource.CreateBuffer(
_gl,
"creating terrain global index buffer");
AllocateGpuBuffers(initialSlotCapacity);
ConfigureVao(_globalVao, _globalVbo, _globalEbo);
}
catch
{
TrackedGlResource.DeleteVertexArray(
_gl,
_globalVao,
"rolling back terrain global VAO");
TrackedGlResource.DeleteBuffer(
_gl,
_globalVbo,
_globalVboCapacityBytes,
"rolling back terrain global vertex buffer");
TrackedGlResource.DeleteBuffer(
_gl,
_globalEbo,
_globalEboCapacityBytes,
"rolling back terrain global index buffer");
_globalVao = 0;
_globalVbo = 0;
_globalEbo = 0;
_globalVboCapacityBytes = 0;
_globalEboCapacityBytes = 0;
throw;
}
_indirectBuffer = _gl.GenBuffer();
}
/// <summary>
/// Phase U.3: hand the renderer the SHARED terrain-clip UBO (binding=2)
/// created by <see cref="ClipFrame.UploadShared"/>. The renderer binds it to
/// binding=2 before its draw. Pass 0 to fall back to the internal no-clip UBO
/// (count 0 = ungated terrain).
/// Resets the indirect-command submission cursor for a GPU-fenced frame
/// slot. A retail outside view may draw terrain more than once in a frame;
/// each draw receives storage that cannot overwrite an earlier command.
/// </summary>
public void SetClipUbo(uint sharedClipUbo) => _sharedClipUbo = sharedClipUbo;
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_indirectBuffersByFrame.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
_retirementLedger.RetryPendingPublications();
_dynamicFrameSlot = frameSlot;
_dynamicBufferCursor = 0;
_dynamicFrameStarted = true;
}
private void ActivateNextIndirectBuffer()
{
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
List<DynamicIndirectBuffer> frameBuffers = _indirectBuffersByFrame[_dynamicFrameSlot];
if (_dynamicBufferCursor == frameBuffers.Count)
{
uint buffer = TrackedGlResource.CreateBuffer(
_gl,
$"creating terrain indirect buffer for frame slot {_dynamicFrameSlot}");
try
{
frameBuffers.Add(new DynamicIndirectBuffer { Buffer = buffer });
}
catch
{
TrackedGlResource.DeleteBuffer(
_gl,
buffer,
0,
"rolling back terrain indirect buffer");
throw;
}
}
DynamicIndirectBuffer active = frameBuffers[_dynamicBufferCursor++];
_indirectBuffer = active.Buffer;
_indirectCapacity = active.Capacity;
}
private void PersistIndirectCapacity()
{
_indirectBuffersByFrame[_dynamicFrameSlot][_dynamicBufferCursor - 1].Capacity =
_indirectCapacity;
}
/// <summary>
/// Hand the renderer the current aligned terrain-clip range (binding=2).
/// Each outside-view slice occupies a distinct range in the current
/// GPU-fenced frame's UBO arena.
/// </summary>
public void SetClipUbo(TerrainClipBufferBinding sharedClipBinding) =>
_sharedClipBinding = sharedClipBinding;
/// <summary>
/// Two-tier streaming entry point. Accepts a prebuilt mesh from
@ -146,15 +268,21 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
$"Expected {IndicesPerLandblock} indices, got {meshData.Indices.Length}",
nameof(meshData));
if (_idToSlot.ContainsKey(landblockId))
RemoveLandblock(landblockId);
// A prior replacement may have committed the logical slot switch
// before queue publication failed. Retry those retained physical-slot
// transactions before allocating more terrain storage.
_alloc.RetryPendingPublications();
bool replacing = _idToSlot.TryGetValue(landblockId, out int replacedSlot);
int slot = _alloc.Allocate(out var needsGrow);
if (needsGrow)
bool published = false;
try
{
int newCap = Math.Max(_alloc.Capacity * 2, slot + 1);
EnsureCapacity(newCap);
}
if (needsGrow)
{
int newCap = Math.Max(_alloc.Capacity * 2, slot + 1);
EnsureCapacity(newCap);
}
// Bake worldOrigin into vertex positions; capture min/max Z for AABB.
var bakedVerts = new TerrainVertex[VertsPerLandblock];
@ -179,41 +307,66 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize);
nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo);
fixed (TerrainVertex* p = bakedVerts)
{
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboByteOffset,
(nuint)(VertsPerLandblock * VertexSize), p);
}
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
fixed (TerrainVertex* p = bakedVerts)
{
TrackedGlResource.UpdateBufferSubData(
_gl,
BufferTargetARB.ArrayBuffer,
_globalVbo,
vboByteOffset,
VertsPerLandblock * VertexSize,
p,
$"uploading terrain vertices for 0x{landblockId:X8}");
}
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo);
fixed (uint* p = bakedIndices)
{
_gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, eboByteOffset,
(nuint)(IndicesPerLandblock * IndexSize), p);
}
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
fixed (uint* p = bakedIndices)
{
TrackedGlResource.UpdateBufferSubData(
_gl,
BufferTargetARB.ElementArrayBuffer,
_globalEbo,
eboByteOffset,
IndicesPerLandblock * IndexSize,
p,
$"uploading terrain indices for 0x{landblockId:X8}");
}
_slots[slot] = new SlotData
_slots[slot] = new SlotData
{
LandblockId = landblockId,
WorldOrigin = worldOrigin,
FirstIndex = (uint)(slot * IndicesPerLandblock),
IndexCount = IndicesPerLandblock,
AabbMin = new Vector3(worldOrigin.X, worldOrigin.Y, zMin),
AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax),
};
_idToSlot[landblockId] = slot;
published = true;
if (replacing)
{
_slots[replacedSlot] = null;
_alloc.FreeAfterGpuUse(replacedSlot);
}
}
finally
{
LandblockId = landblockId,
WorldOrigin = worldOrigin,
FirstIndex = (uint)(slot * IndicesPerLandblock),
IndexCount = IndicesPerLandblock,
AabbMin = new Vector3(worldOrigin.X, worldOrigin.Y, zMin),
AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax),
};
_idToSlot[landblockId] = slot;
if (!published)
_alloc.ReleaseUnsubmitted(slot);
}
}
public void RemoveLandblock(uint landblockId)
{
// Removal clears the logical lookup before retirement publication. A
// retry therefore has to advance retained publications even when the
// landblock is no longer present in the map.
_alloc.RetryPendingPublications();
if (!_idToSlot.TryGetValue(landblockId, out var slot))
return;
_idToSlot.Remove(landblockId);
_slots[slot] = null;
_alloc.Free(slot);
_alloc.FreeAfterGpuUse(slot);
// No GPU clear: the per-frame DEIC array won't reference this slot.
}
@ -252,6 +405,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
ndcClipAabb);
}
if (_visibleSlots.Count == 0) return;
ActivateNextIndirectBuffer();
// Build DEIC array.
if (_deicScratch.Length < _visibleSlots.Count)
@ -272,23 +426,31 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// Grow indirect buffer if needed.
if (_visibleSlots.Count > _indirectCapacity)
{
_indirectCapacity = Math.Max(64, _visibleSlots.Count * 2);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer);
_gl.BufferData(GLEnum.DrawIndirectBuffer,
(nuint)(_indirectCapacity * sizeof(DrawElementsIndirectCommand)),
null, GLEnum.DynamicDraw);
}
else
{
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer);
int grownCapacity = Math.Max(64, _visibleSlots.Count * 2);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.DrawIndirectBuffer,
_indirectBuffer,
checked((long)_indirectCapacity * sizeof(DrawElementsIndirectCommand)),
checked((long)grownCapacity * sizeof(DrawElementsIndirectCommand)),
GLEnum.DynamicDraw,
"growing terrain indirect command buffer");
_indirectCapacity = grownCapacity;
}
// Upload DEIC array.
fixed (DrawElementsIndirectCommand* p = _deicScratch)
{
_gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0,
(nuint)(_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)), p);
TrackedGlResource.UpdateBufferSubData(
_gl,
GLEnum.DrawIndirectBuffer,
_indirectBuffer,
0,
checked((long)_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)),
p,
"uploading terrain indirect commands");
}
PersistIndirectCapacity();
// Bind shader + uniforms + atlas handles.
// Verified Phase W Stage 4 (T4.2): terrain projects from the camera view-proj;
@ -352,11 +514,96 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
public void Dispose()
{
_gl.DeleteVertexArray(_globalVao);
_gl.DeleteBuffer(_globalVbo);
_gl.DeleteBuffer(_globalEbo);
_gl.DeleteBuffer(_indirectBuffer);
if (_fallbackClipUbo != 0) { _gl.DeleteBuffer(_fallbackClipUbo); _fallbackClipUbo = 0; } // Phase U.3
if (_disposed)
return;
_retirementLedger.RetryPendingPublications();
if (_disposeResources is null)
{
var releases = new List<(string Name, Action Release)>();
if (_globalVao != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
_globalVao,
"deleting terrain global VAO");
releases.Add(("global-vao", release.Run));
}
if (_globalVbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_globalVbo,
_globalVboCapacityBytes,
"deleting terrain global vertex buffer");
releases.Add(("global-vbo", release.Run));
}
if (_globalEbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_globalEbo,
_globalEboCapacityBytes,
"deleting terrain global index buffer");
releases.Add(("global-ebo", release.Run));
}
for (int frame = 0; frame < _indirectBuffersByFrame.Length; frame++)
{
List<DynamicIndirectBuffer> frameBuffers = _indirectBuffersByFrame[frame];
for (int index = 0; index < frameBuffers.Count; index++)
{
DynamicIndirectBuffer buffer = frameBuffers[index];
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
buffer.Buffer,
checked((long)buffer.Capacity * sizeof(DrawElementsIndirectCommand)),
"deleting terrain indirect command buffer");
releases.Add(($"indirect-{frame}-{index}", release.Run));
}
}
if (_fallbackClipUbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_fallbackClipUbo,
ClipFrame.TerrainUboBytes,
"deleting terrain fallback clip UBO");
releases.Add(("fallback-clip-ubo", release.Run));
}
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more terrain GPU resources could not be released.");
}
_globalVao = 0;
_globalVbo = 0;
_globalEbo = 0;
_globalVboCapacityBytes = 0;
_globalEboCapacityBytes = 0;
foreach (List<DynamicIndirectBuffer> frameBuffers in _indirectBuffersByFrame)
frameBuffers.Clear();
_indirectBuffer = 0;
_indirectCapacity = 0;
_dynamicFrameStarted = false;
_fallbackClipUbo = 0;
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"Terrain GPU resources released with exceptional committed outcomes.");
}
}
// ----------------------------------------------------------------
@ -393,28 +640,48 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
/// <summary>
/// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared
/// <see cref="ClipFrame"/> UBO (<see cref="SetClipUbo"/>); otherwise lazily
/// <see cref="ClipFrame"/> UBO range (<see cref="SetClipUbo"/>); otherwise lazily
/// creates + binds a no-clip fallback (count 0 = ungated) so the shader never
/// reads an unbound UBO. The fallback is std140-sized to
/// <see cref="ClipFrame.TerrainUboBytes"/> and zero-filled (count 0).
/// </summary>
private void BindClipUboBinding2()
{
if (_sharedClipUbo != 0)
if (_sharedClipBinding.IsValid)
{
_gl.BindBufferBase(BufferTargetARB.UniformBuffer,
ClipFrame.TerrainClipUboBinding, _sharedClipUbo);
_sharedClipBinding.Bind(_gl);
return;
}
if (_fallbackClipUbo == 0)
{
_fallbackClipUbo = _gl.GenBuffer();
var zero = stackalloc byte[ClipFrame.TerrainUboBytes];
for (int i = 0; i < ClipFrame.TerrainUboBytes; i++) zero[i] = 0;
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _fallbackClipUbo);
_gl.BufferData(BufferTargetARB.UniformBuffer,
(nuint)ClipFrame.TerrainUboBytes, zero, BufferUsageARB.DynamicDraw);
uint fallback = TrackedGlResource.CreateBuffer(
_gl,
"creating terrain fallback clip UBO");
try
{
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.UniformBuffer,
fallback,
0,
ClipFrame.TerrainUboBytes,
BufferUsageARB.DynamicDraw,
zero,
"allocating terrain fallback clip UBO");
_fallbackClipUbo = fallback;
}
catch
{
TrackedGlResource.DeleteBuffer(
_gl,
fallback,
0,
"rolling back terrain fallback clip UBO");
throw;
}
}
_gl.BindBufferBase(BufferTargetARB.UniformBuffer,
ClipFrame.TerrainClipUboBinding, _fallbackClipUbo);
@ -422,23 +689,35 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private void AllocateGpuBuffers(int capacitySlots)
{
nuint vboBytes = (nuint)(capacitySlots * VertsPerLandblock * VertexSize);
nuint eboBytes = (nuint)(capacitySlots * IndicesPerLandblock * IndexSize);
long vboBytes = checked((long)capacitySlots * VertsPerLandblock * VertexSize);
long eboBytes = checked((long)capacitySlots * IndicesPerLandblock * IndexSize);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo);
_gl.BufferData(BufferTargetARB.ArrayBuffer, vboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.ArrayBuffer,
_globalVbo,
_globalVboCapacityBytes,
vboBytes,
BufferUsageARB.DynamicDraw,
"allocating terrain global vertex storage");
_globalVboCapacityBytes = vboBytes;
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo);
_gl.BufferData(BufferTargetARB.ElementArrayBuffer, eboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.ElementArrayBuffer,
_globalEbo,
_globalEboCapacityBytes,
eboBytes,
BufferUsageARB.DynamicDraw,
"allocating terrain global index storage");
_globalEboCapacityBytes = eboBytes;
}
private void ConfigureVao()
private void ConfigureVao(uint vao, uint vbo, uint ebo)
{
_gl.BindVertexArray(_globalVao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo);
_gl.BindVertexArray(vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
uint stride = (uint)VertexSize;
@ -460,6 +739,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
_gl.BindVertexArray(0);
GLHelpers.ThrowOnResourceError(_gl, "configuring terrain VAO");
}
internal static void CollectVisibleCells(
@ -588,43 +868,129 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private void EnsureCapacity(int newCapacity)
{
if (newCapacity <= _alloc.Capacity) return;
if (newCapacity <= _alloc.Capacity)
return;
// Allocate new VBO + EBO at new size; copy old contents; swap; recreate VAO.
uint newVbo = _gl.GenBuffer();
uint newEbo = _gl.GenBuffer();
var grownSlots = new SlotData?[newCapacity];
Array.Copy(_slots, grownSlots, _slots.Length);
nuint newVboBytes = (nuint)(newCapacity * VertsPerLandblock * VertexSize);
nuint newEboBytes = (nuint)(newCapacity * IndicesPerLandblock * IndexSize);
nuint oldVboBytes = (nuint)(_alloc.Capacity * VertsPerLandblock * VertexSize);
nuint oldEboBytes = (nuint)(_alloc.Capacity * IndicesPerLandblock * IndexSize);
long newVboBytes = checked((long)newCapacity * VertsPerLandblock * VertexSize);
long newEboBytes = checked((long)newCapacity * IndicesPerLandblock * IndexSize);
uint newVbo = 0;
uint newEbo = 0;
uint newVao = 0;
long allocatedNewVboBytes = 0;
long allocatedNewEboBytes = 0;
bool published = false;
try
{
newVbo = TrackedGlResource.CreateBuffer(
_gl,
"creating grown terrain vertex buffer");
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.ArrayBuffer,
newVbo,
0,
newVboBytes,
BufferUsageARB.DynamicDraw,
"allocating grown terrain vertex buffer");
allocatedNewVboBytes = newVboBytes;
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, newVbo);
_gl.BufferData(BufferTargetARB.ArrayBuffer, newVboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo);
_gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer,
0, 0, oldVboBytes);
_gl.DeleteBuffer(_globalVbo);
_globalVbo = newVbo;
newEbo = TrackedGlResource.CreateBuffer(
_gl,
"creating grown terrain index buffer");
TrackedGlResource.AllocateBufferStorage(
_gl,
BufferTargetARB.ElementArrayBuffer,
newEbo,
0,
newEboBytes,
BufferUsageARB.DynamicDraw,
"allocating grown terrain index buffer");
allocatedNewEboBytes = newEboBytes;
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, newEbo);
_gl.BufferData(BufferTargetARB.ElementArrayBuffer, newEboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo);
_gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer,
0, 0, oldEboBytes);
_gl.DeleteBuffer(_globalEbo);
_globalEbo = newEbo;
GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers (precondition)");
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo);
_gl.CopyBufferSubData(
CopyBufferSubDataTarget.CopyReadBuffer,
CopyBufferSubDataTarget.CopyWriteBuffer,
0,
0,
checked((nuint)_globalVboCapacityBytes));
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo);
_gl.CopyBufferSubData(
CopyBufferSubDataTarget.CopyReadBuffer,
CopyBufferSubDataTarget.CopyWriteBuffer,
0,
0,
checked((nuint)_globalEboCapacityBytes));
GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers");
// Recreate VAO with new buffer bindings.
_gl.DeleteVertexArray(_globalVao);
_globalVao = _gl.GenVertexArray();
ConfigureVao();
newVao = TrackedGlResource.CreateVertexArray(
_gl,
"creating grown terrain VAO");
ConfigureVao(newVao, newVbo, newEbo);
// Grow slot tracking array.
Array.Resize(ref _slots, newCapacity);
_alloc.GrowTo(newCapacity);
uint oldVao = _globalVao;
uint oldVbo = _globalVbo;
uint oldEbo = _globalEbo;
long oldVboBytes = _globalVboCapacityBytes;
long oldEboBytes = _globalEboCapacityBytes;
_globalVao = newVao;
_globalVbo = newVbo;
_globalEbo = newEbo;
_globalVboCapacityBytes = newVboBytes;
_globalEboCapacityBytes = newEboBytes;
_slots = grownSlots;
_alloc.GrowTo(newCapacity);
published = true;
// Older submitted draws captured the former VAO/buffer bindings.
// Retire the complete old set only after the replacement is valid.
RetryableGpuResourceRelease oldVaoRelease =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
oldVao,
"retiring terrain VAO after growth");
RetryableGpuResourceRelease oldVboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
oldVbo,
oldVboBytes,
"retiring terrain vertex buffer after growth");
RetryableGpuResourceRelease oldEboRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
oldEbo,
oldEboBytes,
"retiring terrain index buffer after growth");
_retirementLedger.RetireMany(
[oldVaoRelease, oldVboRelease, oldEboRelease]);
}
finally
{
if (!published)
{
TrackedGlResource.DeleteVertexArray(
_gl,
newVao,
"rolling back grown terrain VAO");
TrackedGlResource.DeleteBuffer(
_gl,
newVbo,
allocatedNewVboBytes,
"rolling back grown terrain vertex buffer");
TrackedGlResource.DeleteBuffer(
_gl,
newEbo,
allocatedNewEboBytes,
"rolling back grown terrain index buffer");
}
}
}
private sealed class SlotData

View file

@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -23,11 +25,25 @@ public sealed unsafe class TextRenderer : IDisposable
private readonly GL _gl;
private readonly Shader _shader;
private readonly uint _vao;
private readonly uint _vbo;
private uint _vao;
private uint _vbo;
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
private int _vboCapacityBytes;
private sealed class FrameBufferSet
{
public uint Vao;
public uint Vbo;
public int CapacityBytes;
public int UsedBytes;
}
private readonly FrameBufferSet[] _frameBuffers = new FrameBufferSet[3];
private FrameBufferSet? _activeFrameBuffer;
internal long DynamicBufferCapacityBytes =>
_frameBuffers.Sum(set => (long)set.CapacityBytes);
private readonly List<float> _textBuf = new(8192);
private readonly List<float> _rectBuf = new(1024);
// Submission-ordered sprite segments: consecutive DrawSprite calls with the
@ -65,22 +81,8 @@ public sealed unsafe class TextRenderer : IDisposable
Path.Combine(shaderDir, "ui_text.vert"),
Path.Combine(shaderDir, "ui_text.frag"));
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _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);
for (int i = 0; i < _frameBuffers.Length; i++)
_frameBuffers[i] = CreateFrameBufferSet();
// 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
@ -97,6 +99,63 @@ public sealed unsafe class TextRenderer : IDisposable
_gl.BindTexture(TextureTarget.Texture2D, 0);
}
/// <summary>
/// Selects the GPU-fenced frame slot and resets its append cursor. Every
/// UI segment rendered during the frame receives a distinct byte range;
/// later text or sprite batches cannot overwrite an earlier in-flight draw.
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_frameBuffers.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
FrameBufferSet set = _frameBuffers[frameSlot];
set.UsedBytes = 0;
_activeFrameBuffer = set;
_vao = set.Vao;
_vbo = set.Vbo;
_vboCapacityBytes = set.CapacityBytes;
}
private FrameBufferSet CreateFrameBufferSet()
{
uint vao = 0;
uint vbo = 0;
try
{
vao = TrackedGlResource.CreateVertexArray(
_gl,
"TextRenderer frame VAO creation");
vbo = TrackedGlResource.CreateBuffer(
_gl,
"TextRenderer frame VBO creation");
var set = new FrameBufferSet { Vao = vao, Vbo = 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;
}
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;
}
}
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
public void Begin(Vector2 screenSize)
{
@ -357,8 +416,8 @@ public sealed unsafe class TextRenderer : IDisposable
var seg = spriteSegs[i];
if (seg.Verts.Count == 0) continue;
_gl.BindTexture(TextureTarget.Texture2D, seg.Texture);
UploadBuffer(seg.Verts);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(seg.Verts.Count / FloatsPerVertex));
int firstVertex = UploadBuffer(seg.Verts);
_gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)(seg.Verts.Count / FloatsPerVertex));
}
}
@ -366,8 +425,8 @@ public sealed unsafe class TextRenderer : IDisposable
if (rectVerts > 0)
{
_shader.SetInt("uUseTexture", 0);
UploadBuffer(rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts);
int firstVertex = UploadBuffer(rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)rectVerts);
}
// 3. Textured debug-font text glyphs on top.
@ -377,34 +436,59 @@ public sealed unsafe class TextRenderer : IDisposable
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
_shader.SetInt("uTex", 0);
UploadBuffer(textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)textVerts);
int firstVertex = UploadBuffer(textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)textVerts);
}
}
private void UploadBuffer(List<float> buf)
private int UploadBuffer(List<float> buf)
{
int bytes = buf.Count * sizeof(float);
if (bytes == 0) return;
if (bytes == 0) return 0;
FrameBufferSet set = _activeFrameBuffer
?? throw new InvalidOperationException("BeginFrame must be called before rendering text.");
int byteOffset = set.UsedBytes;
int requiredBytes = checked(byteOffset + bytes);
if (bytes > _vboCapacityBytes)
if (requiredBytes > _vboCapacityBytes)
{
fixed (float* p = CollectionsMarshal.AsSpan(buf))
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)bytes, p, BufferUsageARB.DynamicDraw);
_vboCapacityBytes = bytes;
}
else
{
fixed (float* p = CollectionsMarshal.AsSpan(buf))
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)bytes, p);
int newCapacity = DynamicBufferCapacity.Grow(
_vboCapacityBytes,
requiredBytes);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ArrayBuffer,
_vbo,
_vboCapacityBytes,
newCapacity,
GLEnum.DynamicDraw,
"TextRenderer frame VBO growth");
_vboCapacityBytes = newCapacity;
}
fixed (float* p = CollectionsMarshal.AsSpan(buf))
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, (nint)byteOffset, (nuint)bytes, p);
set.UsedBytes = requiredBytes;
set.CapacityBytes = _vboCapacityBytes;
return byteOffset / (FloatsPerVertex * sizeof(float));
}
public void Dispose()
{
_gl.DeleteTexture(_whiteTex);
_gl.DeleteBuffer(_vbo);
_gl.DeleteVertexArray(_vao);
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();
}
}

View file

@ -1,34 +1,27 @@
// src/AcDream.App/Rendering/TextureCache.cs
using AcDream.Core.Textures;
using AcDream.Core.World;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Silk.NET.OpenGL;
using System.Linq;
using PixelFormatId = DatReaderWriter.Enums.PixelFormat;
using SurfaceType = DatReaderWriter.Enums.SurfaceType;
namespace AcDream.App.Rendering;
public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposable
public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable
{
private readonly GL _gl;
private readonly DatCollection _dats;
private readonly Dictionary<uint, uint> _handlesBySurfaceId = new();
private readonly Dictionary<uint, (int w, int h)> _sizeBySurfaceId = new();
/// <summary>
/// Composite cache for surface-with-override-origtex entries (Phase 5
/// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId),
/// value = GL texture handle.
/// </summary>
private readonly Dictionary<(uint surfaceId, uint origTexOverride), uint> _handlesByOverridden = new();
/// <summary>
/// Composite cache for palette-overridden entries (Phase 5 SubPalettes).
/// Key = (baseSurfaceId, origTexOverride, paletteHash), value = handle.
/// paletteHash is a cheap combined hash of the PaletteOverride's ids +
/// offsets + lengths so two entities with equivalent palette setups
/// share the same decoded texture.
/// </summary>
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new();
private readonly IDatReaderWriter _dats;
// Handle and decoded dimensions are one atomic cache entry. Keeping them
// in separate dictionaries allowed GetOrUpload(surfaceId) followed by the
// sized overload to upload a second GL texture and orphan the first.
private readonly Dictionary<uint, (uint Handle, int Width, int Height)>
_surfacesById = new();
private readonly Dictionary<(uint SurfaceId, uint OrigTextureId), (int Width, int Height)>
_decodedDimensionsByTexture = new();
private uint _magentaHandle;
// Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids
@ -44,15 +37,32 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private readonly List<uint> _adhocHandles = new();
private readonly Wb.BindlessSupport? _bindless;
private readonly CompositeTextureArrayCache? _compositeTextures;
// Bindless / Texture2DArray parallel caches. Keys mirror the legacy three
// caches so a surface used by both the legacy (Texture2D, sampler2D) and
// modern (Texture2DArray, sampler2DArray) paths is uploaded twice — once
// per target. Each entry stores both the GL texture name (for Dispose
// cleanup) and the resident bindless handle (returned to callers).
private readonly Dictionary<uint, (uint Name, ulong Handle)> _bindlessBySurfaceId = new();
private readonly Dictionary<(uint surfaceId, uint origTexOverride), (uint Name, ulong Handle)> _bindlessByOverridden = new();
private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), (uint Name, ulong Handle)> _bindlessByPalette = new();
// Standalone Texture2DArray caches. Shared world surfaces use WB's atlas;
// this base cache remains for consumers such as particle rendering.
// Per-entity override composites are owner-scoped but share pooled array
// storage. Retail CSurface ownership releases immediately while ImgTex
// residency remains separately purgeable; CompositeTextureArrayCache
// mirrors that split without one GL object per material composite.
private readonly StandaloneBindlessTextureCache? _particleTextures;
private readonly Dictionary<(uint surfaceId, uint origTexOverride), bool> _paletteIndexedByTexture = new();
internal int OwnedBindlessTextureCount => _compositeTextures?.ActiveResourceCount ?? 0;
internal int TextureOwnerCount => _compositeTextures?.OwnerCount ?? 0;
internal int CachedCompositeTextureCount => _compositeTextures?.CachedEntryCount ?? 0;
internal int CachedUnownedCompositeCount => _compositeTextures?.UnownedEntryCount ?? 0;
internal long CachedUnownedCompositeBytes => _compositeTextures?.UnownedBytes ?? 0;
internal int CompositeAtlasCount => _compositeTextures?.AtlasCount ?? 0;
internal long CompositeAtlasBytes => _compositeTextures?.AllocatedBytes ?? 0;
internal int CompositeFrameUploadCount => _compositeTextures?.FrameUploadCount ?? 0;
internal long CompositeFrameUploadBytes => _compositeTextures?.FrameUploadBytes ?? 0;
internal bool CanStartCompositeUpload => _compositeTextures?.CanStartUpload == true;
internal int CachedParticleTextureCount => _particleTextures?.EntryCount ?? 0;
internal int ActiveParticleTextureCount => _particleTextures?.ActiveResourceCount ?? 0;
internal int ParticleTextureOwnerCount => _particleTextures?.OwnerCount ?? 0;
internal int CachedUnownedParticleTextureCount => _particleTextures?.UnownedEntryCount ?? 0;
internal long CachedUnownedParticleTextureBytes => _particleTextures?.UnownedBytes ?? 0;
// Phase N.6 slice 1 (2026-05-11): per-upload metadata for the
// ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload
@ -68,11 +78,28 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private int _dumpFrameCounter;
private bool _surfaceHistogramAlreadyDumped;
public TextureCache(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null)
public TextureCache(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
: this(gl, dats, bindless, ImmediateGpuResourceRetirementQueue.Instance)
{
}
internal TextureCache(
GL gl,
IDatReaderWriter dats,
Wb.BindlessSupport? bindless,
IGpuResourceRetirementQueue retirementQueue)
{
_gl = gl;
_dats = dats;
_bindless = bindless;
ArgumentNullException.ThrowIfNull(retirementQueue);
if (bindless is not null)
{
_compositeTextures = new CompositeTextureArrayCache(gl, bindless, retirementQueue);
_particleTextures = new StandaloneBindlessTextureCache(
new ParticleTextureBackend(this),
retirementQueue);
}
}
/// <summary>
@ -81,17 +108,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
/// missing or uses an unsupported format.
/// </summary>
public uint GetOrUpload(uint surfaceId)
{
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var h))
return h;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
DumpAlphaHistogram(surfaceId, decoded);
h = UploadRgba8(decoded);
_handlesBySurfaceId[surfaceId] = h;
return h;
}
=> GetOrUploadSurfaceCore(surfaceId, out _, out _);
/// <summary>
/// Like <see cref="GetOrUpload(uint)"/> but also returns the decoded
@ -99,19 +116,27 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
/// compute slice UVs. Cached alongside the handle.
/// </summary>
public uint GetOrUpload(uint surfaceId, out int width, out int height)
=> GetOrUploadSurfaceCore(surfaceId, out width, out height);
private uint GetOrUploadSurfaceCore(uint surfaceId, out int width, out int height)
{
if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing)
&& _sizeBySurfaceId.TryGetValue(surfaceId, out var sz))
if (_surfacesById.TryGetValue(surfaceId, out var existing))
{
width = sz.w; height = sz.h;
return existing;
width = existing.Width;
height = existing.Height;
return existing.Handle;
}
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
DecodedTexture decoded = DecodeFromDats(
surfaceId,
origTextureOverride: null,
paletteOverride: null);
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
DumpAlphaHistogram(surfaceId, decoded);
uint h = UploadRgba8(decoded);
_handlesBySurfaceId[surfaceId] = h;
_sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height);
width = decoded.Width; height = decoded.Height;
_surfacesById.Add(surfaceId, (h, decoded.Width, decoded.Height));
width = decoded.Width;
height = decoded.Height;
return h;
}
@ -200,129 +225,228 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
}
/// <summary>
/// Get or upload a texture for a Surface id but with its
/// <c>OrigTextureId</c> replaced by <paramref name="overrideOrigTextureId"/>.
/// The Surface's other properties (type flags, color, translucency,
/// clipmap handling, default palette) are preserved — only the
/// SurfaceTexture lookup is swapped. This is how the server's
/// CreateObject.TextureChanges are applied at render time.
/// Caches under a composite key so multiple entities can share.
/// Acquires the exact DAT-decoded one-layer texture array for a live
/// particle emitter. Equivalent surfaces are shared; the cache ownership
/// ends with <see cref="ReleaseParticleTextureOwner"/>.
/// </summary>
public uint GetOrUploadWithOrigTextureOverride(uint surfaceId, uint overrideOrigTextureId)
internal ulong AcquireParticleTexture(int emitterHandle, uint surfaceId)
{
var key = (surfaceId, overrideOrigTextureId);
if (_handlesByOverridden.TryGetValue(key, out var h))
return h;
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(emitterHandle);
ArgumentOutOfRangeException.ThrowIfZero(surfaceId);
StandaloneBindlessTextureCache textures = EnsureParticleTexturesAvailable();
uint ownerId = checked((uint)emitterHandle);
if (textures.TryAcquire(
ownerId,
surfaceId,
out StandaloneBindlessTextureResource? existing))
{
return existing.Handle;
}
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null);
h = UploadRgba8(decoded);
_handlesByOverridden[key] = h;
return h;
DecodedTexture decoded = DecodeFromDats(
surfaceId,
origTextureOverride: null,
paletteOverride: null);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = 0;
try
{
handle = _bindless!.GetResidentHandle(name);
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"making particle surface 0x{surfaceId:X8} resident");
var resource = new StandaloneBindlessTextureResource
{
SurfaceId = surfaceId,
Name = name,
Handle = handle,
Bytes = checked((long)decoded.Width * decoded.Height * 4L),
};
textures.AddAndAcquire(ownerId, resource);
return handle;
}
catch (Exception residencyFailure)
{
List<Exception>? cleanupFailures = null;
void Attempt(Action cleanup)
{
try { cleanup(); }
catch (Exception ex) { (cleanupFailures ??= []).Add(ex); }
}
bool residencyReleased = handle == 0;
if (handle != 0)
{
Attempt(() =>
{
_bindless!.MakeNonResident(handle);
Wb.GLHelpers.ThrowOnResourceError(
_gl,
"rolling back particle texture residency");
residencyReleased = true;
});
}
if (residencyReleased)
Attempt(() => DeleteUploadedTexture(name));
if (cleanupFailures is not null)
{
cleanupFailures.Insert(0, residencyFailure);
throw new AggregateException(
"Particle texture residency and rollback both failed.",
cleanupFailures);
}
throw;
}
}
internal void ReleaseParticleTextureOwner(int emitterHandle)
{
if (emitterHandle <= 0 || _particleTextures is null)
return;
_particleTextures.ReleaseOwner(checked((uint)emitterHandle));
}
/// <summary>
/// Full Phase 5 override: for palette-indexed textures (PFID_P8 /
/// PFID_INDEX16), applies <paramref name="paletteOverride"/>'s
/// subpalette overlays on top of the texture's default palette
/// before decoding. Non-palette formats ignore the palette override.
/// Also honors <paramref name="overrideOrigTextureId"/> if non-null.
/// Owner-scoped bindless variant for a server-supplied original-texture
/// replacement. Stores compatible composites in a pooled Texture2DArray
/// and returns its resident handle plus the assigned layer. Equivalent
/// composites are shared until their final live owner leaves. Throws if
/// BindlessSupport wasn't provided.
/// </summary>
public uint GetOrUploadWithPaletteOverride(
internal BindlessTextureLocation GetOrUploadWithOrigTextureOverrideBindless(
uint ownerLocalId,
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride)
=> GetOrUploadWithPaletteOverride(surfaceId, overrideOrigTextureId, paletteOverride,
HashPaletteOverride(paletteOverride));
/// <summary>
/// Overload that accepts a precomputed palette hash. Lets callers (e.g.
/// the WB draw dispatcher) compute the hash ONCE per entity and reuse
/// it across every (part, batch) lookup, avoiding the per-batch
/// FNV-1a fold over <see cref="PaletteOverride.SubPalettes"/>.
/// </summary>
public uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride,
ulong precomputedPaletteHash)
uint overrideOrigTextureId)
{
uint origTexKey = overrideOrigTextureId ?? 0;
var key = (surfaceId, origTexKey, precomputedPaletteHash);
if (_handlesByPalette.TryGetValue(key, out var h))
return h;
CompositeTextureArrayCache composites = EnsureCompositeTexturesAvailable();
var key = new CompositeTextureKey(
CompositeTextureKind.OriginalTextureOverride,
surfaceId,
overrideOrigTextureId,
Palette: default);
if (composites.TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing))
return existing;
if (!composites.CanStartUpload)
return default;
(int width, int height) = ResolveDecodedDimensions(surfaceId, overrideOrigTextureId);
if (!composites.CanPrepareUpload(width, height))
return default;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: paletteOverride);
h = UploadRgba8(decoded);
_handlesByPalette[key] = h;
return h;
DecodedTexture decoded = DecodeFromDats(
surfaceId,
origTextureOverride: overrideOrigTextureId,
paletteOverride: null);
return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added)
? added
: default;
}
/// <summary>
/// 64-bit bindless handle variant of <see cref="GetOrUpload"/> for the WB
/// modern rendering path. Uploads the texture as a 1-layer Texture2DArray
/// (so the shader's <c>sampler2DArray</c> can sample at layer 0) and returns
/// a resident bindless handle. Caches by surfaceId in a separate dictionary
/// from the legacy Texture2D path; the same surface may be uploaded twice
/// if used by both paths (acceptable transition cost — N.6 deletes the legacy
/// path).
/// Owner-scoped bindless palette composite. Applies the palette override on
/// top of the texture's default palette before decoding, stores compatible
/// composites in a pooled Texture2DArray, and returns its resident handle
/// plus the assigned layer. Structural identity is computed once per entity.
/// Throws if BindlessSupport wasn't provided to the constructor.
/// </summary>
public ulong GetOrUploadBindless(uint surfaceId)
{
EnsureBindlessAvailable();
if (_bindlessBySurfaceId.TryGetValue(surfaceId, out var entry))
return entry.Handle;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = _bindless!.GetResidentHandle(name);
_bindlessBySurfaceId[surfaceId] = (name, handle);
return handle;
}
/// <summary>
/// 64-bit bindless handle variant of <see cref="GetOrUploadWithOrigTextureOverride"/>
/// for the WB modern rendering path. Uploads the texture as a 1-layer
/// Texture2DArray with the override SurfaceTexture id and returns a resident
/// bindless handle. Caches under a separate composite key from the legacy
/// path. Throws if BindlessSupport wasn't provided to the constructor.
/// </summary>
public ulong GetOrUploadWithOrigTextureOverrideBindless(uint surfaceId, uint overrideOrigTextureId)
{
EnsureBindlessAvailable();
var key = (surfaceId, overrideOrigTextureId);
if (_bindlessByOverridden.TryGetValue(key, out var entry))
return entry.Handle;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: null);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = _bindless!.GetResidentHandle(name);
_bindlessByOverridden[key] = (name, handle);
return handle;
}
/// <summary>
/// 64-bit bindless handle variant of <see cref="GetOrUploadWithPaletteOverride"/>
/// for the WB modern rendering path. Applies the palette override on top of
/// the texture's default palette before decoding, uploads as a 1-layer
/// Texture2DArray, and returns a resident bindless handle. Takes a
/// precomputed palette hash so the WB dispatcher can compute it once per
/// entity. Throws if BindlessSupport wasn't provided to the constructor.
/// </summary>
public ulong GetOrUploadWithPaletteOverrideBindless(
internal BindlessTextureLocation GetOrUploadWithPaletteOverrideBindless(
uint ownerLocalId,
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride,
ulong precomputedPaletteHash)
PaletteCompositeIdentity paletteIdentity)
{
EnsureBindlessAvailable();
CompositeTextureArrayCache composites = EnsureCompositeTexturesAvailable();
uint origTexKey = overrideOrigTextureId ?? 0;
var key = (surfaceId, origTexKey, precomputedPaletteHash);
if (_bindlessByPalette.TryGetValue(key, out var entry))
return entry.Handle;
var decoded = DecodeFromDats(surfaceId, origTextureOverride: overrideOrigTextureId, paletteOverride: paletteOverride);
uint name = UploadRgba8AsLayer1Array(decoded);
ulong handle = _bindless!.GetResidentHandle(name);
_bindlessByPalette[key] = (name, handle);
return handle;
var key = new CompositeTextureKey(
CompositeTextureKind.PaletteComposite,
surfaceId,
origTexKey,
paletteIdentity);
if (composites.TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing))
return existing;
if (!composites.CanStartUpload)
return default;
(int width, int height) = ResolveDecodedDimensions(surfaceId, overrideOrigTextureId);
if (!composites.CanPrepareUpload(width, height))
return default;
DecodedTexture decoded = DecodeFromDats(
surfaceId,
origTextureOverride: overrideOrigTextureId,
paletteOverride: paletteOverride);
return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added)
? added
: default;
}
/// <summary>
/// Retail applies a palette composite only to P8/INDEX16 image data.
/// Cache the resolved source format so animated entities do not reopen the
/// DAT chain every frame.
/// </summary>
internal bool IsPaletteIndexed(uint surfaceId, uint? overrideOrigTextureId)
{
uint origTexKey = overrideOrigTextureId ?? 0;
var key = (surfaceId, origTexKey);
if (_paletteIndexedByTexture.TryGetValue(key, out bool indexed))
return indexed;
Surface? surface = _dats.Get<Surface>(surfaceId);
if (surface is null || surface.Type.HasFlag(SurfaceType.Base1Solid))
return _paletteIndexedByTexture[key] = false;
uint surfaceTextureId = overrideOrigTextureId ?? (uint)surface.OrigTextureId;
SurfaceTexture? texture = _dats.Get<SurfaceTexture>(surfaceTextureId);
if (texture is null || texture.Textures.Count == 0)
return _paletteIndexedByTexture[key] = false;
uint renderSurfaceId = (uint)texture.Textures[0];
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out RenderSurface? renderSurface)
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out renderSurface))
return _paletteIndexedByTexture[key] = false;
indexed = renderSurface.Format is PixelFormatId.PFID_P8 or PixelFormatId.PFID_INDEX16;
_paletteIndexedByTexture[key] = indexed;
return indexed;
}
private (int Width, int Height) ResolveDecodedDimensions(
uint surfaceId,
uint? overrideOrigTextureId)
{
var key = (surfaceId, overrideOrigTextureId ?? 0);
if (_decodedDimensionsByTexture.TryGetValue(key, out var cached))
return cached;
Surface? surface = _dats.Get<Surface>(surfaceId);
if (surface is null
|| surface.Type.HasFlag(SurfaceType.Base1Solid)
|| (uint)surface.OrigTextureId == 0)
return _decodedDimensionsByTexture[key] = (1, 1);
uint surfaceTextureId = overrideOrigTextureId ?? (uint)surface.OrigTextureId;
SurfaceTexture? texture = _dats.Get<SurfaceTexture>(surfaceTextureId);
if (texture is null || texture.Textures.Count == 0)
return _decodedDimensionsByTexture[key] = (1, 1);
uint renderSurfaceId = (uint)texture.Textures[0];
if ((!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out RenderSurface? renderSurface)
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out renderSurface))
|| renderSurface.Width <= 0
|| renderSurface.Height <= 0
|| renderSurface.SourceData is null)
return _decodedDimensionsByTexture[key] = (1, 1);
return _decodedDimensionsByTexture[key] = (renderSurface.Width, renderSurface.Height);
}
/// <summary>
/// Retail <c>CSurface::Destroy</c> (0x005361F0) releases its current
/// <c>ImgTex</c>. Mirror that ownership boundary for per-entity composites.
/// </summary>
public void ReleaseOwner(uint localEntityId)
{
EnsureCompositeTexturesAvailable().ReleaseOwner(localEntityId);
}
private void EnsureBindlessAvailable()
@ -333,6 +457,49 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
"WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport).");
}
private CompositeTextureArrayCache EnsureCompositeTexturesAvailable()
{
EnsureBindlessAvailable();
return _compositeTextures!;
}
private StandaloneBindlessTextureCache EnsureParticleTexturesAvailable()
{
EnsureBindlessAvailable();
return _particleTextures!;
}
private sealed class ParticleTextureBackend(TextureCache owner)
: IStandaloneBindlessTextureBackend
{
public void MakeNonResident(StandaloneBindlessTextureResource resource)
{
owner._bindless!.MakeNonResident(resource.Handle);
Wb.GLHelpers.ThrowOnResourceError(
owner._gl,
$"releasing particle texture handle {resource.Handle}");
}
public void Delete(StandaloneBindlessTextureResource resource)
=> owner.DeleteUploadedTexture(resource.Name);
}
/// <summary>
/// Advances bounded composite-cache maintenance once per render frame.
/// Logical owner release is immediate; at most one over-budget layer and
/// one empty backing array are physically retired in this call.
/// </summary>
public void TickCompositeTextureCache() => _compositeTextures?.Tick();
/// <summary>
/// Retires at most one over-budget standalone particle texture per render
/// frame. Keeping this separate from owner release avoids portal-time GPU
/// destruction bursts without changing live particle range or quality.
/// </summary>
public void TickParticleTextureCache() => _particleTextures?.Tick();
public void BeginCompositeTextureFrame() => _compositeTextures?.BeginFrame();
/// <summary>
/// Cheap 64-bit hash over a palette override's identity so two
/// entities with the same palette setup share a decode. Internal so
@ -354,6 +521,9 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
return h;
}
internal static PaletteCompositeIdentity GetPaletteIdentity(PaletteOverride palette) =>
new(palette, HashPaletteOverride(palette));
/// <summary>
/// Phase N.6 slice 1: one-shot surface-format histogram dump for the
/// atlas-opportunity audit. Activated by ACDREAM_DUMP_SURFACES=1; fires
@ -434,12 +604,19 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
bucketsByTriple[tripleKey] = bucketsByTriple.GetValueOrDefault(tripleKey) + 1;
}
foreach (var kv in _handlesBySurfaceId) Emit(kv.Key, kv.Value);
foreach (var kv in _handlesByOverridden) Emit(kv.Key.surfaceId, kv.Value);
foreach (var kv in _handlesByPalette) Emit(kv.Key.surfaceId, kv.Value);
foreach (var kv in _bindlessBySurfaceId) Emit(kv.Key, kv.Value.Name);
foreach (var kv in _bindlessByOverridden) Emit(kv.Key.surfaceId, kv.Value.Name);
foreach (var kv in _bindlessByPalette) Emit(kv.Key.surfaceId, kv.Value.Name);
foreach (var kv in _surfacesById) Emit(kv.Key, kv.Value.Handle);
_particleTextures?.VisitEntries(resource => Emit(resource.SurfaceId, resource.Name));
_compositeTextures?.VisitEntries((surfaceId, width, height) =>
{
int bytes = checked(width * height * 4);
totalBytes += bytes;
sb.AppendLine($"0x{surfaceId:X8}, {width}, {height}, RGBA8_COMPOSITE_LAYER, {bytes}");
bucketsByDim[(width, height)] = bucketsByDim.GetValueOrDefault((width, height)) + 1;
bucketsByFormat["RGBA8_COMPOSITE_LAYER"] =
bucketsByFormat.GetValueOrDefault("RGBA8_COMPOSITE_LAYER") + 1;
bucketsByTriple[(width, height, "RGBA8_COMPOSITE_LAYER")] =
bucketsByTriple.GetValueOrDefault((width, height, "RGBA8_COMPOSITE_LAYER")) + 1;
});
sb.AppendLine();
sb.AppendLine("# Rollups");
@ -572,31 +749,47 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)
{
uint tex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, tex);
if (tex == 0)
throw new InvalidOperationException("OpenGL did not create a 2D texture.");
try
{
_gl.BindTexture(TextureTarget.Texture2D, tex);
fixed (byte* p = decoded.Rgba8)
_gl.TexImage2D(
TextureTarget.Texture2D,
0,
InternalFormat.Rgba8,
(uint)decoded.Width,
(uint)decoded.Height,
0,
PixelFormat.Rgba,
PixelType.UnsignedByte,
p);
fixed (byte* p = decoded.Rgba8)
_gl.TexImage2D(
TextureTarget.Texture2D,
0,
InternalFormat.Rgba8,
(uint)decoded.Width,
(uint)decoded.Height,
0,
PixelFormat.Rgba,
PixelType.UnsignedByte,
p);
// Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat
// font's small glyphs. Other surfaces use bilinear.
int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear;
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
// Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat
// font's small glyphs. Other surfaces use bilinear.
int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear;
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"uploading 2D RGBA8 texture {decoded.Width}x{decoded.Height}");
_gl.BindTexture(TextureTarget.Texture2D, 0);
_uploadMetadata[tex] = (decoded.Width, decoded.Height, "RGBA8_DECODED");
return tex;
TrackUploadedTexture(tex, decoded.Width, decoded.Height);
return tex;
}
catch
{
_gl.DeleteTexture(tex);
throw;
}
finally
{
_gl.BindTexture(TextureTarget.Texture2D, 0);
}
}
/// <summary>
@ -607,88 +800,99 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
private uint UploadRgba8AsLayer1Array(DecodedTexture decoded)
{
uint tex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2DArray, tex);
if (tex == 0)
throw new InvalidOperationException("OpenGL did not create a one-layer texture array.");
try
{
_gl.BindTexture(TextureTarget.Texture2DArray, tex);
fixed (byte* p = decoded.Rgba8)
_gl.TexImage3D(
TextureTarget.Texture2DArray,
0,
InternalFormat.Rgba8,
(uint)decoded.Width,
(uint)decoded.Height,
depth: 1,
border: 0,
PixelFormat.Rgba,
PixelType.UnsignedByte,
p);
fixed (byte* p = decoded.Rgba8)
_gl.TexImage3D(
TextureTarget.Texture2DArray,
0,
InternalFormat.Rgba8,
(uint)decoded.Width,
(uint)decoded.Height,
depth: 1,
border: 0,
PixelFormat.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.Repeat);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
_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.Repeat);
_gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
Wb.GLHelpers.ThrowOnResourceError(
_gl,
$"uploading one-layer RGBA8 array {decoded.Width}x{decoded.Height}");
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
_uploadMetadata[tex] = (decoded.Width, decoded.Height, "RGBA8_DECODED");
return tex;
TrackUploadedTexture(tex, decoded.Width, decoded.Height);
return tex;
}
catch
{
_gl.DeleteTexture(tex);
throw;
}
finally
{
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
}
}
private void TrackUploadedTexture(uint name, int width, int height)
{
_uploadMetadata[name] = (width, height, "RGBA8_DECODED");
long bytes = checked((long)width * height * 4L);
Wb.GpuMemoryTracker.TrackResourceAllocation(Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackAllocation(bytes, Wb.GpuResourceType.Texture);
}
private void DeleteUploadedTexture(uint name)
{
_gl.DeleteTexture(name);
Wb.GLHelpers.ThrowOnResourceError(_gl, $"deleting uploaded texture {name}");
if (_uploadMetadata.Remove(name, out var metadata))
{
long bytes = checked((long)metadata.Width * metadata.Height * 4L);
Wb.GpuMemoryTracker.TrackDeallocation(bytes, Wb.GpuResourceType.Texture);
Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture);
}
}
public void Dispose()
{
// Phase 1: make all bindless handles non-resident BEFORE any
// DeleteTexture call. ARB_bindless_texture requires that resident
// handles be released before their backing texture is deleted —
// interleaving per-entry is UB. Single null-guard around the whole
// block (cleaner than per-call null-conditionals).
if (_bindless is not null)
{
foreach (var (_, handle) in _bindlessBySurfaceId.Values)
_bindless.MakeNonResident(handle);
foreach (var (_, handle) in _bindlessByOverridden.Values)
_bindless.MakeNonResident(handle);
foreach (var (_, handle) in _bindlessByPalette.Values)
_bindless.MakeNonResident(handle);
}
// GameWindow drains frame-flight fences before this teardown. The
// bindless caches make every handle non-resident before deleting
// their backing storage.
_particleTextures?.Dispose();
_compositeTextures?.Dispose();
// Phase 2: delete the Texture2DArray textures backing those handles.
foreach (var (name, _) in _bindlessBySurfaceId.Values)
_gl.DeleteTexture(name);
_bindlessBySurfaceId.Clear();
foreach (var (name, _) in _bindlessByOverridden.Values)
_gl.DeleteTexture(name);
_bindlessByOverridden.Clear();
foreach (var (name, _) in _bindlessByPalette.Values)
_gl.DeleteTexture(name);
_bindlessByPalette.Clear();
_paletteIndexedByTexture.Clear();
// Phase 3: legacy Texture2D textures.
foreach (var h in _handlesBySurfaceId.Values)
_gl.DeleteTexture(h);
_handlesBySurfaceId.Clear();
foreach (var h in _handlesByOverridden.Values)
_gl.DeleteTexture(h);
_handlesByOverridden.Clear();
foreach (var h in _handlesByPalette.Values)
_gl.DeleteTexture(h);
_handlesByPalette.Clear();
// Legacy Texture2D textures.
foreach (var entry in _surfacesById.Values)
DeleteUploadedTexture(entry.Handle);
_surfacesById.Clear();
if (_magentaHandle != 0)
{
_gl.DeleteTexture(_magentaHandle);
DeleteUploadedTexture(_magentaHandle);
_magentaHandle = 0;
}
// RenderSurface (UI sprite) handles — pre-existing gap: this dict was populated
// by GetOrUploadRenderSurface but was not swept here before this fix.
foreach (var h in _handlesByRenderSurfaceId.Values)
_gl.DeleteTexture(h);
DeleteUploadedTexture(h);
_handlesByRenderSurfaceId.Clear();
// Ad-hoc handles from the public UploadRgba8(byte[],int,int,bool) wrapper
// (IconComposer composited icons). Not stored in any keyed cache.
foreach (var h in _adhocHandles)
_gl.DeleteTexture(h);
DeleteUploadedTexture(h);
_adhocHandles.Clear();
}
}

View file

@ -37,6 +37,12 @@ public sealed class EntityEffectController : IAnimationHookSink
private readonly Dictionary<uint, Queue<PendingEffect>> _pendingByServerGuid = new();
private readonly Dictionary<uint, WorldEntity> _staticOwners = new();
private readonly HashSet<uint> _syntheticOwners = new();
private readonly HashSet<LiveEntityRecord> _dirtyLiveOwners =
new(ReferenceEqualityComparer.Instance);
private readonly List<LiveEntityRecord> _dirtyLiveOwnerOrder = [];
private readonly List<LiveEntityRecord> _dirtyLiveOwnerSnapshot = [];
private readonly List<uint> _readyGuidSnapshot = [];
private uint _posePublishLocalId;
private Action<string>? _diagnosticSink = Console.WriteLine;
public EntityEffectController(
@ -58,6 +64,8 @@ public sealed class EntityEffectController : IAnimationHookSink
_ownerUnregistered = ownerUnregistered ?? (_ => { });
_ownerSoundTableChanged = ownerSoundTableChanged ?? ((_, _) => { });
_runner.DiagnosticSink = message => _diagnosticSink?.Invoke(message);
_poses.EffectPoseChanged += OnEffectPoseChanged;
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
}
public Action<string>? DiagnosticSink
@ -68,6 +76,7 @@ public sealed class EntityEffectController : IAnimationHookSink
public int PendingPacketCount => _pendingByServerGuid.Values.Sum(queue => queue.Count);
public int ReadyOwnerCount => _profilesByLocalId.Count;
internal int LastPoseRefreshOwnerVisitCount { get; private set; }
public void HandleDirect(PlayPhysicsScript message)
{
@ -222,7 +231,9 @@ public sealed class EntityEffectController : IAnimationHookSink
public void ClearNetworkState()
{
foreach (uint serverGuid in _readyGenerationByServerGuid.Keys.ToArray())
_readyGuidSnapshot.Clear();
_readyGuidSnapshot.AddRange(_readyGenerationByServerGuid.Keys);
foreach (uint serverGuid in _readyGuidSnapshot)
{
if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId))
{
@ -233,20 +244,54 @@ public sealed class EntityEffectController : IAnimationHookSink
}
_readyGenerationByServerGuid.Clear();
_pendingByServerGuid.Clear();
_dirtyLiveOwners.Clear();
_dirtyLiveOwnerOrder.Clear();
_dirtyLiveOwnerSnapshot.Clear();
}
/// <summary>Refreshes every live root after animation/movement.</summary>
/// <summary>
/// Publishes only roots dirtied by movement, animation, cell projection,
/// or an authoritative position update. The queue is detached before
/// callbacks run, so a callback-produced mutation is retained for the
/// following update rather than invalidating this traversal.
/// </summary>
public void RefreshLiveOwnerPoses()
{
foreach (uint serverGuid in _readyGenerationByServerGuid.Keys.ToArray())
LastPoseRefreshOwnerVisitCount = 0;
if (_dirtyLiveOwnerOrder.Count == 0)
return;
_dirtyLiveOwnerSnapshot.Clear();
_dirtyLiveOwnerSnapshot.AddRange(_dirtyLiveOwnerOrder);
_dirtyLiveOwnerOrder.Clear();
_dirtyLiveOwners.Clear();
foreach (LiveEntityRecord record in _dirtyLiveOwnerSnapshot)
{
if (!TryGetReadyLocalId(serverGuid, out uint localId))
if (!_liveEntities.TryGetRecord(record.ServerGuid, out LiveEntityRecord current)
|| !ReferenceEquals(current, record)
|| !TryGetReadyLocalId(record.ServerGuid, out uint localId)
|| record.WorldEntity?.Id != localId)
{
continue;
RefreshLiveAnchor(serverGuid, localId);
TryReplayPending(serverGuid, localId);
}
LastPoseRefreshOwnerVisitCount++;
RefreshLiveAnchor(record.ServerGuid, localId);
TryReplayPending(record.ServerGuid, localId);
}
}
/// <summary>
/// Marks an accepted authoritative root mutation. The record reference,
/// rather than only its server GUID, isolates a queued update from later
/// delete/recreate reuse of that GUID.
/// </summary>
public void MarkLiveOwnerPoseDirty(uint serverGuid)
{
if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record))
MarkLiveOwnerPoseDirty(record);
}
public bool PlayDirect(uint ownerLocalId, uint scriptDid)
{
if (!CanStartOwner(ownerLocalId))
@ -411,6 +456,37 @@ public sealed class EntityEffectController : IAnimationHookSink
return false;
}
private void OnEffectPoseChanged(uint localId)
{
if (_posePublishLocalId == localId)
return;
if (_liveEntities.TryGetServerGuid(localId, out uint serverGuid)
&& _liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record))
{
MarkLiveOwnerPoseDirty(record);
}
}
private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
{
if (visible)
MarkLiveOwnerPoseDirty(record);
}
private void MarkLiveOwnerPoseDirty(LiveEntityRecord record)
{
if (!_readyGenerationByServerGuid.TryGetValue(
record.ServerGuid,
out ushort readyGeneration)
|| readyGeneration != record.Generation
|| !_dirtyLiveOwners.Add(record))
{
return;
}
_dirtyLiveOwnerOrder.Add(record);
}
private void RefreshLiveAnchor(uint serverGuid, uint localId)
{
if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)
@ -423,7 +499,18 @@ public sealed class EntityEffectController : IAnimationHookSink
// bookkeeping Position would collapse a weapon effect to the
// parent's origin.
if (record.ProjectionKind is LiveEntityProjectionKind.World)
_poses.UpdateRoot(entity);
{
uint previousPublish = _posePublishLocalId;
_posePublishLocalId = localId;
try
{
_poses.UpdateRoot(entity);
}
finally
{
_posePublishLocalId = previousPublish;
}
}
Vector3 anchor = _poses.TryGetRootPose(localId, out Matrix4x4 rootWorld)
? rootWorld.Translation

View file

@ -15,7 +15,10 @@ namespace AcDream.App.Rendering.Vfx;
/// (<c>0x0051D180</c>). This registry is the modern, read-only seam exposing
/// those same final frames without coupling Core effects to the renderer.
/// </remarks>
public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityEffectCellSource
public sealed class EntityEffectPoseRegistry :
IEntityEffectPoseSource,
IEntityEffectCellSource,
IEntityEffectPoseChangeSource
{
private sealed class PoseRecord
{
@ -27,6 +30,8 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
private readonly Dictionary<uint, PoseRecord> _poses = new();
public event Action<uint>? EffectPoseChanged;
public int Count => _poses.Count;
public void Publish(WorldEntity entity, IReadOnlyList<Matrix4x4> partLocal)
@ -60,17 +65,24 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
* Matrix4x4.CreateTranslation(entity.Position);
bool changed;
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
{
record = new PoseRecord();
_poses.Add(entity.Id, record);
changed = true;
}
else
{
changed = record.RootWorld != rootWorld
|| record.CellId != (entity.EffectCellId ?? entity.ParentCellId ?? 0u);
}
record.RootWorld = rootWorld;
record.CellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u;
if (entity.IndexedPartTransforms.Count > 0)
{
CopyParts(
changed |= CopyParts(
record,
entity.IndexedPartTransforms,
entity.IndexedPartAvailable);
@ -78,15 +90,26 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
else
{
if (record.PartLocal.Length != entity.MeshRefs.Count)
{
record.PartLocal = new Matrix4x4[entity.MeshRefs.Count];
changed = true;
}
if (record.PartAvailable.Length != entity.MeshRefs.Count)
{
record.PartAvailable = new bool[entity.MeshRefs.Count];
changed = true;
}
for (int i = 0; i < entity.MeshRefs.Count; i++)
{
changed |= record.PartLocal[i] != entity.MeshRefs[i].PartTransform
|| !record.PartAvailable[i];
record.PartLocal[i] = entity.MeshRefs[i].PartTransform;
record.PartAvailable[i] = true;
}
}
if (changed)
EffectPoseChanged?.Invoke(entity.Id);
}
public void Publish(
@ -100,15 +123,23 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
return;
ArgumentNullException.ThrowIfNull(partLocal);
bool changed;
if (!_poses.TryGetValue(localEntityId, out PoseRecord? record))
{
record = new PoseRecord();
_poses.Add(localEntityId, record);
changed = true;
}
else
{
changed = record.RootWorld != rootWorld || record.CellId != cellId;
}
record.RootWorld = rootWorld;
record.CellId = cellId;
CopyParts(record, partLocal, availability);
changed |= CopyParts(record, partLocal, availability);
if (changed)
EffectPoseChanged?.Invoke(localEntityId);
}
/// <summary>Refresh only the moving root while retaining current part poses.</summary>
@ -117,15 +148,36 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
ArgumentNullException.ThrowIfNull(entity);
if (!_poses.TryGetValue(entity.Id, out PoseRecord? record))
return false;
record.RootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
Matrix4x4 rootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
* Matrix4x4.CreateTranslation(entity.Position);
record.CellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u;
uint cellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u;
if (record.RootWorld == rootWorld && record.CellId == cellId)
return true;
record.RootWorld = rootWorld;
record.CellId = cellId;
EffectPoseChanged?.Invoke(entity.Id);
return true;
}
public bool Remove(uint localEntityId) => _poses.Remove(localEntityId);
public bool Remove(uint localEntityId)
{
if (!_poses.Remove(localEntityId))
return false;
EffectPoseChanged?.Invoke(localEntityId);
return true;
}
public void Clear() => _poses.Clear();
public void Clear()
{
if (_poses.Count == 0)
return;
uint[] removedOwners = _poses.Keys.ToArray();
_poses.Clear();
foreach (uint owner in removedOwners)
EffectPoseChanged?.Invoke(owner);
}
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
{
@ -196,21 +248,32 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
return false;
}
private static void CopyParts(
private static bool CopyParts(
PoseRecord record,
IReadOnlyList<Matrix4x4> partLocal,
IReadOnlyList<bool>? availability)
{
if (availability is not null && availability.Count != partLocal.Count)
throw new ArgumentException("Part pose and availability counts must match.");
bool changed = false;
if (record.PartLocal.Length != partLocal.Count)
{
record.PartLocal = new Matrix4x4[partLocal.Count];
changed = true;
}
if (record.PartAvailable.Length != partLocal.Count)
{
record.PartAvailable = new bool[partLocal.Count];
changed = true;
}
for (int i = 0; i < partLocal.Count; i++)
{
bool partAvailable = availability is null || availability[i];
changed |= record.PartLocal[i] != partLocal[i]
|| record.PartAvailable[i] != partAvailable;
record.PartLocal[i] = partLocal[i];
record.PartAvailable[i] = availability is null || availability[i];
record.PartAvailable[i] = partAvailable;
}
return changed;
}
}

View file

@ -32,63 +32,131 @@ namespace AcDream.App.Rendering;
/// </summary>
public sealed class ViewconeCuller
{
private readonly Dictionary<uint, Vector4[][]> _cellPlanes = new();
private Vector4[][] _outsidePlanes = Array.Empty<Vector4[]>();
private const int MaxRetainedCellPlaneSets = 512;
private const int MaxRetainedPlanesPerCell = 256;
private const int MaxRetainedSlicesPerCell = 64;
private readonly Dictionary<uint, PlaneSet> _cellPlanes = new();
private readonly Stack<PlaneSet> _planeSetPool = new();
private PlaneSet _outsidePlanes = new();
private readonly record struct SliceRange(int Start, int Count);
private readonly record struct LiftedPlane(Vector4 Equation, float NormalLength);
/// <summary>
/// Contiguous per-cell plane storage. Reusing two Lists per visible cell
/// avoids rebuilding a jagged array graph on every render frame while
/// retaining the exact slice boundaries used by retail's any-view test.
/// </summary>
private sealed class PlaneSet
{
public List<LiftedPlane> Planes { get; } = new();
public List<SliceRange> Slices { get; } = new();
public bool IsRetainable =>
Planes.Capacity <= MaxRetainedPlanesPerCell
&& Slices.Capacity <= MaxRetainedSlicesPerCell;
public void Reset()
{
Planes.Clear();
Slices.Clear();
}
}
/// <summary>True when the outside view is a full-screen pass-all (the
/// synthetic outdoor root) — every outside-test passes.</summary>
public bool OutsideIsFullScreen { get; private set; }
public static ViewconeCuller Build(ClipFrameAssembly assembly, in Matrix4x4 viewProjection)
public static ViewconeCuller Build(
ClipFrameAssembly assembly,
in Matrix4x4 viewProjection,
ViewconeCuller? reuse = null)
{
ArgumentNullException.ThrowIfNull(assembly);
var culler = new ViewconeCuller();
var culler = reuse ?? new ViewconeCuller();
culler.Reset();
foreach (var (cellId, slices) in assembly.CellIdToViewSlices)
{
var lifted = new Vector4[slices.Length][];
PlaneSet lifted = culler.RentPlaneSet();
for (int s = 0; s < slices.Length; s++)
lifted[s] = LiftPlanes(slices[s].Planes, viewProjection);
AppendLiftedSlice(lifted, slices[s].Planes, viewProjection);
culler._cellPlanes[cellId] = lifted;
}
var outside = assembly.OutsideViewSlices;
var outsideLifted = new Vector4[outside.Length][];
bool fullScreen = false;
for (int s = 0; s < outside.Length; s++)
{
outsideLifted[s] = LiftPlanes(outside[s].Planes, viewProjection);
AppendLiftedSlice(culler._outsidePlanes, outside[s].Planes, viewProjection);
if (outside[s].Planes.Length == 0)
fullScreen = true;
}
culler._outsidePlanes = outsideLifted;
culler.OutsideIsFullScreen = fullScreen;
return culler;
}
private static Vector4[] LiftPlanes(Vector4[] clipPlanes, in Matrix4x4 m)
private void Reset()
{
if (clipPlanes.Length == 0)
return Array.Empty<Vector4>();
var world = new Vector4[clipPlanes.Length];
foreach (PlaneSet set in _cellPlanes.Values)
{
set.Reset();
if (set.IsRetainable && _planeSetPool.Count < MaxRetainedCellPlaneSets)
_planeSetPool.Push(set);
}
_cellPlanes.Clear();
if (_outsidePlanes.IsRetainable)
_outsidePlanes.Reset();
else
_outsidePlanes = new PlaneSet();
OutsideIsFullScreen = false;
}
private PlaneSet RentPlaneSet()
{
PlaneSet result = _planeSetPool.Count != 0
? _planeSetPool.Pop()
: new PlaneSet();
result.Reset();
return result;
}
private static void AppendLiftedSlice(
PlaneSet destination,
Vector4[] clipPlanes,
in Matrix4x4 m)
{
int start = destination.Planes.Count;
for (int i = 0; i < clipPlanes.Length; i++)
{
var p = clipPlanes[i];
world[i] = new Vector4(
var equation = new Vector4(
m.M11 * p.X + m.M12 * p.Y + m.M13 * p.Z + m.M14 * p.W,
m.M21 * p.X + m.M22 * p.Y + m.M23 * p.Z + m.M24 * p.W,
m.M31 * p.X + m.M32 * p.Y + m.M33 * p.Z + m.M34 * p.W,
m.M41 * p.X + m.M42 * p.Y + m.M43 * p.Z + m.M44 * p.W);
float normalLength = MathF.Sqrt(
equation.X * equation.X
+ equation.Y * equation.Y
+ equation.Z * equation.Z);
destination.Planes.Add(new LiftedPlane(equation, normalLength));
}
return world;
destination.Slices.Add(new SliceRange(start, clipPlanes.Length));
}
private static bool SphereInsidePlanes(Vector4[] planes, in Vector3 center, float radius)
private static bool SphereInsidePlanes(
PlaneSet set,
SliceRange slice,
in Vector3 center,
float radius)
{
for (int i = 0; i < planes.Length; i++)
int end = slice.Start + slice.Count;
for (int i = slice.Start; i < end; i++)
{
var l = planes[i];
float nLen = MathF.Sqrt(l.X * l.X + l.Y * l.Y + l.Z * l.Z);
LiftedPlane plane = set.Planes[i];
Vector4 l = plane.Equation;
float nLen = plane.NormalLength;
if (nLen < 1e-12f)
continue; // degenerate plane — no constraint
float dist = l.X * center.X + l.Y * center.Y + l.Z * center.Z + l.W;
@ -103,10 +171,10 @@ public sealed class ViewconeCuller
/// retail). A zero-plane slice is pass-all.</summary>
public bool SphereVisibleInCell(uint cellId, in Vector3 center, float radius)
{
if (!_cellPlanes.TryGetValue(cellId, out var slices))
if (!_cellPlanes.TryGetValue(cellId, out PlaneSet? set))
return false;
for (int s = 0; s < slices.Length; s++)
if (SphereInsidePlanes(slices[s], center, radius))
for (int s = 0; s < set.Slices.Count; s++)
if (SphereInsidePlanes(set, set.Slices[s], center, radius))
return true;
return false;
}
@ -118,8 +186,8 @@ public sealed class ViewconeCuller
{
if (OutsideIsFullScreen)
return true;
for (int s = 0; s < _outsidePlanes.Length; s++)
if (SphereInsidePlanes(_outsidePlanes[s], center, radius))
for (int s = 0; s < _outsidePlanes.Slices.Count; s++)
if (SphereInsidePlanes(_outsidePlanes, _outsidePlanes.Slices[s], center, radius))
return true;
return false;
}
@ -128,8 +196,12 @@ public sealed class ViewconeCuller
/// slice; its statics pre-filter tests against exactly that slice).</summary>
public bool SphereVisibleInOutsideSlice(int sliceIndex, in Vector3 center, float radius)
{
if ((uint)sliceIndex >= (uint)_outsidePlanes.Length)
if ((uint)sliceIndex >= (uint)_outsidePlanes.Slices.Count)
return false;
return SphereInsidePlanes(_outsidePlanes[sliceIndex], center, radius);
return SphereInsidePlanes(
_outsidePlanes,
_outsidePlanes.Slices[sliceIndex],
center,
radius);
}
}

View file

@ -23,5 +23,14 @@ public sealed class AcSurfaceMetadataTable
public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
=> _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);
public void Remove(ulong gfxObjId)
{
foreach ((ulong Id, int SurfaceIndex) key in _table.Keys)
{
if (key.Id == gfxObjId)
_table.TryRemove(key, out _);
}
}
public void Clear() => _table.Clear();
}

View file

@ -26,6 +26,10 @@ internal sealed class ContiguousRangeAllocator
public int HighWaterMark { get; private set; }
public int Free => Capacity - Used;
public int LargestFreeRange => _free.Count == 0 ? 0 : _free.Max(range => range.Length);
public int TrailingFreeLength =>
_free.Count != 0 && _free[^1].End == Capacity
? _free[^1].Length
: 0;
public bool TryAllocate(int length, out MeshBufferRange allocation)
{
@ -73,6 +77,30 @@ internal sealed class ContiguousRangeAllocator
InsertAndCoalesce(new MeshBufferRange(oldCapacity, newCapacity - oldCapacity));
}
/// <summary>
/// Removes an unused tail after the matching physical GPU buffer has been
/// replaced. Live offsets are unchanged, so no render-data rewrite is
/// required.
/// </summary>
public void Shrink(int newCapacity)
{
if (newCapacity <= 0 || newCapacity >= Capacity)
throw new ArgumentOutOfRangeException(nameof(newCapacity));
if (newCapacity < HighWaterMark)
throw new InvalidOperationException("Cannot trim a GPU arena through a live allocation.");
MeshBufferRange tail = _free.Count == 0 ? default : _free[^1];
if (tail.End != Capacity || tail.Offset > newCapacity)
throw new InvalidOperationException("The requested GPU arena tail is not wholly free.");
if (tail.Offset == newCapacity)
_free.RemoveAt(_free.Count - 1);
else
_free[^1] = new MeshBufferRange(tail.Offset, newCapacity - tail.Offset);
Capacity = newCapacity;
RecalculateHighWaterMark();
}
public void Release(MeshBufferRange allocation)
{
if (allocation.Length <= 0
@ -84,6 +112,7 @@ internal sealed class ContiguousRangeAllocator
InsertAndCoalesce(allocation);
Used = checked(Used - allocation.Length);
RecalculateHighWaterMark();
}
private void InsertAndCoalesce(MeshBufferRange released)
@ -114,4 +143,11 @@ internal sealed class ContiguousRangeAllocator
_free.Insert(index, new MeshBufferRange(start, end - start));
}
private void RecalculateHighWaterMark()
{
HighWaterMark = _free.Count != 0 && _free[^1].End == Capacity
? _free[^1].Offset
: Capacity;
}
}

View file

@ -5,13 +5,21 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// The exact owner is still inside a reference transition, so its requested
/// logical removal has been queued and must be retried by the live-entity
/// teardown owner after the transition unwinds.
/// </summary>
public sealed class EntityPresentationRemovalDeferredException(uint serverGuid)
: InvalidOperationException(
$"Live entity 0x{serverGuid:X8} presentation removal is deferred until its active reference transition completes.");
/// <summary>
/// Routes server-spawned (<c>CreateObject</c>) entities through the
/// per-instance rendering path. Server entities always carry per-instance
/// customizations (palette overrides, texture changes, part swaps) that
/// don't fit WB's atlas key, so they bypass the atlas and use the existing
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// path which already hash-keys overrides for caching.
/// customizations (palette overrides, texture changes, part swaps). Shared
/// surfaces use the WB atlas while per-instance texture composites are owned
/// by the live entity and released with it.
///
/// <para>
/// Companion to <see cref="LandblockSpawnAdapter"/>: that adapter handles
@ -21,17 +29,6 @@ namespace AcDream.App.Rendering.Wb;
/// </para>
///
/// <para>
/// <b>Per-entity texture decode</b>: when <c>entity.PaletteOverride</c> is
/// non-null, the adapter calls
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// once per surface id that is known at spawn time (those on
/// <see cref="MeshRef.SurfaceOverrides"/>). Surfaces whose ids are only
/// discoverable by opening the GfxObj dat are decoded lazily by the draw
/// dispatcher (Task 22) on first use — that matches the existing
/// <c>StaticMeshRenderer</c> behavior.
/// </para>
///
/// <para>
/// <b>Sequencer factory</b>: the adapter is constructed with a
/// <c>Func&lt;WorldEntity, AnimationSequencer&gt;</c> factory so tests can
/// inject a stub without needing a live DatCollection or MotionTable.
@ -47,24 +44,67 @@ namespace AcDream.App.Rendering.Wb;
/// </summary>
public sealed class EntitySpawnAdapter
{
private readonly ITextureCachePerInstance _textureCache;
private readonly IEntityTextureLifetime _textureLifetime;
private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory;
private readonly IWbMeshAdapter? _meshAdapter;
// Per-server-guid state. Written on OnCreate, released on OnRemove.
// One logical owner per server GUID. Animated state survives projection
// suspension, while the resident bit controls the shorter GPU-presentation
// lifetime. The exact WorldEntity reference makes delayed visibility edges
// from a displaced GUID generation harmless.
// Single-threaded: called only from the render thread (same as GpuWorldState).
private readonly Dictionary<uint, AnimatedEntityState> _stateByGuid = new();
private readonly Dictionary<uint, Owner> _ownersByGuid = new();
// Per-server-guid set of GfxObj ids registered with the mesh adapter,
// so OnRemove can decrement each. Per-instance entities don't go through
// LandblockSpawnAdapter, so without this their meshes would never load
// (WB doesn't know they exist).
private readonly Dictionary<uint, HashSet<ulong>> _meshIdsByGuid = new();
private sealed class Owner(
WorldEntity entity,
AnimatedEntityState state,
HashSet<ulong> meshIds)
{
public WorldEntity Entity { get; } = entity;
public AnimatedEntityState State { get; } = state;
public HashSet<ulong> MeshIds { get; set; } = meshIds;
public HashSet<ulong> MeshReferencesHeld { get; } = new();
public bool IsPresentationResident { get; set; }
public bool TextureReleaseRequired { get; set; }
public PresentationTransition Transition { get; set; }
public bool RemovalPending { get; set; }
/// <param name="textureCache">
/// Per-instance texture decode path. In production this is the
/// <see cref="TextureCache"/> instance (which implements
/// <see cref="ITextureCachePerInstance"/>); in tests it is a capturing mock.
public bool HasPresentationResources
{
get
{
if (TextureReleaseRequired)
return true;
return MeshReferencesHeld.Count != 0;
}
}
public bool IsFullyResident
{
get
{
if (!IsPresentationResident || !TextureReleaseRequired)
return false;
return MeshReferencesHeld.SetEquals(MeshIds);
}
}
public bool IsFullySuspended =>
!IsPresentationResident && !HasPresentationResources;
}
private enum PresentationTransition
{
None,
Resuming,
Suspending,
ChangingAppearance,
}
/// <param name="textureLifetime">
/// Per-entity texture lifetime owner. Production uses
/// <see cref="TextureCache"/>; tests use a recording implementation.
/// </param>
/// <param name="sequencerFactory">
/// Factory that builds an <see cref="AnimationSequencer"/> for a given
@ -74,20 +114,19 @@ public sealed class EntitySpawnAdapter
/// returns a stub sequencer.
/// </param>
/// <param name="meshAdapter">
/// Optional WB mesh adapter. When non-null, <see cref="OnCreate"/>
/// registers each unique <c>MeshRef.GfxObjId</c> with the adapter so WB
/// background-loads the mesh data; <see cref="OnRemove"/> decrements the
/// matching ref counts. When null, the adapter only tracks per-instance
/// state without driving WB lifecycle (test mode + flag-off mode).
/// Optional WB mesh adapter. When non-null, presentation residency
/// registers each unique <c>MeshRef.GfxObjId</c> so WB background-loads
/// the mesh data. Projection suspension or logical removal balances those
/// references. When null, the adapter only tracks per-instance state.
/// </param>
public EntitySpawnAdapter(
ITextureCachePerInstance textureCache,
IEntityTextureLifetime textureLifetime,
Func<WorldEntity, AnimationSequencer> sequencerFactory,
IWbMeshAdapter? meshAdapter = null)
{
ArgumentNullException.ThrowIfNull(textureCache);
ArgumentNullException.ThrowIfNull(textureLifetime);
ArgumentNullException.ThrowIfNull(sequencerFactory);
_textureCache = textureCache;
_textureLifetime = textureLifetime;
_sequencerFactory = sequencerFactory;
_meshAdapter = meshAdapter;
}
@ -105,29 +144,6 @@ public sealed class EntitySpawnAdapter
// are handled by LandblockSpawnAdapter, not here.
if (entity.ServerGuid == 0) return null;
// Pre-warm the per-instance texture cache for surfaces whose ids are
// already known at spawn time (those appearing as keys in
// MeshRef.SurfaceOverrides). GfxObj sub-mesh surface ids that aren't
// covered by SurfaceOverrides are decoded lazily by the draw
// dispatcher on first use — consistent with StaticMeshRenderer.
if (entity.PaletteOverride is { } paletteOverride)
{
foreach (var meshRef in entity.MeshRefs)
{
if (meshRef.SurfaceOverrides is null) continue;
// SurfaceOverrides maps surfaceId → origTextureOverride (may be 0
// meaning "no texture swap, just the palette override applies").
foreach (var (surfaceId, origTexOverride) in meshRef.SurfaceOverrides)
{
_textureCache.GetOrUploadWithPaletteOverride(
surfaceId,
origTexOverride == 0 ? null : origTexOverride,
paletteOverride);
}
}
}
// A.5 T18: populate cached AABB so WalkEntities reads from the cache
// rather than recomputing Position±5 per frame. Called here because
// all entity-state initialization (position, rotation) is complete
@ -147,41 +163,228 @@ public sealed class EntitySpawnAdapter
foreach (var po in entity.PartOverrides)
state.SetPartOverride(po.PartIndex, po.GfxObjId);
_stateByGuid[entity.ServerGuid] = state;
HashSet<ulong> meshIds = _meshAdapter is null
? []
: CollectMeshIds(entity.MeshRefs, entity.PartOverrides);
// Register each unique GfxObj id with WB so the meshes background-load.
// Snapshot each unique GfxObj id for the shorter presentation lifetime.
// Includes both the entity's natural MeshRefs AND any server-sent
// PartOverride GfxObjs (weapons, clothing, helmets) — those replace the
// Setup default and need their own mesh data uploaded.
if (_meshAdapter is not null)
// Construct the replacement completely before displacing a live owner.
// Sequencer/appearance construction is allowed to fail; in that case
// the prior GUID incarnation and its presentation references remain
// valid. Retirement is also completed before replacement publication:
// a failed texture or mesh release therefore leaves the prior owner in
// the dictionary, with its per-resource progress available to retry.
var replacementOwner = new Owner(entity, state, meshIds);
if (_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? displacedOwner))
{
var unique = new HashSet<ulong>();
foreach (var meshRef in entity.MeshRefs)
unique.Add((ulong)meshRef.GfxObjId);
foreach (var po in entity.PartOverrides)
unique.Add((ulong)po.GfxObjId);
if (displacedOwner.RemovalPending)
{
throw new EntityPresentationRemovalDeferredException(entity.ServerGuid);
}
_meshIdsByGuid[entity.ServerGuid] = unique;
foreach (var id in unique) _meshAdapter.IncrementRefCount(id);
if (displacedOwner.Transition != PresentationTransition.None)
{
throw new InvalidOperationException(
$"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation transition was already in progress.");
}
if (displacedOwner.HasPresentationResources)
{
if (!SuspendPresentation(displacedOwner))
{
throw new InvalidOperationException(
$"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation teardown was already in progress.");
}
}
_ownersByGuid[entity.ServerGuid] = replacementOwner;
}
else
{
_ownersByGuid.Add(entity.ServerGuid, replacementOwner);
}
return state;
}
/// <summary>
/// Changes only the shorter presentation/GPU lifetime of an already-created
/// live entity. A visible projection acquires WB mesh references; suspension
/// releases those references and every owner-scoped composite texture. The
/// animated state remains registered and no create-time scripts are replayed.
/// Duplicate edges and edges for an older incarnation of a reused server GUID
/// are ignored. A failed release keeps the published resident state and the
/// exact unfinished resources so the same edge can be retried safely.
/// </summary>
/// <returns><c>true</c> when this call applied a residency edge.</returns>
public bool SetPresentationResident(WorldEntity entity, bool resident)
{
ArgumentNullException.ThrowIfNull(entity);
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|| !ReferenceEquals(owner.Entity, entity)
|| owner.RemovalPending
|| (resident ? owner.IsFullyResident : owner.IsFullySuspended))
{
return false;
}
return resident
? ResumePresentation(owner)
: SuspendPresentation(owner);
}
/// <summary>
/// Reconciles the mesh-reference set for an in-place retail
/// <c>SmartBox::UpdateVisualDesc</c> mutation. Added meshes are acquired
/// before <paramref name="publishAppearance"/> makes the new appearance
/// visible. Only then is the exact new mesh set published and superseded
/// references released. A failed release remains represented by
/// <see cref="Owner.MeshReferencesHeld"/> and is retried by the next
/// residency edge or logical teardown.
/// <paramref name="afterPublication"/> runs after that commit point but
/// before retirement, so dependent pose/cache publication cannot leave the
/// new entity appearance paired with rolled-back mesh ownership.
/// </summary>
/// <remarks>
/// The exact <see cref="WorldEntity"/> reference is the incarnation token.
/// A delayed appearance callback from a GUID-reused owner is ignored. The
/// method is render-thread only; a re-entrant request is rejected before
/// its publication callback can mutate the active owner.
/// </remarks>
/// <returns>
/// <c>true</c> when the matching owner's appearance was published;
/// otherwise <c>false</c> for a stale, removing, or re-entrant owner.
/// </returns>
public bool OnAppearanceChanged(
WorldEntity entity,
IReadOnlyList<MeshRef> meshRefs,
IReadOnlyList<PartOverride> partOverrides,
Action publishAppearance,
Action? afterPublication = null)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(meshRefs);
ArgumentNullException.ThrowIfNull(partOverrides);
ArgumentNullException.ThrowIfNull(publishAppearance);
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|| !ReferenceEquals(owner.Entity, entity)
|| owner.RemovalPending
|| owner.Transition != PresentationTransition.None)
{
return false;
}
HashSet<ulong> nextMeshIds = _meshAdapter is null
? []
: CollectMeshIds(meshRefs, partOverrides);
owner.Transition = PresentationTransition.ChangingAppearance;
var acquiredThisTransition = new List<ulong>();
bool meshSetPublished = false;
try
{
if (owner.IsPresentationResident && _meshAdapter is not null)
AcquireMissingMeshReferences(owner, nextMeshIds, acquiredThisTransition);
publishAppearance();
// Publication is the commit point: every desired resident mesh is
// owned before this assignment. Superseded references may remain
// temporarily held if their release reports a retryable failure,
// but they are no longer part of the published appearance set.
owner.MeshIds = nextMeshIds;
meshSetPublished = true;
afterPublication?.Invoke();
List<Exception>? releaseFailures = owner.IsPresentationResident
? ReleaseMeshReferencesOutside(owner, nextMeshIds)
: ReleaseAllMeshReferences(owner);
if (releaseFailures is not null)
{
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} appearance mesh retirement failed.",
releaseFailures);
}
return true;
}
catch (Exception acquireOrPublicationFailure) when (!meshSetPublished)
{
// Acquisition failed before publication. Preserve the prior exact
// mesh set and undo only references acquired by this transition.
List<Exception>? rollbackFailures = RollBackAcquiredMeshReferences(
owner,
acquiredThisTransition);
if (rollbackFailures is null)
throw;
rollbackFailures.Insert(0, acquireOrPublicationFailure);
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} appearance publication and mesh rollback failed.",
rollbackFailures);
}
finally
{
owner.Transition = PresentationTransition.None;
}
}
/// <summary>
/// Release the per-entity state for <paramref name="serverGuid"/>. Called
/// on <c>RemoveObject</c>. Unknown guids (never spawned, or already
/// removed) are silently ignored.
/// removed) are silently ignored. Cleanup failure leaves the owner registered
/// and propagates the exception; a later call resumes its unfinished releases.
/// </summary>
public void OnRemove(uint serverGuid)
{
_stateByGuid.Remove(serverGuid);
=> _ = TryRemove(serverGuid, expectedEntity: null);
if (_meshAdapter is not null && _meshIdsByGuid.TryGetValue(serverGuid, out var ids))
private bool TryRemove(uint serverGuid, WorldEntity? expectedEntity)
{
if (!_ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
|| (expectedEntity is not null && !ReferenceEquals(owner.Entity, expectedEntity)))
{
foreach (var id in ids) _meshAdapter.DecrementRefCount(id);
_meshIdsByGuid.Remove(serverGuid);
return false;
}
owner.RemovalPending = true;
if (owner.Transition != PresentationTransition.None)
throw new EntityPresentationRemovalDeferredException(serverGuid);
// A resident owner still holds both mesh and potentially-lazy texture
// resources. A suspended owner released them at the visibility edge,
// so logical removal must not decrement or release a second time. Do
// not remove the dictionary entry until cleanup succeeds: otherwise a
// release exception would orphan its remaining references and make a
// later OnRemove retry impossible.
if (owner.HasPresentationResources && !SuspendPresentation(owner))
return false;
if (_ownersByGuid.TryGetValue(serverGuid, out Owner? current)
&& ReferenceEquals(current, owner))
{
_ownersByGuid.Remove(serverGuid);
return true;
}
return false;
}
/// <summary>
/// Releases a logical owner only when <paramref name="entity"/> is the exact
/// incarnation currently registered for its server GUID. This is the live
/// runtime teardown entry point: a delayed callback from an older generation
/// cannot remove a replacement that reused the same GUID.
/// </summary>
/// <returns><c>true</c> when the matching owner was removed.</returns>
public bool OnRemove(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
return TryRemove(entity.ServerGuid, entity);
}
/// <summary>
@ -190,5 +393,209 @@ public sealed class EntitySpawnAdapter
/// been removed.
/// </summary>
public AnimatedEntityState? GetState(uint serverGuid)
=> _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null;
=> _ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
? owner.State
: null;
private bool ResumePresentation(Owner owner)
{
if (owner.Transition != PresentationTransition.None)
return false;
owner.Transition = PresentationTransition.Resuming;
var acquiredThisTransition = new List<ulong>();
bool desiredMeshSetAcquired = false;
try
{
if (_meshAdapter is not null)
AcquireMissingMeshReferences(owner, owner.MeshIds, acquiredThisTransition);
desiredMeshSetAcquired = true;
owner.TextureReleaseRequired = true;
owner.IsPresentationResident = true;
List<Exception>? releaseFailures = ReleaseMeshReferencesOutside(
owner,
owner.MeshIds);
if (releaseFailures is not null)
{
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation mesh reconciliation failed.",
releaseFailures);
}
return true;
}
catch (Exception acquireFailure) when (!desiredMeshSetAcquired)
{
List<Exception>? rollbackFailures = RollBackAcquiredMeshReferences(
owner,
acquiredThisTransition);
if (rollbackFailures is null)
throw;
rollbackFailures.Insert(0, acquireFailure);
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation resume and rollback failed.",
rollbackFailures);
}
finally
{
owner.Transition = PresentationTransition.None;
}
}
private bool SuspendPresentation(Owner owner)
{
if (owner.Transition != PresentationTransition.None)
return false;
// IsPresentationResident deliberately remains true until every release
// succeeds. Transition prevents a re-entrant duplicate edge from
// releasing the same resource twice, while the completion markers keep
// partial progress retryable after an exception.
owner.Transition = PresentationTransition.Suspending;
List<Exception>? failures = null;
if (owner.TextureReleaseRequired)
{
try
{
_textureLifetime.ReleaseOwner(owner.Entity.Id);
owner.TextureReleaseRequired = false;
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
List<Exception>? meshFailures = ReleaseAllMeshReferences(owner);
if (meshFailures is not null)
(failures ??= new List<Exception>()).AddRange(meshFailures);
if (failures is not null)
{
owner.Transition = PresentationTransition.None;
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation suspension failed.",
failures);
}
owner.IsPresentationResident = false;
owner.Transition = PresentationTransition.None;
return true;
}
private static HashSet<ulong> CollectMeshIds(
IReadOnlyList<MeshRef> meshRefs,
IReadOnlyList<PartOverride> partOverrides)
{
var unique = new HashSet<ulong>();
for (int i = 0; i < meshRefs.Count; i++)
unique.Add(meshRefs[i].GfxObjId);
for (int i = 0; i < partOverrides.Count; i++)
unique.Add(partOverrides[i].GfxObjId);
return unique;
}
private void AcquireMissingMeshReferences(
Owner owner,
HashSet<ulong> desiredMeshIds,
List<ulong> acquiredThisTransition)
{
if (_meshAdapter is null)
return;
foreach (ulong meshId in desiredMeshIds)
{
if (owner.MeshReferencesHeld.Contains(meshId))
continue;
try
{
_meshAdapter.IncrementRefCount(meshId);
owner.MeshReferencesHeld.Add(meshId);
acquiredThisTransition.Add(meshId);
}
catch (MeshReferenceMutationException error)
{
if (error.MutationCommitted)
{
owner.MeshReferencesHeld.Add(meshId);
acquiredThisTransition.Add(meshId);
}
throw;
}
}
}
private List<Exception>? RollBackAcquiredMeshReferences(
Owner owner,
List<ulong> acquiredThisTransition)
{
if (_meshAdapter is null)
return null;
List<Exception>? failures = null;
for (int i = acquiredThisTransition.Count - 1; i >= 0; i--)
{
ulong meshId = acquiredThisTransition[i];
if (!owner.MeshReferencesHeld.Contains(meshId))
continue;
try
{
_meshAdapter.DecrementRefCount(meshId);
owner.MeshReferencesHeld.Remove(meshId);
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
owner.MeshReferencesHeld.Remove(meshId);
(failures ??= new List<Exception>()).Add(error);
}
}
return failures;
}
private List<Exception>? ReleaseMeshReferencesOutside(
Owner owner,
HashSet<ulong> desiredMeshIds)
{
if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0)
return null;
List<Exception>? failures = null;
ulong[] heldSnapshot = [.. owner.MeshReferencesHeld];
foreach (ulong meshId in heldSnapshot)
{
if (desiredMeshIds.Contains(meshId))
continue;
try
{
_meshAdapter.DecrementRefCount(meshId);
owner.MeshReferencesHeld.Remove(meshId);
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
owner.MeshReferencesHeld.Remove(meshId);
(failures ??= new List<Exception>()).Add(error);
}
}
return failures;
}
private List<Exception>? ReleaseAllMeshReferences(Owner owner)
{
if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0)
return null;
return ReleaseMeshReferencesOutside(owner, []);
}
}

View file

@ -1,9 +1,11 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Starts CPU mesh extraction for a completed EnvCell build without publishing
/// that build to the live renderer. ObjectMeshManager owns its thread-safe work
/// queue; the render-thread commit remains a small atomic state replacement.
/// Starts CPU mesh extraction after the completed EnvCell build has been
/// published and its geometry ids have acquired render ownership. This order
/// lets ObjectMeshManager cancel queued work as soon as a landblock leaves the
/// current streaming generation; stale portal destinations never keep decoder
/// jobs or surface lists alive.
/// </summary>
public static class EnvCellMeshPreparationScheduler
{
@ -11,8 +13,11 @@ public static class EnvCellMeshPreparationScheduler
EnvCellLandblockBuild build,
ObjectMeshManager meshManager)
{
var scheduled = new HashSet<ulong>();
foreach (var shell in build.Shells)
{
if (!scheduled.Add(shell.GeometryId))
continue;
_ = meshManager.PrepareEnvCellGeomMeshDataAsync(
shell.GeometryId,
shell.EnvironmentId,

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,32 @@ namespace AcDream.App.Rendering.Wb {
Device = device;
}
/// <summary>
/// Always-on error boundary for resource transactions. Most render-path
/// checks remain Debug-only because <c>glGetError</c> is a synchronous
/// driver call; allocation/upload code must not publish CPU state after
/// OpenGL reported OOM, context loss, or a rejected transfer in Release.
/// Call exactly once before committing each transaction.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void ThrowOnResourceError(GL gl, string context) {
GLEnum error = gl.GetError();
if (error == GLEnum.NoError)
return;
var errors = new System.Text.StringBuilder();
do {
if (errors.Length != 0)
errors.Append(", ");
errors.Append(error).Append(" (").Append(GetErrorDetails(error)).Append(')');
error = gl.GetError();
} while (error != GLEnum.NoError);
string message = $"OpenGL resource transaction failed: {errors}. Context: {context}";
Logger?.LogError(message);
throw new InvalidOperationException(message);
}
#if DEBUG
private static bool _loggedVersion = false;
@ -140,46 +166,6 @@ namespace AcDream.App.Rendering.Wb {
return info.ToString();
}
/// <summary>
/// Validates texture completeness for mipmapping
/// </summary>
public static bool ValidateTextureMipmapStatus(GL gl, GLEnum target, out string errorMessage) {
try {
gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureWidth, out int width);
gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureHeight, out int height);
gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureInternalFormat, out int format);
if (width == 0 || height == 0) {
errorMessage = "Texture has zero dimensions";
return false;
}
// Check if format is valid for mipmap generation
var internalFormat = (InternalFormat)format;
if (IsCompressedFormat(internalFormat)) {
errorMessage = $"Compressed format {internalFormat} does not support automatic mipmap generation";
return false;
}
errorMessage = String.Empty;
return true;
}
catch (Exception ex) {
errorMessage = $"Exception during validation: {ex.Message}";
return false;
}
}
private static bool IsCompressedFormat(InternalFormat format) {
return format == InternalFormat.CompressedRgbaS3TCDxt1Ext ||
format == InternalFormat.CompressedRgbaS3TCDxt3Ext ||
format == InternalFormat.CompressedRgbaS3TCDxt5Ext ||
format == InternalFormat.CompressedRgbS3TCDxt1Ext ||
format == InternalFormat.CompressedSrgbAlphaS3TCDxt1Ext ||
format == InternalFormat.CompressedSrgbAlphaS3TCDxt3Ext ||
format == InternalFormat.CompressedSrgbAlphaS3TCDxt5Ext;
}
/// <summary>
/// Logs current OpenGL state for debugging
/// </summary>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,103 @@
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// A contiguous GPU-buffer suballocator whose released ranges become
/// reusable only after the frame-retirement queue says that older draws have
/// finished. This prevents <c>BufferSubData</c> from overwriting a range that
/// an in-flight draw still reads.
/// </summary>
internal sealed class GpuRetiredRangeAllocator
{
private readonly ContiguousRangeAllocator _allocator;
private readonly GpuRetirementLedger _retirementLedger;
private readonly Dictionary<MeshBufferRange, RetryableGpuResourceRelease> _pendingReleases = [];
private int _pendingReleaseCount;
private int _pendingReleaseLength;
public GpuRetiredRangeAllocator(int capacity, IGpuResourceRetirementQueue retirement)
{
_allocator = new ContiguousRangeAllocator(capacity);
_retirementLedger = new GpuRetirementLedger(
retirement ?? throw new ArgumentNullException(nameof(retirement)));
}
public int Capacity => _allocator.Capacity;
public int Used => _allocator.Used;
public int HighWaterMark => _allocator.HighWaterMark;
public int LargestFreeRange => _allocator.LargestFreeRange;
public int TrailingFreeLength => _allocator.TrailingFreeLength;
public int PendingReleaseCount => _pendingReleaseCount;
public int PendingReleaseLength => _pendingReleaseLength;
public bool TryAllocate(int length, out MeshBufferRange allocation) =>
_allocator.TryAllocate(length, out allocation);
public void Grow(int newCapacity) => _allocator.Grow(newCapacity);
public void Shrink(int newCapacity) => _allocator.Shrink(newCapacity);
public void ReleaseAfterGpuUse(MeshBufferRange allocation)
{
if (_pendingReleases.TryGetValue(allocation, out RetryableGpuResourceRelease? pending))
{
// The caller retries this method when queue publication failed.
// Re-publish the retained transaction instead of accounting for a
// second logical release of the same physical range.
try
{
_retirementLedger.RetryPendingPublication(pending);
}
catch when (pending.IsComplete)
{
// An immediate retirement queue may surface a wrapper failure
// after the retained transaction itself fully committed.
}
return;
}
int nextPendingCount = checked(_pendingReleaseCount + 1);
int nextPendingLength = checked(_pendingReleaseLength + allocation.Length);
var release = new RetryableGpuResourceRelease(
() => _allocator.Release(allocation),
() => _pendingReleaseCount = checked(_pendingReleaseCount - 1),
() => _pendingReleaseLength = checked(
_pendingReleaseLength - allocation.Length),
() =>
{
if (!_pendingReleases.Remove(allocation))
{
throw new InvalidOperationException(
"GPU buffer range retirement lost its ownership record.");
}
});
_pendingReleases.Add(allocation, release);
_pendingReleaseCount = nextPendingCount;
_pendingReleaseLength = nextPendingLength;
try
{
_retirementLedger.Retire(release);
}
catch when (release.IsComplete)
{
// Completion is stronger than publication acknowledgement. The
// physical range and its accounting already converged.
}
}
/// <summary>
/// Releases a range that failed before it could be submitted in a draw.
/// </summary>
public void ReleaseUnsubmitted(MeshBufferRange allocation)
{
if (_pendingReleases.ContainsKey(allocation))
{
throw new InvalidOperationException(
"A GPU-submitted range cannot be released as unsubmitted.");
}
_allocator.Release(allocation);
}
}

View file

@ -12,7 +12,6 @@ namespace AcDream.App.Rendering.Wb;
/// without depending on dispatcher internals.
/// </summary>
internal readonly record struct GroupKey(
uint Ibo,
uint FirstIndex,
int BaseVertex,
int IndexCount,

View file

@ -0,0 +1,12 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Logical-owner lifetime seam for per-entity texture composites. Retail
/// <c>CSurface::Destroy</c> (0x005361F0) releases its current <c>ImgTex</c>;
/// live-object teardown must do the same for modern bindless composites.
/// </summary>
public interface IEntityTextureLifetime
{
/// <summary>Release every composite acquired by one local entity id.</summary>
void ReleaseOwner(uint localEntityId);
}

View file

@ -1,22 +0,0 @@
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Seam interface over the per-instance palette-override decode path in
/// <see cref="TextureCache"/>. Extracted so <see cref="EntitySpawnAdapter"/>
/// can be tested without a live GL context.
/// </summary>
public interface ITextureCachePerInstance
{
/// <summary>
/// Decode (or return cached) the palette-overridden texture for
/// <paramref name="surfaceId"/>. Delegates to
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> in
/// production.
/// </summary>
uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride);
}

View file

@ -1,5 +1,25 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Reports the physical outcome when a mesh-reference callback cannot provide
/// the normal strong exception guarantee. <see cref="MutationCommitted"/> lets
/// a transactional owner reconcile its marker without guessing whether a
/// throwing backend already changed the reference count.
/// </summary>
public sealed class MeshReferenceMutationException : Exception
{
public MeshReferenceMutationException(
string message,
bool mutationCommitted,
Exception innerException)
: base(message, innerException)
{
MutationCommitted = mutationCommitted;
}
public bool MutationCommitted { get; }
}
/// <summary>
/// Mockable interface over <see cref="WbMeshAdapter"/> so adapters that
/// drive ref-count lifecycle (e.g. LandblockSpawnAdapter, EntitySpawnAdapter)
@ -7,7 +27,17 @@ namespace AcDream.App.Rendering.Wb;
/// </summary>
public interface IWbMeshAdapter
{
/// <summary>
/// Acquires one logical reference. A normal exception guarantees that no
/// reference was acquired; <see cref="MeshReferenceMutationException"/>
/// explicitly reports the exceptional backend case where it was committed.
/// </summary>
void IncrementRefCount(ulong id);
/// <summary>
/// Releases one logical reference under the same committed-outcome contract
/// as <see cref="IncrementRefCount"/>.
/// </summary>
void DecrementRefCount(ulong id);
/// <summary>

View file

@ -9,20 +9,24 @@ namespace AcDream.App.Rendering.Wb;
/// entities (procedural / dat-hydrated, identified by
/// <c>ServerGuid == 0</c>) drive ref counts. Server-spawned entities
/// (per-instance tier) are skipped — those go through
/// <c>EntitySpawnAdapter</c> + <c>TextureCache.GetOrUploadWithPaletteOverride</c>
/// <c>EntitySpawnAdapter</c> and the owner-scoped texture path
/// (see Phase N.4 spec, Architecture → Two-tier rendering split).
///
/// <para>
/// On load: walks the landblock's atlas-tier entities, collects unique
/// GfxObj ids from their <c>MeshRefs</c>, calls
/// <c>IncrementRefCount</c> per id, and pins each specialized EnvCell geometry
/// id without starting generic GfxObj decode. Snapshots both id-sets per
/// landblock so unload can match the load 1:1.
/// id without starting generic GfxObj decode. Each reference has an explicit
/// desired/held marker so a throwing backend cannot make the logical snapshot
/// disagree with the physical reference count.
/// </para>
///
/// <para>
/// On unload: looks up both snapshots, calls <c>DecrementRefCount</c> per id,
/// drops the snapshots. Unknown / never-loaded landblocks no-op.
/// On unload: releases only references whose held marker is still set. A
/// before-commit failure remains retryable; an after-commit
/// <see cref="MeshReferenceMutationException"/> advances the marker before the
/// exception is propagated. The registration is dropped only after every held
/// reference has been released. Unknown / never-loaded landblocks no-op.
/// </para>
///
/// <para>
@ -42,15 +46,21 @@ namespace AcDream.App.Rendering.Wb;
/// </summary>
public sealed class LandblockSpawnAdapter
{
private readonly IWbMeshAdapter _adapter;
private sealed class ReferenceRegistration
{
public bool Desired;
public bool Held;
}
// Maps landblock id → unique GfxObj ids registered for that landblock.
// Written on load, read+cleared on unload. Single-threaded (streaming worker).
private readonly Dictionary<uint, HashSet<ulong>> _idsByLandblock = new();
// EnvCell shells are prepared through PrepareEnvCellGeomMeshDataAsync rather
// than generic GfxObj loading, but still require explicit lifetime pins.
// Keep their synthetic ids separate so registration uses the no-decode pin.
private readonly Dictionary<uint, HashSet<ulong>> _additionalReadinessIdsByLandblock = new();
private sealed class LandblockRegistration
{
public bool WantsLoaded;
public Dictionary<ulong, ReferenceRegistration> Ordinary { get; } = new();
public Dictionary<ulong, ReferenceRegistration> Prepared { get; } = new();
}
private readonly IWbMeshAdapter _adapter;
private readonly Dictionary<uint, LandblockRegistration> _registrations = new();
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
{
@ -81,33 +91,40 @@ public sealed class LandblockSpawnAdapter
unique.Add((ulong)meshRef.GfxObjId);
}
if (!_idsByLandblock.TryGetValue(landblock.LandblockId, out var registered))
HashSet<ulong>? preparedIds = additionalReadinessIds is null
? null
: new HashSet<ulong>(additionalReadinessIds);
if (!_registrations.TryGetValue(landblock.LandblockId, out var registration))
{
_idsByLandblock[landblock.LandblockId] = unique;
foreach (var id in unique) _adapter.IncrementRefCount(id);
registration = new LandblockRegistration { WantsLoaded = true };
_registrations.Add(landblock.LandblockId, registration);
}
else
else if (!registration.WantsLoaded)
{
foreach (var id in unique)
{
if (registered.Add(id))
_adapter.IncrementRefCount(id);
}
// This is a new load edge that arrived while a preceding unload
// still had unfinished releases. The new snapshot replaces the old
// desired set. Any still-held overlap remains acquired; obsolete
// residual references are released by Reconcile below.
MarkAllUndesired(registration.Ordinary);
MarkAllUndesired(registration.Prepared);
registration.WantsLoaded = true;
}
if (!_additionalReadinessIdsByLandblock.TryGetValue(
landblock.LandblockId,
out var additional))
{
additional = new HashSet<ulong>();
_additionalReadinessIdsByLandblock[landblock.LandblockId] = additional;
}
if (additionalReadinessIds is not null)
{
foreach (var id in additionalReadinessIds)
if (additional.Add(id))
_adapter.PinPreparedRenderData(id);
}
MarkDesired(registration.Ordinary, unique);
if (preparedIds is not null)
MarkDesired(registration.Prepared, preparedIds);
List<Exception>? failures = null;
ReleaseUndesired(registration.Ordinary, ref failures);
ReleaseUndesired(registration.Prepared, ref failures);
PruneReleasedUndesired(registration.Ordinary);
PruneReleasedUndesired(registration.Prepared);
AcquireDesired(registration.Ordinary, prepared: false, ref failures);
AcquireDesired(registration.Prepared, prepared: true, ref failures);
ThrowFailures(
failures,
$"Landblock 0x{landblock.LandblockId:X8} mesh-reference acquisition did not fully converge.");
}
/// <summary>
@ -117,17 +134,19 @@ public sealed class LandblockSpawnAdapter
/// </summary>
public bool IsLandblockRenderReady(uint landblockId)
{
if (!_idsByLandblock.TryGetValue(landblockId, out var registered)
|| !_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
{
if (!_registrations.TryGetValue(landblockId, out var registration)
|| !registration.WantsLoaded)
return false;
}
foreach (var id in registered)
if (!_adapter.IsRenderDataReady(id))
foreach (var pair in registration.Ordinary)
if (!pair.Value.Desired
|| !pair.Value.Held
|| !_adapter.IsRenderDataReady(pair.Key))
return false;
foreach (var id in additional)
if (!_adapter.IsRenderDataReady(id))
foreach (var pair in registration.Prepared)
if (!pair.Value.Desired
|| !pair.Value.Held
|| !_adapter.IsRenderDataReady(pair.Key))
return false;
return true;
}
@ -139,11 +158,127 @@ public sealed class LandblockSpawnAdapter
/// </summary>
public void OnLandblockUnloaded(uint landblockId)
{
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
foreach (var id in unique) _adapter.DecrementRefCount(id);
if (_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
foreach (var id in additional) _adapter.DecrementRefCount(id);
_idsByLandblock.Remove(landblockId);
_additionalReadinessIdsByLandblock.Remove(landblockId);
if (!_registrations.TryGetValue(landblockId, out var registration))
return;
registration.WantsLoaded = false;
MarkAllUndesired(registration.Ordinary);
MarkAllUndesired(registration.Prepared);
List<Exception>? failures = null;
ReleaseUndesired(registration.Ordinary, ref failures);
ReleaseUndesired(registration.Prepared, ref failures);
PruneReleasedUndesired(registration.Ordinary);
PruneReleasedUndesired(registration.Prepared);
// Even an after-commit backend exception may report failure after the
// final physical release. Drop the registration before propagating in
// that case so a caller retry cannot decrement the same reference.
if (registration.Ordinary.Count == 0 && registration.Prepared.Count == 0)
_registrations.Remove(landblockId);
ThrowFailures(
failures,
$"Landblock 0x{landblockId:X8} mesh-reference release did not fully converge.");
}
private static void MarkDesired(
Dictionary<ulong, ReferenceRegistration> registrations,
IEnumerable<ulong> ids)
{
foreach (ulong id in ids)
{
if (!registrations.TryGetValue(id, out var reference))
{
reference = new ReferenceRegistration();
registrations.Add(id, reference);
}
reference.Desired = true;
}
}
private static void MarkAllUndesired(
Dictionary<ulong, ReferenceRegistration> registrations)
{
foreach (var reference in registrations.Values)
reference.Desired = false;
}
private void AcquireDesired(
Dictionary<ulong, ReferenceRegistration> registrations,
bool prepared,
ref List<Exception>? failures)
{
foreach (var pair in registrations)
{
ReferenceRegistration reference = pair.Value;
if (!reference.Desired || reference.Held)
continue;
try
{
if (prepared)
_adapter.PinPreparedRenderData(pair.Key);
else
_adapter.IncrementRefCount(pair.Key);
reference.Held = true;
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
reference.Held = true;
(failures ??= new List<Exception>()).Add(error);
}
}
}
private void ReleaseUndesired(
Dictionary<ulong, ReferenceRegistration> registrations,
ref List<Exception>? failures)
{
foreach (var pair in registrations)
{
ReferenceRegistration reference = pair.Value;
if (reference.Desired || !reference.Held)
continue;
try
{
_adapter.DecrementRefCount(pair.Key);
reference.Held = false;
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
reference.Held = false;
(failures ??= new List<Exception>()).Add(error);
}
}
}
private static void PruneReleasedUndesired(
Dictionary<ulong, ReferenceRegistration> registrations)
{
List<ulong>? released = null;
foreach (var pair in registrations)
{
if (!pair.Value.Desired && !pair.Value.Held)
(released ??= new List<ulong>()).Add(pair.Key);
}
if (released is null)
return;
foreach (ulong id in released)
registrations.Remove(id);
}
private static void ThrowFailures(List<Exception>? failures, string message)
{
if (failures is null)
return;
if (failures.Count == 1)
throw failures[0];
throw new AggregateException(message, failures);
}
}

View file

@ -19,9 +19,6 @@ namespace AcDream.App.Rendering.Wb {
public int Height { get; private set; }
public TextureFormat Format => TextureFormat.RGBA8;
public ulong BindlessHandle { get; private set; }
public ulong BindlessWrapHandle { get; private set; }
public ulong BindlessClampHandle { get; private set; }
/// <inheritdoc/>
public ManagedGLTexture(OpenGLGraphicsDevice device, byte[]? source, int width, int height, TextureParameters? texParams = null) {
@ -54,12 +51,10 @@ namespace AcDream.App.Rendering.Wb {
if (p.EnableAnisotropicFiltering && _device.RenderSettings.EnableAnisotropicFiltering)
{
float maxAnisotropy = 0f;
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out maxAnisotropy);
if (maxAnisotropy > 0)
if (_device.MaxSupportedAnisotropy > 0)
{
GL.TexParameter(GLEnum.Texture2D, GLEnum.TextureMaxAnisotropy, maxAnisotropy);
GL.TexParameter(GLEnum.Texture2D, GLEnum.TextureMaxAnisotropy,
_device.MaxSupportedAnisotropy);
}
}
@ -72,15 +67,6 @@ namespace AcDream.App.Rendering.Wb {
GpuMemoryTracker.TrackAllocation(CalculateSize(), GpuResourceType.Texture);
if (_device.HasBindless && _device.BindlessExtension != null) {
BindlessHandle = _device.BindlessExtension.GetTextureHandle(_texture);
BindlessWrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(_texture, _device.WrapSampler);
BindlessClampHandle = _device.BindlessExtension.GetTextureSamplerHandle(_texture, _device.ClampSampler);
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessWrapHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessClampHandle);
}
}
private long CalculateSize() {
@ -112,12 +98,6 @@ namespace AcDream.App.Rendering.Wb {
GL.GetInteger(GLEnum.TextureBinding2D, out int oldBinding);
GL.BindTexture(GLEnum.Texture2D, _texture);
bool wasResident = false;
if (BindlessHandle != 0 && _device.BindlessExtension != null && _device.BindlessExtension.IsTextureHandleResident(BindlessHandle)) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
wasResident = true;
}
fixed (byte* ptr = data) {
GL.TexSubImage2D(
GLEnum.Texture2D,
@ -135,10 +115,6 @@ namespace AcDream.App.Rendering.Wb {
// Generate mipmaps if needed
GL.GenerateMipmap(GLEnum.Texture2D);
if (wasResident && BindlessHandle != 0 && _device.BindlessExtension != null) {
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
}
GL.BindTexture(GLEnum.Texture2D, (uint)oldBinding);
GL.ActiveTexture((GLEnum)oldActiveTexture);
GLHelpers.CheckErrors(GL);
@ -172,20 +148,6 @@ namespace AcDream.App.Rendering.Wb {
protected void ReleaseTexture() {
_device.QueueGLAction(GL => {
if (_device.BindlessExtension != null) {
if (BindlessHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
BindlessHandle = 0;
}
if (BindlessWrapHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessWrapHandle);
BindlessWrapHandle = 0;
}
if (BindlessClampHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessClampHandle);
BindlessClampHandle = 0;
}
}
if (_texture != 0) {
GL.DeleteTexture(_texture);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);

View file

@ -6,6 +6,7 @@ using TextureHelpers = AcDream.Core.Rendering.Wb.TextureHelpers;
using Microsoft.Extensions.Logging;
using Silk.NET.OpenGL;
using System.Runtime.InteropServices;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb {
public class ManagedGLTextureArray : ITextureArray {
@ -18,14 +19,15 @@ namespace AcDream.App.Rendering.Wb {
private readonly bool _isCompressed;
private int _mipmapDirtyCount = 0;
private readonly object _mipmapLock = new object();
private uint _pboId;
private int _pboSize;
private readonly List<TextureLayerUpdate> _pendingUpdates = new();
private int _disposeQueued;
private int _disposePublicationQueued;
private int _disposeRetirementAccepted;
private RetryableGpuResourceRelease? _disposeRelease;
private struct TextureLayerUpdate {
public int Layer;
public int Offset;
public int Size;
public required byte[] Data;
public PixelFormat? UploadPixelFormat;
public PixelType? UploadPixelType;
}
@ -36,13 +38,12 @@ namespace AcDream.App.Rendering.Wb {
public int Size { get; private set; }
public TextureFormat Format { get; private set; }
public nint NativePtr { get; private set; }
public ulong BindlessHandle { get; private set; }
public ulong BindlessWrapHandle { get; private set; }
public ulong BindlessClampHandle { get; private set; }
public long TotalSizeInBytes => CalculateTotalSize();
/// <summary>
/// #105 diagnostic: staged layer updates (PBO writes + pending list) not yet
/// #105 diagnostic: staged layer updates (retained decoded payloads) not yet
/// applied to the GL texture by <see cref="ProcessDirtyUpdates"/>. Layers with
/// a pending update sample UNDEFINED content (TexStorage3D contents) until the
/// flush runs — a stuck non-zero count at standstill is the white-walls mechanism.
@ -69,67 +70,121 @@ namespace AcDream.App.Rendering.Wb {
_isCompressed = IsCompressedFormat(format);
GLHelpers.CheckErrors(GL);
NativePtr = (nint)GL.GenTexture();
if (NativePtr == 0) {
throw new InvalidOperationException("Failed to generate texture array.");
}
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture);
uint textureName = 0;
ulong wrapHandle = 0;
ulong clampHandle = 0;
bool textureTracked = false;
bool textureBytesTracked = false;
bool wrapResident = false;
bool clampResident = false;
long textureBytes = CalculateTotalSize();
GLHelpers.CheckErrors(GL);
try {
textureName = GL.GenTexture();
if (textureName == 0)
throw new InvalidOperationException("Failed to generate texture array.");
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture);
textureTracked = true;
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
GLHelpers.CheckErrors(GL);
GL.BindTexture(GLEnum.Texture2DArray, textureName);
int maxDimension = Math.Max(width, height);
int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1;
int maxDimension = Math.Max(width, height);
int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1;
GL.TexStorage3D(GLEnum.Texture2DArray, (uint)mipLevels, format.ToGL(), (uint)width, (uint)height,
(uint)size);
GLHelpers.CheckErrorsWithContext(GL,
$"Creating texture array storage (Format={format}, Size={width}x{height}x{size}, MipLevels={mipLevels})");
GL.TexStorage3D(GLEnum.Texture2DArray, (uint)mipLevels, format.ToGL(), (uint)width, (uint)height,
(uint)size);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter,
(int)p.MinFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, mipLevels - 1);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter,
(int)p.MinFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, (int)mipLevels - 1);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT);
if (p.EnableAnisotropicFiltering && graphicsDevice.RenderSettings.EnableAnisotropicFiltering) {
float maxAnisotropy = 0f;
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out maxAnisotropy);
if (maxAnisotropy > 0) {
GL.TexParameter(GLEnum.Texture2DArray, GLEnum.TextureMaxAnisotropy, maxAnisotropy);
if (p.EnableAnisotropicFiltering
&& graphicsDevice.RenderSettings.EnableAnisotropicFiltering
&& graphicsDevice.MaxSupportedAnisotropy > 0) {
GL.TexParameter(
GLEnum.Texture2DArray,
GLEnum.TextureMaxAnisotropy,
graphicsDevice.MaxSupportedAnisotropy);
}
if (format == TextureFormat.A8) {
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleB, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red);
}
GLHelpers.ThrowOnResourceError(
GL,
$"creating texture array {format} {width}x{height}x{size} ({mipLevels} mip levels)");
GpuMemoryTracker.TrackAllocation(textureBytes, GpuResourceType.Texture);
textureBytesTracked = true;
if (_device.HasBindless && _device.BindlessExtension != null) {
wrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.WrapSampler);
clampHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.ClampSampler);
_device.BindlessExtension.MakeTextureHandleResident(wrapHandle);
wrapResident = true;
_device.BindlessExtension.MakeTextureHandleResident(clampHandle);
clampResident = true;
GLHelpers.ThrowOnResourceError(GL, "making texture-array sampler handles resident");
}
NativePtr = (nint)textureName;
BindlessWrapHandle = wrapHandle;
BindlessClampHandle = clampHandle;
}
// Set texture swizzle for single-channel formats
if (format == TextureFormat.A8) {
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleB, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red);
catch (Exception constructionFailure) {
// Constructor failure cannot use Dispose: the object was never
// published and queued teardown would make retries accumulate
// invalid resident handles. Attempt every independent cleanup.
List<Exception>? cleanupFailures = null;
void Attempt(Action cleanup) {
try { cleanup(); }
catch (Exception ex) { (cleanupFailures ??= []).Add(ex); }
}
if (_device.BindlessExtension != null) {
if (clampResident)
Attempt(() => {
_device.BindlessExtension.MakeTextureHandleNonResident(clampHandle);
GLHelpers.ThrowOnResourceError(GL, "rolling back clamp texture-array handle");
clampResident = false;
});
if (wrapResident)
Attempt(() => {
_device.BindlessExtension.MakeTextureHandleNonResident(wrapHandle);
GLHelpers.ThrowOnResourceError(GL, "rolling back wrap texture-array handle");
wrapResident = false;
});
}
// Deleting a texture while either bindless sampler handle is
// still resident is undefined. A pre-commit residency failure
// therefore retains the texture instead of risking a driver
// reset during constructor rollback.
if (textureName != 0 && !clampResident && !wrapResident)
Attempt(() => {
GL.DeleteTexture(textureName);
GLHelpers.ThrowOnResourceError(GL, "rolling back texture array");
if (textureBytesTracked)
GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture);
if (textureTracked)
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
});
if (cleanupFailures is not null) {
cleanupFailures.Insert(0, constructionFailure);
throw new AggregateException(
"Texture-array construction and rollback both failed.",
cleanupFailures);
}
throw;
}
GLHelpers.CheckErrors(GL);
GpuMemoryTracker.TrackAllocation(CalculateTotalSize(), GpuResourceType.Texture);
if (_device.HasBindless && _device.BindlessExtension != null) {
BindlessHandle = _device.BindlessExtension.GetTextureHandle((uint)NativePtr);
BindlessWrapHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.WrapSampler);
BindlessClampHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.ClampSampler);
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessWrapHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessClampHandle);
finally {
GL.ActiveTexture(TextureUnit.Texture0);
GL.BindTexture(GLEnum.Texture2DArray, 0);
RenderStateCache.CurrentAtlas = 0;
}
_pboId = GL.GenBuffer();
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
}
public long CalculateTotalSize() {
@ -151,7 +206,7 @@ namespace AcDream.App.Rendering.Wb {
return totalSize;
}
private bool IsCompressedFormat(TextureFormat format) {
private static bool IsCompressedFormat(TextureFormat format) {
return format == TextureFormat.DXT1 ||
format == TextureFormat.DXT3 ||
format == TextureFormat.DXT5;
@ -220,127 +275,156 @@ namespace AcDream.App.Rendering.Wb {
$"Layer index {layer} is out of range [0, {Size - 1}] (Slot={Slot}).");
}
int currentPboOffset = 0;
ValidateUploadPayload(
Format,
Width,
Height,
data.Length,
uploadPixelFormat,
uploadPixelType);
lock (_mipmapLock) {
if (_pendingUpdates.Count > 0) {
var lastUpdate = _pendingUpdates[^1];
currentPboOffset = lastUpdate.Offset + lastUpdate.Size;
}
// Align to 4 bytes for safety
currentPboOffset = (currentPboOffset + 3) & ~3;
if (currentPboOffset + data.Length > _pboSize) {
// Flush existing updates first because BufferData will orphan/clear the PBO
if (_pendingUpdates.Count > 0) {
ProcessDirtyUpdatesInternal();
}
currentPboOffset = 0;
int newSize = Math.Max(_pboSize * 2, data.Length);
newSize = Math.Max(newSize, GetExpectedDataSize() * 4); // Initial size 4 layers
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
GL.BufferData(GLEnum.PixelUnpackBuffer, (nuint)newSize, (void*)0, GLEnum.StreamDraw);
if (_pboSize > 0) {
GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer);
}
_pboSize = newSize;
GpuMemoryTracker.TrackAllocation(_pboSize, GpuResourceType.Buffer);
}
else {
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
}
fixed (byte* ptr = data) {
GL.BufferSubData(GLEnum.PixelUnpackBuffer, (nint)currentPboOffset, (nuint)data.Length, ptr);
}
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
_pendingUpdates.Add(new TextureLayerUpdate {
// Retain the immutable decoded payload until the once-per-frame
// atlas flush. The former per-atlas PBO permanently reserved
// several MiB for every array and duplicated each upload
// through BufferSubData before TexSubImage3D.
var update = new TextureLayerUpdate {
Layer = layer,
Offset = currentPboOffset,
Size = data.Length,
Data = data,
UploadPixelFormat = uploadPixelFormat,
UploadPixelType = uploadPixelType
});
};
int existingIndex = _pendingUpdates.FindLastIndex(pending => pending.Layer == layer);
if (existingIndex >= 0)
_pendingUpdates[existingIndex] = update;
else
_pendingUpdates.Add(update);
_needsMipmapRegeneration = true;
_mipmapDirtyCount++;
if (existingIndex < 0)
_mipmapDirtyCount++;
}
}
public void ProcessDirtyUpdates() {
public long ProcessDirtyUpdates() {
lock (_mipmapLock) {
ProcessDirtyUpdatesInternal();
return ProcessDirtyUpdatesInternal(generateMipmaps: true);
}
}
private unsafe void ProcessDirtyUpdatesInternal() {
if (_pendingUpdates.Count == 0 && !_needsMipmapRegeneration) return;
private unsafe long ProcessDirtyUpdatesInternal(bool generateMipmaps) {
if (_pendingUpdates.Count == 0
&& (!generateMipmaps || !_needsMipmapRegeneration)) return 0;
long generatedBytes = 0;
GLHelpers.CheckErrors(GL);
GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture);
// This runs in WbMeshAdapter.Tick before any draw pass. Establish
// the upload phase's canonical texture state directly instead of
// synchronously querying driver state for every dirty array.
GL.ActiveTexture(TextureUnit.Texture0);
RenderStateCache.CurrentAtlas = 0;
GL.GetInteger(GLEnum.TextureBinding2DArray, out int oldBinding);
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
bool mipmapWorkCompleted = false;
try {
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
bool wasResident = false;
if (BindlessHandle != 0 && _device.BindlessExtension != null && _device.BindlessExtension.IsTextureHandleResident(BindlessHandle)) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
wasResident = true;
}
if (_pendingUpdates.Count > 0) {
// A non-zero pixel-unpack binding changes pointer arguments
// into byte offsets. Direct client-memory uploads therefore
// establish the canonical zero binding once for the batch.
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
GL.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
GL.PixelStore(PixelStoreParameter.UnpackRowLength, 0);
GL.PixelStore(PixelStoreParameter.UnpackSkipRows, 0);
GL.PixelStore(PixelStoreParameter.UnpackSkipPixels, 0);
if (_pendingUpdates.Count > 0) {
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
foreach (var update in _pendingUpdates) {
fixed (byte* data = update.Data) {
if (_isCompressed) {
var internalFormat = Format.ToCompressedGL();
GL.CompressedTexSubImage3D(
GLEnum.Texture2DArray,
0,
0,
0,
update.Layer,
(uint)Width,
(uint)Height,
1,
internalFormat,
(uint)update.Data.Length,
data);
}
else {
var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat();
var pixelType = update.UploadPixelType ?? Format.ToPixelType();
GL.TexSubImage3D(
GLEnum.Texture2DArray,
0,
0,
0,
update.Layer,
(uint)Width,
(uint)Height,
1,
pixelFormat,
pixelType,
data);
}
}
}
}
foreach (var update in _pendingUpdates) {
if (generateMipmaps && _needsMipmapRegeneration && _mipmapDirtyCount > 0) {
if (_isCompressed) {
var internalFormat = Format.ToCompressedGL();
GL.CompressedTexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer,
(uint)Width, (uint)Height, 1, internalFormat, (uint)update.Size, (void*)update.Offset);
_logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot);
}
else {
var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat();
var pixelType = update.UploadPixelType ?? Format.ToPixelType();
GL.TexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer, (uint)Width, (uint)Height, 1,
pixelFormat, pixelType, (void*)update.Offset);
try {
// Width, height and format were validated when the
// immutable storage was allocated. Re-reading them
// here forced three CPU/GPU synchronization points
// for every dirty atlas without adding safety.
GL.GenerateMipmap(GLEnum.Texture2DArray);
generatedBytes = TotalSizeInBytes;
}
catch (Exception ex) {
_logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}); retaining upload state for retry.", Slot);
throw;
}
}
}
// Release builds must observe transfer/OOM/context errors
// before the pending offsets and dirty mip state are cleared.
// One check covers every layer in this array plus its single
// mip generation, keeping the synchronization cost bounded by
// dirty arrays rather than uploaded textures.
GLHelpers.ThrowOnResourceError(
GL,
$"committing texture-array updates (Slot={Slot}, Layers={_pendingUpdates.Count})");
mipmapWorkCompleted = generateMipmaps
&& _needsMipmapRegeneration
&& _mipmapDirtyCount > 0;
}
finally {
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
_pendingUpdates.Clear();
GL.BindTexture(GLEnum.Texture2DArray, 0);
GL.ActiveTexture(TextureUnit.Texture0);
}
if (_needsMipmapRegeneration && _mipmapDirtyCount > 0) {
if (_isCompressed) {
_logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot);
}
else if (!GLHelpers.ValidateTextureMipmapStatus(GL, GLEnum.Texture2DArray, out var errorMessage)) {
_logger.LogWarning("Mipmap validation failed for texture array (Slot={Slot}): {Error}", Slot, errorMessage);
}
else {
try {
GL.GenerateMipmap(GLEnum.Texture2DArray);
}
catch (Exception ex) {
_logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}).", Slot);
}
}
// Commit CPU-side completion only after glGetError confirms the
// uploads/mipmap work succeeded. If the driver rejects an
// operation, the retained payloads and dirty flags remain intact and the
// atlas stays in ObjectMeshManager's dirty set for a later retry.
_pendingUpdates.Clear();
if (mipmapWorkCompleted) {
_mipmapDirtyCount = 0;
_needsMipmapRegeneration = false;
}
if (wasResident && BindlessHandle != 0 && _device.BindlessExtension != null) {
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
}
GL.BindTexture(GLEnum.Texture2DArray, (uint)oldBinding);
GL.ActiveTexture((GLEnum)oldActiveTexture);
GLHelpers.CheckErrors(GL);
return generatedBytes;
}
private void ClearLayerForMipmap(int layer) {
@ -351,17 +435,51 @@ namespace AcDream.App.Rendering.Wb {
}
private int GetExpectedDataSize() {
if (_isCompressed) {
return TextureHelpers.GetCompressedLayerSize(Width, Height, Format);
return CalculateExpectedDataSize(Format, Width, Height);
}
internal static int CalculateExpectedDataSize(TextureFormat format, int width, int height) {
if (IsCompressedFormat(format))
return TextureHelpers.GetCompressedLayerSize(width, height, format);
return format switch {
TextureFormat.RGBA8 => checked(width * height * 4),
TextureFormat.RGB8 => checked(width * height * 3),
TextureFormat.A8 => checked(width * height),
TextureFormat.Rgba32f => checked(width * height * 16),
_ => throw new NotSupportedException($"Unsupported format {format}")
};
}
internal static void ValidateUploadPayload(
TextureFormat format,
int width,
int height,
int dataLength,
PixelFormat? uploadPixelFormat,
PixelType? uploadPixelType) {
int expectedBytes = CalculateExpectedDataSize(format, width, height);
if (dataLength != expectedBytes) {
throw new ArgumentException(
$"Texture-array layer payload has {dataLength} bytes; expected exactly {expectedBytes} "
+ $"for {format} {width}x{height}.",
nameof(dataLength));
}
return Format switch {
TextureFormat.RGBA8 => Width * Height * 4,
TextureFormat.RGB8 => Width * Height * 3,
TextureFormat.A8 => Width * Height * 1,
TextureFormat.Rgba32f => Width * Height * 16,
_ => throw new NotSupportedException($"Unsupported format {Format}")
};
if (IsCompressedFormat(format)) {
if (uploadPixelFormat.HasValue || uploadPixelType.HasValue)
throw new ArgumentException("Compressed texture uploads cannot specify pixel format/type overrides.");
return;
}
PixelFormat expectedFormat = format.ToPixelFormat();
PixelType expectedType = format.ToPixelType();
if ((uploadPixelFormat ?? expectedFormat) != expectedFormat
|| (uploadPixelType ?? expectedType) != expectedType) {
throw new ArgumentException(
$"Upload descriptor {uploadPixelFormat}/{uploadPixelType} does not match "
+ $"the {expectedFormat}/{expectedType} transfer required by {format}.");
}
}
public void RemoveLayer(int layer) {
@ -376,15 +494,8 @@ namespace AcDream.App.Rendering.Wb {
_usedLayers[layer] = false;
// Make layer defined for mipmap completeness (uncompressed only)
if (!_isCompressed) {
ClearLayerForMipmap(layer);
}
lock (_mipmapLock) {
_mipmapDirtyCount++; // Mark dirty to regen
_needsMipmapRegeneration = true;
}
// An unreferenced layer needs no clear or whole-array mip
// regeneration before AddTexture overwrites it on reuse.
}
public bool IsLayerUsed(int layer) {
@ -396,6 +507,22 @@ namespace AcDream.App.Rendering.Wb {
return _usedLayers.Count(x => x);
}
/// <summary>
/// True once disposal is durably owned by a queued GL publication,
/// the frame-retirement queue, or a completed retained release. A
/// caller may only commit its own logical disposal after this becomes
/// true; otherwise a synchronous enqueue failure still needs retry.
/// </summary>
internal bool HasDurableDisposeOwnership {
get {
if (Volatile.Read(ref _disposeQueued) == 0)
return false;
return Volatile.Read(ref _disposePublicationQueued) != 0
|| Volatile.Read(ref _disposeRetirementAccepted) != 0
|| Volatile.Read(ref _disposeRelease) is null;
}
}
public void Unbind() {
GL.BindTexture(GLEnum.Texture2DArray, 0);
GLHelpers.CheckErrors(GL);
@ -409,37 +536,95 @@ namespace AcDream.App.Rendering.Wb {
}
public void Dispose() {
_device.QueueGLAction(GL => {
if (_device.BindlessExtension != null) {
if (BindlessHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
BindlessHandle = 0;
if (Interlocked.CompareExchange(ref _disposeQueued, 1, 0) != 0) {
ScheduleDisposeRelease();
return;
}
uint textureName = (uint)NativePtr;
ulong bindlessWrapHandle = BindlessWrapHandle;
ulong bindlessClampHandle = BindlessClampHandle;
long textureBytes = CalculateTotalSize();
NativePtr = 0;
BindlessWrapHandle = 0;
BindlessClampHandle = 0;
_disposeRelease = new RetryableGpuResourceRelease(
() => {
if (_device.BindlessExtension != null && bindlessWrapHandle != 0)
GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle (precondition)");
},
() => {
if (_device.BindlessExtension != null && bindlessWrapHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(bindlessWrapHandle);
GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle");
}
if (BindlessWrapHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessWrapHandle);
BindlessWrapHandle = 0;
},
() => {
if (_device.BindlessExtension != null && bindlessClampHandle != 0)
GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle (precondition)");
},
() => {
if (_device.BindlessExtension != null && bindlessClampHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(bindlessClampHandle);
GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle");
}
if (BindlessClampHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessClampHandle);
BindlessClampHandle = 0;
},
() => {
if (textureName != 0)
GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName} (precondition)");
},
() => {
if (textureName != 0) {
GL.DeleteTexture(textureName);
GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName}");
}
}
if (NativePtr != 0) {
GL.DeleteTexture((uint)NativePtr);
GLHelpers.CheckErrors(GL);
GpuMemoryTracker.TrackDeallocation(CalculateTotalSize(), GpuResourceType.Texture);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
NativePtr = 0;
}
if (_pboId != 0) {
GL.DeleteBuffer(_pboId);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
if (_pboSize > 0) {
GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer);
},
() => {
if (textureName != 0)
GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture);
},
() => {
if (textureName != 0)
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
},
() => _disposeRelease = null);
ScheduleDisposeRelease();
}
private void ScheduleDisposeRelease(bool forNextPass = false) {
RetryableGpuResourceRelease? release = _disposeRelease;
if (release is null || release.IsComplete || Volatile.Read(ref _disposeRetirementAccepted) != 0)
return;
if (Interlocked.CompareExchange(ref _disposePublicationQueued, 1, 0) != 0)
return;
try {
Action<GL> publish = GL => {
Volatile.Write(ref _disposePublicationQueued, 0);
try {
_device.RetireGpuResource(release.Run);
Volatile.Write(ref _disposeRetirementAccepted, 1);
}
_pboId = 0;
}
});
catch {
// Retire may fail before accepting the callback, or an
// immediate queue may surface a partial release. The
// release cursor makes this next-pass retry exact.
ScheduleDisposeRelease(forNextPass: true);
throw;
}
};
if (forNextPass)
_device.QueueGLActionForNextPass(publish);
else
_device.QueueGLAction(publish);
}
catch {
Volatile.Write(ref _disposePublicationQueued, 0);
throw;
}
}
}
}

View file

@ -3,6 +3,23 @@ using AcDream.Content;
namespace AcDream.App.Rendering.Wb;
internal enum MeshStageResult
{
Staged,
AlreadyStaged,
HighWater,
}
/// <summary>
/// Immutable identity for one staging claim. Object ids can be released and
/// reacquired while a render-thread upload is in flight; the generation keeps
/// a stale completion or retry from consuming the replacement claim.
/// </summary>
internal readonly record struct MeshUploadQueueItem(
ObjectMeshData Data,
long SourceBytes,
ulong Generation);
/// <summary>
/// Deduplicated CPU-to-GPU staging queue. One object id may be queued or
/// in-flight at a time; a retry retains ownership until upload succeeds or
@ -10,24 +27,209 @@ namespace AcDream.App.Rendering.Wb;
/// </summary>
internal sealed class MeshUploadStagingQueue
{
private readonly ConcurrentQueue<ObjectMeshData> _queue = new();
private readonly ConcurrentDictionary<ulong, byte> _ownedIds = new();
internal const int DefaultMaximumCount = 256;
internal const long DefaultMaximumBytes = 128L * 1024 * 1024;
internal const long DefaultMaximumSingleEntryBytes = 128L * 1024 * 1024;
public bool Stage(ObjectMeshData data)
private readonly object _gate = new();
private readonly Queue<Entry> _queue = new();
private readonly Dictionary<ulong, ulong> _generationById = new();
private readonly int _maximumCount;
private readonly long _maximumBytes;
private readonly long _maximumSingleEntryBytes;
private long _queuedBytes;
private long _claimedBytes;
private ulong _nextGeneration;
private readonly record struct Entry(ObjectMeshData Data, long Bytes, ulong Generation)
{
if (!_ownedIds.TryAdd(data.ObjectId, 0))
return false;
data.UploadAttempts = 0;
_queue.Enqueue(data);
return true;
public MeshUploadQueueItem Item => new(Data, Bytes, Generation);
}
public bool TryDequeue(out ObjectMeshData? data) => _queue.TryDequeue(out data);
public MeshUploadStagingQueue(
int maximumCount = DefaultMaximumCount,
long maximumBytes = DefaultMaximumBytes,
long maximumSingleEntryBytes = 0)
{
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1);
ArgumentOutOfRangeException.ThrowIfNegative(maximumSingleEntryBytes);
if (maximumSingleEntryBytes == 0)
maximumSingleEntryBytes = Math.Max(maximumBytes, DefaultMaximumSingleEntryBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumSingleEntryBytes, maximumBytes);
_maximumCount = maximumCount;
_maximumBytes = maximumBytes;
_maximumSingleEntryBytes = maximumSingleEntryBytes;
}
public void Requeue(ObjectMeshData data) => _queue.Enqueue(data);
public bool Stage(ObjectMeshData data)
=> TryStage(data) == MeshStageResult.Staged;
public void Complete(ulong objectId) => _ownedIds.TryRemove(objectId, out _);
public MeshStageResult TryStage(ObjectMeshData data)
{
ArgumentNullException.ThrowIfNull(data);
long bytes = ObjectMeshManager.EstimateUploadBytes(data);
lock (_gate)
{
if (_generationById.ContainsKey(data.ObjectId))
return MeshStageResult.AlreadyStaged;
if (bytes > _maximumSingleEntryBytes)
throw new NotSupportedException(
$"Mesh 0x{data.ObjectId:X10} requires {bytes:N0} staged bytes; "
+ $"the supported per-object maximum is {_maximumSingleEntryBytes:N0} bytes.");
// Permit one explicitly bounded oversized head item so unusual
// content cannot starve. Every other producer observes the strict
// count/byte watermark; active decoders retain their result in the
// bounded CPU cache and retry staging after the consumer drains.
if (_generationById.Count != 0
&& (_generationById.Count >= _maximumCount
|| bytes > _maximumBytes - Math.Min(_claimedBytes, _maximumBytes)))
{
return MeshStageResult.HighWater;
}
ulong generation = checked(++_nextGeneration);
_generationById.Add(data.ObjectId, generation);
data.UploadAttempts = 0;
_queue.Enqueue(new Entry(data, bytes, generation));
_queuedBytes = checked(_queuedBytes + bytes);
_claimedBytes = checked(_claimedBytes + bytes);
return MeshStageResult.Staged;
}
}
public bool TryDequeue(out MeshUploadQueueItem item)
{
lock (_gate)
{
if (!_queue.TryDequeue(out Entry entry))
{
item = default;
return false;
}
_queuedBytes -= entry.Bytes;
item = entry.Item;
return true;
}
}
public bool TryPeek(out MeshUploadQueueItem item)
{
lock (_gate)
{
if (!_queue.TryPeek(out Entry entry))
{
item = default;
return false;
}
item = entry.Item;
return true;
}
}
public int Count { get { lock (_gate) return _queue.Count; } }
public int ClaimCount { get { lock (_gate) return _generationById.Count; } }
public long QueuedBytes { get { lock (_gate) return _queuedBytes; } }
public long ClaimedBytes { get { lock (_gate) return _claimedBytes; } }
public bool IsAtHighWater
{
get
{
lock (_gate)
return _generationById.Count >= _maximumCount || _claimedBytes >= _maximumBytes;
}
}
public void Requeue(MeshUploadQueueItem item)
{
ArgumentNullException.ThrowIfNull(item.Data);
lock (_gate)
{
ValidateCurrentGeneration(item);
if (item.SourceBytes > _maximumSingleEntryBytes)
throw new InvalidOperationException("Cannot retry mesh data after staging ownership completed.");
_queue.Enqueue(new Entry(item.Data, item.SourceBytes, item.Generation));
_queuedBytes = checked(_queuedBytes + item.SourceBytes);
}
}
public void Complete(MeshUploadQueueItem item)
{
lock (_gate)
{
ValidateCurrentGeneration(item);
_generationById.Remove(item.Data.ObjectId);
_claimedBytes = checked(_claimedBytes - item.SourceBytes);
}
}
/// <summary>
/// Completes a dequeued, now-unowned generation and atomically hands the
/// same CPU payload to an owner that raced in while it was dequeued. A
/// concurrent cache hit either sees the old staging claim and this method
/// requeues, or runs after the claim is removed and stages for itself; the
/// mesh cannot fall through the gap between those operations.
/// </summary>
public bool CompleteOrRestageIfOwned(
MeshUploadQueueItem item,
MeshOwnershipCounter ownership)
{
ArgumentNullException.ThrowIfNull(item.Data);
ArgumentNullException.ThrowIfNull(ownership);
lock (_gate)
{
ValidateCurrentGeneration(item);
_generationById.Remove(item.Data.ObjectId);
_claimedBytes = checked(_claimedBytes - item.SourceBytes);
if (!ownership.IsOwned(item.Data.ObjectId))
return false;
ulong generation = checked(++_nextGeneration);
_generationById.Add(item.Data.ObjectId, generation);
item.Data.UploadAttempts = 0;
_queue.Enqueue(new Entry(item.Data, item.SourceBytes, generation));
_queuedBytes = checked(_queuedBytes + item.SourceBytes);
_claimedBytes = checked(_claimedBytes + item.SourceBytes);
return true;
}
}
public int DiscardUnownedPrefix(MeshOwnershipCounter ownership, int maximum)
{
ArgumentNullException.ThrowIfNull(ownership);
ArgumentOutOfRangeException.ThrowIfNegative(maximum);
int discarded = 0;
lock (_gate)
{
while (discarded < maximum
&& _queue.TryPeek(out Entry entry)
&& !ownership.IsOwned(entry.Data.ObjectId))
{
_queue.Dequeue();
_queuedBytes -= entry.Bytes;
if (_generationById.TryGetValue(entry.Data.ObjectId, out ulong generation)
&& generation == entry.Generation)
{
_generationById.Remove(entry.Data.ObjectId);
_claimedBytes = checked(_claimedBytes - entry.Bytes);
}
discarded++;
}
}
return discarded;
}
private void ValidateCurrentGeneration(MeshUploadQueueItem item)
{
if (!_generationById.TryGetValue(item.Data.ObjectId, out ulong current)
|| current != item.Generation)
{
throw new InvalidOperationException(
$"Stale mesh-upload generation {item.Generation} for 0x{item.Data.ObjectId:X10}; current={current}.");
}
}
}
/// <summary>
@ -63,46 +265,74 @@ internal sealed class MeshOwnershipCounter
internal sealed class CpuMeshUploadCache
{
private readonly Dictionary<ulong, ObjectMeshData> _data = new();
private readonly Dictionary<ulong, long> _bytesById = new();
private readonly LinkedList<ulong> _lru = new();
private readonly int _capacity;
private readonly long _byteCapacity;
private long _residentBytes;
public CpuMeshUploadCache(int capacity)
public CpuMeshUploadCache(int capacity, long byteCapacity = 128L * 1024 * 1024)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
ArgumentOutOfRangeException.ThrowIfLessThan(byteCapacity, 1);
_capacity = capacity;
_byteCapacity = byteCapacity;
}
internal int Count { get { lock (_data) return _data.Count; } }
internal long ResidentBytes { get { lock (_data) return _residentBytes; } }
public bool TryGetAndStage(
ulong id,
MeshUploadStagingQueue staging,
out ObjectMeshData? data)
out ObjectMeshData? data,
out MeshStageResult stageResult)
{
lock (_data)
{
if (!_data.TryGetValue(id, out data))
if (!_data.TryGetValue(id, out data)) {
stageResult = default;
return false;
}
_lru.Remove(id);
_lru.AddLast(id);
staging.Stage(data);
stageResult = staging.TryStage(data);
return true;
}
}
public void Store(ObjectMeshData data)
public bool Store(ObjectMeshData data)
{
ArgumentNullException.ThrowIfNull(data);
long bytes = ObjectMeshManager.EstimateUploadBytes(data);
// Reject before touching the replacement/LRU state. The prior loop
// evicted everything and then published one arbitrarily large entry,
// allowing the cache-hit path to replay an unbounded payload forever.
if (bytes > _byteCapacity)
return false;
lock (_data)
{
if (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity)
if (_bytesById.Remove(data.ObjectId, out long replacedBytes))
_residentBytes -= replacedBytes;
while (_lru.Count != 0
&& (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity
|| (_data.Count != 0 && bytes > _byteCapacity - _residentBytes)))
{
ulong oldest = _lru.First!.Value;
_lru.RemoveFirst();
_data.Remove(oldest);
if (_bytesById.Remove(oldest, out long oldestBytes))
_residentBytes -= oldestBytes;
}
_data[data.ObjectId] = data;
_bytesById[data.ObjectId] = bytes;
_residentBytes = checked(_residentBytes + bytes);
_lru.Remove(data.ObjectId);
_lru.AddLast(data.ObjectId);
return true;
}
}
@ -111,7 +341,9 @@ internal sealed class CpuMeshUploadCache
lock (_data)
{
_data.Clear();
_bytesById.Clear();
_lru.Clear();
_residentBytes = 0;
}
}
}

View file

@ -0,0 +1,186 @@
namespace AcDream.App.Rendering.Wb;
internal readonly record struct MeshUploadCost(
long SourceBytes,
long ArrayAllocationBytes,
long MipmapBytes,
int NewArrayCount,
long BufferUploadBytes = 0,
long BufferAllocationBytes = 0,
long BufferCopyBytes = 0,
int NewBufferCount = 0,
ulong AdmissionKey = 0);
internal readonly record struct MeshUploadBudgetLimits(
int MaximumObjects,
long MaximumSourceBytes,
long MaximumArrayAllocationBytes,
long MaximumMipmapBytes,
int MaximumNewArrays,
long MaximumBufferUploadBytes = long.MaxValue,
long MaximumBufferAllocationBytes = long.MaxValue,
long MaximumBufferCopyBytes = long.MaxValue,
int MaximumNewBuffers = int.MaxValue,
long MaximumSingleSourceBytes = long.MaxValue,
long MaximumSingleArrayAllocationBytes = long.MaxValue,
long MaximumSingleMipmapBytes = long.MaxValue,
int MaximumSingleNewArrays = int.MaxValue,
long MaximumSingleBufferUploadBytes = long.MaxValue);
/// <summary>
/// Pure prefix-admission policy for render-thread mesh uploads. Ordinary
/// uploads fit one frame in every independent CPU/GPU dimension. A physically
/// indivisible FIFO head may exceed a soft frame budget only when it is below
/// the explicit single-operation ceiling; it runs immediately rather than
/// accumulating fictional credits across idle frames. Global-buffer growth
/// and prefix copies are never admitted here: they are real, chunked
/// maintenance operations driven by <see cref="GlobalMeshBuffer"/>.
/// </summary>
internal sealed class MeshUploadFrameBudget
{
private readonly MeshUploadBudgetLimits _limits;
public MeshUploadFrameBudget(MeshUploadBudgetLimits limits)
{
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumObjects, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSourceBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumArrayAllocationBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumMipmapBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumNewArrays, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferUploadBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferAllocationBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferCopyBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumNewBuffers, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleSourceBytes, limits.MaximumSourceBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleArrayAllocationBytes, limits.MaximumArrayAllocationBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleMipmapBytes, limits.MaximumMipmapBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleNewArrays, limits.MaximumNewArrays);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleBufferUploadBytes, limits.MaximumBufferUploadBytes);
_limits = limits;
}
public int ObjectCount { get; private set; }
public long SourceBytes { get; private set; }
public long ArrayAllocationBytes { get; private set; }
public long MipmapBytes { get; private set; }
public int NewArrayCount { get; private set; }
public long BufferUploadBytes { get; private set; }
public long BufferAllocationBytes { get; private set; }
public long BufferCopyBytes { get; private set; }
public int NewBufferCount { get; private set; }
public void Reset()
{
ObjectCount = 0;
SourceBytes = 0;
ArrayAllocationBytes = 0;
MipmapBytes = 0;
NewArrayCount = 0;
BufferUploadBytes = 0;
BufferAllocationBytes = 0;
BufferCopyBytes = 0;
NewBufferCount = 0;
}
public bool TryAdmit(MeshUploadCost cost)
{
Validate(cost);
if (cost.BufferAllocationBytes != 0
|| cost.BufferCopyBytes != 0
|| cost.NewBufferCount != 0)
{
throw new InvalidOperationException(
"Global-buffer allocation/copy work must be progressed as real maintenance before upload admission.");
}
bool oversizedHead = ObjectCount == 0 && ExceedsSingleFrame(cost);
if (oversizedHead)
{
if (cost.SourceBytes > _limits.MaximumSingleSourceBytes
|| cost.ArrayAllocationBytes > _limits.MaximumSingleArrayAllocationBytes
|| cost.MipmapBytes > _limits.MaximumSingleMipmapBytes
|| cost.NewArrayCount > _limits.MaximumSingleNewArrays
|| cost.BufferUploadBytes > _limits.MaximumSingleBufferUploadBytes)
{
throw new NotSupportedException(
$"Mesh upload generation {cost.AdmissionKey} exceeds the supported single-operation ceiling.");
}
Admit(cost);
return true;
}
if (ObjectCount >= _limits.MaximumObjects
|| WouldExceed(SourceBytes, cost.SourceBytes, _limits.MaximumSourceBytes)
|| WouldExceed(ArrayAllocationBytes, cost.ArrayAllocationBytes, _limits.MaximumArrayAllocationBytes)
|| WouldExceed(MipmapBytes, cost.MipmapBytes, _limits.MaximumMipmapBytes)
|| cost.NewArrayCount > _limits.MaximumNewArrays - NewArrayCount
|| WouldExceed(BufferUploadBytes, cost.BufferUploadBytes, _limits.MaximumBufferUploadBytes)
|| WouldExceed(BufferAllocationBytes, cost.BufferAllocationBytes, _limits.MaximumBufferAllocationBytes)
|| WouldExceed(BufferCopyBytes, cost.BufferCopyBytes, _limits.MaximumBufferCopyBytes)
|| cost.NewBufferCount > _limits.MaximumNewBuffers - NewBufferCount)
{
return false;
}
Admit(cost);
return true;
}
/// <summary>Records work that actually executed this frame.</summary>
public void RecordBufferMaintenance(long allocationBytes, long copyBytes, int newBufferCount)
{
ArgumentOutOfRangeException.ThrowIfNegative(allocationBytes);
ArgumentOutOfRangeException.ThrowIfNegative(copyBytes);
ArgumentOutOfRangeException.ThrowIfNegative(newBufferCount);
if (WouldExceed(BufferAllocationBytes, allocationBytes, _limits.MaximumBufferAllocationBytes)
|| WouldExceed(BufferCopyBytes, copyBytes, _limits.MaximumBufferCopyBytes)
|| newBufferCount > _limits.MaximumNewBuffers - NewBufferCount)
{
throw new InvalidOperationException(
"Executed global-buffer maintenance exceeded its real per-frame bound.");
}
BufferAllocationBytes = checked(BufferAllocationBytes + allocationBytes);
BufferCopyBytes = checked(BufferCopyBytes + copyBytes);
NewBufferCount = checked(NewBufferCount + newBufferCount);
}
private bool ExceedsSingleFrame(MeshUploadCost cost) =>
cost.SourceBytes > _limits.MaximumSourceBytes
|| cost.ArrayAllocationBytes > _limits.MaximumArrayAllocationBytes
|| cost.MipmapBytes > _limits.MaximumMipmapBytes
|| cost.NewArrayCount > _limits.MaximumNewArrays
|| cost.BufferUploadBytes > _limits.MaximumBufferUploadBytes
|| cost.BufferAllocationBytes > _limits.MaximumBufferAllocationBytes
|| cost.BufferCopyBytes > _limits.MaximumBufferCopyBytes
|| cost.NewBufferCount > _limits.MaximumNewBuffers;
private void Admit(MeshUploadCost cost)
{
ObjectCount++;
SourceBytes = checked(SourceBytes + cost.SourceBytes);
ArrayAllocationBytes = checked(ArrayAllocationBytes + cost.ArrayAllocationBytes);
MipmapBytes = checked(MipmapBytes + cost.MipmapBytes);
NewArrayCount = checked(NewArrayCount + cost.NewArrayCount);
BufferUploadBytes = checked(BufferUploadBytes + cost.BufferUploadBytes);
BufferAllocationBytes = checked(BufferAllocationBytes + cost.BufferAllocationBytes);
BufferCopyBytes = checked(BufferCopyBytes + cost.BufferCopyBytes);
NewBufferCount = checked(NewBufferCount + cost.NewBufferCount);
}
// Keep the comparison overflow-free even when a bounded indivisible head
// has truthfully exceeded a soft per-frame budget.
private static bool WouldExceed(long current, long added, long maximum) =>
added > maximum - Math.Min(current, maximum);
private static void Validate(MeshUploadCost cost)
{
ArgumentOutOfRangeException.ThrowIfNegative(cost.SourceBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.ArrayAllocationBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.MipmapBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.NewArrayCount);
ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferUploadBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferAllocationBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferCopyBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.NewBufferCount);
}
}

File diff suppressed because it is too large Load diff

View file

@ -22,17 +22,43 @@ namespace AcDream.App.Rendering.Wb {
public unsafe class OpenGLGraphicsDevice : BaseGraphicsDevice {
private readonly ILogger _log;
private readonly DebugRenderSettings _renderSettings;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue _resourceRetirement;
public GL GL { get; }
public DebugRenderSettings RenderSettings => _renderSettings;
private readonly ConcurrentQueue<Action<GL>> _glThreadQueue = new();
private readonly ConcurrentQueue<Action<GL>> _nextGlThreadQueue = new();
internal bool HasPendingGLWork =>
!_glThreadQueue.IsEmpty || !_nextGlThreadQueue.IsEmpty;
public void QueueGLAction(Action<GL> action) {
_glThreadQueue.Enqueue(action);
}
internal void QueueGLActionForNextPass(Action<GL> action) {
ArgumentNullException.ThrowIfNull(action);
_nextGlThreadQueue.Enqueue(action);
}
public void ProcessGLQueue() {
// Retry the prior pass before ordinary work (notably sampler
// deletion), but process only the captured generation so a
// persistent driver failure cannot spin this frame forever.
int retryCount = _nextGlThreadQueue.Count;
for (int i = 0; i < retryCount && _nextGlThreadQueue.TryDequeue(out Action<GL>? retry); i++) {
try {
retry(GL);
} catch (Exception ex) {
_log.LogError(ex, "Error processing retryable GL queue action");
}
}
// Normal actions retain drain-to-empty semantics because teardown
// actions intentionally enqueue dependent atlas releases here.
// A persistent retryable error must not starve unrelated uploads
// and releases forever: the retry generation remains bounded to
// one attempt per pass, while ordinary work still makes progress.
while (_glThreadQueue.TryDequeue(out var action)) {
try {
action(GL);
@ -61,6 +87,7 @@ namespace AcDream.App.Rendering.Wb {
public uint WrapSampler { get; private set; }
/// <summary>OpenGL sampler object with TextureWrapMode.ClampToEdge (for meshes without wrapping UVs).</summary>
public uint ClampSampler { get; private set; }
internal float MaxSupportedAnisotropy { get; private set; }
private ManagedGLUniformBuffer? _sceneDataBuffer;
/// <summary>Shared SceneData UBO.</summary>
@ -83,12 +110,23 @@ namespace AcDream.App.Rendering.Wb {
protected OpenGLGraphicsDevice() : base() {
_log = null!;
_renderSettings = null!;
_resourceRetirement = null!;
GL = null!;
}
public OpenGLGraphicsDevice(GL gl, ILogger log, DebugRenderSettings renderSettings, bool allowBindless = true) : base() {
public OpenGLGraphicsDevice(GL gl, ILogger log, DebugRenderSettings renderSettings, bool allowBindless = true)
: this(gl, log, renderSettings, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance, allowBindless) {
}
internal OpenGLGraphicsDevice(
GL gl,
ILogger log,
DebugRenderSettings renderSettings,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
bool allowBindless = true) : base() {
_log = log;
_renderSettings = renderSettings;
_resourceRetirement = resourceRetirement ?? throw new ArgumentNullException(nameof(resourceRetirement));
GL = gl;
GLHelpers.Init(this, log);
@ -114,26 +152,30 @@ namespace AcDream.App.Rendering.Wb {
GL.GenBuffers(1, out uint instanceVbo);
InstanceVBO = instanceVbo;
// Query this immutable device limit once. Atlas construction can
// happen hundreds of times during portal streaming; repeating a
// driver GetFloat for every texture serialized the upload burst.
if (renderSettings.EnableAnisotropicFiltering) {
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso);
MaxSupportedAnisotropy = Math.Max(0f, maxAniso);
}
// Create sampler objects for wrap vs clamp
WrapSampler = GL.GenSampler();
GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.Repeat);
GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.Repeat);
GL.SamplerParameter(WrapSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear);
GL.SamplerParameter(WrapSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
if (renderSettings.EnableAnisotropicFiltering) {
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso);
if (maxAniso > 0) GL.SamplerParameter(WrapSampler, GLEnum.TextureMaxAnisotropy, maxAniso);
}
if (MaxSupportedAnisotropy > 0)
GL.SamplerParameter(WrapSampler, GLEnum.TextureMaxAnisotropy, MaxSupportedAnisotropy);
ClampSampler = GL.GenSampler();
GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.ClampToEdge);
GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.ClampToEdge);
GL.SamplerParameter(ClampSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear);
GL.SamplerParameter(ClampSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
if (renderSettings.EnableAnisotropicFiltering) {
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso);
if (maxAniso > 0) GL.SamplerParameter(ClampSampler, GLEnum.TextureMaxAnisotropy, maxAniso);
}
if (MaxSupportedAnisotropy > 0)
GL.SamplerParameter(ClampSampler, GLEnum.TextureMaxAnisotropy, MaxSupportedAnisotropy);
_sceneDataBuffer = new ManagedGLUniformBuffer(this, BufferUsage.Dynamic, Marshal.SizeOf<SceneData>());
@ -145,6 +187,15 @@ namespace AcDream.App.Rendering.Wb {
ParticleBatcher = null!;
}
/// <summary>
/// Retires a GL resource only after every submitted draw that could
/// reference it has completed on the GPU.
/// </summary>
internal void RetireGpuResource(Action release) => _resourceRetirement.Retire(release);
internal AcDream.App.Rendering.IGpuResourceRetirementQueue ResourceRetirement =>
_resourceRetirement;
private void InitializeSharedDebugResources() {
// Unit quad vertices for two triangles (0 to 1 for length, -0.5 to 0.5 for thickness)
float[] quadVertices = {
@ -608,14 +659,26 @@ namespace AcDream.App.Rendering.Wb {
GpuMemoryTracker.TrackDeallocation(instanceBufferCapacity * instanceBufferStride);
}
}
if (wrapSampler != 0) {
gl.DeleteSampler(wrapSampler);
}
if (clampSampler != 0) {
gl.DeleteSampler(clampSampler);
}
});
// Bindless texture-array retirements embed these samplers in their
// resident handles. Ordinary GL work must keep flowing when one
// retry is sick, but sampler deletion itself is dependency-ordered
// behind the retry queue. Requeue into the next generation (never
// the drain-to-empty ordinary queue) to remain one attempt/frame.
Action<GL>? deleteSamplersWhenSafe = null;
deleteSamplersWhenSafe = gl => {
if (!_nextGlThreadQueue.IsEmpty) {
QueueGLActionForNextPass(deleteSamplersWhenSafe!);
return;
}
if (wrapSampler != 0)
gl.DeleteSampler(wrapSampler);
if (clampSampler != 0)
gl.DeleteSampler(clampSampler);
};
QueueGLActionForNextPass(deleteSamplersWhenSafe);
InstanceVBO = 0;
InstanceVBOPtr = null;
WrapSampler = 0;

View file

@ -58,9 +58,11 @@ namespace AcDream.App.Rendering.Wb {
if (emitter.HwGfxObjId.DataId != 0) {
_meshManager.IncrementRefCount(emitter.HwGfxObjId.DataId);
_meshManager.PrepareMeshDataAsync(emitter.HwGfxObjId.DataId, isSetup: false);
}
if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) {
_meshManager.IncrementRefCount(emitter.GfxObjId.DataId);
_meshManager.PrepareMeshDataAsync(emitter.GfxObjId.DataId, isSetup: false);
}
}
@ -69,10 +71,12 @@ namespace AcDream.App.Rendering.Wb {
if (_gfxRenderData == null) {
var gfxId = _emitter.HwGfxObjId.DataId != 0 ? _emitter.HwGfxObjId.DataId : _emitter.GfxObjId.DataId;
if (gfxId != 0) {
_meshManager.PrepareMeshDataAsync(gfxId, isSetup: false);
_gfxRenderData = _meshManager.TryGetRenderData(gfxId);
}
}
if (_textureRenderData == null && _emitter.GfxObjId.DataId != 0) {
_meshManager.PrepareMeshDataAsync(_emitter.GfxObjId.DataId, isSetup: false);
_textureRenderData = _meshManager.TryGetRenderData(_emitter.GfxObjId.DataId);
}

View file

@ -0,0 +1,135 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Per-resource progress for a teardown which must attempt every independent
/// release even when an earlier release fails. Successful operations are never
/// replayed. A backend which throws after changing ownership reports that fact
/// with <see cref="MeshReferenceMutationException.MutationCommitted"/> so the
/// ledger can preserve the physical outcome while still surfacing the error.
/// </summary>
internal sealed class RetryableResourceReleaseLedger
{
private readonly Entry[] _entries;
private int _remaining;
private bool _advancing;
public RetryableResourceReleaseLedger(IEnumerable<(string Name, Action Release)> entries)
{
ArgumentNullException.ThrowIfNull(entries);
_entries = entries
.Select(entry => new Entry(entry.Name, entry.Release))
.ToArray();
_remaining = _entries.Length;
}
public bool IsComplete => _remaining == 0;
public int RemainingCount => _remaining;
/// <summary>
/// Attempts every unfinished operation once. Ordinary exceptions retain
/// the operation for a later attempt; committed-outcome exceptions mark it
/// complete. Either kind remains in the returned diagnostics.
/// </summary>
public ResourceReleaseAttempt Advance()
{
// Release callbacks may synchronously drain their owner. The active
// top-level pass already owns every unfinished entry; a nested pass
// must not give a failed later entry a second attempt in this frame.
if (_advancing)
return new ResourceReleaseAttempt(0, 0, []);
_advancing = true;
List<ResourceReleaseFailure>? failures = null;
int attempted = 0;
int completed = 0;
try
{
for (int i = 0; i < _entries.Length; i++)
{
Entry entry = _entries[i];
if (entry.Completed || entry.Running)
continue;
attempted++;
entry.Running = true;
try
{
entry.Release();
entry.Completed = true;
_remaining--;
completed++;
}
catch (Exception error)
{
bool committed = error is MeshReferenceMutationException
{
MutationCommitted: true,
};
if (committed)
{
entry.Completed = true;
_remaining--;
completed++;
}
(failures ??= []).Add(
new ResourceReleaseFailure(entry.Name, error, committed));
}
finally
{
entry.Running = false;
}
}
}
finally
{
_advancing = false;
}
return new ResourceReleaseAttempt(
attempted,
completed,
failures ?? []);
}
private sealed class Entry
{
public Entry(string name, Action release)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("A resource-release stage needs a name.", nameof(name));
ArgumentNullException.ThrowIfNull(release);
Name = name;
Release = release;
}
public string Name { get; }
public Action Release { get; }
public bool Completed { get; set; }
public bool Running { get; set; }
}
}
internal readonly record struct ResourceReleaseFailure(
string Stage,
Exception Error,
bool MutationCommitted);
internal readonly record struct ResourceReleaseAttempt(
int AttemptedCount,
int CompletedCount,
IReadOnlyList<ResourceReleaseFailure> Failures)
{
public bool HasFailures => Failures.Count != 0;
public AggregateException ToException(string message) =>
new(
message,
Failures.Select(failure =>
new InvalidOperationException(
$"Resource-release stage '{failure.Stage}' failed "
+ (failure.MutationCommitted
? "after committing its mutation."
: "before committing its mutation."),
failure.Error)));
}

View file

@ -6,8 +6,38 @@ using Silk.NET.OpenGL;
using System;
using System.Collections.Generic;
using PixelFormat = Silk.NET.OpenGL.PixelFormat;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb {
internal sealed class TextureAtlasDisposeTransaction {
private bool _running;
public bool IsComplete { get; private set; }
public bool IsRunning => _running;
public void Advance(
Action retryLayerRetirements,
Action disposeTextureArray,
Action commitLogicalDisposal) {
ArgumentNullException.ThrowIfNull(retryLayerRetirements);
ArgumentNullException.ThrowIfNull(disposeTextureArray);
ArgumentNullException.ThrowIfNull(commitLogicalDisposal);
if (IsComplete || _running)
return;
_running = true;
try {
retryLayerRetirements();
disposeTextureArray();
commitLogicalDisposal();
IsComplete = true;
}
finally {
_running = false;
}
}
}
/// <summary>
/// Manages texture arrays grouped by (Width, Height, Format).
/// Deduplicates textures by a TextureKey and supports reference counting.
@ -20,41 +50,99 @@ namespace AcDream.App.Rendering.Wb {
private readonly TextureFormat _format;
private readonly Dictionary<TextureKey, int> _textureIndices = new();
private readonly Dictionary<int, int> _refCounts = new();
private readonly Stack<int> _freeSlots = new();
private int _nextIndex = 0;
private const int InitialCapacity = 32;
private readonly TextureAtlasSlotAllocator _slots;
private readonly TextureAtlasLayerRetirement _layerRetirement;
private readonly TextureAtlasDisposeTransaction _disposeTransaction = new();
private readonly Action<TextureAtlasManager>? _onGpuSafeEmpty;
private bool _disposed;
internal const long TargetArrayBytes = 8L * 1024 * 1024;
internal const int MaximumArrayLayers = 32;
public uint Slot { get; }
public ManagedGLTextureArray TextureArray { get; private set; } = null!;
public int UsedSlots => _textureIndices.Count;
public int TotalSlots => TextureArray?.Size ?? InitialCapacity;
public int FreeSlots => TotalSlots - UsedSlots;
public int TotalSlots => TextureArray?.Size ?? 0;
public int AvailableSlots => _slots.AvailableCount;
internal bool IsGpuSafeEmpty => UsedSlots == 0 && AvailableSlots == TotalSlots;
internal long AllocatedBytes {
get {
return TextureArray.TotalSizeInBytes;
}
}
internal long LastUseSequence { get; set; }
internal int Width => _textureWidth;
internal int Height => _textureHeight;
internal TextureFormat Format => _format;
public TextureAtlasManager(OpenGLGraphicsDevice graphicsDevice, int width, int height, TextureFormat format = TextureFormat.RGBA8) {
public TextureAtlasManager(
OpenGLGraphicsDevice graphicsDevice,
int width,
int height,
TextureFormat format = TextureFormat.RGBA8,
Action<TextureAtlasManager>? onGpuSafeEmpty = null) {
Slot = _nextSlot++;
_graphicsDevice = graphicsDevice;
_textureWidth = width;
_textureHeight = height;
_format = format;
TextureArray = (ManagedGLTextureArray)graphicsDevice.CreateTextureArrayInternal(format, width, height, InitialCapacity, TextureParameters.ClampToEdge);
_onGpuSafeEmpty = onGpuSafeEmpty;
_layerRetirement = new TextureAtlasLayerRetirement(graphicsDevice.ResourceRetirement);
int capacity = CalculateInitialCapacity(width, height, format);
TextureArray = (ManagedGLTextureArray)graphicsDevice.CreateTextureArrayInternal(format, width, height, capacity, TextureParameters.ClampToEdge);
_slots = new TextureAtlasSlotAllocator(TextureArray.Size);
}
/// <summary>
/// Keeps each physical array near an eight-MiB target instead of
/// reserving 32 layers for every size class. The old fixed capacity
/// made a single 1024x1024 RGBA texture allocate roughly 171 MiB once
/// its mip chain was included, and made glGenerateMipmap process all
/// 32 layers during destination streaming.
/// </summary>
internal static int CalculateInitialCapacity(int width, int height, TextureFormat format) {
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
long bytesPerLayer = CalculateMipChainBytes(width, height, format);
long targetLayers = Math.Max(1L, TargetArrayBytes / bytesPerLayer);
return checked((int)Math.Min(targetLayers, MaximumArrayLayers));
}
internal static long CalculateMipChainBytes(int width, int height, TextureFormat format) {
long total = 0;
int w = width;
int h = height;
while (true) {
total = checked(total + CalculateLevelBytes(w, h, format));
if (w == 1 && h == 1) return total;
w = Math.Max(1, w >> 1);
h = Math.Max(1, h >> 1);
}
}
internal static long CalculateArrayBytes(int width, int height, TextureFormat format) =>
checked(CalculateMipChainBytes(width, height, format)
* CalculateInitialCapacity(width, height, format));
internal static long CalculateLevelBytes(int width, int height, TextureFormat format) => format switch {
TextureFormat.RGBA8 => checked((long)width * height * 4L),
TextureFormat.RGB8 => checked((long)width * height * 3L),
TextureFormat.A8 => checked((long)width * height),
TextureFormat.Rgba32f => checked((long)width * height * 16L),
TextureFormat.DXT1 => checked((long)Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 8L),
TextureFormat.DXT3 or TextureFormat.DXT5 => checked((long)Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 16L),
_ => throw new NotSupportedException($"Unsupported texture-atlas format {format}.")
};
public int AddTexture(TextureKey key, byte[] data, PixelFormat? uploadPixelFormat = null, PixelType? uploadPixelType = null) {
ObjectDisposedException.ThrowIf(_disposed || _disposeTransaction.IsRunning, this);
_layerRetirement.RetryPendingPublications();
if (_textureIndices.TryGetValue(key, out var existingIndex)) {
_refCounts[existingIndex]++;
return existingIndex;
}
int index;
if (_freeSlots.Count > 0) {
index = _freeSlots.Pop();
}
else {
index = _nextIndex++;
if (index >= TextureArray.Size) {
throw new Exception($"Texture atlas is full! {TextureArray.Size} / {_nextIndex} used.");
}
}
int index = _slots.Rent();
try {
TextureArray.UpdateLayer(index, data, uploadPixelFormat, uploadPixelType);
@ -63,14 +151,15 @@ namespace AcDream.App.Rendering.Wb {
return index;
}
catch (Exception) {
if (!_textureIndices.ContainsKey(key)) {
_freeSlots.Push(index);
}
if (!_textureIndices.ContainsKey(key))
_slots.Return(index);
throw;
}
}
public void ReleaseTexture(TextureKey key) {
ObjectDisposedException.ThrowIf(_disposed || _disposeTransaction.IsRunning, this);
_layerRetirement.RetryPendingPublications();
if (!_textureIndices.TryGetValue(key, out var index)) return;
if (!_refCounts.ContainsKey(index)) return;
@ -79,21 +168,74 @@ namespace AcDream.App.Rendering.Wb {
if (_refCounts[index] <= 0) {
_textureIndices.Remove(key);
_refCounts.Remove(index);
_freeSlots.Push(index);
TextureArray?.RemoveLayer(index);
// The CPU no longer references this layer, but previously
// submitted draws may still sample it. Recycle the slot only
// after their GPU fence has signaled; no clear or whole-array
// mip regeneration is needed for an unreferenced layer.
_layerRetirement.Retire(
() => {
if (!_disposed)
_slots.Return(index);
},
() => {
if (!_disposed && IsGpuSafeEmpty)
_onGpuSafeEmpty?.Invoke(this);
});
}
}
internal void RetryPendingRetirements() =>
_layerRetirement.RetryPendingPublications();
public bool HasTexture(TextureKey key) => _textureIndices.ContainsKey(key);
public int GetTextureIndex(TextureKey key) =>
_textureIndices.TryGetValue(key, out var index) ? index : -1;
public void Dispose() {
TextureArray?.Dispose();
_textureIndices.Clear();
_refCounts.Clear();
_freeSlots.Clear();
if (_disposed) return;
_disposeTransaction.Advance(
_layerRetirement.RetryPendingPublications,
() => {
TextureArray?.Dispose();
if (TextureArray is not null && !TextureArray.HasDurableDisposeOwnership)
throw new InvalidOperationException(
"Texture-array disposal returned without retaining or publishing its physical release.");
},
() => {
_textureIndices.Clear();
_refCounts.Clear();
_disposed = true;
});
}
}
/// <summary>
/// Owns the publication and callback cursor for returned atlas layers.
/// Removing the logical texture key commits immediately; this owner keeps
/// the physical layer reachable until the frame queue accepts it and keeps
/// the empty-atlas observer separate from the slot return so it cannot make
/// a callback retry return the same slot twice.
/// </summary>
internal sealed class TextureAtlasLayerRetirement
{
private readonly GpuRetirementLedger _ledger;
public TextureAtlasLayerRetirement(IGpuResourceRetirementQueue queue) =>
_ledger = new GpuRetirementLedger(queue);
internal int AwaitingPublicationCount => _ledger.AwaitingPublicationCount;
public void Retire(Action returnLayer, Action notifyGpuSafeEmpty)
{
ArgumentNullException.ThrowIfNull(returnLayer);
ArgumentNullException.ThrowIfNull(notifyGpuSafeEmpty);
_ledger.Retire(new RetryableGpuResourceRelease(
returnLayer,
notifyGpuSafeEmpty));
}
public void RetryPendingPublications() =>
_ledger.RetryPendingPublications();
}
}

View file

@ -0,0 +1,55 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Owns the physical layer slots in one texture array. A released texture
/// remains rented until the GPU-retirement callback returns its layer, so
/// <see cref="AvailableCount"/> never advertises a CPU-unreferenced layer
/// that an in-flight draw may still sample.
/// </summary>
internal sealed class TextureAtlasSlotAllocator
{
private readonly bool[] _rented;
private readonly Stack<int> _returned = new();
private int _next;
public TextureAtlasSlotAllocator(int capacity)
{
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
_rented = new bool[capacity];
}
public int AvailableCount => _returned.Count + (_rented.Length - _next);
public int Rent()
{
int slot;
if (_returned.Count != 0)
{
slot = _returned.Pop();
}
else
{
if (_next == _rented.Length)
throw new InvalidOperationException(
$"Texture atlas has no GPU-safe layer available ({_rented.Length} layers)."
);
slot = _next++;
}
if (_rented[slot])
throw new InvalidOperationException($"Texture atlas layer {slot} was rented twice.");
_rented[slot] = true;
return slot;
}
public void Return(int slot)
{
ArgumentOutOfRangeException.ThrowIfNegative(slot);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(slot, _next);
if (!_rented[slot])
throw new InvalidOperationException($"Texture atlas layer {slot} was returned twice.");
_rented[slot] = false;
_returned.Push(slot);
}
}

View file

@ -0,0 +1,242 @@
using Silk.NET.OpenGL;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Always-on transaction boundary and accounting for raw dynamic GL objects.
/// Growth keeps the published CPU capacity unchanged until BufferData succeeds;
/// OpenGL leaves the previous data store intact when allocation reports an
/// error, so the caller can continue using the old capacity or unwind.
/// </summary>
internal static unsafe class TrackedGlResource
{
public static RetryableGpuResourceRelease CreateRetryableBufferDeletion(
GL gl,
uint buffer,
long capacityBytes,
string context)
{
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
return new RetryableGpuResourceRelease(
() => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"),
() =>
{
gl.DeleteBuffer(buffer);
// Per the GL error contract, a command which generates an
// error does not change object state. Keep mutation and its
// validation in one retryable stage so that failed deletion
// is issued again, while later accounting remains untouched.
GLHelpers.ThrowOnResourceError(gl, context);
},
() =>
{
if (capacityBytes != 0)
GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer);
},
() => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer));
}
public static RetryableGpuResourceRelease CreateRetryableVertexArrayDeletion(
GL gl,
uint vertexArray,
string context,
Action? trackDeallocation = null)
{
if (vertexArray == 0)
throw new ArgumentOutOfRangeException(nameof(vertexArray));
return new RetryableGpuResourceRelease(
() => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"),
() =>
{
gl.DeleteVertexArray(vertexArray);
GLHelpers.ThrowOnResourceError(gl, context);
},
trackDeallocation
?? (() => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO)));
}
public static void AllocateBufferStorage(
GL gl,
BufferTargetARB target,
uint buffer,
long previousBytes,
long newBytes,
BufferUsageARB usage,
string context)
=> AllocateBufferStorage(
gl,
(GLEnum)target,
buffer,
previousBytes,
newBytes,
(GLEnum)usage,
context);
public static void AllocateBufferStorage(
GL gl,
BufferTargetARB target,
uint buffer,
long previousBytes,
long newBytes,
BufferUsageARB usage,
void* data,
string context)
=> AllocateBufferStorage(
gl,
(GLEnum)target,
buffer,
previousBytes,
newBytes,
(GLEnum)usage,
data,
context);
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;
}
}
public static uint CreateVertexArray(GL gl, string context)
{
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
uint name = gl.GenVertexArray();
try
{
if (name == 0)
throw new InvalidOperationException($"OpenGL returned vertex-array name zero. Context: {context}");
GLHelpers.ThrowOnResourceError(gl, context);
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO);
return name;
}
catch
{
if (name != 0)
gl.DeleteVertexArray(name);
throw;
}
}
public static void AllocateBufferStorage(
GL gl,
GLEnum target,
uint buffer,
long previousBytes,
long newBytes,
GLEnum usage,
string context)
=> AllocateBufferStorage(
gl,
target,
buffer,
previousBytes,
newBytes,
usage,
null,
context);
public static void AllocateBufferStorage(
GL gl,
GLEnum target,
uint buffer,
long previousBytes,
long newBytes,
GLEnum usage,
void* data,
string context)
{
ArgumentOutOfRangeException.ThrowIfNegative(previousBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(newBytes, 1);
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.BindBuffer(target, buffer);
gl.BufferData(target, checked((nuint)newBytes), data, usage);
GLHelpers.ThrowOnResourceError(gl, context);
long delta = checked(newBytes - previousBytes);
if (delta > 0)
GpuMemoryTracker.TrackAllocation(delta, GpuResourceType.Buffer);
else if (delta < 0)
GpuMemoryTracker.TrackDeallocation(-delta, GpuResourceType.Buffer);
}
public static void UpdateBufferSubData(
GL gl,
BufferTargetARB target,
uint buffer,
nint byteOffset,
long byteCount,
void* data,
string context)
=> UpdateBufferSubData(
gl,
(GLEnum)target,
buffer,
byteOffset,
byteCount,
data,
context);
public static void UpdateBufferSubData(
GL gl,
GLEnum target,
uint buffer,
nint byteOffset,
long byteCount,
void* data,
string context)
{
ArgumentOutOfRangeException.ThrowIfNegative(byteOffset);
ArgumentOutOfRangeException.ThrowIfLessThan(byteCount, 1);
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
ArgumentNullException.ThrowIfNull(data);
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.BindBuffer(target, buffer);
gl.BufferSubData(target, byteOffset, checked((nuint)byteCount), data);
GLHelpers.ThrowOnResourceError(gl, context);
}
public static void DeleteBuffer(GL gl, uint buffer, long capacityBytes, string context)
{
if (buffer == 0)
return;
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.DeleteBuffer(buffer);
GLHelpers.ThrowOnResourceError(gl, context);
if (capacityBytes != 0)
GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
}
public static void DeleteVertexArray(GL gl, uint vertexArray, string context)
{
if (vertexArray == 0)
return;
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.DeleteVertexArray(vertexArray);
GLHelpers.ThrowOnResourceError(gl, context);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO);
}
}

File diff suppressed because it is too large Load diff

View file

@ -17,18 +17,56 @@ namespace AcDream.App.Rendering.Wb;
/// so the rest of the renderer doesn't need to know about WB's types directly.
///
/// <para>
/// As of Phase O-T7, all DAT I/O routes through <see cref="DatCollectionAdapter"/>
/// (backed by our shared <see cref="DatCollection"/>) — the separate
/// As of Phase O-T7, all DAT I/O routes through the runtime-owned shared
/// <see cref="IDatReaderWriter"/> facade — the separate
/// <c>DefaultDatReaderWriter</c> file-handle set has been removed.
/// </para>
/// </summary>
public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
{
internal const int MaximumUploadsPerFrame = 8;
internal const long MaximumUploadBytesPerFrame = 8L * 1024 * 1024;
internal const long MaximumArrayAllocationBytesPerFrame = 8L * 1024 * 1024;
internal const long MaximumMipmapBytesPerFrame = 8L * 1024 * 1024;
internal const int MaximumNewArraysPerFrame = 1;
internal const long MaximumBufferUploadBytesPerFrame = 8L * 1024 * 1024;
// A GL buffer store is indivisible. The active vertex arena's explicit
// supported ceiling is therefore the truthful one-allocation bound; only
// its prefix copy is staged across frames.
internal const long MaximumBufferAllocationBytesPerFrame =
GlobalMeshBuffer.MaximumVertexBufferBytes;
internal const long MaximumBufferCopyBytesPerFrame = 32L * 1024 * 1024;
internal const int MaximumNewBuffersPerFrame = 1;
internal const long MaximumSingleUploadBytes = 128L * 1024 * 1024;
internal const long MaximumSingleArrayAllocationBytes = 128L * 1024 * 1024;
internal const long MaximumSingleMipmapBytes = 128L * 1024 * 1024;
internal const int MaximumSingleNewArrays = 16;
internal const long MaximumSingleBufferUploadBytes = 32L * 1024 * 1024;
internal const int MaximumReclaimedMeshesPerFrame = MaximumUploadsPerFrame;
internal const long MaximumReclaimedMeshBytesPerFrame = 64L * 1024 * 1024;
internal const int MaximumStaleDiscardsPerFrame = 64;
private readonly OpenGLGraphicsDevice? _graphicsDevice;
private readonly ObjectMeshManager? _meshManager;
private readonly DatCollection? _dats;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement;
private readonly IDatReaderWriter? _dats;
private readonly AcSurfaceMetadataTable _metadataTable = new();
private readonly HashSet<ulong> _metadataPopulated = new();
private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits(
MaximumUploadsPerFrame,
MaximumUploadBytesPerFrame,
MaximumArrayAllocationBytesPerFrame,
MaximumMipmapBytesPerFrame,
MaximumNewArraysPerFrame,
MaximumBufferUploadBytesPerFrame,
MaximumBufferAllocationBytesPerFrame,
MaximumBufferCopyBytesPerFrame,
MaximumNewBuffersPerFrame,
MaximumSingleUploadBytes,
MaximumSingleArrayAllocationBytes,
MaximumSingleMipmapBytes,
MaximumSingleNewArrays,
MaximumSingleBufferUploadBytes));
private readonly HashSet<TextureAtlasManager> _mipmapsBudgeted = new();
/// <summary>
/// True when this instance was created via <see cref="CreateUninitialized"/>;
@ -37,32 +75,64 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
private readonly bool _isUninitialized;
private bool _disposed;
private AcDream.App.Rendering.OrderedResourceTeardown? _teardown;
internal int LastUploadCount { get; private set; }
internal long LastUploadBytes { get; private set; }
internal int LastStaleDiscardCount { get; private set; }
internal long LastArrayAllocationBytes { get; private set; }
internal long LastPlannedMipmapBytes { get; private set; }
internal int LastNewArrayCount { get; private set; }
internal long LastBufferUploadBytes { get; private set; }
internal long LastBufferAllocationBytes { get; private set; }
internal long LastBufferCopyBytes { get; private set; }
internal int LastNewBufferCount { get; private set; }
internal int LastMipmapArrayCount { get; private set; }
internal long LastMipmapBytes { get; private set; }
internal int StagedUploadBacklog => _meshManager?.StagedMeshCount ?? 0;
internal long StagedUploadBytes => _meshManager?.StagedMeshBytes ?? 0;
internal bool StagingAtHighWater => _meshManager?.StagingAtHighWater ?? false;
internal (int Count, long Bytes) CpuMeshCacheDiagnostics =>
_meshManager?.CpuCacheDiagnostics ?? default;
/// <summary>
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → DatCollectionAdapter
/// → ObjectMeshManager.
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → shared bounded
/// DAT facade → ObjectMeshManager.
/// </summary>
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current
/// thread (construction runs GL queries; call from OnLoad).</param>
/// <param name="dats">acdream's DatCollection, used to populate the surface
/// <param name="dats">acdream's shared runtime DAT facade, used to populate the surface
/// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with
/// the rest of the client; read-only access from the render thread.</param>
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses
/// NullLogger internally.</param>
public WbMeshAdapter(GL gl, DatCollection dats, ILogger<WbMeshAdapter> logger)
public WbMeshAdapter(GL gl, IDatReaderWriter dats, ILogger<WbMeshAdapter> logger)
: this(gl, dats, logger, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance)
{
}
internal WbMeshAdapter(
GL gl,
IDatReaderWriter dats,
ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(logger);
_dats = dats;
_graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings());
_resourceRetirement = resourceRetirement;
_graphicsDevice = new OpenGLGraphicsDevice(
gl,
logger,
new DebugRenderSettings(),
resourceRetirement);
_graphicsDevice.ParticleBatcher = new ParticleBatcher(_graphicsDevice);
// ConsoleErrorLogger surfaces WB's silently-caught exceptions
// (ObjectMeshManager.PrepareMeshData try/catch at line ~589).
_meshManager = new ObjectMeshManager(
_graphicsDevice,
new DatCollectionAdapter(dats),
dats,
new ConsoleErrorLogger<ObjectMeshManager>());
}
@ -157,7 +227,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// An uninitialized adapter owns no render pipeline, so it must not
// manufacture a readiness wait that can never complete. The modern
// production path is always initialized at startup.
return _isUninitialized || _meshManager?.TryGetRenderData(id) is not null;
return _isUninitialized || (_meshManager?.EnsureRenderDataReady(id) ?? false);
}
/// <inheritdoc/>
@ -166,9 +236,12 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
if (_isUninitialized || _meshManager is null) return;
_meshManager.IncrementRefCount(id);
bool firstEver = _metadataPopulated.Add(id);
if (firstEver)
PopulateMetadata(id);
bool metadataClaimed = false;
try
{
metadataClaimed = _metadataPopulated.Add(id);
if (metadataClaimed)
PopulateMetadata(id);
// WB's IncrementRefCount alone only bumps a usage counter; it does
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
@ -197,8 +270,37 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused.
if (firstEver || _meshManager.TryGetRenderData(id) is null)
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
if (metadataClaimed || _meshManager.TryGetRenderData(id) is null)
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
}
catch (Exception acquireFailure)
{
if (metadataClaimed)
{
_metadataPopulated.Remove(id);
_metadataTable.Remove(id);
}
// IncrementRefCount is a public ownership boundary. Metadata/DAT
// preparation happens after ObjectMeshManager commits its count,
// so compensate before reporting failure. ObjectMeshManager's
// decrement has a strong exception guarantee; if compensation
// itself fails, expose that the increment remains committed so a
// higher-level transactional owner can retain its held marker.
try
{
_meshManager.DecrementRefCount(id);
}
catch (Exception rollbackFailure)
{
throw new MeshReferenceMutationException(
$"Mesh 0x{id:X10} reference acquisition failed and its committed increment could not be rolled back.",
mutationCommitted: true,
new AggregateException(acquireFailure, rollbackFailure));
}
throw;
}
}
/// <inheritdoc/>
@ -263,6 +365,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
if (_isUninitialized) return;
if (_disposed) return;
ObjectMeshManager meshManager = _meshManager!;
_graphicsDevice!.ProcessGLQueue();
// #125: drain staged uploads; a FAILED upload (UploadMeshData returned
// null from its catch) is re-staged for a LATER frame, not dropped. The
@ -270,43 +373,190 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// inside the while would let a deterministic failure spin the queue in a
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
// a genuine defect surfaces loudly instead of the old silent sticky drop.
List<ObjectMeshData>? requeue = null;
while (_meshManager!.TryDequeueStagedMeshData(out var meshData))
List<MeshUploadQueueItem>? requeue = null;
_uploadBudget.Reset();
_mipmapsBudgeted.Clear();
int staleDiscardCount = 0;
bool arenaBackpressured = false;
GlobalMeshBuffer? globalBuffer = meshManager.GlobalBuffer;
// A backing-store migration is real GL work, not a cost estimate. One
// bounded copy chunk runs per frame while the old VAO remains active.
// Uploads stay queued until the atomic final swap has completed.
if (globalBuffer?.IsMigrationInProgress == true)
{
if (meshData is null)
continue;
if (_meshManager.UploadOrRequeue(meshData))
GlobalMeshMaintenanceStep maintenance =
globalBuffer.AdvanceMigration(MaximumBufferCopyBytesPerFrame);
_uploadBudget.RecordBufferMaintenance(
maintenance.AllocationBytes,
maintenance.CopyBytes,
maintenance.NewBufferCount);
arenaBackpressured = !maintenance.Completed;
}
while (globalBuffer?.IsMigrationInProgress != true
&& _uploadBudget.BufferCopyBytes == 0
&& _uploadBudget.ObjectCount < MaximumUploadsPerFrame)
{
staleDiscardCount += meshManager.DiscardUnownedStagedPrefix(
MaximumStaleDiscardsPerFrame - staleDiscardCount);
if (staleDiscardCount >= MaximumStaleDiscardsPerFrame)
break;
if (!meshManager.TryPeekStagedMeshData(out MeshUploadQueueItem next))
break;
GlobalMeshCapacityResult capacity;
GlobalMeshMaintenanceStep maintenance;
try
{
capacity = meshManager.EnsureGlobalBufferCapacity(next, out maintenance);
}
catch (NotSupportedException error)
{
RejectUnsupportedHead(meshManager, next, error);
throw;
}
if (capacity == GlobalMeshCapacityResult.MigrationStarted)
{
_uploadBudget.RecordBufferMaintenance(
maintenance.AllocationBytes,
maintenance.CopyBytes,
maintenance.NewBufferCount);
arenaBackpressured = true;
break;
}
if (capacity == GlobalMeshCapacityResult.MigrationInProgress)
{
arenaBackpressured = true;
break;
}
if (capacity == GlobalMeshCapacityResult.NeedsReclamation)
{
// Do not evict another batch while the frame-fence queue is
// already returning ranges or retiring an old backing store.
// Waiting for real allocator state prevents three frames of
// duplicate eviction before the first release becomes reusable.
if (globalBuffer?.HasPendingReclamation != true)
{
var reclaimed = meshManager.ReclaimUnusedResources(
MaximumReclaimedMeshesPerFrame,
MaximumReclaimedMeshBytesPerFrame,
forceArenaReclamation: true);
if (reclaimed.Count == 0)
{
throw new NotSupportedException(
$"The live global-mesh working set cannot fit the supported "
+ $"{GlobalMeshBuffer.MaximumVertexBufferBytes + GlobalMeshBuffer.MaximumIndexBufferBytes:N0}-byte arena "
+ $"(blocked staging generation {next.Generation}, object 0x{next.Data.ObjectId:X10}).");
}
}
arenaBackpressured = true;
break;
}
MeshUploadCost cost;
try
{
cost = meshManager.PlanUploadCost(
next.Data,
_mipmapsBudgeted,
next.Generation);
if (!_uploadBudget.TryAdmit(cost))
break;
}
catch (NotSupportedException error)
{
RejectUnsupportedHead(meshManager, next, error);
throw;
}
if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem meshData))
break;
if (meshData.Generation != next.Generation)
throw new InvalidOperationException("The staged mesh FIFO head changed during render-thread admission.");
if (meshManager.UploadOrRequeue(meshData))
(requeue ??= new()).Add(meshData);
meshManager.AddDirtyAtlasesTo(_mipmapsBudgeted);
}
if (requeue is not null)
foreach (var m in requeue)
_meshManager.RequeueStagedMeshData(m);
meshManager.RequeueStagedMeshData(m);
meshManager.SetArenaBackpressure(arenaBackpressured);
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
var pendingBefore = texProbe
? _meshManager.GetPendingTextureUpdateStats()
? meshManager.GetPendingTextureUpdateStats()
: default;
// #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES
// texture content (PBO write + ManagedGLTextureArray._pendingUpdates) — the
// immutable decoded payloads in ManagedGLTextureArray._pendingUpdates — the
// actual TexSubImage3D copies + mipmap regeneration happen in
// ProcessDirtyUpdates, which WB drives ONCE PER FRAME from its render loop
// (WB GameScene.cs:975 `_meshManager?.GenerateMipmaps()`, just before the
// opaque pass). That call site lived in the GameScene file the N.4/O-T4
// extraction replaced with GameWindow, so the driver was silently dropped:
// staged updates only ever reached the GPU as a side effect of PBO growth,
// and every layer staged after an array's LAST growth kept undefined
// TexStorage3D content behind a valid resident bindless handle — the
// staged updates never reached TexSubImage3D, leaving undefined
// TexStorage3D content behind valid resident bindless handles — the
// intermittent white indoor walls (#105). Pre-fix evidence: 126 updates
// stuck across 34/34 arrays at standstill (texflush-prefix.log). Tick()
// runs before all draw passes (GameWindow OnRender), so this is the exact
// WB-equivalent position.
_meshManager.GenerateMipmaps();
(LastMipmapArrayCount, LastMipmapBytes) = meshManager.GenerateMipmaps();
if (globalBuffer?.HasPendingReclamation != true)
{
meshManager.ReclaimUnusedResources(
MaximumReclaimedMeshesPerFrame,
MaximumReclaimedMeshBytesPerFrame);
}
meshManager.EvictOneEmptyAtlas();
// Arena maintenance is capacity-driven and uses genuinely idle upload
// frames. It never competes with destination materialization, and the
// GlobalMeshBuffer policy retains enough hysteresis that portal-route
// revisits do not alternate shrink/grow every frame.
if (_uploadBudget.ObjectCount == 0
&& LastMipmapArrayCount == 0
&& meshManager.StagedMeshCount == 0
&& globalBuffer?.IsMigrationInProgress == false
&& globalBuffer.TryTrimUnusedTail(out GlobalMeshMaintenanceStep trim))
{
_uploadBudget.RecordBufferMaintenance(
trim.AllocationBytes,
trim.CopyBytes,
trim.NewBufferCount);
meshManager.SetArenaBackpressure(true);
}
LastUploadCount = _uploadBudget.ObjectCount;
LastUploadBytes = _uploadBudget.SourceBytes;
LastStaleDiscardCount = staleDiscardCount;
LastArrayAllocationBytes = _uploadBudget.ArrayAllocationBytes;
LastPlannedMipmapBytes = _uploadBudget.MipmapBytes;
LastNewArrayCount = _uploadBudget.NewArrayCount;
LastBufferUploadBytes = _uploadBudget.BufferUploadBytes;
LastBufferAllocationBytes = _uploadBudget.BufferAllocationBytes;
LastBufferCopyBytes = _uploadBudget.BufferCopyBytes;
LastNewBufferCount = _uploadBudget.NewBufferCount;
if (texProbe)
EmitTexFlushProbe(pendingBefore);
}
private static void RejectUnsupportedHead(
ObjectMeshManager meshManager,
MeshUploadQueueItem expected,
NotSupportedException error)
{
if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem rejected)
|| rejected.Generation != expected.Generation)
{
throw new InvalidOperationException(
"The staged mesh FIFO head changed while rejecting an unsupported generation.",
error);
}
meshManager.RejectUnsupportedStagedUpload(rejected, error);
}
// #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled.
private int _lastTexFlushBefore = -1;
private int _texFlushHeartbeat;
@ -351,9 +601,46 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
/// <inheritdoc/>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_meshManager?.Dispose();
_graphicsDevice?.Dispose();
if (_disposed)
return;
// These are dependency edges, not merely a best-effort cleanup list.
// In particular, the sampler objects owned by the device must remain
// alive until every resident texture-array handle has been retired.
_teardown ??= new AcDream.App.Rendering.OrderedResourceTeardown(
() =>
{
// The current global arena is still directly owned by the mesh
// manager. Fence every submitted draw before manager teardown
// can delete that arena's VAO/VBO/IBO.
if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights)
frameFlights.WaitForSubmittedWork();
},
() => _meshManager?.Dispose(),
() => DrainGraphicsQueue("publishing mesh resource retirements"),
() =>
{
if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights)
frameFlights.WaitForSubmittedWork();
},
() => DrainGraphicsQueue("releasing retired mesh resources"),
() => _graphicsDevice?.Dispose(),
() => DrainGraphicsQueue("deleting mesh graphics-device resources"));
_teardown.Advance();
_disposed = _teardown.IsComplete;
}
private void DrainGraphicsQueue(string operation)
{
if (_graphicsDevice is null)
return;
_graphicsDevice.ProcessGLQueue();
if (_graphicsDevice.HasPendingGLWork)
{
throw new InvalidOperationException(
$"OpenGL work remains pending after {operation}; retry adapter disposal to continue the exact stage.");
}
}
}

View file

@ -0,0 +1,29 @@
namespace AcDream.App.Rendering.Wb;
internal enum WbTextureResolutionKind : byte
{
SharedAtlas,
OriginalTextureOverride,
PaletteComposite,
}
/// <summary>
/// Selects the retail texture ownership path for one rendered surface.
/// Retail creates a palette-combined <c>ImgTex</c> only for indexed source
/// images; ordinary surfaces continue to reference their shared image.
/// </summary>
internal static class WbTextureResolutionPolicy
{
public static WbTextureResolutionKind Select(
bool hasOriginalTextureOverride,
bool hasPaletteOverride,
bool sourceIsPaletteIndexed)
{
if (hasPaletteOverride && sourceIsPaletteIndexed)
return WbTextureResolutionKind.PaletteComposite;
return hasOriginalTextureOverride
? WbTextureResolutionKind.OriginalTextureOverride
: WbTextureResolutionKind.SharedAtlas;
}
}