feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend

Vulkan is the sole, user-signed-off backend (V10 landed) and step 1
already removed ImGui/Studio/DevTools. This step deletes the GL
rendering backend itself: every Gpu/Gl/** implementation, the Wb
ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/
BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache,
RenderBootstrap, and RenderFrameGlStateController.

GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/
OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses
its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and
RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone —
there is nothing left to select between. The five world-draw dual-arm
renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer,
ParticleRenderer, SkyRenderer) and the composition roots
(WorldRenderComposition, HostInputCameraComposition,
LivePresentationComposition, FrameRootComposition) collapse to their
RHI-only arm. GL-only diagnostic properties with a live external reader
(DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op
rather than disappearing, since the reader is out of this commit's
scope.

A few GL-flavored mechanisms turned out to be backend-neutral once
isolated: GlConstructionCleanupLedger is renamed
ResourceConstructionCleanupLedger (exception-chain walking has nothing
to do with GL), and GlfwNativePlatformProbe moved out of the otherwise
GL-only GraphicalCapabilityRecord.cs into
GraphicalWindowBackendSelection.cs before the rest of that file was
deleted.

Test files with no surviving subject are deleted outright
(GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests,
PortalDepthShaderParityTests, TextureCacheBindlessTests,
TextRendererFailureSafetyTests, ClipFrameUploadTests, every
Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests);
others get their dead GL-only members trimmed while their live
assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now
reads GpuBindingModel.StorageClipRegions, the same binding index under
its new backend-neutral name; GpuResourceRetirementTransactionTests
drops its OpenGLGraphicsDevice-subclassing test double and the two GL
queue tests it existed for). EnvCellRendererTests' construction helper
now builds a real ObjectMeshManager via VulkanMeshPipelineDevice
instead of passing null through a null-forgiving operator, since the
RHI constructor never tolerated a null mesh manager and the old GL
constructor (which did) is gone.

Deferred to the next two steps, deliberately not touched here: the
Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl
(WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale
csproj comment (the package itself is still load-bearing —
TextureFormat and friends are used well beyond the deleted
ManagedGLUniformBuffer), and the CI/gate scripts.

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors.
Tests: full-solution `dotnet test` green across every project
(App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all
others 100%); the 2 App.Tests names that flake under full-suite
parallel execution (#250-family, documented pre-existing) pass in
isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 02:19:53 +02:00
parent b70b9832ff
commit 8a7a0837e1
121 changed files with 1243 additions and 19840 deletions

View file

@ -3,10 +3,8 @@ 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;
@ -14,22 +12,15 @@ using AcDream.App.Rendering.Residency;
namespace AcDream.App.Rendering;
public sealed unsafe class TextureCache
public sealed 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
@ -55,7 +46,6 @@ public sealed unsafe class TextureCache
// 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;
@ -105,12 +95,10 @@ public sealed unsafe class TextureCache
// 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)
internal TextureCache(IGpuDevice device, IDatReaderWriter dats)
: this(
gl,
device,
dats,
bindless,
ImmediateGpuResourceRetirementQueue.Instance,
Path.Combine(
Path.GetTempPath(),
@ -119,142 +107,57 @@ public sealed unsafe class TextureCache
{
}
/// <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.
var resources = new ResourceCleanupGroup();
CompositeTextureArrayCache? composite = null;
StandaloneBindlessTextureCache? particles = null;
try
{
// 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;
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);
@ -286,44 +189,6 @@ public sealed unsafe class TextureCache
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
@ -491,20 +356,12 @@ public sealed unsafe class TextureCache
}
/// <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.
/// The identity a UI upload is accounted under. There is no GL name on the
/// Vulkan-only backend, so a descending synthetic counter supplies one; 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 UploadAccountingName(IGpuTexture texture) => _nextSyntheticUploadName--;
private uint _nextSyntheticUploadName = uint.MaxValue;
@ -521,44 +378,6 @@ public sealed unsafe class TextureCache
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
@ -588,81 +407,17 @@ public sealed unsafe class TextureCache
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;
}
return AcquireParticleTextureRhi(textures, ownerId, surfaceId, decoded);
}
/// <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.
/// slot, owned by the same emitter-scoped cache.
///
/// <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>
/// <para>Linear/clamped matches the filtering the deleted GL arm's own
/// one-layer array upload set 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,
@ -714,8 +469,9 @@ public sealed unsafe class TextureCache
/// 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.
/// composites are shared until their final live owner leaves. Returns
/// <see langword="default"/> (an empty location) if a composite upload
/// can't start or the decoded size can't be prepared this frame.
/// </summary>
internal BindlessTextureLocation GetOrUploadWithOrigTextureOverrideBindless(
uint ownerLocalId,
@ -750,7 +506,8 @@ public sealed unsafe class TextureCache
/// 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.
/// Returns <see langword="default"/> (an empty location) if a composite
/// upload can't start or the decoded size can't be prepared this frame.
/// </summary>
internal BindlessTextureLocation GetOrUploadWithPaletteOverrideBindless(
uint ownerLocalId,
@ -853,14 +610,6 @@ public sealed unsafe class TextureCache
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
@ -880,29 +629,9 @@ public sealed unsafe class TextureCache
_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
/// Campaign V slice V6l: the table slot is released first and the image
/// second — 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)
@ -1043,7 +772,6 @@ public sealed unsafe class TextureCache
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) =>
{
@ -1191,101 +919,6 @@ public sealed unsafe class TextureCache
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");
@ -1294,19 +927,10 @@ public sealed unsafe class TextureCache
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"/>.
/// Memory-tracking bookkeeping only — used for every <see cref="IGpuTexture"/>
/// entry, whose GPU resource is released by <see cref="IGpuTexture.Dispose"/>
/// through the device's own retirement queue.
/// </summary>
private void UntrackUploadedTexture(uint name)
{
@ -1328,17 +952,6 @@ public sealed unsafe class TextureCache
_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-