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

@ -39,3 +39,18 @@ layout(std430, binding = 9) readonly buffer TextureTableBuf {
// existing call site already follows that exact pattern and a function cannot
// return an opaque sampler type built from a runtime value in GLSL.
#define ACDREAM_TEXTURE_HANDLE(idx) gTextureTable[idx]
// Campaign V slice V6d: samples a table slot that holds a plain 2-D texture.
//
// The two backends disagree about what a 2-D table entry IS, and this macro is
// the one place that difference lives. Under GL a bindless handle carries its
// own texture type, so a GL_TEXTURE_2D entry is reconstructed as a sampler2D
// and read with a 2-component UV. Under Vulkan the table is one descriptor
// array whose element type is fixed at sampler2DArray, so the same entry is a
// one-layer array read at layer 0 (see tools/ShaderCompiler/VulkanGlslPreamble.cs).
//
// That asymmetry is deliberate and is what keeps the retained UI's textures
// exactly as they are on GL — including the paperdoll/appraisal FBO colour
// texture, which is an externally-owned GL_TEXTURE_2D registered by the §7.1
// transitional seam and cannot be made an array before V4g moves its renderer.
#define ACDREAM_SAMPLE_2D(idx, uv) texture(sampler2D(gTextureTable[idx]), uv)

View file

@ -2,12 +2,21 @@
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
uniform mat4 uView;
uniform mat4 uProjection;
// Campaign V slice V6d: the separate uView/uProjection pair converged into the
// pinned GpuPushConstants block's single uViewProjection. Vulkan has no default
// uniform block, so a loose uniform cannot exist there at all — a shader either
// reads a field of the shared push-constant block or it cannot be expressed.
//
// The product now happens once on the CPU (DebugLineRenderer.Flush) instead of
// once per vertex, so the rounding differs in the last bits. That is why this
// convergence is measured on its own: these lines are diagnostic-only geometry,
// drawn only when collision wireframes are switched on, so the offline gate
// reads nothing but its own ambient noise.
uniform mat4 uViewProjection;
out vec3 vColor;
void main() {
vColor = aColor;
gl_Position = uProjection * uView * vec4(aPos, 1.0);
gl_Position = uViewProjection * vec4(aPos, 1.0);
}

View file

@ -3,13 +3,12 @@
"shaders": [
{
"name": "debug_line",
"vulkanReady": false,
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "e6a535ed722a034482cfe09e15ac2308ecde2bb54bc7d303cb5874b5b347eb61",
"compiled": false,
"message": "debug_line.vert:62: error: \u0027uProjection\u0027 : undeclared identifier"
"sourceSha256": "069ef7c89eea80c68e28f44b9e065c5cd3cc9220e8ba63261d345377025c2ea1",
"compiled": true
},
{
"stage": "frag",
@ -26,13 +25,13 @@
"stage": "vert",
"sourceSha256": "c35f767ab07fa9df805f9e77f4851f517c153dd2ef2efa6d49d0c24b688e4f56",
"compiled": false,
"message": "mesh.vert:70: error: \u0027uModel\u0027 : undeclared identifier"
"message": "mesh.vert:71: error: \u0027uModel\u0027 : undeclared identifier"
},
{
"stage": "frag",
"sourceSha256": "4d6478543a9a903a3453581fa847e096aaecf01f38ebb2921572663bad8e24ea",
"compiled": false,
"message": "mesh.frag:157: error: \u0027uDiffuse\u0027 : undeclared identifier"
"message": "mesh.frag:158: error: \u0027uDiffuse\u0027 : undeclared identifier"
}
]
},
@ -44,13 +43,13 @@
"stage": "vert",
"sourceSha256": "1ec2f4af83e73102d87997244a35b69ad5e9ece4b1ad78e2b5ece4d58fab5530",
"compiled": false,
"message": "mesh_modern.vert:379: error: \u0027assign\u0027 : cannot convert from \u0027 global highp uint\u0027 to \u0027layout( location=4) flat out highp 2-component vector of uint\u0027"
"message": "mesh_modern.vert:380: error: \u0027assign\u0027 : cannot convert from \u0027 global highp uint\u0027 to \u0027layout( location=4) flat out highp 2-component vector of uint\u0027"
},
{
"stage": "frag",
"sourceSha256": "3aea96cba6c2afc49caae7545f9e42603b6b17afa50b3254beca60f95af5d2f3",
"compiled": false,
"message": "mesh_modern.frag:105: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled"
"message": "mesh_modern.frag:106: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled"
}
]
},
@ -62,13 +61,13 @@
"stage": "vert",
"sourceSha256": "6a6ebeaacba95e5e4e8a308ed7c4cd805b80f305650c1e9e03e2bdfc6c18f5e7",
"compiled": false,
"message": "particle.vert:82: error: \u0027assign\u0027 : cannot convert from \u0027layout( location=6) in highp uint\u0027 to \u0027layout( location=2) flat out highp 2-component vector of uint\u0027"
"message": "particle.vert:83: error: \u0027assign\u0027 : cannot convert from \u0027layout( location=6) in highp uint\u0027 to \u0027layout( location=2) flat out highp 2-component vector of uint\u0027"
},
{
"stage": "frag",
"sourceSha256": "3924ecbabf051349bc6a13e6cff370725a0832f8baf515decdb7e0394304006d",
"compiled": false,
"message": "particle.frag:59: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled"
"message": "particle.frag:60: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled"
}
]
},
@ -85,7 +84,7 @@
"stage": "frag",
"sourceSha256": "0da368243e967388990f4f4b90e2304044af6187de45f70499a3e4ece8dfd5a8",
"compiled": false,
"message": "particle_mesh.frag:61: error: \u0027uTextureIndex\u0027 : undeclared identifier"
"message": "particle_mesh.frag:62: error: \u0027uTextureIndex\u0027 : undeclared identifier"
}
]
},
@ -97,13 +96,13 @@
"stage": "vert",
"sourceSha256": "d338e9b03686b7baf79d5121c5c8d0f24037979cc58f203957d7bd97b02b1cc2",
"compiled": false,
"message": "sky.vert:150: error: \u0027uUvScroll\u0027 : undeclared identifier"
"message": "sky.vert:151: error: \u0027uUvScroll\u0027 : undeclared identifier"
},
{
"stage": "frag",
"sourceSha256": "8084af39f65ae399c73e3ca864376ef20ba8a1c495ee4774be6a82af3872c51c",
"compiled": false,
"message": "sky.frag:75: error: \u0027uDiffuse\u0027 : undeclared identifier"
"message": "sky.frag:76: error: \u0027uDiffuse\u0027 : undeclared identifier"
}
]
},
@ -115,31 +114,29 @@
"stage": "vert",
"sourceSha256": "4de580ce11b8d755d3558dc49bf7ebccec54d307595d91c38b5c5d552d645c7e",
"compiled": false,
"message": "terrain_modern.vert:218: error: \u0027uProjection\u0027 : undeclared identifier"
"message": "terrain_modern.vert:219: error: \u0027uProjection\u0027 : undeclared identifier"
},
{
"stage": "frag",
"sourceSha256": "6003b81df6da6cbea7f00310bd956348bc7b2525345dd490b0b6a3b6428340d9",
"compiled": false,
"message": "terrain_modern.frag:107: error: \u0027uTexTiling\u0027 : undeclared identifier"
"message": "terrain_modern.frag:108: error: \u0027uTexTiling\u0027 : undeclared identifier"
}
]
},
{
"name": "ui_text",
"vulkanReady": false,
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "6c4b0cb8b05da648a5e335db6747cb239dd1fbf95333b658557f52b39eadf4a3",
"compiled": false,
"message": "ui_text.vert:64: error: \u0027uScreenSize\u0027 : undeclared identifier"
"sourceSha256": "4ddc52f18ea953b33e9263dc2703397f63f4b104bc45d67edc06531145bd9d53",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "7287a9f19530979b00de01a8f6c3865905ae3f72e5f219ce228b41915c58d60d",
"compiled": false,
"message": "ui_text.frag:57: error: \u0027uUseTexture\u0027 : undeclared identifier"
"sourceSha256": "43cb53aa8c933f7800142b72ea81bafc923fd7039fd460022d55543081d0aee5",
"compiled": true
}
]
},

Binary file not shown.

Binary file not shown.

View file

@ -1,19 +1,45 @@
#version 430 core
#extension GL_ARB_bindless_texture : require
in vec2 vUv;
in vec4 vColor;
out vec4 FragColor;
uniform sampler2D uTex;
uniform int uUseTexture;
// Campaign V slice V6d: the retained UI samples the device's global texture
// table instead of whatever happened to be bound to texture unit 0. The old
// `uniform sampler2D uTex` plus `uniform int uUseTexture` pair could not exist
// under Vulkan — there is no default uniform block, and an opaque sampler
// cannot be a push constant — so both are expressed with the pinned block's two
// texture-table slots. There is no third field and none was needed: which slots
// are ASSIGNED is itself the mode.
//
// uTextureIndexB assigned -> single-channel coverage source (a font atlas):
// red is the glyph's alpha, and the colour's RGB
// is NOT multiplied by it.
// uTextureIndexA assigned -> RGBA colour source (dat chrome, icons, sprites),
// modulated by the vertex colour/tint.
// neither assigned -> a flat quad in the vertex colour. Untextured
// rects and DrawFill take this path; the old
// shader's uUseTexture==0 branch is unchanged.
//
// Exactly one of the two is ever assigned at a time, so the branch is uniform
// across a draw.
uniform uint uTextureIndexA;
uniform uint uTextureIndexB;
// GpuTextureSlot.Unassigned. It is a loud sentinel precisely so it can be
// tested for rather than silently resolving to slot 0 — the failure mode that
// produced the magenta 1x1 UI placeholder. Both branches below are guarded, so
// it never reaches a sampler.
const uint kUnassignedTextureSlot = 0xFFFFFFFFu;
void main() {
if (uUseTexture == 1) {
if (uTextureIndexB != kUnassignedTextureSlot) {
// Font atlas is a single-channel R8 texture; red = coverage alpha.
float coverage = texture(uTex, vUv).r;
float coverage = ACDREAM_SAMPLE_2D(uTextureIndexB, vUv).r;
FragColor = vec4(vColor.rgb, vColor.a * coverage);
} else if (uUseTexture == 2) {
} else if (uTextureIndexA != kUnassignedTextureSlot) {
// RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha.
FragColor = texture(uTex, vUv) * vColor;
FragColor = ACDREAM_SAMPLE_2D(uTextureIndexA, vUv) * vColor;
} else {
FragColor = vColor;
}

View file

@ -3,16 +3,28 @@ layout(location = 0) in vec2 aPos; // screen pixels, origin top-left
layout(location = 1) in vec2 aUv;
layout(location = 2) in vec4 aColor;
uniform vec2 uScreenSize;
// Campaign V slice V6d: uScreenSize moved into the pinned GpuPushConstants
// block's two spare scalars — uParamA is the framebuffer width in pixels,
// uParamB its height. Vulkan has no default uniform block, so a loose
// `uniform vec2` cannot exist there; the block's fields are the only uniforms
// a shader can read, and the two unclaimed scalars are exactly the right shape.
//
// The arithmetic below is byte-for-byte what it was: the same two divisions and
// the same NDC mapping, reading two floats instead of one vec2.
uniform float uParamA;
uniform float uParamB;
out vec2 vUv;
out vec4 vColor;
void main() {
// Convert pixel coords (origin top-left, +Y down) to NDC (origin center, +Y up).
// This is GL-convention NDC on both backends: the Vulkan backend renders with
// a negative viewport height, so it consumes the same +Y-up clip space and no
// renderer performs a flip of its own.
vec2 ndc = vec2(
aPos.x / uScreenSize.x * 2.0 - 1.0,
1.0 - aPos.y / uScreenSize.y * 2.0);
aPos.x / uParamA * 2.0 - 1.0,
1.0 - aPos.y / uParamB * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0);
vUv = aUv;
vColor = aColor;