feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache UI path onto IGpuDevice
Second attempt at V4a afterceec3bc4was reverted at9aaf97e7for 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, parenta97e04aevs 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>
This commit is contained in:
parent
a97e04ae3d
commit
096dd203fa
26 changed files with 954 additions and 467 deletions
|
|
@ -2,6 +2,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;
|
||||
|
|
@ -17,6 +19,7 @@ public sealed unsafe class TextureCache
|
|||
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
|
||||
|
|
@ -28,17 +31,29 @@ public sealed unsafe class TextureCache
|
|||
_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, uint> _handlesByRenderSurfaceId = new();
|
||||
private readonly Dictionary<uint, (int w, int h)> _rsSizeById = new();
|
||||
private readonly Dictionary<uint, GpuUiTextureEntry> _renderSurfaceGpuTextures = new();
|
||||
|
||||
// Ad-hoc handles produced by the public UploadRgba8(byte[],int,int,bool) wrapper
|
||||
// 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
|
||||
// GL texture objects until process exit.
|
||||
private readonly List<uint> _adhocHandles = new();
|
||||
// GPU texture objects/slots until process exit.
|
||||
private readonly List<GpuUiTextureEntry> _adhocGpuTextures = new();
|
||||
|
||||
private readonly Wb.BindlessSupport? _bindless;
|
||||
private readonly CompositeTextureArrayCache? _compositeTextures;
|
||||
|
|
@ -86,9 +101,14 @@ public sealed unsafe class TextureCache
|
|||
private int _dumpFrameCounter;
|
||||
private bool _surfaceHistogramAlreadyDumped;
|
||||
|
||||
public TextureCache(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
|
||||
// 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,
|
||||
|
|
@ -101,6 +121,7 @@ public sealed unsafe class TextureCache
|
|||
|
||||
internal TextureCache(
|
||||
GL gl,
|
||||
IGpuDevice device,
|
||||
IDatReaderWriter dats,
|
||||
Wb.BindlessSupport? bindless,
|
||||
IGpuResourceRetirementQueue retirementQueue,
|
||||
|
|
@ -109,6 +130,7 @@ public sealed unsafe class TextureCache
|
|||
{
|
||||
budgets ??= ResidencyBudgetOptions.Default;
|
||||
_gl = gl;
|
||||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||
_dats = dats;
|
||||
_bindless = bindless;
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(diagnosticsDirectory);
|
||||
|
|
@ -231,11 +253,10 @@ public sealed unsafe class TextureCache
|
|||
/// </summary>
|
||||
public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false)
|
||||
{
|
||||
if (_handlesByRenderSurfaceId.TryGetValue(renderSurfaceId, out var existing)
|
||||
&& _rsSizeById.TryGetValue(renderSurfaceId, out var sz))
|
||||
if (_renderSurfaceGpuTextures.TryGetValue(renderSurfaceId, out GpuUiTextureEntry existing))
|
||||
{
|
||||
width = sz.w; height = sz.h;
|
||||
return existing;
|
||||
width = existing.Width; height = existing.Height;
|
||||
return existing.GlName;
|
||||
}
|
||||
|
||||
DecodedTexture decoded;
|
||||
|
|
@ -256,11 +277,62 @@ public sealed unsafe class TextureCache
|
|||
decoded = DecodedTexture.Magenta;
|
||||
}
|
||||
|
||||
uint h = UploadRgba8(decoded, nearest);
|
||||
_handlesByRenderSurfaceId[renderSurfaceId] = h;
|
||||
_rsSizeById[renderSurfaceId] = (decoded.Width, decoded.Height);
|
||||
GpuUiTextureEntry entry = UploadUiTexture(decoded, nearest, $"ui-rendersurface-0x{renderSurfaceId:X8}");
|
||||
_renderSurfaceGpuTextures[renderSurfaceId] = entry;
|
||||
width = decoded.Width; height = decoded.Height;
|
||||
return h;
|
||||
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>
|
||||
|
|
@ -815,14 +887,15 @@ public sealed unsafe class TextureCache
|
|||
|
||||
/// <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="_adhocHandles"/> and deleted by
|
||||
/// 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)
|
||||
{
|
||||
uint h = UploadRgba8(new DecodedTexture(rgba, width, height), nearest);
|
||||
_adhocHandles.Add(h);
|
||||
return h;
|
||||
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)
|
||||
|
|
@ -932,7 +1005,18 @@ public sealed unsafe class TextureCache
|
|||
{
|
||||
_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);
|
||||
|
|
@ -962,16 +1046,26 @@ public sealed unsafe class TextureCache
|
|||
_magentaHandle = 0;
|
||||
}
|
||||
|
||||
// RenderSurface (UI sprite) handles — pre-existing gap: this dict was populated
|
||||
// by GetOrUploadRenderSurface but was not swept here before this fix.
|
||||
foreach (var h in _handlesByRenderSurfaceId.Values)
|
||||
DeleteUploadedTexture(h);
|
||||
_handlesByRenderSurfaceId.Clear();
|
||||
// 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 handles from the public UploadRgba8(byte[],int,int,bool) wrapper
|
||||
// Ad-hoc textures from the public UploadRgba8(byte[],int,int,bool) wrapper
|
||||
// (IconComposer composited icons). Not stored in any keyed cache.
|
||||
foreach (var h in _adhocHandles)
|
||||
DeleteUploadedTexture(h);
|
||||
_adhocHandles.Clear();
|
||||
foreach (GpuUiTextureEntry entry in _adhocGpuTextures)
|
||||
{
|
||||
entry.Texture.Dispose();
|
||||
_device.ReleaseTextureSlot(entry.Slot);
|
||||
UntrackUploadedTexture(entry.GlName);
|
||||
}
|
||||
_adhocGpuTextures.Clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue