feat(render): put the retained UI and debug lines on both backends

Campaign V slice V6d, commit 2 of 3. TextRenderer and DebugLineRenderer were the only two renderers speaking the RHI, and both refused any device that was not a GlGpuDevice. They now refuse nothing: this is the first production rendering acdream can do on Vulkan.

Three things had to go.

The loose uniforms. debug_line declared uView and uProjection separately and DebugLineRenderer set them straight against the compiled GL program, because the pinned push-constant block carries one combined matrix and IGpuPassEncoder has no verb for arbitrary named uniforms. That was never portable — Vulkan has no default uniform block at all — so the shader converged on uViewProjection and Flush multiplies on the CPU. System.Numerics is row-vector convention while GLSL reads the floats column-major, which transposes, so the CPU equivalent of the old per-vertex uProjection * uView is view * projection. The product now rounds once per frame rather than once per vertex; these lines only draw when collision wireframes are switched on, so the offline gate sees nothing of it. ui_text's uScreenSize became the block's two spare scalars, uParamA and uParamB, with the same two divisions and the same NDC mapping around them.

The sampling mode. uUseTexture selected between font coverage, RGBA modulate and flat colour, and no field of the 96-byte block means that. It did not need one: which of the two texture-table slots is assigned IS the mode. uTextureIndexB assigned means a single-channel coverage source, uTextureIndexA assigned means an RGBA colour source, neither assigned means the vertex colour alone. GpuTextureSlot.Unassigned is already a loud sentinel for exactly this kind of question, and both branches guard so it never reaches a sampler. That also retired the 1x1 white fill texture: DrawFill routed solid quads through the sprite bucket relying on white times colour, and the untextured branch produces the same value with no texture at all. Multiplying by 1.0 changes no bits, and the gate agrees.

The texture binding. The classic glActiveTexture/glBindTexture path survived V4a because DrawSprite takes an arbitrary texture from sixty-odd widget call sites. But TextureCache had already registered every one of those into the device's table — the classic path was consuming the raw GL name that registration also produced. The UI's currency is now UiTextureTableHandle, a one-based table index whose zero is the same "no texture" every widget already guards on; a raw slot index would have turned all of those guards into silent false negatives, since slot 0 is perfectly valid. One-based rather than the slot itself because GpuTextureSlot is internal to the pinned contract while UiRenderContext.DrawSprite, TextureCache.GetOrUploadRenderSurface and a dozen widget properties are public, and neither publishing a contract type nor converting the retained UI to internal belongs in this slice.

Two consequences worth stating. The two backends disagree about what a 2-D table entry is — GL reconstructs a sampler2D from the bindless handle, Vulkan reads layer 0 of its sampler2DArray descriptor array — and ACDREAM_SAMPLE_2D is the one place that lives. Keeping GL on sampler2D is what leaves the UI's textures exactly as they are, including the paperdoll/appraisal FBO colour texture, which is an externally-owned GL_TEXTURE_2D from the §7.1 transitional seam and cannot become an array before V4g. On the Vulkan side, sampled views are now always layered, which also removes a latent invalid usage V6c shipped: it registered a Type2D offscreen view into a descriptor array whose element type is sampler2DArray.

And one real fix. Sampling through the table means a bound sampler object overrides the texture's own parameters. Nearest-requested UI art used to get its point filtering from a glTexParameter applied before the bindless handle went resident, so registering it with the stock WorldRepeat sampler would have made every retail icon and dat-font glyph silently bilinear. Those now register with a nearest-and-repeat sampler.

Supporting moves: GlGpuDevice.CreatePipeline splices common.glsl the same way Shader does, since an RHI shader that reads the table needs the table declared; GlGpuPassEncoder binds the device's table with the pipeline, which is the GL analogue of Vulkan binding descriptor set 2 per draw, and has to be per-bind because every raw-GL world renderer puts its own privately-numbered table at that binding; and the encoder derives GL_MULTISAMPLE from the pass's SampleCount, which is where the retained UI's hand-rolled glDisable belonged all along. TextRenderGlStateScope is deleted — the encoder's ambient capture restored a strict superset of it — and its failure-safety test follows the guarantee to GlAmbientCapabilityState, which gains a fakeable seam and, with it, the multisample-dimension coverage #249 recorded as missing.

App tests 4,057 passed / 3 skipped, unchanged from commit 1. Offline pixel gate against 871c406b: differing fraction 2.31e-05, 13 pixels of 563,200 compared — below the documented 15-23 pixel same-commit noise band, on a change that redraws every pixel of the retained UI through a different sampling path. The capture was inspected: vitals, spell bar, radar, toolbar icons and slot digits, chat window and Send button all present and correctly placed. Both new .spv pairs compile; the manifest records ui_text and debug_line as Vulkan-ready, leaving six pairs blocked on the world-renderer slices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 08:51:32 +02:00
parent 871c406b99
commit f6f58a12db
26 changed files with 763 additions and 615 deletions

View file

@ -108,10 +108,10 @@ public sealed class GlTextureOwnershipTests
// from raw GlResourceCommand calls to IGpuDevice.CreatePipeline/
// CreateTexture, whose own checked-commit construction
// (GlResourceCommand.CreateName / ShaderProgramConstruction.Build,
// predating this slice) is what those two now delegate to. What
// TextRenderer's OWN constructor still owns is the ordered
// pipeline-then-texture sequence and disposing whichever of the two
// it already created if the other throws.
// predating this slice) is what those now delegate to. Slice V6d then
// removed the white fill texture, so TextRenderer's constructor owns a
// single resource and holds no GL name of any kind — every checked
// commit boundary it depends on lives behind the RHI.
string text = File.ReadAllText(Path.Combine(
root, "src", "AcDream.App", "Rendering", "TextRenderer.cs"));
string bindless = File.ReadAllText(Path.Combine(
@ -124,14 +124,9 @@ public sealed class GlTextureOwnershipTests
Assert.Contains("GlResourceCommand.DeleteProgram", shaderPrograms, StringComparison.Ordinal);
Assert.Contains("_anisotropyBindingMutation.Execute", terrain, StringComparison.Ordinal);
Assert.Contains("GlResourceCommand.DeleteTexture", terrain, StringComparison.Ordinal);
AssertAppearsInOrder(
text,
"pipeline = device.CreatePipeline(",
"whiteTexture = device.CreateTexture(",
"catch",
"whiteTexture?.Dispose();",
"pipeline?.Dispose();",
"throw;");
Assert.Contains("_pipeline = device.CreatePipeline(", text, StringComparison.Ordinal);
Assert.DoesNotContain("GlResourceCommand", text, StringComparison.Ordinal);
Assert.DoesNotContain("GlName", text, StringComparison.Ordinal);
Assert.Contains("make bindless handle", bindless, StringComparison.Ordinal);
Assert.Contains("GlResourceCommand.Execute", bindless, StringComparison.Ordinal);
}

View file

@ -131,18 +131,20 @@ public sealed class ResourceCleanupGroupTests
}
/// <summary>
/// Campaign V slice V4a: TextRenderer's constructor no longer owns raw
/// VAO/VBO/texture GL names through a ResourceCleanupGroup ledger — it
/// creates exactly two device-owned resources (a pipeline, then the white
/// fill texture) and, since a later failure could otherwise orphan an
/// already-created pipeline, disposes whichever of the two it already
/// created if the other throws. This replaces the pre-V4a assertion of
/// the same name, which pinned the old multi-resource raw-GL shape
/// (Shader + three flight-indexed VAO/VBO pairs + a hand-rolled white
/// texture) that no longer exists.
/// Campaign V slice V4a moved TextRenderer's constructor off raw
/// VAO/VBO/texture GL names and onto two device-owned resources — a
/// pipeline and a 1x1 white fill texture — with a catch that disposed
/// whichever already existed when the other threw.
///
/// Slice V6d removed the white texture: the shader gained an untextured
/// branch that produces what white-times-colour produced, so the fill needs
/// no texture at all. That leaves exactly ONE owned resource, which is a
/// stronger property than correct rollback — with nothing to orphan there is
/// no partial-construction window to get wrong. This test pins that, so
/// re-growing a second resource without re-growing the rollback fails here.
/// </summary>
[Fact]
public void TextRendererDisposesWhicheverConstructorResourceAlreadyExistsOnFailure()
public void TextRendererConstructorOwnsExactlyOneDeviceResource()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
@ -151,20 +153,27 @@ public sealed class ResourceCleanupGroupTests
"Rendering",
"TextRenderer.cs"));
AssertAppearsInOrder(
source,
"IGpuPipeline? pipeline = null;",
"IGpuTexture? whiteTexture = null;",
"try",
"pipeline = device.CreatePipeline(",
"whiteTexture = device.CreateTexture(",
"device.RegisterTexture(whiteTexture, whiteSampler);",
"catch",
"whiteTexture?.Dispose();",
"pipeline?.Dispose();",
"throw;",
"_pipeline = pipeline;",
"_whiteTexture = whiteTexture;");
Assert.Equal(1, CountOccurrences(source, "device.CreatePipeline("));
Assert.Equal(0, CountOccurrences(source, "device.CreateTexture("));
Assert.Equal(0, CountOccurrences(source, "device.CreateBuffer("));
Assert.Equal(0, CountOccurrences(source, "device.CreateSampler("));
Assert.Equal(0, CountOccurrences(source, "device.RegisterTexture("));
// And the one resource is released.
Assert.Contains("public void Dispose() => _pipeline.Dispose();", source, StringComparison.Ordinal);
}
private static int CountOccurrences(string source, string needle)
{
int count = 0;
for (int i = source.IndexOf(needle, StringComparison.Ordinal);
i >= 0;
i = source.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
{
count++;
}
return count;
}
[Fact]

View file

@ -1,13 +1,22 @@
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu.Gl;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// The retained UI's whole draw is one RHI pass, and a failure anywhere inside
/// it must not leave GL state behind for the raw-GL world renderers that run in
/// the next frame. Until Campaign V slice V6d, TextRenderer owned a private
/// state scope for that; V6d made the renderer backend-neutral and moved the
/// guarantee onto <see cref="GlAmbientCapabilityState"/>, which every RHI pass
/// gets. These tests follow it there.
/// </summary>
public sealed class TextRendererFailureSafetyTests
{
[Fact]
public void Flush_CompilesCompleteGlStateScopeAsFinallyAroundBothDrawLayers()
public void Flush_CompilesThePassEncoderAsFinallyAroundBothDrawLayers()
{
MethodInfo flush = typeof(TextRenderer).GetMethod(nameof(TextRenderer.Flush))!;
MethodBody body = flush.GetMethodBody()!;
@ -18,27 +27,36 @@ public sealed class TextRendererFailureSafetyTests
"Rendering",
"TextRenderer.cs"));
// The encoder's `using` is the finally: disposing it closes the pass,
// which is what restores the ambient capability state the pass changed.
Assert.Contains(
body.ExceptionHandlingClauses,
clause => clause.Flags == ExceptionHandlingClauseOptions.Finally);
AssertAppearsInOrder(
source,
"using var stateScope = new TextRenderGlStateScope(_glState);",
"_gl.Disable(EnableCap.Multisample);",
"using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription",
"encoder.BindPipeline(_pipeline);",
"DrawLayer(_spriteSegs,",
"DrawLayer(_overlaySpriteSegs,");
// And no raw GL of its own is left: the renderer draws on both backends.
Assert.DoesNotContain("Silk.NET.OpenGL", source, StringComparison.Ordinal);
Assert.DoesNotContain("_gl.", source, StringComparison.Ordinal);
}
[Fact]
public void FailedDraw_RestoresEveryGlValueMutatedByTheTextPass()
public void FailedDraw_RestoresEveryGlValueMutatedByThePass()
{
var gl = new RecordingGlState
{
DepthWrite = false,
DepthFuncValue = DepthFunction.Greater,
BlendSourceRgb = BlendingFactor.DstAlpha,
BlendDestinationRgb = BlendingFactor.OneMinusDstAlpha,
BlendSourceAlpha = BlendingFactor.One,
BlendDestinationAlpha = BlendingFactor.Zero,
CullFaceMode = TriangleFace.Front,
FrontFaceDirection = FrontFaceDirection.CW,
Program = 17,
VertexArray = 23,
ArrayBuffer = 31,
@ -48,31 +66,44 @@ public sealed class TextRendererFailureSafetyTests
gl.SetCapability(EnableCap.Blend, enabled: false);
gl.SetCapability(EnableCap.CullFace, enabled: true);
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: true);
gl.SetCapability(EnableCap.Multisample, enabled: false);
// Enabled on entry — the world's MSAA. The UI pass turns it off and the
// restore has to put it back, which is the exact dimension the V4a
// revert lost and which nothing covered before this test.
gl.SetCapability(EnableCap.Multisample, enabled: true);
gl.TextureBindings[TextureUnit.Texture0] = 41;
gl.TextureBindings[TextureUnit.Texture2] = 43;
StateSnapshot expected = gl.Capture();
Action failedDraw = () =>
{
using var stateScope = new TextRenderGlStateScope(gl);
gl.SetCapability(EnableCap.DepthTest, enabled: false);
gl.SetCapability(EnableCap.Blend, enabled: true);
gl.SetCapability(EnableCap.CullFace, enabled: false);
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false);
gl.SetCapability(EnableCap.Multisample, enabled: true);
gl.DepthMask(true);
gl.BlendFuncSeparate(
BlendingFactor.SrcAlpha,
BlendingFactor.OneMinusSrcAlpha,
BlendingFactor.SrcAlpha,
BlendingFactor.OneMinusSrcAlpha);
gl.UseProgram(101);
gl.BindVertexArray(103);
gl.BindBuffer(BufferTargetARB.ArrayBuffer, 107);
gl.ActiveTexture(TextureUnit.Texture0);
gl.BindTexture(TextureTarget.Texture2D, 109);
throw new InvalidOperationException("draw upload");
GlAmbientCapabilityState ambient = GlAmbientCapabilityState.Capture(gl);
try
{
gl.SetCapability(EnableCap.DepthTest, enabled: false);
gl.SetCapability(EnableCap.Blend, enabled: true);
gl.SetCapability(EnableCap.CullFace, enabled: false);
gl.SetCapability(EnableCap.SampleAlphaToCoverage, enabled: false);
gl.SetCapability(EnableCap.Multisample, enabled: false);
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Lequal);
gl.BlendFuncSeparate(
BlendingFactor.SrcAlpha,
BlendingFactor.OneMinusSrcAlpha,
BlendingFactor.SrcAlpha,
BlendingFactor.OneMinusSrcAlpha);
gl.CullFace(TriangleFace.Back);
gl.FrontFace(FrontFaceDirection.Ccw);
gl.UseProgram(101);
gl.BindVertexArray(103);
gl.BindBuffer(BufferTargetARB.ArrayBuffer, 107);
gl.ActiveTexture(TextureUnit.Texture0);
gl.BindTexture(TextureTarget.Texture2D, 109);
throw new InvalidOperationException("draw upload");
}
finally
{
ambient.Restore(gl);
}
};
Assert.Throws<InvalidOperationException>(failedDraw);
@ -87,10 +118,13 @@ public sealed class TextRendererFailureSafetyTests
bool AlphaToCoverage,
bool Multisample,
bool DepthWrite,
DepthFunction DepthFunc,
BlendingFactor BlendSourceRgb,
BlendingFactor BlendDestinationRgb,
BlendingFactor BlendSourceAlpha,
BlendingFactor BlendDestinationAlpha,
TriangleFace CullFaceMode,
FrontFaceDirection FrontFaceDirection,
uint Program,
uint VertexArray,
uint ArrayBuffer,
@ -98,15 +132,18 @@ public sealed class TextRendererFailureSafetyTests
uint Texture0,
uint Texture2);
private sealed class RecordingGlState : ITextRenderGlStateApi
private sealed class RecordingGlState : IGlAmbientStateApi
{
private readonly Dictionary<EnableCap, bool> _capabilities = [];
public bool DepthWrite { get; set; }
public DepthFunction DepthFuncValue { get; set; }
public BlendingFactor BlendSourceRgb { get; set; }
public BlendingFactor BlendDestinationRgb { get; set; }
public BlendingFactor BlendSourceAlpha { get; set; }
public BlendingFactor BlendDestinationAlpha { get; set; }
public TriangleFace CullFaceMode { get; set; }
public FrontFaceDirection FrontFaceDirection { get; set; }
public uint Program { get; set; }
public uint VertexArray { get; set; }
public uint ArrayBuffer { get; set; }
@ -120,10 +157,13 @@ public sealed class TextRendererFailureSafetyTests
IsEnabled(EnableCap.SampleAlphaToCoverage),
IsEnabled(EnableCap.Multisample),
DepthWrite,
DepthFuncValue,
BlendSourceRgb,
BlendDestinationRgb,
BlendSourceAlpha,
BlendDestinationAlpha,
CullFaceMode,
FrontFaceDirection,
Program,
VertexArray,
ArrayBuffer,
@ -140,6 +180,9 @@ public sealed class TextRendererFailureSafetyTests
GetPName.BlendDstRgb => (int)BlendDestinationRgb,
GetPName.BlendSrcAlpha => (int)BlendSourceAlpha,
GetPName.BlendDstAlpha => (int)BlendDestinationAlpha,
GetPName.DepthFunc => (int)DepthFuncValue,
GetPName.CullFaceMode => (int)CullFaceMode,
GetPName.FrontFace => (int)FrontFaceDirection,
GetPName.CurrentProgram => (int)Program,
GetPName.VertexArrayBinding => (int)VertexArray,
GetPName.ArrayBufferBinding => (int)ArrayBuffer,
@ -159,6 +202,8 @@ public sealed class TextRendererFailureSafetyTests
public void DepthMask(bool enabled) => DepthWrite = enabled;
public void DepthFunc(DepthFunction function) => DepthFuncValue = function;
public void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
@ -171,6 +216,10 @@ public sealed class TextRendererFailureSafetyTests
BlendDestinationAlpha = destinationAlpha;
}
public void CullFace(TriangleFace face) => CullFaceMode = face;
public void FrontFace(FrontFaceDirection direction) => FrontFaceDirection = direction;
public void UseProgram(uint program) => Program = program;
public void BindVertexArray(uint vertexArray) => VertexArray = vertexArray;