acdream/src/AcDream.App/Rendering/TextureCache.cs
Erik 096dd203fa feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache UI path onto IGpuDevice
Second attempt at V4a after ceec3bc4 was reverted at 9aaf97e7 for losing world
multisampling and a 334-file scope explosion. This lands the same functional
slice with a much smaller footprint and the two structural fixes the revert
postmortem (docs/plans/2026-07-27-vulkan-campaign.md SS7.1) called for.

What moved onto the RHI:
- TextRenderer: the ui_text shader now compiles through IGpuDevice.CreatePipeline
  (one IGpuPipeline, replacing the old hand-rolled Shader class); its three
  fence-buffered per-flight VBOs are gone in favour of a per-IGpuFrame ring
  allocation per draw bucket; its 1x1 white fill texture is created via
  IGpuDevice.CreateTexture and registered into the device's texture table.
  Flush keeps TextRenderGlStateScope and the manual GL disable block verbatim
  (TextRendererFailureSafetyTests pins their literal presence) alongside the
  new pipeline bind - both target the identical final GL state, so this is
  redundant, not contradictory. Sprite/font texture binding stays classic
  (glActiveTexture/glBindTexture) because DrawSprite receives arbitrary
  externally-owned GL texture names from dozens of UI call sites outside this
  slice's scope; IGpuPassEncoder has no verb for that, by design (every other
  RHI consumer samples through the bindless texture table).
- BitmapFont: the stb-baked R8 atlas is created/uploaded through
  IGpuDevice.CreateTexture; TextureId stays a raw GL name extracted from the
  IGpuTexture, since its only consumer is TextRenderer's classic path above.
- DebugLineRenderer: the debug_line shader compiles through
  IGpuDevice.CreatePipeline (LineList topology, depth disabled); Flush ring-
  allocates its vertex data and draws through IGpuPassEncoder. uView/uProjection
  don't fit the shared GpuPushConstants block (one combined VP matrix) so they
  are set directly on the pipeline's compiled program, mirroring TextRenderer.
- TextureCache: GetOrUploadRenderSurface and the public UploadRgba8(byte[],...)
  wrapper now create IGpuTexture+GpuTextureSlot internally, extracting the raw
  GL name for their unchanged uint return type - DrawSprite's signature and its
  16 call sites across the UI are untouched. The world-material path
  (GetOrUpload, the raw layer-array upload) is untouched.
- UiViewport: TextureHandle (uint) -> TextureSlot (GpuTextureSlot), resolved
  back to a raw GL name via TextRenderer.ResolveExternalTextureSlot at draw
  time. Its texture is produced by PaperdollViewportRenderer/
  PrivateEntityViewportRenderer, both still raw GL until V4g, so
  RetailPaperdollFrameView/RetailCreatureAppraisalFrameView register it through
  the pre-approved GlGpuDevice.RegisterExternalColorTexture transitional seam
  (campaign doc SS7.1's final paragraph) instead of inventing anything broader.

The two revert-postmortem fixes, both in Gpu/Gl (never in the pinned Gpu/
contract):
- GlGpuDevice.BeginPass now resets the render-state cache unconditionally on
  every pass, not only a clearing one. The first attempt's crash came from
  exactly this gap: a raw-GL renderer running between two RHI passes changes
  GL program/blend/depth/cull state the cache never observes, so a later
  BindPipeline skipped re-issuing glUseProgram and the following push-constant
  upload threw GL_INVALID_OPERATION.
- GlGpuPassEncoder now captures ambient GL capability state (program, VAO,
  array buffer, texture0 binding, depth test/write/func, blend enable+func,
  cull enable+mode, front face, alpha-to-coverage, multisample) on construction
  and restores it on Dispose, generalizing what TextRenderGlStateScope already
  did for TextRenderer specifically to every RHI pass - this is what stops
  DebugLineRenderer's pipeline bind (which has no scope of its own) from
  leaking state into the next raw-GL renderer. Both are marked transitional,
  deleted at V4h once nothing raw-GL remains.

Frame lifecycle (additive, per the task's own description of this piece):
new GpuDeviceFrameLifetime wraps IGpuDevice.BeginFrame()/IGpuFrame.End() and
exposes the open frame via ICurrentGpuFrameSource. RenderFrameOrchestrator's
IRenderFrameLifetime now routes through this wrapper instead of calling
GpuFrameFlightController directly - GlGpuDevice.BeginFrame already calls
straight through to that same controller, so the fence/slot-rotation contract
is unchanged; the wrapper only additionally yields the IGpuFrame ported
renderers need. No clears moved, no framebuffer binding changed, frame-graph
phase order is untouched. The two now-dead per-slot TextRenderer.BeginFrame(int)
calls in RuntimeRenderFrameBeginResources are removed. The UI Studio
(RenderBootstrap/StudioWindow) gets its own independent RHI device+lifetime,
mirroring the production composition.

Real bug found and fixed while exercising this for the first time: both
BitmapFont and TextureCache's nearest-filter override called TexParameter
AFTER RegisterTexture, which made the bindless handle resident - GL_ARB_
bindless_texture forbids modifying a texture's parameters once its handle is
resident, so this threw GL_INVALID_OPERATION building the retained UI's own
TextRenderer. Fixed by moving both TexParameter blocks before RegisterTexture.

Scope note: touches 25 files (24 modified + this commit's one new file), not
the ~10 the brief estimated, because the frame-lifecycle wiring and the
viewport escape hatch (both explicitly asked for) ripple through five
composition files and two frame presenters that thread IGpuDevice/
ICurrentGpuFrameSource to construction sites. No file outside that necessary
set was touched: no visibility sweep beyond the specific constructors/
properties whose new parameter types are internal (TextRenderer/BitmapFont/
DebugLineRenderer/UiHost's constructors, TextureCache's otherwise-orphaned
convenience overload, UiViewport.TextureSlot), no world-mesh/terrain/particle/
sky file touched, no test deleted or weakened - three source-text conformance
tests (TextRendererPublishesEveryConstructorResourceBeforeLaterGlWork,
GlTextureOwnershipTests' TextRenderer.cs check, and
RenderFrameResourceControllerTests' frame-order check) were replaced with
equivalent assertions against the new construction/wiring shape, since their
pinned invariant was specifically the old raw-GL shape this slice legitimately
replaces.

Gates:
- dotnet build -c Release: 0 warnings, 0 errors.
- dotnet test tests/AcDream.App.Tests -c Release: 3,843 passed / 3 skipped -
  exactly the baseline. Complete solution: 8,906 passed / 5 skipped across all
  nine test projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent a97e04ae vs this
  commit): 26 differing pixels of 563,200 compared (fraction 4.62e-05), pass
  against the 0.001/563-pixel threshold. Verified against a same-commit control
  (two captures at this commit differ by 20 pixels) rather than accepted at
  face value - the two numbers are in the same band, confirming this is normal
  animated-content/frame-pacing noise and not the systematic silhouette-edge
  loss (1,791 pixels, 224x higher) the first attempt's revert diagnosed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 19:37:19 +02:00

1071 lines
47 KiB
C#

// src/AcDream.App/Rendering/TextureCache.cs
using AcDream.Core.Textures;
using AcDream.Core.World;
using AcDream.Content;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Silk.NET.OpenGL;
using System.Linq;
using PixelFormatId = DatReaderWriter.Enums.PixelFormat;
using SurfaceType = DatReaderWriter.Enums.SurfaceType;
using AcDream.App.Rendering.Residency;
namespace AcDream.App.Rendering;
public sealed unsafe class TextureCache
: Wb.IEntityTextureLifetime,
IDisposable
{
private readonly GL _gl;
private readonly IGpuDevice _device;
private readonly IDatReaderWriter _dats;
private readonly string _diagnosticsDirectory;
// 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;
/// <summary>
/// Campaign V slice V4a: one registered <see cref="IGpuTexture"/> plus its
/// device texture-table <see cref="GpuTextureSlot"/>, decoded pixel size,
/// and the raw GL name <see cref="TextRenderer.DrawSprite"/>'s classic
/// texture-unit binding path still needs (see that class's remarks).
/// </summary>
private readonly record struct GpuUiTextureEntry(
IGpuTexture Texture,
GpuTextureSlot Slot,
uint GlName,
int Width,
int Height);
// Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids
// decoded directly (Portal/HighRes → DecodeRenderSurface), bypassing the
// Surface→SurfaceTexture chain that GetOrUpload uses for world materials.
private readonly Dictionary<uint, GpuUiTextureEntry> _renderSurfaceGpuTextures = new();
// Ad-hoc textures produced by the public UploadRgba8(byte[],int,int,bool) wrapper
// (used by IconComposer for composited item icons). These are NOT stored in any
// of the keyed caches above, so Dispose must sweep this list to avoid leaking
// GPU texture objects/slots until process exit.
private readonly List<GpuUiTextureEntry> _adhocGpuTextures = new();
private readonly Wb.BindlessSupport? _bindless;
private readonly CompositeTextureArrayCache? _compositeTextures;
private bool _destinationRevealUploadPriority;
// 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;
internal void SetDestinationRevealUploadPriority(bool enabled) =>
_destinationRevealUploadPriority = enabled;
// Phase N.6 slice 1 (2026-05-11): per-upload metadata for the
// ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload
// time so the dump method doesn't have to query GL state. Keyed by
// GL texture name (same key used in cache value tuples). Format
// label is "RGBA8_DECODED" for the post-decode upload (all uploads
// currently land as RGBA8 regardless of source format).
private readonly Dictionary<uint, (int Width, int Height, string Format)> _uploadMetadata = new();
// Frame counter for the one-shot ACDREAM_DUMP_SURFACES=1 trigger.
// Increments per Tick call; fires the dump once at frame index 600
// and never again for the session. See spec §5.
private int _dumpFrameCounter;
private bool _surfaceHistogramAlreadyDumped;
// internal, not public: IGpuDevice is an internal type (the pinned RHI
// contract), and this convenience overload has no real caller today (both
// production construction sites already target the internal overload
// below) — kept internal rather than deleted to preserve its shape.
internal TextureCache(GL gl, IGpuDevice device, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
: this(
gl,
device,
dats,
bindless,
ImmediateGpuResourceRetirementQueue.Instance,
Path.Combine(
Path.GetTempPath(),
"acdream",
"diagnostics"))
{
}
internal TextureCache(
GL gl,
IGpuDevice device,
IDatReaderWriter dats,
Wb.BindlessSupport? bindless,
IGpuResourceRetirementQueue retirementQueue,
string diagnosticsDirectory,
ResidencyBudgetOptions? budgets = null)
{
budgets ??= ResidencyBudgetOptions.Default;
_gl = gl;
_device = device ?? throw new ArgumentNullException(nameof(device));
_dats = dats;
_bindless = bindless;
ArgumentException.ThrowIfNullOrWhiteSpace(diagnosticsDirectory);
_diagnosticsDirectory = diagnosticsDirectory;
ArgumentNullException.ThrowIfNull(retirementQueue);
if (bindless is not null)
{
var resources = new ResourceCleanupGroup();
CompositeTextureArrayCache? composite = null;
StandaloneBindlessTextureCache? particles = null;
try
{
composite = new CompositeTextureArrayCache(
gl,
bindless,
retirementQueue,
budgets.CompositeUnownedBytes,
budgets.CompositePhysicalBytes);
resources.Add("composite texture cache", composite.Dispose);
particles = new StandaloneBindlessTextureCache(
new ParticleTextureBackend(this),
retirementQueue,
budgets.StandaloneUnownedBytes,
budgets.StandaloneUnownedEntries);
resources.Add("particle texture cache", particles.Dispose);
resources.TransferAll();
}
catch (Exception constructionFailure)
{
resources.RollbackConstructionAndThrow(
"TextureCache construction failed and its child-cache prefix did not cleanly roll back.",
constructionFailure);
}
_compositeTextures = composite;
_particleTextures = particles;
}
}
internal void RegisterResidencySources(ResidencyManager manager)
{
ArgumentNullException.ThrowIfNull(manager);
if (_compositeTextures is not null)
{
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.CompositeTextures,
_compositeTextures.CaptureResidency));
}
if (_particleTextures is not null)
{
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
ResidencyDomain.StandaloneTextures,
CaptureStandaloneResidency));
}
}
private ResidencyDomainSnapshot CaptureStandaloneResidency()
{
StandaloneBindlessTextureCache textures = EnsureParticleTexturesAvailable();
return new ResidencyDomainSnapshot(
ResidencyDomain.StandaloneTextures,
EntryCount: textures.EntryCount,
OwnerCount: textures.OwnerCount,
Charges: new ResidencyCharges(
GpuResidentBytes: checked(
textures.AllocatedBytes - textures.RetiringBytes),
RetiringBytes: textures.RetiringBytes),
BudgetBytes: textures.BudgetBytes);
}
/// <summary>
/// Get or upload the GL texture handle for a Surface id. Returns a
/// 1x1 magenta fallback if the Surface or its RenderSurface chain is
/// missing or uses an unsupported format.
/// </summary>
public uint GetOrUpload(uint surfaceId)
=> GetOrUploadSurfaceCore(surfaceId, out _, out _);
/// <summary>
/// Like <see cref="GetOrUpload(uint)"/> but also returns the decoded
/// pixel dimensions. UI 9-slice geometry needs the source size to
/// 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 (_surfacesById.TryGetValue(surfaceId, out var existing))
{
width = existing.Width;
height = existing.Height;
return existing.Handle;
}
DecodedTexture decoded = DecodeFromDats(
surfaceId,
origTextureOverride: null,
paletteOverride: null);
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
DumpAlphaHistogram(surfaceId, decoded);
uint h = UploadRgba8(decoded);
_surfacesById.Add(surfaceId, (h, decoded.Width, decoded.Height));
width = decoded.Width;
height = decoded.Height;
return h;
}
/// <summary>
/// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded
/// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the
/// Surface→SurfaceTexture chain that <see cref="GetOrUpload(uint)"/> uses
/// for world-geometry materials. This is the correct path for retail UI
/// chrome + font glyph sheets, which reference RenderSurface directly.
/// Paletted (PFID_P8 / PFID_INDEX16) UI sprites — e.g. the selected-object
/// health-bar track 0x0600193E — are decoded against the RenderSurface's own
/// <c>DefaultPaletteId</c> (same starting palette <see cref="DecodeFromDats"/>
/// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns
/// a 1x1 magenta handle on miss.
/// </summary>
public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false)
{
if (_renderSurfaceGpuTextures.TryGetValue(renderSurfaceId, out GpuUiTextureEntry existing))
{
width = existing.Width; height = existing.Height;
return existing.GlName;
}
DecodedTexture decoded;
if (_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
|| _dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
{
// Resolve the surface's own default palette so paletted UI sprites decode
// correctly instead of the magenta fallback (the back-track 0x0600193E behind
// the selected-object health bar is PFID_P8/INDEX16). Non-paletted formats
// (DefaultPaletteId==0) keep the previous null-palette behaviour unchanged.
Palette? palette = rs.DefaultPaletteId != 0
? _dats.Get<Palette>(rs.DefaultPaletteId)
: null;
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
}
else
{
decoded = DecodedTexture.Magenta;
}
GpuUiTextureEntry entry = UploadUiTexture(decoded, nearest, $"ui-rendersurface-0x{renderSurfaceId:X8}");
_renderSurfaceGpuTextures[renderSurfaceId] = entry;
width = decoded.Width; height = decoded.Height;
return entry.GlName;
}
/// <summary>
/// Campaign V slice V4a: creates an <see cref="IGpuTexture"/> for one
/// decoded UI sprite/atlas and registers it into the device's global
/// texture table. Every UI-path texture uses REPEAT addressing (existing
/// behaviour — panel fills and tiled chrome sample UVs greater than 1) and
/// a single mip level (UI sprites never mip). The classic texture-unit
/// binding path <see cref="TextRenderer.DrawSprite"/> uses for these
/// (see that class's remarks) samples the texture object directly rather
/// than through a bound sampler object, so filtering must live on the
/// texture itself — <c>GlGpuTexture</c>'s constructor always sets Linear,
/// which is wrong for <paramref name="nearest"/>-requested (pixel-exact)
/// sprites, so it is overridden here exactly as the old raw-GL path did.
/// </summary>
private GpuUiTextureEntry UploadUiTexture(DecodedTexture decoded, bool nearest, string debugName)
{
IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription(
debugName,
GpuTextureKind.Texture2D,
GpuTextureFormat.Rgba8Unorm,
Width: decoded.Width,
Height: decoded.Height,
LayerCount: 1,
MipLevelCount: 1));
try
{
texture.Upload(0, 0, decoded.Rgba8);
uint glName = ((GlGpuTexture)texture).GlName;
TrackUploadedTexture(glName, decoded.Width, decoded.Height);
// MUST happen BEFORE RegisterTexture below: ARB_bindless_texture
// forbids glTexParameter on a texture once its bindless handle has
// been made resident (GL_INVALID_OPERATION). See BitmapFont's
// constructor for the same fix and full explanation.
if (nearest)
{
_gl.BindTexture(TextureTarget.Texture2D, glName);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest);
_gl.BindTexture(TextureTarget.Texture2D, 0);
}
IGpuSampler sampler = _device.CreateSampler(GpuSamplerDescription.WorldRepeat);
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height);
}
catch
{
texture.Dispose();
throw;
}
}
/// <summary>
/// Alpha-channel histogram for one decoded texture. Used to diagnose
/// "why are clouds not transparent" — if cloud textures come out with
/// alpha = 1.0 everywhere we know the decode path strips the alpha
/// channel somewhere. Printed once per unique surfaceId under
/// <c>ACDREAM_DUMP_SKY=1</c>. Adds ~2ms per texture upload, negligible.
/// </summary>
private static void DumpAlphaHistogram(uint surfaceId, DecodedTexture decoded)
{
if (decoded.Rgba8.Length == 0 || decoded.Width == 0 || decoded.Height == 0)
{
System.Console.WriteLine($"[tex-alpha] surf=0x{surfaceId:X8} empty");
return;
}
int total = decoded.Rgba8.Length / 4;
// Bucket alpha in 10 bins.
var buckets = new int[10];
int aMin = 255, aMax = 0;
long aSum = 0;
for (int i = 0; i < decoded.Rgba8.Length; i += 4)
{
int a = decoded.Rgba8[i + 3];
if (a < aMin) aMin = a;
if (a > aMax) aMax = a;
aSum += a;
int b = a * 10 / 256;
if (b > 9) b = 9;
buckets[b]++;
}
float aMean = aSum / (float)total / 255f;
var pct = new string[10];
for (int i = 0; i < 10; i++) pct[i] = $"{100.0 * buckets[i] / total:F0}%";
System.Console.WriteLine(
$"[tex-alpha] surf=0x{surfaceId:X8} {decoded.Width}x{decoded.Height} " +
$"a_min={aMin / 255f:F3} a_max={aMax / 255f:F3} a_mean={aMean:F3} " +
$"bins[0-9]={string.Join(",", pct)}");
}
/// <summary>
/// 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>
internal ulong AcquireParticleTexture(int emitterHandle, uint surfaceId)
{
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;
}
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>
/// 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>
internal BindlessTextureLocation GetOrUploadWithOrigTextureOverrideBindless(
uint ownerLocalId,
uint surfaceId,
uint overrideOrigTextureId)
{
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;
DecodedTexture decoded = DecodeFromDats(
surfaceId,
origTextureOverride: overrideOrigTextureId,
paletteOverride: null);
return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added)
? added
: default;
}
/// <summary>
/// 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>
internal BindlessTextureLocation GetOrUploadWithPaletteOverrideBindless(
uint ownerLocalId,
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride,
PaletteCompositeIdentity paletteIdentity)
{
CompositeTextureArrayCache composites = EnsureCompositeTexturesAvailable();
uint origTexKey = overrideOrigTextureId ?? 0;
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()
{
if (_bindless is null)
throw new InvalidOperationException(
"TextureCache constructed without BindlessSupport — cannot generate bindless handles. " +
"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(_destinationRevealUploadPriority);
/// <summary>
/// Cheap 64-bit hash over a palette override's identity so two
/// entities with the same palette setup share a decode. Internal so
/// the WB dispatcher can compute it once per entity.
/// </summary>
internal static ulong HashPaletteOverride(PaletteOverride p)
{
// Not cryptographic — just needs to distinguish override setups
// for caching. Start with base palette id, fold in each entry.
ulong h = 0xCBF29CE484222325UL; // FNV-1a offset basis
const ulong prime = 0x100000001B3UL;
h = (h ^ p.BasePaletteId) * prime;
foreach (var sp in p.SubPalettes)
{
h = (h ^ sp.SubPaletteId) * prime;
h = (h ^ sp.Offset) * prime;
h = (h ^ sp.Length) * prime;
}
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
/// once after BOTH gates pass:
/// 1. <c>_dumpFrameCounter &gt;= 600</c> — at least 600 OnRender ticks
/// have elapsed (catches the "we're already past startup boilerplate"
/// bound; ~10s at 60fps, ~3s at 200fps).
/// 2. <c>_uploadMetadata.Count &gt;= 100</c> — the cache contains at
/// least 100 uploaded textures, indicating streaming has actually
/// pulled in world content (not just sky/UI/font). The original
/// frame-only gate fired during the login/handshake phase where
/// OnRender ticks at GUI rates but no world has streamed in.
/// Output goes to the host-provided portable diagnostics directory.
/// Zero cost
/// when off. See spec §5 in
/// docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md.
/// </summary>
public void TickSurfaceHistogramDumpIfEnabled()
{
if (_surfaceHistogramAlreadyDumped) return;
if (!string.Equals(System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SURFACES"), "1", StringComparison.Ordinal)) return;
_dumpFrameCounter++;
if (_dumpFrameCounter < 600) return;
if (_uploadMetadata.Count < 100) return;
DumpSurfaceHistogram();
_surfaceHistogramAlreadyDumped = true;
}
private void DumpSurfaceHistogram()
{
try
{
DumpSurfaceHistogramCore();
}
catch (Exception ex)
{
// Diagnostic-only path. If the dump file can't be written
// (disk full, permission denied, antivirus lock, path too
// long) we must NOT crash OnRender — that would invalidate
// the very measurement pass this diagnostic is meant to
// support. Log to stderr and let the caller mark the dump
// as "already done" so it doesn't retry every frame.
Console.Error.WriteLine($"[N6-DUMP] Failed to write surface histogram: {ex.Message}");
}
}
private void DumpSurfaceHistogramCore()
{
System.IO.Directory.CreateDirectory(_diagnosticsDirectory);
var outPath = System.IO.Path.Combine(
_diagnosticsDirectory,
"n6-surfaces.txt");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"# acdream surface-format histogram — generated {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}");
sb.AppendLine("# Per-entry: surfaceId(hex), width, height, format, byteCount");
sb.AppendLine();
// Walk every cached entry across the 6 caches, dedupe by GL name.
var seen = new HashSet<uint>();
long totalBytes = 0;
var bucketsByDim = new Dictionary<(int W, int H), int>();
var bucketsByFormat = new Dictionary<string, int>();
var bucketsByTriple = new Dictionary<(int W, int H, string F), int>();
void Emit(uint surfaceId, uint name)
{
if (!seen.Add(name)) return;
if (!_uploadMetadata.TryGetValue(name, out var meta)) return;
int bytes = meta.Width * meta.Height * 4;
totalBytes += bytes;
sb.AppendLine($"0x{surfaceId:X8}, {meta.Width}, {meta.Height}, {meta.Format}, {bytes}");
var dimKey = (meta.Width, meta.Height);
bucketsByDim[dimKey] = bucketsByDim.GetValueOrDefault(dimKey) + 1;
bucketsByFormat[meta.Format] = bucketsByFormat.GetValueOrDefault(meta.Format) + 1;
var tripleKey = (meta.Width, meta.Height, meta.Format);
bucketsByTriple[tripleKey] = bucketsByTriple.GetValueOrDefault(tripleKey) + 1;
}
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");
sb.AppendLine($"# Total unique GL textures: {seen.Count}");
sb.AppendLine($"# Total bytes (sum of W*H*4): {totalBytes}");
sb.AppendLine("# Top 10 (W,H) dimension buckets:");
foreach (var kv in bucketsByDim.OrderByDescending(kv => kv.Value).Take(10))
sb.AppendLine($"# {kv.Key.W}x{kv.Key.H}: {kv.Value}");
sb.AppendLine("# Format buckets:");
foreach (var kv in bucketsByFormat.OrderByDescending(kv => kv.Value))
sb.AppendLine($"# {kv.Key}: {kv.Value}");
sb.AppendLine("# Top 10 (W,H,format) triples — atlas-opportunity input:");
foreach (var kv in bucketsByTriple.OrderByDescending(kv => kv.Value).Take(10))
sb.AppendLine($"# {kv.Key.W}x{kv.Key.H} {kv.Key.F}: {kv.Value}");
System.IO.File.WriteAllText(outPath, sb.ToString());
Console.WriteLine($"[N6-DUMP] Surface histogram written to {outPath} ({seen.Count} textures, {totalBytes} bytes)");
}
private DecodedTexture DecodeFromDats(uint surfaceId, uint? origTextureOverride, PaletteOverride? paletteOverride)
{
var surface = _dats.Get<Surface>(surfaceId);
if (surface is null)
{
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-miss] Surface 0x{surfaceId:X8} -> magenta (thread={System.Environment.CurrentManagedThreadId})");
return DecodedTexture.Magenta;
}
// Base1Solid surfaces (and any with OrigTextureId==0) carry a ColorValue
// instead of a texture chain. Overrides are irrelevant here — there's
// no texture chain to swap — so the override is ignored for solid-color
// surfaces. Translucency is honored so Base1Solid|Translucent surfaces
// with Translucency=1.0 become alpha=0, which the mesh shader's discard
// cutout makes invisible.
if (surface.Type.HasFlag(SurfaceType.Base1Solid) || (uint)surface.OrigTextureId == 0)
return SurfaceDecoder.DecodeSolidColor(surface.ColorValue, surface.Translucency);
// Use the override SurfaceTexture id when present, otherwise the
// Surface's native OrigTextureId.
uint surfaceTextureId = origTextureOverride ?? (uint)surface.OrigTextureId;
var surfaceTexture = _dats.Get<SurfaceTexture>(surfaceTextureId);
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
{
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-miss] SurfaceTexture 0x{surfaceTextureId:X8} (surface 0x{surfaceId:X8}) -> magenta (thread={System.Environment.CurrentManagedThreadId})");
return DecodedTexture.Magenta;
}
uint renderSurfaceId = (uint)surfaceTexture.Textures[0];
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
{
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
Console.WriteLine($"[tex-miss] RenderSurface 0x{renderSurfaceId:X8} (surface 0x{surfaceId:X8}) -> magenta (thread={System.Environment.CurrentManagedThreadId})");
return DecodedTexture.Magenta;
}
// Start with the texture's default palette, then apply overlays.
// ACViewer's Render/TextureCache.IndexToColor does the same and never
// consults ObjDesc.BasePaletteId for palette-indexed textures — the
// RenderSurface's own default palette is the starting point.
Palette? basePalette = rs.DefaultPaletteId != 0
? _dats.Get<Palette>(rs.DefaultPaletteId)
: null;
Palette? effectivePalette = basePalette;
if (paletteOverride is not null && basePalette is not null && paletteOverride.SubPalettes.Count > 0)
{
effectivePalette = ComposePalette(basePalette, paletteOverride);
}
// Clipmap surfaces use palette indices 0..7 as transparent sentinels.
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive);
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive);
}
/// <summary>
/// Build a composite palette by copying subpalette ranges into a
/// mutable copy of the base. Ported from ACViewer's
/// Render/TextureCache.IndexToColor, with network-side Offset/Length
/// multiplied by 8 to recover the raw palette-index units (ACE's
/// writer divides by 8 before writing).
/// </summary>
private Palette ComposePalette(Palette basePalette, PaletteOverride paletteOverride)
{
var composed = new Palette();
composed.Colors.AddRange(basePalette.Colors);
foreach (var sp in paletteOverride.SubPalettes)
{
var subPal = _dats.Get<Palette>(sp.SubPaletteId);
if (subPal is null) continue;
int startIdx = sp.Offset * 8;
// Length == 0 is the sentinel for "entire palette" per
// Chorizite.ACProtocol.Types.Subpalette docs. Use a value
// large enough to cover any real palette; we clamp below.
int count = sp.Length == 0 ? 2048 : sp.Length * 8;
for (int j = 0; j < count; j++)
{
int idx = startIdx + j;
if (idx >= composed.Colors.Count || idx >= subPal.Colors.Count)
break;
composed.Colors[idx] = subPal.Colors[idx];
}
}
return composed;
}
/// <summary>Uploads a raw RGBA8 byte array as a Texture2D. Used by
/// <see cref="AcDream.App.UI.IconComposer"/> to upload CPU-composited icon layers.
/// The returned handle is tracked in <see cref="_adhocGpuTextures"/> and deleted by
/// <see cref="Dispose"/>. Callers must NOT also store the handle in any of the
/// keyed caches — that would cause a double-delete on Dispose.</summary>
public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false)
{
GpuUiTextureEntry entry = UploadUiTexture(
new DecodedTexture(rgba, width, height), nearest, "ui-adhoc-rgba8");
_adhocGpuTextures.Add(entry);
return entry.GlName;
}
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)
{
uint tex = _gl.GenTexture();
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);
// 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}");
TrackUploadedTexture(tex, decoded.Width, decoded.Height);
return tex;
}
catch
{
_gl.DeleteTexture(tex);
throw;
}
finally
{
_gl.BindTexture(TextureTarget.Texture2D, 0);
}
}
/// <summary>
/// Variant of <see cref="UploadRgba8"/> that uploads pixel data as a 1-layer
/// Texture2DArray. Required by the WB modern rendering path which samples via
/// sampler2DArray in its bindless shader. Pixel data is identical.
/// </summary>
private uint UploadRgba8AsLayer1Array(DecodedTexture decoded)
{
uint tex = _gl.GenTexture();
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);
_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}");
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}");
UntrackUploadedTexture(name);
}
/// <summary>
/// Memory-tracking bookkeeping only, without a raw GL delete — used for
/// the Campaign V slice V4a UI-path <see cref="IGpuTexture"/> entries,
/// whose GL name is released by <see cref="IGpuTexture.Dispose"/> through
/// the device's own retirement queue rather than by
/// <see cref="DeleteUploadedTexture"/>.
/// </summary>
private void UntrackUploadedTexture(uint 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()
{
// 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();
_paletteIndexedByTexture.Clear();
// Legacy Texture2D textures.
foreach (var entry in _surfacesById.Values)
DeleteUploadedTexture(entry.Handle);
_surfacesById.Clear();
if (_magentaHandle != 0)
{
DeleteUploadedTexture(_magentaHandle);
_magentaHandle = 0;
}
// RenderSurface (UI sprite) textures — Campaign V slice V4a: each
// entry's IGpuTexture.Dispose() releases the underlying GL name
// through the device's own retirement queue, so only the memory-
// tracking bookkeeping and the registered slot need releasing here.
foreach (GpuUiTextureEntry entry in _renderSurfaceGpuTextures.Values)
{
entry.Texture.Dispose();
_device.ReleaseTextureSlot(entry.Slot);
UntrackUploadedTexture(entry.GlName);
}
_renderSurfaceGpuTextures.Clear();
// Ad-hoc textures from the public UploadRgba8(byte[],int,int,bool) wrapper
// (IconComposer composited icons). Not stored in any keyed cache.
foreach (GpuUiTextureEntry entry in _adhocGpuTextures)
{
entry.Texture.Dispose();
_device.ReleaseTextureSlot(entry.Slot);
UntrackUploadedTexture(entry.GlName);
}
_adhocGpuTextures.Clear();
}
}