Amendment 3 of three: the paperdoll and creature-appraisal views render on the
Vulkan arm. Plan section 5.5.16 defect 3 named two backend fixes as the
precondition; both are here, and running it found two more the note could not
have known about.
Fix 1: a layered sampled view per render target. An ATTACHMENT view must be
VK_IMAGE_VIEW_TYPE_2D and the global texture table's descriptor array is
sampler2DArray, so the attachment view cannot legally be registered into it -
section 5.5.7 recorded that as invalid usage rather than a mismatch that samples
oddly, and V6k made RegisterTexture refuse it loudly and name this fix.
VulkanGpuTexture now creates a SECOND, layered view over the same image for a
colour render target: one image, one allocation, two ways of looking at it,
legal without any creation flag. SampledView is what the table registers for
every texture, so the question disappears rather than being answered.
Fix 2: sample-count pipeline variants for WbDrawDispatcher. Vulkan requires a
pipeline's rasterizationSamples to equal the pass it draws in, and this
dispatcher draws in two passes with different counts - the multisampled
backbuffer world pass and the single-sampled offscreen target, which the
contract fixes at one sample. Its five pipelines became a MeshPipelineSet with
two instances, selected at bind time from the live pass rather than from the
scope, which is the same shape section 5.5.8 gave the depth-format problem. When
the backbuffer is single-sampled the two sets are one object, so nothing is
built twice and nothing is freed twice. The offscreen target's DEPTH attachment
also had to take the device's own combined depth/stencil format rather than the
contract enum's literal D24_UNORM_S8_UINT: a pipeline bakes one depth/stencil
format under dynamic rendering and the same pipelines draw in both passes, so a
second format would make one of the two undefined.
Fix 3, which running it found: entity APPEARANCE composites were still
bindless-only, so no entity with a palette override could be drawn on the Vulkan
arm at all - the doll being one, and every creature and player besides. The
backend that serves it has existed since V6i-2 and had no production consumer;
it has one now. TextureCache builds the composite cache on both arms, and
EnsureCompositeTexturesAvailable stops asking about bindless. Nothing about the
cache itself changed: the sharing, the bounded unowned LRU, the metered upload
budget and the retirement fence were already backend-neutral.
Fix 4, which the first successful capture found: the doll rendered upside down.
UiViewport has flipped V since V4a because a GL framebuffer's origin is
bottom-left, so its colour texture samples bottom-up. A Vulkan image's origin is
top-left and the backend's negative viewport height stores the rendered image
that way round, so the same flip stands the doll on its head. That is a property
of the backend that made the texture, not of the widget that draws it, so
IUiViewportRenderer answers TextureIsBottomUp and UiViewport asks. The line this
replaces had predicted exactly this failure since it was written.
The seam. WbDrawDispatcher's RHI arm borrows its pass from IWorldPassScope
rather than opening one, so a viewport that opens a pass of its own has to
publish it there for the span of the draw. Publish is on the interface now for
that. It does not nest: the world phase has closed its own pass by the time
private presentation runs, which is where these viewports have always drawn.
Gates. Release build green. App tests 4,129/3 skips; complete Release suite
9,192/5 (one solution-wide run reported a single App failure that did not
reproduce in the App suite alone or in a second solution-wide run - the
documented rerun-singly flake class; the failing test name was not surfaced by
the runner and is not carried forward as a claim). Strict GL offline pixel gate
against 08ffe141: 3.55e-05, 20 differing pixels of 563,200, inside the
documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the desktop witness
and 3/3 on the client capture. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings.
And the two captures the offline gate cannot reach, both connected and both
inspected. The Vulkan paperdoll (artifacts/v6l-vk-paperdoll3) renders the doll
upright, in armour, at the right scale, over a transparent background, and is
indistinguishable from the same capture on GL taken minutes later
(artifacts/v6l-gl-paperdoll) - which is also the no-regression check for the V
change. Particles (artifacts/v6l-vk-poi versus artifacts/v6l-gl-poi, cropped
4x at artifacts/crop-vk-glow.png and crop-gl-glow.png): Holtburg's forge plume
and its field of glint sprites draw in the same places with the same alpha
compositing on both backends, the puffs differing only in phase because two
launches cannot agree on an emitter's age.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1375 lines
61 KiB
C#
1375 lines
61 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"))
|
|
{
|
|
}
|
|
|
|
/// <param name="gl">
|
|
/// The GL context, or null on a backend that has none. Campaign V slice V6h:
|
|
/// the UI path (<see cref="GetOrUploadRenderSurface"/>, <see cref="UploadRgba8"/>)
|
|
/// is entirely <see cref="IGpuDevice"/>-driven and runs on either backend,
|
|
/// while the world paths — the legacy <c>Texture2D</c> upload, particle
|
|
/// arrays, and the composite/bindless caches — still speak raw GL and are
|
|
/// unreachable without it. A null context therefore reaches exactly the same
|
|
/// code a null <paramref name="bindless"/> already gated, and every world
|
|
/// entry point throws with the slice that owns it named. Removed at V4t,
|
|
/// which ports the world texture stack onto the RHI.
|
|
/// </param>
|
|
internal TextureCache(
|
|
GL? gl,
|
|
IGpuDevice device,
|
|
IDatReaderWriter dats,
|
|
Wb.BindlessSupport? bindless,
|
|
IGpuResourceRetirementQueue retirementQueue,
|
|
string diagnosticsDirectory,
|
|
ResidencyBudgetOptions? budgets = null)
|
|
{
|
|
budgets ??= ResidencyBudgetOptions.Default;
|
|
_gl = gl;
|
|
if (gl is null && bindless is not null)
|
|
{
|
|
throw new ArgumentException(
|
|
"Bindless composite/particle texture caches require a GL context.",
|
|
nameof(bindless));
|
|
}
|
|
|
|
_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,
|
|
WorldDevice,
|
|
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;
|
|
}
|
|
else
|
|
{
|
|
// Campaign V slice V6l: both owner-scoped caches exist on both arms.
|
|
//
|
|
// Everything about them that matters — sharing equivalent surfaces
|
|
// between owners, the bounded unowned LRU, the metered upload budget,
|
|
// and retirement behind the frame-flight fence — is already
|
|
// backend-neutral; only how one entry is created and destroyed
|
|
// differs, which is exactly what the two backend interfaces are for.
|
|
// RhiCompositeTextureArrayBackend is V6i-2's, built and exercised at
|
|
// startup since that slice but with no production consumer until now;
|
|
// ParticleRhiTextureBackend is this slice's.
|
|
var resources = new ResourceCleanupGroup();
|
|
CompositeTextureArrayCache? composite = null;
|
|
StandaloneBindlessTextureCache? particles = null;
|
|
try
|
|
{
|
|
composite = new CompositeTextureArrayCache(
|
|
new RhiCompositeTextureArrayBackend(device),
|
|
retirementQueue,
|
|
budgets.CompositeUnownedBytes,
|
|
budgets.CompositePhysicalBytes);
|
|
resources.Add("composite texture cache", composite.Dispose);
|
|
particles = new StandaloneBindlessTextureCache(
|
|
new ParticleRhiTextureBackend(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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The GL context the world texture paths need. Campaign V slice V6h: a
|
|
/// Vulkan-composed cache serves the UI path through <see cref="IGpuDevice"/>
|
|
/// alone and never reaches here, so a failure names the slice that owns the
|
|
/// port rather than dereferencing null.
|
|
/// </summary>
|
|
private GL Gl => _gl ?? throw new InvalidOperationException(
|
|
"This TextureCache owns no GL context: the world texture paths " +
|
|
"(Texture2D upload, particle arrays, composite/bindless caches) are " +
|
|
"unavailable until Campaign V slice V4t ports them onto the RHI.");
|
|
|
|
/// <summary>
|
|
/// The GL backend's device, for the world texture paths' table
|
|
/// registrations (Campaign V slice V4t). Those paths already require a GL
|
|
/// context — see <see cref="Gl"/> — so the same construction that makes
|
|
/// <see cref="_gl"/> non-null makes this cast sound; a Vulkan-composed
|
|
/// cache serves only the UI path through <see cref="IGpuDevice"/> and never
|
|
/// reaches here.
|
|
/// </summary>
|
|
private GlGpuDevice WorldDevice => _device as GlGpuDevice
|
|
?? throw new InvalidOperationException(
|
|
"This TextureCache's device is not the GL backend's: the world " +
|
|
"texture paths intern their bindless handles into GlGpuDevice's " +
|
|
"texture table (Campaign V slice V4t).");
|
|
|
|
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.
|
|
///
|
|
/// <para>Campaign V slice V6d: the returned value is a
|
|
/// <see cref="UiTextureTableHandle"/> — a one-based index into the device's
|
|
/// global texture table — not a raw GL texture name. Every caller passes it
|
|
/// straight to <see cref="TextRenderer.DrawSprite"/>, which samples the
|
|
/// table; nothing reads it as a GL name, and on Vulkan there is no GL name.
|
|
/// Zero still means "no texture", which is what every widget guards on.</para>
|
|
/// </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 UiTextureTableHandle.FromSlot(existing.Slot);
|
|
}
|
|
|
|
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 UiTextureTableHandle.FromSlot(entry.Slot);
|
|
}
|
|
|
|
/// <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).
|
|
///
|
|
/// <para>Campaign V slice V6d: the sampler is now what filtering actually
|
|
/// comes from. Before this slice the draw bound the texture object directly,
|
|
/// so filtering lived on the texture and <paramref name="nearest"/> was
|
|
/// applied with a raw <c>glTexParameter</c> before the bindless handle was
|
|
/// made resident. Sampling through the table means a bound sampler object
|
|
/// overrides those parameters, so a nearest-requested sprite has to be
|
|
/// registered with a nearest SAMPLER or every retail icon and dat-font
|
|
/// glyph would silently become bilinear.</para>
|
|
/// </summary>
|
|
/// <summary>
|
|
/// Campaign V slice V6k: one world Surface as a device texture-table slot,
|
|
/// sampled with the wrap mode the caller needs.
|
|
///
|
|
/// <para>The sky's RHI arm is the only consumer, and it exists because that
|
|
/// arm has no GL texture name to intern a bindless handle from — a Vulkan
|
|
/// draw cannot sample a GL handle. The decode is the same
|
|
/// <see cref="DecodeFromDats"/> the GL path uses, so the pixels are
|
|
/// identical; what differs is that the image is created through
|
|
/// <see cref="IGpuDevice.CreateTexture"/> and paired with a real sampler
|
|
/// object rather than baked into a handle.</para>
|
|
///
|
|
/// <para>Keyed by (surface, wrap) for the same reason the GL arm keys its
|
|
/// handles that way: a table entry is a combined image sampler, so the dome
|
|
/// sampled CLAMP_TO_EDGE and a scrolling cloud sheet sampled REPEAT are two
|
|
/// entries even when they name one decoded texture.</para>
|
|
/// </summary>
|
|
internal GpuTextureSlot RegisterWorldSurface(uint surfaceId, bool repeat)
|
|
{
|
|
var key = (surfaceId, repeat);
|
|
if (_worldSurfaceGpuTextures.TryGetValue(key, out GpuUiTextureEntry existing))
|
|
return existing.Slot;
|
|
|
|
DecodedTexture decoded = DecodeFromDats(
|
|
surfaceId,
|
|
origTextureOverride: null,
|
|
paletteOverride: null);
|
|
GpuUiTextureEntry entry = UploadWorldSurfaceTexture(
|
|
decoded,
|
|
repeat,
|
|
$"world-surface-0x{surfaceId:X8}{(repeat ? "-repeat" : "-clamp")}");
|
|
_worldSurfaceGpuTextures[key] = entry;
|
|
return entry.Slot;
|
|
}
|
|
|
|
private readonly Dictionary<(uint SurfaceId, bool Repeat), GpuUiTextureEntry>
|
|
_worldSurfaceGpuTextures = new();
|
|
|
|
private GpuUiTextureEntry UploadWorldSurfaceTexture(
|
|
DecodedTexture decoded,
|
|
bool repeat,
|
|
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 = UploadAccountingName(texture);
|
|
TrackUploadedTexture(glName, decoded.Width, decoded.Height);
|
|
|
|
// Linear/linear with a single level — the filtering
|
|
// TextureCache's own GL uploads have always used for sky surfaces,
|
|
// and the wrap mode SamplerCache's two objects express on GL.
|
|
IGpuSampler sampler = _device.CreateSampler(
|
|
repeat ? GpuSamplerDescription.WorldRepeat : GpuSamplerDescription.WorldClamp);
|
|
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
|
|
return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height);
|
|
}
|
|
catch
|
|
{
|
|
texture.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
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 = UploadAccountingName(texture);
|
|
TrackUploadedTexture(glName, decoded.Width, decoded.Height);
|
|
|
|
IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat);
|
|
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
|
|
return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height);
|
|
}
|
|
catch
|
|
{
|
|
texture.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The identity a UI upload is accounted under. On GL it is the texture's
|
|
/// own GL name — unchanged, so the VRAM ledger and the
|
|
/// <c>ACDREAM_DUMP_SURFACES</c> histogram key off exactly what they always
|
|
/// did. On any other backend there is no such name, so a descending
|
|
/// synthetic counter supplies one; it starts at <c>uint.MaxValue</c> because
|
|
/// GL hands out small ascending names and the two spaces share the
|
|
/// <c>_uploadMetadata</c> dictionary. The value is a dictionary key and a
|
|
/// dedup token only — Campaign V slice V6d removed the last draw-time
|
|
/// consumer of a raw GL name, so nothing binds it.
|
|
/// </summary>
|
|
private uint UploadAccountingName(IGpuTexture texture) =>
|
|
texture is GlGpuTexture glTexture
|
|
? glTexture.GlName
|
|
: _nextSyntheticUploadName--;
|
|
|
|
private uint _nextSyntheticUploadName = uint.MaxValue;
|
|
|
|
/// <summary>
|
|
/// Point sampling with REPEAT addressing — pixel-exact retail UI art that is
|
|
/// still tiled by nine-slice chrome and meter tracks. Neither stock preset
|
|
/// fits: <c>UiNearest</c> clamps, <c>WorldRepeat</c> filters.
|
|
/// </summary>
|
|
private static readonly GpuSamplerDescription UiNearestRepeat = new(
|
|
GpuFilter.Nearest,
|
|
GpuFilter.Nearest,
|
|
GpuMipFilter.None,
|
|
GpuAddressMode.Repeat,
|
|
GpuAddressMode.Repeat,
|
|
MaxAnisotropy: 1f);
|
|
|
|
/// <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"/>.
|
|
///
|
|
/// <para>Campaign V slice V4t: returns the device texture-table
|
|
/// <see cref="GpuTextureSlot"/> rather than the raw bindless handle. The
|
|
/// handle is still created, made resident and destroyed here — only the
|
|
/// table entry belongs to the device.</para>
|
|
/// </summary>
|
|
internal GpuTextureSlot 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.Slot;
|
|
}
|
|
|
|
DecodedTexture decoded = DecodeFromDats(
|
|
surfaceId,
|
|
origTextureOverride: null,
|
|
paletteOverride: null);
|
|
|
|
// Campaign V slice V6l: the RHI arm has no GL name to intern a bindless
|
|
// handle from, so the image is created through IGpuDevice and paired
|
|
// with a real sampler object. The decode above is the same one the GL
|
|
// arm uses, so the pixels are identical; the shader still samples layer
|
|
// zero of a one-layer array, which is what a Texture2D registered into
|
|
// the table is on Vulkan (VulkanTextureFormatMapping.SampledViewTypeOf).
|
|
if (_gl is null)
|
|
return AcquireParticleTextureRhi(textures, ownerId, surfaceId, decoded);
|
|
|
|
uint name = UploadRgba8AsLayer1Array(decoded);
|
|
ulong handle = 0;
|
|
try
|
|
{
|
|
handle = _bindless!.GetResidentHandle(name);
|
|
Wb.GLHelpers.ThrowOnResourceError(
|
|
Gl,
|
|
$"making particle surface 0x{surfaceId:X8} resident");
|
|
GpuTextureSlot slot = WorldDevice.RegisterWorldTextureHandle(handle);
|
|
var resource = new StandaloneBindlessTextureResource
|
|
{
|
|
SurfaceId = surfaceId,
|
|
Name = name,
|
|
Handle = handle,
|
|
Slot = slot,
|
|
Bytes = checked((long)decoded.Width * decoded.Height * 4L),
|
|
};
|
|
textures.AddAndAcquire(ownerId, resource);
|
|
return slot;
|
|
}
|
|
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(() =>
|
|
{
|
|
// Slice V4t: the table entry may or may not have been made
|
|
// before the failure. Releasing an unregistered handle is a
|
|
// no-op, so this covers both without asking which.
|
|
WorldDevice.ReleaseWorldTextureHandle(handle);
|
|
_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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6l: one particle surface as a device texture-table
|
|
/// slot, owned by the same emitter-scoped cache the GL arm uses.
|
|
///
|
|
/// <para>Linear/clamped is the filtering the GL arm's own one-layer array
|
|
/// upload sets on itself, and a particle sheet's UVs never leave [0,1] —
|
|
/// the quad's own texcoords are the unit square — so the wrap mode is not a
|
|
/// visible choice, it is just the safe one.</para>
|
|
/// </summary>
|
|
private GpuTextureSlot AcquireParticleTextureRhi(
|
|
StandaloneBindlessTextureCache textures,
|
|
uint ownerId,
|
|
uint surfaceId,
|
|
DecodedTexture decoded)
|
|
{
|
|
IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription(
|
|
$"particle-surface-0x{surfaceId:X8}",
|
|
GpuTextureKind.Texture2D,
|
|
GpuTextureFormat.Rgba8Unorm,
|
|
Width: decoded.Width,
|
|
Height: decoded.Height,
|
|
LayerCount: 1,
|
|
MipLevelCount: 1));
|
|
try
|
|
{
|
|
texture.Upload(0, 0, decoded.Rgba8);
|
|
uint accountingName = UploadAccountingName(texture);
|
|
TrackUploadedTexture(accountingName, decoded.Width, decoded.Height);
|
|
|
|
IGpuSampler sampler = _device.CreateSampler(GpuSamplerDescription.WorldClamp);
|
|
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
|
|
textures.AddAndAcquire(ownerId, new StandaloneBindlessTextureResource
|
|
{
|
|
SurfaceId = surfaceId,
|
|
Name = accountingName,
|
|
Texture = texture,
|
|
Slot = slot,
|
|
Bytes = checked((long)decoded.Width * decoded.Height * 4L),
|
|
});
|
|
return slot;
|
|
}
|
|
catch
|
|
{
|
|
texture.Dispose();
|
|
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).");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6l: no longer gated on bindless. The composite cache is
|
|
/// constructed on both arms — V6i-2's RHI backend is what serves the one
|
|
/// without a GL context — so the only failure left is a cache that was never
|
|
/// built at all.
|
|
/// </summary>
|
|
private CompositeTextureArrayCache EnsureCompositeTexturesAvailable() =>
|
|
_compositeTextures ?? throw new InvalidOperationException(
|
|
"This TextureCache owns no composite texture array cache.");
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6l: no longer gated on bindless. The particle cache is
|
|
/// constructed on both arms, so the only failure left is a cache that was
|
|
/// never built at all.
|
|
/// </summary>
|
|
private StandaloneBindlessTextureCache EnsureParticleTexturesAvailable() =>
|
|
_particleTextures ?? throw new InvalidOperationException(
|
|
"This TextureCache owns no standalone particle texture cache.");
|
|
|
|
private sealed class ParticleTextureBackend(TextureCache owner)
|
|
: IStandaloneBindlessTextureBackend
|
|
{
|
|
public void MakeNonResident(StandaloneBindlessTextureResource resource)
|
|
{
|
|
// Slice V4t: retire the table entry before its handle stops being
|
|
// resident. Idempotent, so a retried release stays correct.
|
|
owner.WorldDevice.ReleaseWorldTextureHandle(resource.Handle);
|
|
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>
|
|
/// Campaign V slice V6l: the same ownership boundary on a backend with no
|
|
/// bindless handles. The table slot is released first and the image second,
|
|
/// which is the same order the GL arm uses and for the same reason — a
|
|
/// submitted-but-unretired frame may still sample the slot, and
|
|
/// <see cref="IGpuDevice.ReleaseTextureSlot"/> is what defers its reuse.
|
|
/// </summary>
|
|
private sealed class ParticleRhiTextureBackend(TextureCache owner)
|
|
: IStandaloneBindlessTextureBackend
|
|
{
|
|
public void MakeNonResident(StandaloneBindlessTextureResource resource)
|
|
{
|
|
if (resource.Slot.IsAssigned)
|
|
owner._device.ReleaseTextureSlot(resource.Slot);
|
|
}
|
|
|
|
public void Delete(StandaloneBindlessTextureResource resource)
|
|
{
|
|
resource.Texture?.Dispose();
|
|
owner.UntrackUploadedTexture(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 >= 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 >= 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 texture is tracked in <see cref="_adhocGpuTextures"/> and deleted by
|
|
/// <see cref="Dispose"/>. Callers must NOT also store the returned handle in any
|
|
/// of the keyed caches — that would cause a double-delete on Dispose.
|
|
///
|
|
/// <para>Campaign V slice V6d: returns a <see cref="UiTextureTableHandle"/>
|
|
/// rather than a GL texture name, for the reason given on
|
|
/// <see cref="GetOrUploadRenderSurface"/>.</para>
|
|
/// </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 UiTextureTableHandle.FromSlot(entry.Slot);
|
|
}
|
|
|
|
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();
|
|
|
|
// Campaign V slice V6k: world Surface textures created through the RHI
|
|
// for the sky's backend-neutral arm. Same ownership shape as the UI
|
|
// entries above — the device retires the image, this releases the slot.
|
|
foreach (GpuUiTextureEntry entry in _worldSurfaceGpuTextures.Values)
|
|
{
|
|
entry.Texture.Dispose();
|
|
_device.ReleaseTextureSlot(entry.Slot);
|
|
UntrackUploadedTexture(entry.GlName);
|
|
}
|
|
_worldSurfaceGpuTextures.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();
|
|
}
|
|
}
|