Rotation (user-directed): the procedural starfield rotates once per Dereth day (~2 real hours - constellations visibly wheel through a night) about a celestial pole ~41 deg above the northern horizon, plus dayOfYear/360 of seasonal drift so the 360-day year changes the night sky. One SkyParams float (272-byte block, layout test re-pinned) carries dayFraction + dayOfYear/360 from the world clock; sky.frag applies a Rodrigues rotation to the sample direction so stars and mottle turn together. Impossible with retail's static stretched layer. Fade rework (the 2026-08-23 two-screenshot gate finding): the per-vertex vTint signal carried the sun-facing product and blanked stars across the entire twilight half of the sky. The fade now reads the UNIFORM ambient term - identical star visibility in every compass direction, same dusk-to-dawn schedule - with one deliberate exception: a thin suppression band hugging the low sky toward the sun's azimuth while the sun term is strong, so stars still wash out inside the actual twilight glow. Guards updated (rotation anchor, uniform-fade anchor, 272-byte layout); both sky SPIR-V hashes re-pinned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
129 lines
5.5 KiB
C#
129 lines
5.5 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 272 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)]
|
|
// IA-26: the enhanced night sky's rotation turns plus its three pad
|
|
// words, filling the seventeenth vec4 exactly.
|
|
[InlineData("NightSkyRotationTurns", 256)]
|
|
[InlineData("NsPadA", 260)]
|
|
[InlineData("NsPadB", 264)]
|
|
[InlineData("NsPadC", 268)]
|
|
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(272, 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();
|
|
}
|
|
}
|