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:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
121
src/AcDream.App/Rendering/BoundedUnownedResourceCache.cs
Normal file
121
src/AcDream.App/Rendering/BoundedUnownedResourceCache.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) >= 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"/> > 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;
|
||||
|
|
|
|||
933
src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
Normal file
933
src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
Normal 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);
|
||||
}
|
||||
30
src/AcDream.App/Rendering/DynamicBufferCapacity.cs
Normal file
30
src/AcDream.App/Rendering/DynamicBufferCapacity.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
31
src/AcDream.App/Rendering/FixedEntityTextureOwnerLease.cs
Normal file
31
src/AcDream.App/Rendering/FixedEntityTextureOwnerLease.cs
Normal 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);
|
||||
}
|
||||
155
src/AcDream.App/Rendering/FramePacingController.cs
Normal file
155
src/AcDream.App/Rendering/FramePacingController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
31
src/AcDream.App/Rendering/FramePacingPolicy.cs
Normal file
31
src/AcDream.App/Rendering/FramePacingPolicy.cs
Normal 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
486
src/AcDream.App/Rendering/GpuFrameFlightController.cs
Normal file
486
src/AcDream.App/Rendering/GpuFrameFlightController.cs
Normal 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);
|
||||
}
|
||||
89
src/AcDream.App/Rendering/GpuRetiredTerrainSlotAllocator.cs
Normal file
89
src/AcDream.App/Rendering/GpuRetiredTerrainSlotAllocator.cs
Normal 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();
|
||||
}
|
||||
36
src/AcDream.App/Rendering/OrderedResourceTeardown.cs
Normal file
36
src/AcDream.App/Rendering/OrderedResourceTeardown.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/AcDream.App/Rendering/OwnerScopedResourceRegistry.cs
Normal file
61
src/AcDream.App/Rendering/OwnerScopedResourceRegistry.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
139
src/AcDream.App/Rendering/ParticleEmitterRetirementTracker.cs
Normal file
139
src/AcDream.App/Rendering/ParticleEmitterRetirementTracker.cs
Normal 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
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
154
src/AcDream.App/Rendering/PortalMeshReferenceOwner.cs
Normal file
154
src/AcDream.App/Rendering/PortalMeshReferenceOwner.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 < 0; <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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
104
src/AcDream.App/Rendering/ResourceShutdownTransaction.cs
Normal file
104
src/AcDream.App/Rendering/ResourceShutdownTransaction.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
242
src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs
Normal file
242
src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WorldEntity, AnimationSequencer></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, []);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
103
src/AcDream.App/Rendering/Wb/GpuRetiredRangeAllocator.cs
Normal file
103
src/AcDream.App/Rendering/Wb/GpuRetiredRangeAllocator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
12
src/AcDream.App/Rendering/Wb/IEntityTextureLifetime.cs
Normal file
12
src/AcDream.App/Rendering/Wb/IEntityTextureLifetime.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
186
src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs
Normal file
186
src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs
Normal 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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
135
src/AcDream.App/Rendering/Wb/RetryableResourceReleaseLedger.cs
Normal file
135
src/AcDream.App/Rendering/Wb/RetryableResourceReleaseLedger.cs
Normal 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)));
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs
Normal file
55
src/AcDream.App/Rendering/Wb/TextureAtlasSlotAllocator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
242
src/AcDream.App/Rendering/Wb/TrackedGlResource.cs
Normal file
242
src/AcDream.App/Rendering/Wb/TrackedGlResource.cs
Normal 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
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
src/AcDream.App/Rendering/Wb/WbTextureResolutionPolicy.cs
Normal file
29
src/AcDream.App/Rendering/Wb/WbTextureResolutionPolicy.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue