Campaign V slice V6e, last of three. Sky was the hardest of the four pairs
because it was the only one that still worked the way a 2004 shader works: a
dozen loose uniforms pushed one glUniform call at a time, and a texture bound to
unit 0 with a sampler object chosen per submesh. Vulkan GLSL has neither a
default uniform block nor a way to declare a bare sampler, so both had to move —
and the second one had a sting in it.
The uniforms go into a `SkyParams` std140 block at uniform binding 4, the new
pre-authorized constant in GpuBindingModel (1, 2 and 3 are SceneLighting, the
terrain clip block and terrain tiling; the contract test now proves the three
constants and that literal 2 do not collide). Three matrices are 192 bytes on
their own, so the 96-byte push-constant block was never in the running. The
block's member order IS its layout: std140 aligns a vec3 to 16 bytes while using
12, so each of the three lighting vectors is followed by the float that rides in
its pad word, which is why colours and per-surface scalars interleave rather
than grouping by meaning. SkyParamsLayoutTests asserts all twelve offsets and
the 256-byte size, because getting one member wrong would read the sun direction
as a colour with no compile error, no link error and no GL error to say so.
The texture is the interesting half. sky.frag now reads through the shared table
(ACDREAM_SAMPLE_2D), and a bindless handle BAKES its sampler — so the
per-submesh Repeat-versus-ClampToEdge choice, which used to be a glBindSampler
on unit 0, becomes which slot the submesh asks for. SkyRenderer interns one
handle per (texture, wrap) pair, exactly as ManagedGLTextureArray has done since
the world path went bindless, and exactly the shape Vulkan's table has, where an
entry is a combined image sampler. Same two SamplerCache objects, same wrap
behaviour, consulted once at interning instead of once per draw. A pleasant
consequence: the sky no longer touches texture unit 0, so the load-bearing
`BindSampler(0, 0)` restore at the end of the pass — there because the binding
was global state that would otherwise force ClampToEdge on the next renderer —
has nothing left to undo and is gone.
Gates. Release build clean; App tests 4,072 passed / 3 skipped (4,057 baseline,
plus the sentinel guard from the previous commit and fourteen sky-layout
assertions). Offline pixel gate against 95f8c25f: 18 px of 563,200 compared
(3.20e-05), inside the documented 15–23 px band.
That gate masks the sky for determinism, so it proves nothing about this commit
and the sky renderer has no automated pixel coverage at all. What was done
instead: a base-versus-head offline capture at ALL SEVEN day groups, built by
stashing the change and rebuilding so the two runs differ only in this commit.
Every pair matches in gradient, cloud sheet, horizon band and fog — including
day group 2's salmon cloud band and day group 6's green one, which between them
exercise texture sampling, per-vertex tint, blend mode and fog. Then 3/3
RENDERED on the desktop-witness repeat-connected gate.
That bounds the risk; it does not close it. The offline camera is fixed and
looks down, so a thin band of dome is all it ever sees: the sun and moon
(additive, high) and the rain cylinder (the one sky mesh that surrounds the
camera, and the one whose REPEAT wrap is most visible) remain unproven. Recorded
as user-gate debt in §5.1 alongside V2c's and V4e's particles — check it by
standing outside at dawn or dusk, and by standing in rain.
Manifest: 8/9 pairs compile. `terrain_modern` is the last production pair, and
it is blocked on V4d's content rather than on dialect — details in §5.5's slice
table. `mesh` has no consumer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
123 lines
5.3 KiB
C#
123 lines
5.3 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6e: the sky's dozen loose uniforms became one std140
|
|
/// uniform block, and this is the test that keeps the CPU struct and the GLSL
|
|
/// block describing the same 256 bytes.
|
|
///
|
|
/// <para>It exists because the failure mode is silent. std140 gives a
|
|
/// <c>vec3</c> 16-byte alignment while only using 12, so the block deliberately
|
|
/// parks a <c>float</c> in each of those pad words — which is why the lighting
|
|
/// colours and the per-surface scalars interleave rather than being grouped by
|
|
/// meaning. Get one member out of order and the shader reads the sun direction
|
|
/// where a colour should be: no compile error, no link error, no GL error, just
|
|
/// a wrong sky that only a human looking at the screen would catch. The offline
|
|
/// pixel gate masks the sky band for determinism, so nothing automated is
|
|
/// watching. This is the substitute.</para>
|
|
/// </summary>
|
|
public sealed class SkyParamsLayoutTests
|
|
{
|
|
private static Type SkyParamsType =>
|
|
typeof(AcDream.App.Rendering.Sky.SkyRenderer)
|
|
.GetNestedType("SkyParams", BindingFlags.NonPublic)
|
|
?? throw new InvalidOperationException("SkyRenderer.SkyParams is missing.");
|
|
|
|
[Theory]
|
|
// Three transforms first: mat4 is 4 vec4s in std140, so these need no thought.
|
|
[InlineData("Model", 0)]
|
|
[InlineData("SkyView", 64)]
|
|
[InlineData("SkyProjection", 128)]
|
|
// Then three (vec3, float) couples. Each float rides in the pad word the
|
|
// vec3's 16-byte alignment would otherwise waste.
|
|
[InlineData("AmbientColor", 192)]
|
|
[InlineData("Emissive", 204)]
|
|
[InlineData("SunColor", 208)]
|
|
[InlineData("DiffuseFactor", 220)]
|
|
[InlineData("SunDir", 224)]
|
|
[InlineData("Transparency", 236)]
|
|
// Finally a vec2 (8-byte aligned) and the last two scalars, filling the
|
|
// sixteenth vec4 exactly.
|
|
[InlineData("UvScroll", 240)]
|
|
[InlineData("ApplyFog", 248)]
|
|
[InlineData("SurfOpacity", 252)]
|
|
public void EveryMemberSitsWhereStd140PutsIt(string member, int expectedOffset)
|
|
{
|
|
Assert.Equal(
|
|
new IntPtr(expectedOffset),
|
|
Marshal.OffsetOf(SkyParamsType, member));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheBlockIsAWholeNumberOfVec4s()
|
|
{
|
|
int declared = (int)(SkyParamsType
|
|
.GetField("SizeInBytes", BindingFlags.Public | BindingFlags.Static)
|
|
?.GetRawConstantValue()
|
|
?? throw new InvalidOperationException("SkyParams.SizeInBytes is missing."));
|
|
|
|
Assert.Equal(256, declared);
|
|
Assert.Equal(declared, Marshal.SizeOf(SkyParamsType));
|
|
// std140 rounds a block up to its largest member's alignment (16).
|
|
Assert.Equal(0, declared % 16);
|
|
}
|
|
|
|
[Fact]
|
|
public void BothStagesDeclareTheBlockIdentically()
|
|
{
|
|
// A uniform block named in two stages of one program must be declared
|
|
// the same way in both, or the link fails — but only if someone reads
|
|
// it, and sky.frag reads three of the twelve members. Comparing the
|
|
// declarations directly means a divergence is caught by a test rather
|
|
// than by a driver's link log at startup.
|
|
string shaders = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
|
|
string vertexBlock = ExtractSkyParamsBlock(File.ReadAllText(Path.Combine(shaders, "sky.vert")));
|
|
string fragmentBlock = ExtractSkyParamsBlock(File.ReadAllText(Path.Combine(shaders, "sky.frag")));
|
|
|
|
Assert.Equal(vertexBlock, fragmentBlock);
|
|
|
|
// And the binding must be the one the CPU binds the buffer to.
|
|
Assert.Equal(4u, AcDream.App.Rendering.Gpu.GpuBindingModel.UniformSkyParams);
|
|
Assert.Contains("binding = 4) uniform SkyParams {", vertexBlock);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The block's declaration with comments and whitespace runs collapsed, so
|
|
/// the comparison is about members rather than formatting.
|
|
/// </summary>
|
|
private static string ExtractSkyParamsBlock(string source)
|
|
{
|
|
int start = source.IndexOf("layout(std140", StringComparison.Ordinal);
|
|
while (start >= 0)
|
|
{
|
|
int end = source.IndexOf("};", start, StringComparison.Ordinal);
|
|
Assert.True(end > start, "A layout(std140 …) block was never closed.");
|
|
string block = source[start..(end + 2)];
|
|
if (block.Contains("uniform SkyParams", StringComparison.Ordinal))
|
|
return Normalise(block);
|
|
start = source.IndexOf("layout(std140", end, StringComparison.Ordinal);
|
|
}
|
|
|
|
throw new InvalidOperationException("No SkyParams block found in the shader source.");
|
|
}
|
|
|
|
private static string Normalise(string block)
|
|
{
|
|
var text = new System.Text.StringBuilder();
|
|
foreach (string rawLine in block.Replace("\r\n", "\n").Split('\n'))
|
|
{
|
|
int comment = rawLine.IndexOf("//", StringComparison.Ordinal);
|
|
string line = comment >= 0 ? rawLine[..comment] : rawLine;
|
|
string collapsed = string.Join(' ', line.Split(
|
|
(char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
|
if (collapsed.Length > 0)
|
|
text.Append(collapsed).Append('\n');
|
|
}
|
|
|
|
return text.ToString();
|
|
}
|
|
}
|