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>
232 lines
12 KiB
C#
232 lines
12 KiB
C#
using System.Text;
|
|
|
|
namespace AcDream.Tools.ShaderCompiler;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6c, plan §3.4 and §4.6: the Vulkan half of acdream's
|
|
/// dual-dialect GLSL.
|
|
///
|
|
/// <para>The GLSL sources under <c>Rendering/Shaders</c> are the single source of
|
|
/// truth for both backends, and they are written in the dialect GL accepts. The
|
|
/// three things Vulkan needs on top of that are not source edits — they are
|
|
/// definitions the compiler injects immediately after the <c>#version</c> line:
|
|
/// </para>
|
|
///
|
|
/// <list type="number">
|
|
/// <item><c>ACDREAM_UBO_SET</c> becomes <c>set = 1,</c>. Under GL it expands to
|
|
/// nothing, because GL keeps the SSBO and UBO binding namespaces separate and
|
|
/// <c>BatchBuffer</c> (SSBO binding 1) and <c>SceneLighting</c> (UBO binding 1)
|
|
/// can share a number. Vulkan has one namespace per set, so moving uniform
|
|
/// buffers to set 1 preserves both numbers. <c>common.glsl</c> has carried this
|
|
/// macro since V2 for exactly this moment.</item>
|
|
/// <item>The texture table becomes a real descriptor array at set 2 binding 0,
|
|
/// and <c>ACDREAM_TEXTURE_HANDLE</c> becomes an index rather than a packed
|
|
/// bindless handle. <c>nonuniformEXT</c> is required, not optional: within one
|
|
/// multi-draw dispatch different draws read different <c>Batches[]</c> entries,
|
|
/// and "dynamically uniform" is defined over the whole dispatch on some
|
|
/// implementations.</item>
|
|
/// <item>The shared 96-byte push-constant block is declared, and each loose
|
|
/// uniform name is <c>#define</c>d onto its member. Vulkan GLSL has no default
|
|
/// uniform block, so this is the only way the same source can declare
|
|
/// <c>uniform mat4 uViewProjection;</c> for GL and read a push constant for
|
|
/// Vulkan.</item>
|
|
/// </list>
|
|
///
|
|
/// <para>Injection rather than source rewriting is the whole design. A textual
|
|
/// transform over the real shader bodies would be a small compiler with its own
|
|
/// failure modes, and it would fail silently — a shader that compiles but reads
|
|
/// the wrong storage buffer looks exactly like a shader that works until
|
|
/// someone renders with it. A preamble either defines what the body needs or the
|
|
/// compile fails loudly, which is a property worth more than convenience.</para>
|
|
/// </summary>
|
|
internal static class VulkanGlslPreamble
|
|
{
|
|
/// <summary>
|
|
/// The loose uniform names the shared push-constant block carries, in the
|
|
/// order <c>GpuPushConstants</c> declares them. Any shader whose uniforms are
|
|
/// all in this list needs nothing but the preamble; any shader with a
|
|
/// uniform outside it cannot be expressed against the pinned contract and is
|
|
/// reported rather than guessed at.
|
|
/// </summary>
|
|
internal static IReadOnlyList<string> PushConstantFields { get; } =
|
|
[
|
|
"uViewProjection",
|
|
"uDrawIDOffset",
|
|
"uLightingMode",
|
|
"uRenderPass",
|
|
"uLightDebug",
|
|
"uTextureIndexA",
|
|
"uTextureIndexB",
|
|
"uParamA",
|
|
"uParamB",
|
|
];
|
|
|
|
/// <summary>
|
|
/// Builds the text inserted after the <c>#version</c> directive.
|
|
/// <paramref name="stage"/> only affects which stage-specific rewrites are
|
|
/// emitted.
|
|
/// </summary>
|
|
internal static string Build(string stage)
|
|
{
|
|
var text = new StringBuilder();
|
|
text.AppendLine("// ---- injected by tools/compile-shaders.ps1 (Campaign V slice V6c) ----");
|
|
text.AppendLine("// Vulkan dialect only. The GL backend compiles the same source with none");
|
|
text.AppendLine("// of this, which is what keeps one GLSL file the single source of truth.");
|
|
text.AppendLine("#extension GL_EXT_nonuniform_qualifier : require");
|
|
if (stage == "vert")
|
|
{
|
|
// gl_DrawID is Vulkan's shaderDrawParameters feature, and glslang
|
|
// still gates the identifier behind the ARB extension name even when
|
|
// targeting Vulkan. Declared here so a source that does not name it
|
|
// still gets it.
|
|
text.AppendLine("#extension GL_ARB_shader_draw_parameters : require");
|
|
}
|
|
|
|
text.AppendLine();
|
|
text.AppendLine("// §3.4 set 1: every uniform buffer. Under GL this macro expands to nothing.");
|
|
text.AppendLine("#undef ACDREAM_UBO_SET");
|
|
text.AppendLine("#define ACDREAM_UBO_SET set = 1,");
|
|
text.AppendLine();
|
|
text.AppendLine("// §4.4 set 2: the global sampled-texture table that replaces");
|
|
text.AppendLine("// GL_ARB_bindless_texture. Variable count, partially bound,");
|
|
text.AppendLine("// update-after-bind; the CPU never writes it per frame.");
|
|
text.AppendLine("layout(set = 2, binding = 0) uniform sampler2DArray uTextures[];");
|
|
text.AppendLine("#undef ACDREAM_TEXTURE_HANDLE");
|
|
text.AppendLine("#define ACDREAM_TEXTURE_HANDLE(idx) (idx)");
|
|
text.AppendLine("#define ACDREAM_TEXTURE(idx) uTextures[nonuniformEXT(uint(idx))]");
|
|
// Slice V6d: the 2-D read. GL reconstructs a sampler2D from the entry's
|
|
// bindless handle; Vulkan has one descriptor array whose element type is
|
|
// fixed at sampler2DArray, so a 2-D entry is a one-layer array read at
|
|
// layer 0. See common.glsl for the GL half and for why the UI's
|
|
// textures stay plain GL_TEXTURE_2D objects.
|
|
text.AppendLine(
|
|
"#define ACDREAM_SAMPLE_2D(idx, uv) texture(ACDREAM_TEXTURE(idx), vec3((uv), 0.0))");
|
|
text.AppendLine();
|
|
text.AppendLine("// §3.4 push constants: one shared 96-byte block, so switching pipelines");
|
|
text.AppendLine("// mid-pass invalidates neither descriptors nor constants.");
|
|
text.AppendLine("layout(push_constant) uniform AcdreamPushBlock {");
|
|
text.AppendLine(" mat4 viewProjection;");
|
|
text.AppendLine(" int drawIdOffset;");
|
|
text.AppendLine(" int lightingMode;");
|
|
text.AppendLine(" int renderPass;");
|
|
text.AppendLine(" int lightDebug;");
|
|
text.AppendLine(" uint textureIndexA;");
|
|
text.AppendLine(" uint textureIndexB;");
|
|
text.AppendLine(" float paramA;");
|
|
text.AppendLine(" float paramB;");
|
|
text.AppendLine("} acdreamPush;");
|
|
text.AppendLine();
|
|
text.AppendLine("#define uViewProjection acdreamPush.viewProjection");
|
|
text.AppendLine("#define uDrawIDOffset acdreamPush.drawIdOffset");
|
|
text.AppendLine("#define uLightingMode acdreamPush.lightingMode");
|
|
text.AppendLine("#define uRenderPass acdreamPush.renderPass");
|
|
text.AppendLine("#define uLightDebug acdreamPush.lightDebug");
|
|
text.AppendLine("#define uTextureIndexA acdreamPush.textureIndexA");
|
|
text.AppendLine("#define uTextureIndexB acdreamPush.textureIndexB");
|
|
text.AppendLine("#define uParamA acdreamPush.paramA");
|
|
text.AppendLine("#define uParamB acdreamPush.paramB");
|
|
text.AppendLine();
|
|
text.AppendLine("// §4.6: gl_DrawIDARB stays as written — glslang exposes it for Vulkan");
|
|
text.AppendLine("// under the same ARB extension name. gl_InstanceIndex already includes");
|
|
text.AppendLine("// firstInstance, so the GL idiom gl_BaseInstanceARB + gl_InstanceID");
|
|
text.AppendLine("// collapses to it exactly.");
|
|
text.AppendLine(stage == "vert"
|
|
? "#define gl_BaseInstanceARB 0\n"
|
|
+ "#define gl_InstanceID gl_InstanceIndex\n"
|
|
+ "#define gl_VertexID gl_VertexIndex"
|
|
: "// (the vertex/instance-index rewrites apply to the vertex stage only)");
|
|
text.AppendLine("// ---- end injected preamble ----");
|
|
return text.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns <paramref name="source"/> with the preamble inserted after its
|
|
/// <c>#version</c> line and the version raised to 450, which is the floor for
|
|
/// Vulkan GLSL. Everything else is untouched: this never edits a shader body.
|
|
/// </summary>
|
|
internal static string Apply(string source, string stage)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(source);
|
|
string[] lines = source.Replace("\r\n", "\n").Split('\n');
|
|
var output = new StringBuilder();
|
|
bool injected = false;
|
|
|
|
foreach (string line in lines)
|
|
{
|
|
string trimmed = line.TrimStart();
|
|
if (!injected && trimmed.StartsWith("#version", StringComparison.Ordinal))
|
|
{
|
|
// 450 is the floor for Vulkan GLSL; a source already asking for
|
|
// more keeps it, because a shader that opted into 460 did so for
|
|
// a feature and quietly downgrading it would be a silent change.
|
|
output.AppendLine(HighestVersion(trimmed) >= 460 ? "#version 460 core" : "#version 450 core");
|
|
output.Append(Build(stage));
|
|
injected = true;
|
|
continue;
|
|
}
|
|
|
|
// Bindless textures are what the set-2 descriptor array replaces;
|
|
// requiring the extension under Vulkan is an error rather than a
|
|
// no-op. (GL_ARB_shader_draw_parameters is kept — glslang still gates
|
|
// gl_DrawID behind that name when targeting Vulkan.)
|
|
if (trimmed.StartsWith("#extension GL_ARB_bindless_texture", StringComparison.Ordinal))
|
|
{
|
|
output.AppendLine($"// (dropped for Vulkan: {trimmed})");
|
|
continue;
|
|
}
|
|
|
|
// Vulkan GLSL has no default uniform block, so a loose
|
|
// `uniform mat4 uViewProjection;` is illegal however it is spelled.
|
|
// Dropping the DECLARATION is what lets the preamble's #define
|
|
// redirect the name onto a push-constant member; a #define alone
|
|
// would only rewrite the declaration into a worse one. Uniform BLOCK
|
|
// declarations (which carry a `{`) are untouched, and an opaque
|
|
// sampler or a name with no push-constant home simply becomes an
|
|
// undeclared identifier — a loud, specific compiler error naming the
|
|
// shader that still needs its port slice.
|
|
if (IsDefaultBlockUniformDeclaration(trimmed))
|
|
{
|
|
output.AppendLine($"// (declaration dropped for Vulkan: {trimmed})");
|
|
continue;
|
|
}
|
|
|
|
output.AppendLine(line);
|
|
}
|
|
|
|
if (!injected)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The shader has no #version directive, so there is nowhere to inject the Vulkan preamble.");
|
|
}
|
|
|
|
return output.ToString();
|
|
}
|
|
|
|
/// <summary>The numeric version a <c>#version</c> directive asks for, or 450 when it cannot be read.</summary>
|
|
internal static int HighestVersion(string versionDirective)
|
|
{
|
|
string[] parts = versionDirective.Split(
|
|
[' ', '\t'],
|
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
return parts.Length >= 2 && int.TryParse(parts[1], out int version) ? version : 450;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True for a loose (default-block) uniform declaration —
|
|
/// <c>uniform mat4 uViewProjection;</c> or <c>uniform float uTexTiling[36];</c> —
|
|
/// and false for a uniform BLOCK, which opens a brace and is legal in both
|
|
/// dialects.
|
|
/// </summary>
|
|
internal static bool IsDefaultBlockUniformDeclaration(string trimmedLine)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(trimmedLine);
|
|
if (!trimmedLine.StartsWith("uniform ", StringComparison.Ordinal))
|
|
return false;
|
|
|
|
// A block declaration is `uniform Name {` — possibly with the brace on
|
|
// the next line, in which case there is no semicolon here either.
|
|
int comment = trimmedLine.IndexOf("//", StringComparison.Ordinal);
|
|
string code = comment >= 0 ? trimmedLine[..comment] : trimmedLine;
|
|
return !code.Contains('{') && code.TrimEnd().EndsWith(';');
|
|
}
|
|
}
|