feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache onto IGpuDevice

TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.

Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.

Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):

- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
  The cache assumes it is the sole writer of GL program/blend/depth/cull
  state, which was true while it had zero real consumers, but every
  still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
  mutates that same GL state directly and never informs the cache. Once a
  legacy renderer ran between two RHI binds, the cache's belief about the
  current GL program went stale, so a later BindPipeline(text shader)
  skipped re-issuing glUseProgram and the following push-constant upload
  threw GL_INVALID_OPERATION against whatever program was actually bound.
  Reset() at the frame boundary is the same defensive move BeginPass
  already makes after a forced clear (see its comment); it costs one
  redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
  GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
  computed from GpuPipelineDescription.SampleCount at BindPipeline time -
  mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
  toggle.

Collateral, scoped to keep the port real rather than a stub:

- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
  every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
  TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
  Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
  every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
  check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
  slot (the device's default white texture), so the old sentinel would
  have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
  public AcDream.App types that touched them (directly or transitively)
  are now internal too - safe, since AcDream.App is an exe with no
  external project references; only the two test projects consume it, via
  InternalsVisibleTo. A handful of unrelated types the sweep caught
  (ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
  as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
  were reverted back to public where making them internal would have
  either cascaded into unrelated files or broken xUnit's public-member
  discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
  color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
  produce (V4g's scope) into the device's texture table for
  UiViewport.TextureHandle, via a temporary
  GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
  part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
  now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
  conformance tests keyed to TextRenderer's old multi-resource
  construction shape (Shader + per-flight FrameBufferSet array + white
  texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
  - that shape is gone, replaced by one IGpuPipeline created through
    IGpuDevice. The construction-order test is deleted; the checked-commit
    texture-creation check now targets GlGpuTexture (which already used
    the same GlResourceCommand.CreateName primitive before this slice).

Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
  TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
  skipped (was 3,843/3 entering this slice - net 3 fewer tests:
  TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
  TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
  method). Full solution: 8,908 passed / 5 skipped across all nine test
  projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
  vs this commit): differing fraction 0.318% (1,791/563,200 compared
  pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
  than waved through: a diff heatmap plus 4x crops at the differing
  clusters show zero differences anywhere in the retained UI, terrain,
  scenery, or static meshes - every differing pixel sits on continuously-
  animated ambient content (flying-insect sprites over the swamp, foliage
  sparkle/dew glints) whose exact phase depends on elapsed wall-clock
  time, the same category the gate's own sky-masking rationale already
  documents and the campaign doc's coverage table explicitly excludes
  ("Not covered - particles"). Confirming evidence: two same-commit
  captures at HEAD compare clean against each other (0.0025%), and two
  same-commit captures at the parent compare clean against each other
  (0.0044%) - only base-vs-head is consistently elevated, which is what
  frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
  ring resets, the render-state reset above) would produce against a
  fixed wall-clock capture deadline, not a rendering defect. Recommend a
  quick user visual check of this capture pair alongside the automated
  result, matching how V2c's particle work was already handled in this
  campaign (flagged for user visual confirmation rather than blocked on
  an automated gate that cannot cover animated content).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 18:22:08 +02:00
parent ec414d60cd
commit ceec3bc440
334 changed files with 3660 additions and 3840 deletions

View file

@ -1,4 +1,4 @@
// src/AcDream.App/Rendering/TextureCache.cs
// src/AcDream.App/Rendering/TextureCache.cs
using AcDream.Core.Textures;
using AcDream.Core.World;
using AcDream.Content;
@ -12,11 +12,12 @@ using AcDream.App.Rendering.Residency;
namespace AcDream.App.Rendering;
public sealed unsafe class TextureCache
internal 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
@ -28,17 +29,40 @@ public sealed unsafe class TextureCache
_decodedDimensionsByTexture = new();
private uint _magentaHandle;
// 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();
/// <summary>
/// Campaign V slice V4a: one registered <see cref="IGpuTexture"/> plus its
/// device texture-table <see cref="GpuTextureSlot"/> and decoded pixel
/// size. 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 — that world path stays on raw GL
/// (<see cref="_surfacesById"/>) until its own campaign slice.
/// </summary>
private readonly record struct GpuUiTextureEntry(
GpuTextureSlot Slot,
IGpuTexture Texture,
int Width,
int Height);
// Ad-hoc handles 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();
private readonly Dictionary<uint, GpuUiTextureEntry> _renderSurfaceGpuTextures = new();
// Ad-hoc GPU textures produced by the public UploadRgba8(byte[],int,int,bool)
// wrapper (used by IconComposer for composited item icons). These are NOT
// stored in the keyed cache above, so Dispose must sweep this list to avoid
// leaking GPU texture-table slots until process exit.
private readonly List<GpuUiTextureEntry> _adhocGpuTextures = new();
// Every UI-path upload uses REPEAT addressing (existing behaviour: panel
// fills and tiled chrome sample UVs > 1) with the caller-selected filter.
// Nearest+Repeat has no predefined GpuSamplerDescription (UiNearest clamps),
// so it is constructed once here; CreateSampler de-duplicates by value.
private static readonly GpuSamplerDescription UiSpriteNearestRepeat = new(
GpuFilter.Nearest,
GpuFilter.Nearest,
GpuMipFilter.None,
GpuAddressMode.Repeat,
GpuAddressMode.Repeat,
MaxAnisotropy: 1f);
private readonly Wb.BindlessSupport? _bindless;
private readonly CompositeTextureArrayCache? _compositeTextures;
@ -82,13 +106,14 @@ public sealed unsafe class TextureCache
// 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.
// and never again for the session. See spec §5.
private int _dumpFrameCounter;
private bool _surfaceHistogramAlreadyDumped;
public TextureCache(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
public TextureCache(GL gl, IGpuDevice device, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
: this(
gl,
device,
dats,
bindless,
ImmediateGpuResourceRetirementQueue.Instance,
@ -101,6 +126,7 @@ public sealed unsafe class TextureCache
internal TextureCache(
GL gl,
IGpuDevice device,
IDatReaderWriter dats,
Wb.BindlessSupport? bindless,
IGpuResourceRetirementQueue retirementQueue,
@ -109,6 +135,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);
@ -219,23 +246,22 @@ public sealed unsafe class TextureCache
/// <summary>
/// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded
/// DIRECTLY (Portal/HighRes DecodeRenderSurface) rather than through the
/// SurfaceSurfaceTexture chain that <see cref="GetOrUpload(uint)"/> uses
/// 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
/// 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
/// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns
/// a 1x1 magenta handle on miss.
/// </summary>
public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false)
public GpuTextureSlot 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.Slot;
}
DecodedTexture decoded;
@ -256,16 +282,43 @@ 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.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). <paramref name="nearest"/>
/// selects point sampling for pixel-crisp glyphs/icons versus bilinear for
/// everything else, matching the GL path's prior per-call choice.
/// </summary>
private GpuUiTextureEntry UploadUiTexture(DecodedTexture decoded, bool nearest, string debugName)
{
int width = Math.Max(1, decoded.Width);
int height = Math.Max(1, decoded.Height);
IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription(
debugName,
GpuTextureKind.Texture2D,
GpuTextureFormat.Rgba8Unorm,
Width: width,
Height: height,
LayerCount: 1,
MipLevelCount: 1));
texture.Upload(0, 0, decoded.Rgba8);
IGpuSampler sampler = _device.CreateSampler(
nearest ? UiSpriteNearestRepeat : GpuSamplerDescription.WorldRepeat);
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
return new GpuUiTextureEntry(slot, texture, decoded.Width, decoded.Height);
}
/// <summary>
/// Alpha-channel histogram for one decoded texture. Used to diagnose
/// "why are clouds not transparent" — if cloud textures come out with
/// "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.
@ -530,7 +583,7 @@ public sealed unsafe class TextureCache
{
if (_bindless is null)
throw new InvalidOperationException(
"TextureCache constructed without BindlessSupport cannot generate bindless handles. " +
"TextureCache constructed without BindlessSupport — cannot generate bindless handles. " +
"WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport).");
}
@ -585,7 +638,7 @@ public sealed unsafe class TextureCache
/// </summary>
internal static ulong HashPaletteOverride(PaletteOverride p)
{
// Not cryptographic just needs to distinguish override setups
// 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;
@ -606,17 +659,17 @@ public sealed unsafe class TextureCache
/// Phase N.6 slice 1: one-shot surface-format histogram dump for the
/// atlas-opportunity audit. Activated by ACDREAM_DUMP_SURFACES=1; fires
/// once after BOTH gates pass:
/// 1. <c>_dumpFrameCounter &gt;= 600</c> at least 600 OnRender ticks
/// 1. <c>_dumpFrameCounter &gt;= 600</c> — at least 600 OnRender ticks
/// have elapsed (catches the "we're already past startup boilerplate"
/// bound; ~10s at 60fps, ~3s at 200fps).
/// 2. <c>_uploadMetadata.Count &gt;= 100</c> the cache contains at
/// 2. <c>_uploadMetadata.Count &gt;= 100</c> — the cache contains at
/// least 100 uploaded textures, indicating streaming has actually
/// pulled in world content (not just sky/UI/font). The original
/// frame-only gate fired during the login/handshake phase where
/// OnRender ticks at GUI rates but no world has streamed in.
/// Output goes to the host-provided portable diagnostics directory.
/// Zero cost
/// when off. See spec §5 in
/// when off. See spec §5 in
/// docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md.
/// </summary>
public void TickSurfaceHistogramDumpIfEnabled()
@ -641,7 +694,7 @@ public sealed unsafe class TextureCache
{
// 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
// 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.
@ -657,7 +710,7 @@ public sealed unsafe class TextureCache
"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($"# acdream surface-format histogram — generated {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}");
sb.AppendLine("# Per-entry: surfaceId(hex), width, height, format, byteCount");
sb.AppendLine();
@ -710,7 +763,7 @@ public sealed unsafe class TextureCache
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:");
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}");
@ -729,8 +782,8 @@ public sealed unsafe class TextureCache
}
// 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
// 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.
@ -759,7 +812,7 @@ public sealed unsafe class TextureCache
// 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
// 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)
@ -817,12 +870,13 @@ public sealed unsafe class TextureCache
/// <see cref="AcDream.App.UI.IconComposer"/> to upload CPU-composited icon layers.
/// The returned handle is tracked in <see cref="_adhocHandles"/> 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)
/// keyed caches — that would cause a double-delete on Dispose.</summary>
public GpuTextureSlot 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-icon");
_adhocGpuTextures.Add(entry);
return entry.Slot;
}
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)
@ -846,7 +900,7 @@ public sealed unsafe class TextureCache
PixelType.UnsignedByte,
p);
// Point (nearest) sampling for pixel-exact UI text bilinear softens the dat
// 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);
@ -962,16 +1016,28 @@ 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) GPU textures — pre-existing gap: this dict was
// populated by GetOrUploadRenderSurface but was not swept here before that fix.
foreach (GpuUiTextureEntry entry in _renderSurfaceGpuTextures.Values)
DisposeUiTexture(entry);
_renderSurfaceGpuTextures.Clear();
// Ad-hoc handles from the public UploadRgba8(byte[],int,int,bool) wrapper
// Ad-hoc GPU 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)
DisposeUiTexture(entry);
_adhocGpuTextures.Clear();
}
/// <summary>
/// Releases a UI-path texture's table slot before disposing the backing
/// <see cref="IGpuTexture"/>. Both route through the device's retirement
/// queue, so releasing the slot first is purely bookkeeping order, not a
/// use-after-free concern.
/// </summary>
private void DisposeUiTexture(GpuUiTextureEntry entry)
{
_device.ReleaseTextureSlot(entry.Slot);
entry.Texture.Dispose();
}
}