Fix atmospheric receiver light direction
Keep both atmospheric vertex receivers on retail's exact authored, unnormalized uLights direction in every shadow-gate state while preserving the selected celestial direction for fragment shadow projection and volumetrics. Regenerate the affected SPIR-V and amend IA-24. Proof adds committed-SPIR-V dataflow checks, parent plain/pipeline source pins, and a real Vulkan mesh+terrain pixel witness owned by Lane=Vulkan. Mutation first failures (all restored): 1. Restoring the mesh shadow/celestial branch failed CommittedProductionAtmosphericReceivers_KeepAuthoredLightAcrossShadowGate at line 27: expected Pixel 128/128/128/255, actual 0/0/0/255. 2. Restoring the terrain shadow/celestial branch failed the same witness at line 28: expected Pixel 128/128/128/255, actual 0/0/0/255. 3. Normalizing the authored mesh direction failed AssertAuthoredHalfIntensity at line 277: expected 126..129, actual 255. 4. Removing atmospheric_volumetric.frag's celestial xyz use failed ProductionShadowAndVolumetricModules_StillNormalizeTheCelestialProjectionDirection at line 61: Assert.Single found no matching member-5 access. 5. Restoring IA-24's old celestial-base-light claim failed BuiltInAndDeclaredShadowGraphsUseTheSameTypedPriorVisibilitySelector at line 1482: the authored unnormalized uLights sole-base-light assertion was absent.
This commit is contained in:
parent
8098875240
commit
94ddde69af
10 changed files with 588 additions and 78 deletions
|
|
@ -0,0 +1,230 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>
|
||||
/// S5 issue #469: inspect the committed production modules so source comments
|
||||
/// or an unused lookalike expression cannot satisfy the authored-light proof.
|
||||
/// </summary>
|
||||
public sealed class AtmosphericLightingDirectionSpirvTests
|
||||
{
|
||||
private const ushort OpExtInst = 12;
|
||||
private const ushort OpConstant = 43;
|
||||
private const ushort OpVariable = 59;
|
||||
private const ushort OpLoad = 61;
|
||||
private const ushort OpAccessChain = 65;
|
||||
private const ushort OpVectorShuffle = 79;
|
||||
private const ushort OpDecorate = 71;
|
||||
private const ushort OpFNegate = 127;
|
||||
private const ushort OpDot = 148;
|
||||
|
||||
private const uint DecorationBinding = 33;
|
||||
private const uint DecorationDescriptorSet = 34;
|
||||
private const uint SceneLightingSet = 1;
|
||||
private const uint SceneLightingBinding = 1;
|
||||
private const uint PackUniformSet = 3;
|
||||
private const uint DirectionalShadowBinding = 6;
|
||||
|
||||
[Theory]
|
||||
[InlineData("mesh_atmospheric.vert.spv", false)]
|
||||
[InlineData("terrain_atmospheric.vert.spv", true)]
|
||||
public void ProductionVertexModule_DotsTheNegatedAuthoredDirectionWithoutNormalization(
|
||||
string fileName,
|
||||
bool terrain)
|
||||
{
|
||||
Spirv module = Spirv.Read(Shader(fileName));
|
||||
|
||||
Assert.Empty(module.RootVariables(PackUniformSet, DirectionalShadowBinding));
|
||||
uint sceneLighting = Assert.Single(module.RootVariables(SceneLightingSet, SceneLightingBinding));
|
||||
Instruction direction = Assert.Single(module.Instructions, instruction =>
|
||||
instruction.OpCode == OpAccessChain
|
||||
&& instruction.Operands[0] == sceneLighting
|
||||
&& IsAuthoredDirectionPath(module, instruction, terrain));
|
||||
Instruction loaded = Assert.Single(module.WithOperand(OpLoad, direction.ResultId));
|
||||
Instruction xyz = Assert.Single(module.WithOperand(OpVectorShuffle, loaded.ResultId));
|
||||
Instruction negated = Assert.Single(module.WithOperand(OpFNegate, xyz.ResultId));
|
||||
Instruction dot = Assert.Single(module.WithOperand(OpDot, negated.ResultId));
|
||||
|
||||
Assert.Equal(negated.ResultId, dot.Operands[1]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("mesh_atmospheric.frag.spv")]
|
||||
[InlineData("terrain_atmospheric.frag.spv")]
|
||||
[InlineData("atmospheric_volumetric.frag.spv")]
|
||||
public void ProductionShadowAndVolumetricModules_StillNormalizeTheCelestialProjectionDirection(
|
||||
string fileName)
|
||||
{
|
||||
Spirv module = Spirv.Read(Shader(fileName));
|
||||
uint shadowBlock = Assert.Single(module.RootVariables(PackUniformSet, DirectionalShadowBinding));
|
||||
Instruction direction = Assert.Single(module.Instructions, instruction =>
|
||||
instruction.OpCode == OpAccessChain
|
||||
&& instruction.Operands[0] == shadowBlock
|
||||
&& instruction.Operands.Length == 2
|
||||
&& module.Constant(instruction.Operands[1]) == 5u);
|
||||
Instruction loaded = Assert.Single(module.WithOperand(OpLoad, direction.ResultId));
|
||||
Instruction xyz = Assert.Single(module.WithOperand(OpVectorShuffle, loaded.ResultId));
|
||||
|
||||
Assert.Contains(
|
||||
module.Instructions,
|
||||
instruction => instruction.OpCode == OpExtInst
|
||||
&& instruction.Operands.Contains(xyz.ResultId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParentPlainShadersAndReceiverSelectionSourcesRemainByteExact()
|
||||
{
|
||||
AssertNormalizedTextHash(
|
||||
"d5af0cbe995bcd10acea92ac7140363f0941105452a89039a7b54c5217c505a2",
|
||||
"src", "AcDream.App", "Rendering", "Shaders", "mesh_modern.vert");
|
||||
AssertBinaryHash(
|
||||
"6194193764616bc88bf967d1fdc2bea5f6f2d1ec2ef94fd492922137503f56e4",
|
||||
"src", "AcDream.App", "Rendering", "Shaders", "spv", "mesh_modern.vert.spv");
|
||||
AssertNormalizedTextHash(
|
||||
"b80c201771b6adf3a13b5564492c5188f53a59e61b4d730fb5037ae322b0dde0",
|
||||
"src", "AcDream.App", "Rendering", "Shaders", "mesh_modern.frag");
|
||||
AssertBinaryHash(
|
||||
"bd47fe8a33e0f1d025fe48fd95b034ba6fe591a6d20e88e636c86238d8773db1",
|
||||
"src", "AcDream.App", "Rendering", "Shaders", "spv", "mesh_modern.frag.spv");
|
||||
AssertNormalizedTextHash(
|
||||
"faf6855222cb2b09da6697c93a93f35cbc57c27bbc124055a6d235c72ef1a23a",
|
||||
"src", "AcDream.App", "Rendering", "Shaders", "terrain_modern.vert");
|
||||
AssertBinaryHash(
|
||||
"8a73d89ef0e51e550327b9ff8c24857e309103b1d491030cf0d4d8594b45068c",
|
||||
"src", "AcDream.App", "Rendering", "Shaders", "spv", "terrain_modern.vert.spv");
|
||||
AssertNormalizedTextHash(
|
||||
"a9f0ad2e679b68ee2de6c4877202f6f5e5089d0f7368fcedd6048891c6848915",
|
||||
"src", "AcDream.App", "Rendering", "Shaders", "terrain_modern.frag");
|
||||
AssertBinaryHash(
|
||||
"7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9",
|
||||
"src", "AcDream.App", "Rendering", "Shaders", "spv", "terrain_modern.frag.spv");
|
||||
AssertNormalizedTextHash(
|
||||
"58c54e02bc0ba1d1086f05ef211e58eb6ba4d28d9a6be9ce3027509fee93ccf0",
|
||||
"src", "AcDream.App", "Rendering", "Wb", "WbDrawDispatcher.DirectionalShadowReceivers.cs");
|
||||
AssertNormalizedTextHash(
|
||||
"ef33fb37b019e6b32932fb501ed87721dab496611c580cb046b8f1e80249efe2",
|
||||
"src", "AcDream.App", "Rendering", "TerrainModernRenderer.DirectionalShadowReceivers.cs");
|
||||
AssertNormalizedTextHash(
|
||||
"55c6512f164376877c8bf28bff7487b7a2ce18ec963798eaeaf795c3a043c6b4",
|
||||
"src", "AcDream.App", "Rendering", "Packs", "RenderPackReceiverPipelineCoordinator.cs");
|
||||
AssertNormalizedTextHash(
|
||||
"34186c77d66bd22aaeff439b31a0ae0a7af8d611ec7d4a3429fc40705034e028",
|
||||
"src", "AcDream.App", "Rendering", "DirectionalShadowReceiver.cs");
|
||||
}
|
||||
|
||||
private static bool IsAuthoredDirectionPath(
|
||||
Spirv module,
|
||||
in Instruction instruction,
|
||||
bool terrain)
|
||||
{
|
||||
if (instruction.Operands.Length != 4
|
||||
|| module.Constant(instruction.Operands[1]) != 0u
|
||||
|| module.Constant(instruction.Operands[3]) != 1u)
|
||||
return false;
|
||||
|
||||
return !terrain || module.Constant(instruction.Operands[2]) == 0u;
|
||||
}
|
||||
|
||||
private static void AssertNormalizedTextHash(string expected, params string[] relativePath)
|
||||
{
|
||||
string text = File.ReadAllText(Path.Combine([RepositoryRoot(), .. relativePath]))
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
Assert.Equal(expected, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant());
|
||||
}
|
||||
|
||||
private static void AssertBinaryHash(string expected, params string[] relativePath) =>
|
||||
Assert.Equal(
|
||||
expected,
|
||||
Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(
|
||||
Path.Combine([RepositoryRoot(), .. relativePath])))).ToLowerInvariant());
|
||||
|
||||
private static string Shader(string fileName) => Path.Combine(
|
||||
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv", fileName);
|
||||
|
||||
private readonly record struct Instruction(ushort OpCode, uint ResultId, uint[] Operands);
|
||||
|
||||
private sealed class Spirv
|
||||
{
|
||||
private readonly Dictionary<uint, Dictionary<uint, uint>> _decorations = [];
|
||||
private readonly Dictionary<uint, uint> _constants = [];
|
||||
|
||||
private Spirv()
|
||||
{
|
||||
}
|
||||
|
||||
internal Dictionary<uint, uint> Variables { get; } = [];
|
||||
internal List<Instruction> Instructions { get; } = [];
|
||||
|
||||
internal static Spirv Read(string path)
|
||||
{
|
||||
byte[] bytes = File.ReadAllBytes(path);
|
||||
Assert.True(bytes.Length >= 20 && bytes.Length % sizeof(uint) == 0, $"{path} is not SPIR-V.");
|
||||
uint[] words = new uint[bytes.Length / sizeof(uint)];
|
||||
Buffer.BlockCopy(bytes, 0, words, 0, bytes.Length);
|
||||
Assert.Equal(0x07230203u, words[0]);
|
||||
|
||||
var module = new Spirv();
|
||||
for (int index = 5; index < words.Length;)
|
||||
{
|
||||
int wordCount = checked((int)(words[index] >> 16));
|
||||
ushort opCode = checked((ushort)(words[index] & 0xFFFFu));
|
||||
Assert.True(wordCount > 0 && index + wordCount <= words.Length);
|
||||
|
||||
if (opCode == OpDecorate && wordCount >= 4)
|
||||
{
|
||||
uint target = words[index + 1];
|
||||
if (!module._decorations.TryGetValue(target, out Dictionary<uint, uint>? values))
|
||||
module._decorations[target] = values = [];
|
||||
values[words[index + 2]] = words[index + 3];
|
||||
}
|
||||
else if (opCode == OpConstant && wordCount >= 4)
|
||||
{
|
||||
module._constants[words[index + 2]] = words[index + 3];
|
||||
}
|
||||
else if (opCode == OpVariable && wordCount >= 4)
|
||||
{
|
||||
module.Variables[words[index + 2]] = words[index + 3];
|
||||
}
|
||||
|
||||
if (HasResult(opCode))
|
||||
{
|
||||
module.Instructions.Add(new Instruction(
|
||||
opCode,
|
||||
words[index + 2],
|
||||
words.AsSpan(index + 3, wordCount - 3).ToArray()));
|
||||
}
|
||||
index += wordCount;
|
||||
}
|
||||
return module;
|
||||
}
|
||||
|
||||
internal uint? Constant(uint id) => _constants.TryGetValue(id, out uint value) ? value : null;
|
||||
|
||||
internal IEnumerable<uint> RootVariables(uint set, uint binding) => Variables.Keys
|
||||
.Where(id => Decoration(id, DecorationDescriptorSet) == set)
|
||||
.Where(id => Decoration(id, DecorationBinding) == binding);
|
||||
|
||||
internal IEnumerable<Instruction> WithOperand(ushort opCode, uint operand) =>
|
||||
Instructions.Where(instruction => instruction.OpCode == opCode
|
||||
&& instruction.Operands.Contains(operand));
|
||||
|
||||
private uint? Decoration(uint id, uint decoration) =>
|
||||
_decorations.TryGetValue(id, out Dictionary<uint, uint>? values)
|
||||
&& values.TryGetValue(decoration, out uint value)
|
||||
? value
|
||||
: null;
|
||||
|
||||
private static bool HasResult(ushort opCode) => opCode is
|
||||
OpExtInst or OpLoad or OpAccessChain or OpVectorShuffle or OpFNegate or OpDot;
|
||||
}
|
||||
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
directory = directory.Parent;
|
||||
return directory?.FullName
|
||||
?? throw new InvalidOperationException("Could not locate the repository root.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using AcDream.App.Rendering.Packs;
|
||||
using AcDream.Core.Lighting;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
public sealed unsafe partial class MeshModernSharedIndexOffscreenTests
|
||||
{
|
||||
[Trait("Lane", "Vulkan")]
|
||||
[Fact]
|
||||
public void CommittedProductionAtmosphericReceivers_KeepAuthoredLightAcrossShadowGate()
|
||||
{
|
||||
lock (VulkanLock)
|
||||
{
|
||||
string shaderDirectory = Path.Combine(
|
||||
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv");
|
||||
using var host = HeadlessVulkanHost.Create(shaderDirectory);
|
||||
|
||||
(Pixel meshOff, Pixel meshOn) = RenderMeshLightingPair(host);
|
||||
(Pixel terrainOff, Pixel terrainOn) = RenderTerrainLightingPair(host);
|
||||
|
||||
Assert.Equal(meshOff, meshOn);
|
||||
Assert.Equal(terrainOff, terrainOn);
|
||||
AssertAuthoredHalfIntensity(meshOff, "mesh");
|
||||
AssertAuthoredHalfIntensity(terrainOff, "terrain");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AtmosphericLightingPixelWitness_IsOwnedByTheDedicatedVulkanLane()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot(), "tests", "AcDream.App.Tests", "Rendering", "Gpu", "Vk",
|
||||
"MeshModernSharedIndexOffscreenTests.AtmosphericLighting.cs"));
|
||||
Assert.Matches(
|
||||
new Regex(
|
||||
@"\[Trait\(""Lane"", ""Vulkan""\)\]\s*"
|
||||
+ @"\[Fact\]\s*public void CommittedProductionAtmosphericReceivers_KeepAuthoredLightAcrossShadowGate",
|
||||
RegexOptions.Singleline),
|
||||
source);
|
||||
Assert.Equal(1, Count(source, "[Trait(\"Lane\", \"Vulkan\")]"));
|
||||
}
|
||||
|
||||
private static (Pixel Disabled, Pixel Enabled) RenderMeshLightingPair(HeadlessVulkanHost host)
|
||||
{
|
||||
VulkanGpuDevice device = host.Device;
|
||||
using IGpuBuffer vertices = device.CreateBuffer(new GpuBufferDescription(
|
||||
"s5-469-mesh-vertices",
|
||||
4 * Marshal.SizeOf<Vertex>(),
|
||||
GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination,
|
||||
GpuMemoryResidency.DeviceLocal));
|
||||
using IGpuBuffer indices = device.CreateBuffer(new GpuBufferDescription(
|
||||
"s5-469-mesh-indices",
|
||||
6 * sizeof(ushort),
|
||||
GpuBufferUsage.Index | GpuBufferUsage.TransferDestination,
|
||||
GpuMemoryResidency.DeviceLocal));
|
||||
Vertex[] vertexData =
|
||||
[
|
||||
new(new Vector3(-0.32f, -0.32f, 0f), Vector3.UnitZ, Vector2.Zero),
|
||||
new(new Vector3( 0.32f, -0.32f, 0f), Vector3.UnitZ, Vector2.UnitX),
|
||||
new(new Vector3( 0.32f, 0.32f, 0f), Vector3.UnitZ, Vector2.One),
|
||||
new(new Vector3(-0.32f, 0.32f, 0f), Vector3.UnitZ, Vector2.UnitY),
|
||||
];
|
||||
vertices.Upload(0, MemoryMarshal.AsBytes<Vertex>(vertexData));
|
||||
indices.Upload(0, MemoryMarshal.AsBytes<ushort>([0, 1, 2, 2, 3, 0]));
|
||||
|
||||
using IGpuRenderTarget target = CreateTarget(device, "s5-469-mesh-offscreen");
|
||||
using IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription
|
||||
{
|
||||
Name = "s5-469-mesh-atmospheric",
|
||||
Shaders = new GpuShaderSet("mesh_atmospheric"),
|
||||
VertexLayout = GpuVertexLayout.WorldMesh,
|
||||
Topology = GpuPrimitiveTopology.TriangleList,
|
||||
Blend = GpuBlendMode.None,
|
||||
Depth = GpuDepthState.Disabled,
|
||||
Cull = GpuCullMode.None,
|
||||
UsesRenderPackShaderAbi = true,
|
||||
SampleCount = 1,
|
||||
});
|
||||
Assert.False(pipeline.Description.Shaders.HasEmbeddedSpirv);
|
||||
Assert.Equal("mesh_atmospheric", pipeline.Description.Shaders.Name);
|
||||
|
||||
using (IGpuFrame frame = device.BeginFrame())
|
||||
{
|
||||
using IGpuPassEncoder encoder = BeginPass(frame, target, "s5-469-mesh-lighting");
|
||||
encoder.BindPipeline(pipeline);
|
||||
encoder.SetPushConstants(GpuPushConstants.Default);
|
||||
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageInstances,
|
||||
[
|
||||
Matrix4x4.CreateTranslation(-0.48f, 0f, 0f),
|
||||
Matrix4x4.CreateTranslation( 0.48f, 0f, 0f),
|
||||
]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageBatches,
|
||||
[new BatchData(device.DefaultTextureSlot.Index, 1f, 0u, 0u)]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageClipSlots, [0u, 0u]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageGlobalLights, [GlobalLight.Zero]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceLightSets,
|
||||
Enumerable.Repeat(-1, 16).ToArray());
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceIndoor, [0u, 0u]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceAlpha, [1f, 1f]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceSelectionLighting,
|
||||
[new Vector2(0f, 1f), new Vector2(0f, 1f)]);
|
||||
BindStorage(frame, encoder, GpuBindingModel.StorageInstanceDetailCategory, [0u, 0u]);
|
||||
BindUniform(frame, encoder, GpuBindingModel.UniformSceneLighting, AuthoredHalfLight());
|
||||
BindUniform(frame, encoder, GpuBindingModel.UniformAtmosphericFrame, default(AtmosphericFrameUniforms));
|
||||
|
||||
encoder.BindVertexBuffer(0, vertices, 0);
|
||||
encoder.BindIndexBuffer(indices, 0, GpuIndexType.UInt16);
|
||||
BindUniform(frame, encoder, GpuBindingModel.UniformDirectionalShadow,
|
||||
ShadowUniforms(device.DefaultTextureSlot, enabled: false, -Vector3.UnitY));
|
||||
encoder.DrawIndexed(6, 1, 0, 0, 0);
|
||||
BindUniform(frame, encoder, GpuBindingModel.UniformDirectionalShadow,
|
||||
ShadowUniforms(device.DefaultTextureSlot, enabled: true, Vector3.UnitX));
|
||||
encoder.DrawIndexed(6, 1, 0, 0, 1);
|
||||
}
|
||||
|
||||
device.WaitIdle();
|
||||
return ReadLightingPair(host, target);
|
||||
}
|
||||
|
||||
private static (Pixel Disabled, Pixel Enabled) RenderTerrainLightingPair(HeadlessVulkanHost host)
|
||||
{
|
||||
VulkanGpuDevice device = host.Device;
|
||||
TerrainVertex[] terrain =
|
||||
[
|
||||
.. TerrainQuad(-0.48f),
|
||||
.. TerrainQuad(0.48f),
|
||||
];
|
||||
using IGpuBuffer vertices = device.CreateBuffer(new GpuBufferDescription(
|
||||
"s5-469-terrain-vertices",
|
||||
terrain.Length * Marshal.SizeOf<TerrainVertex>(),
|
||||
GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination,
|
||||
GpuMemoryResidency.DeviceLocal));
|
||||
vertices.Upload(0, MemoryMarshal.AsBytes<TerrainVertex>(terrain));
|
||||
|
||||
using IGpuRenderTarget target = CreateTarget(device, "s5-469-terrain-offscreen");
|
||||
using IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription
|
||||
{
|
||||
Name = "s5-469-terrain-atmospheric",
|
||||
Shaders = new GpuShaderSet("terrain_atmospheric"),
|
||||
VertexLayout = TerrainModernRenderer.TerrainVertexLayout,
|
||||
Topology = GpuPrimitiveTopology.TriangleList,
|
||||
Blend = GpuBlendMode.None,
|
||||
Depth = GpuDepthState.Disabled,
|
||||
Cull = GpuCullMode.None,
|
||||
UsesRenderPackShaderAbi = true,
|
||||
SampleCount = 1,
|
||||
});
|
||||
Assert.False(pipeline.Description.Shaders.HasEmbeddedSpirv);
|
||||
Assert.Equal("terrain_atmospheric", pipeline.Description.Shaders.Name);
|
||||
|
||||
using (IGpuFrame frame = device.BeginFrame())
|
||||
{
|
||||
using IGpuPassEncoder encoder = BeginPass(frame, target, "s5-469-terrain-lighting");
|
||||
encoder.BindPipeline(pipeline);
|
||||
GpuPushConstants constants = GpuPushConstants.Default;
|
||||
constants.TextureIndexA = device.DefaultTextureSlot.Index;
|
||||
constants.TextureIndexB = device.DefaultTextureSlot.Index;
|
||||
encoder.SetPushConstants(constants);
|
||||
BindUniform(frame, encoder, GpuBindingModel.UniformSceneLighting, AuthoredHalfLight());
|
||||
BindTerrainTiling(frame, encoder);
|
||||
encoder.BindVertexBuffer(0, vertices, 0);
|
||||
|
||||
BindUniform(frame, encoder, GpuBindingModel.UniformDirectionalShadow,
|
||||
ShadowUniforms(device.DefaultTextureSlot, enabled: false, -Vector3.UnitY));
|
||||
encoder.Draw(6, 1, 0, 0);
|
||||
BindUniform(frame, encoder, GpuBindingModel.UniformDirectionalShadow,
|
||||
ShadowUniforms(device.DefaultTextureSlot, enabled: true, Vector3.UnitX));
|
||||
encoder.Draw(6, 1, 6, 0);
|
||||
}
|
||||
|
||||
device.WaitIdle();
|
||||
return ReadLightingPair(host, target);
|
||||
}
|
||||
|
||||
private static IGpuRenderTarget CreateTarget(VulkanGpuDevice device, string name) =>
|
||||
device.CreateRenderTarget(new GpuRenderTargetDescription(
|
||||
name,
|
||||
Extent,
|
||||
Extent,
|
||||
GpuTextureFormat.Rgba8UnormRenderTarget,
|
||||
DepthFormat: null,
|
||||
SampleCount: 1));
|
||||
|
||||
private static IGpuPassEncoder BeginPass(IGpuFrame frame, IGpuRenderTarget target, string name) =>
|
||||
frame.BeginPass(new GpuPassDescription
|
||||
{
|
||||
Name = name,
|
||||
Color = new GpuColorAttachment(
|
||||
target,
|
||||
GpuLoadOp.Clear,
|
||||
GpuStoreOp.Store,
|
||||
new Vector4(0f, 0f, 0f, 1f)),
|
||||
Depth = null,
|
||||
SampleCount = 1,
|
||||
});
|
||||
|
||||
private static SceneLightingUbo AuthoredHalfLight() => new()
|
||||
{
|
||||
Light0 = new UboLight
|
||||
{
|
||||
PosAndKind = new Vector4(0f, 0f, 0f, 0f),
|
||||
DirAndRange = new Vector4(0f, 0f, -0.5f, 0f),
|
||||
ColorAndIntensity = Vector4.One,
|
||||
ConeAngleEtc = Vector4.Zero,
|
||||
},
|
||||
CellAmbient = new Vector4(0f, 0f, 0f, 1f),
|
||||
};
|
||||
|
||||
private static DirectionalShadowUniforms ShadowUniforms(
|
||||
GpuTextureSlot texture,
|
||||
bool enabled,
|
||||
Vector3 celestialSurfaceToLight) => new(
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.Identity,
|
||||
Vector4.Zero,
|
||||
new Vector4(0f, 1f, 0f, 1f),
|
||||
Vector4.Zero,
|
||||
new UInt4(texture.Index, 0u, 1u, enabled ? 1u : 0u),
|
||||
new Vector4(celestialSurfaceToLight, 1f));
|
||||
|
||||
private static void BindTerrainTiling(IGpuFrame frame, IGpuPassEncoder encoder)
|
||||
{
|
||||
GpuRingAllocation allocation = frame.AllocateRing(
|
||||
TerrainTextureTilingTable.UniformBufferBytes,
|
||||
GpuRingUsage.Uniform);
|
||||
allocation.Data.Clear();
|
||||
allocation.AsSpan<float>()[0] = 1f;
|
||||
encoder.BindUniformBuffer(
|
||||
GpuBindingModel.UniformTerrainTiling,
|
||||
allocation.Buffer,
|
||||
allocation.OffsetBytes,
|
||||
(uint)allocation.Data.Length);
|
||||
}
|
||||
|
||||
private static TerrainVertex[] TerrainQuad(float x) =>
|
||||
[
|
||||
new(new Vector3(x - 0.32f, -0.32f, 0.5f)),
|
||||
new(new Vector3(x + 0.32f, -0.32f, 0.5f)),
|
||||
new(new Vector3(x + 0.32f, 0.32f, 0.5f)),
|
||||
new(new Vector3(x - 0.32f, -0.32f, 0.5f)),
|
||||
new(new Vector3(x + 0.32f, 0.32f, 0.5f)),
|
||||
new(new Vector3(x - 0.32f, 0.32f, 0.5f)),
|
||||
];
|
||||
|
||||
private static (Pixel Disabled, Pixel Enabled) ReadLightingPair(
|
||||
HeadlessVulkanHost host,
|
||||
IGpuRenderTarget target)
|
||||
{
|
||||
VulkanGpuRenderTarget vkTarget = Assert.IsType<VulkanGpuRenderTarget>(target);
|
||||
byte[] pixels = ReadBack(
|
||||
host.Vk,
|
||||
host.PhysicalDevice,
|
||||
host.LogicalDevice,
|
||||
host.Queue,
|
||||
host.QueueFamily,
|
||||
vkTarget.ColorResult.Image);
|
||||
return (PixelAt(pixels, 16, 32), PixelAt(pixels, 48, 32));
|
||||
}
|
||||
|
||||
private static Pixel PixelAt(byte[] pixels, int x, int y)
|
||||
{
|
||||
int offset = ((y * Extent) + x) * 4;
|
||||
return new Pixel(pixels[offset], pixels[offset + 1], pixels[offset + 2], pixels[offset + 3]);
|
||||
}
|
||||
|
||||
private static void AssertAuthoredHalfIntensity(Pixel pixel, string receiver)
|
||||
{
|
||||
Assert.InRange(pixel.R, (byte)126, (byte)129);
|
||||
Assert.InRange(pixel.G, (byte)126, (byte)129);
|
||||
Assert.InRange(pixel.B, (byte)126, (byte)129);
|
||||
Assert.True(pixel.A >= 253, $"{receiver} alpha was {pixel.A}, expected opaque.");
|
||||
}
|
||||
|
||||
private static int Count(string text, string token)
|
||||
{
|
||||
int count = 0;
|
||||
for (int index = 0; (index = text.IndexOf(token, index, StringComparison.Ordinal)) >= 0;)
|
||||
{
|
||||
count++;
|
||||
index += token.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 4)]
|
||||
private readonly record struct TerrainVertex(
|
||||
Vector3 Position,
|
||||
Vector3 Normal,
|
||||
uint Packed0,
|
||||
uint Packed1,
|
||||
uint Packed2,
|
||||
uint Packed3)
|
||||
{
|
||||
internal TerrainVertex(Vector3 position)
|
||||
: this(position, Vector3.UnitZ, 0xFFFF_FF00u, uint.MaxValue, uint.MaxValue, 0u)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct Pixel(byte R, byte G, byte B, byte A);
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
|||
/// mesh_modern SPIR-V pair, binds a nonzero shared-transform prefix and local
|
||||
/// sidecars, submits an actual offscreen draw, and the test reads its pixels.
|
||||
/// </summary>
|
||||
public sealed unsafe class MeshModernSharedIndexOffscreenTests
|
||||
public sealed unsafe partial class MeshModernSharedIndexOffscreenTests
|
||||
{
|
||||
private const int Extent = 64;
|
||||
private const uint Prefix = 2;
|
||||
|
|
|
|||
|
|
@ -1224,26 +1224,13 @@ public sealed class AtmosphericPostProcessGraphTests
|
|||
// ── Campaign VM VM6: foliage wind ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ReceiverVertexShadersFallBackToThePlainLightDirectionWhenTheShadowFlagIsClear()
|
||||
public void ReceiverVertexShadersAlwaysUseTheExactUnnormalizedPlainLightDirection()
|
||||
{
|
||||
// Campaign VM VM6 review fix round 5 (F1 BLOCKER, test T3), hardened
|
||||
// round 6 (N1): the original version of this test asserted only
|
||||
// that the plain-pipeline direction SUBSTRING appeared somewhere
|
||||
// in the receiver file — but the round-5 fix's own explanatory
|
||||
// comments ALSO quote that exact substring in prose ("(-uLights[i]
|
||||
// .dirAndRange.xyz, ... matched bit-for-bit)"), so the check was
|
||||
// satisfied by the COMMENT even with the CODE mutated. Mutation-
|
||||
// tested locally against this rewritten test: (1) swapping the
|
||||
// ternary's true/false operands — PASSED the old substring-only
|
||||
// test, FAILS this one; (2) deleting the fallback entirely
|
||||
// (always taking the shadow-block direction, i.e. collapsing the
|
||||
// ternary to just its false arm) — PASSED the old test, FAILS
|
||||
// this one. Comment lines are stripped before matching, and the
|
||||
// assertion is the exact branch SHAPE — the gated-off condition,
|
||||
// then the plain uLights expression on the TRUE arm, then
|
||||
// uShadowLightDirectionAndSource on the FALSE arm — via an
|
||||
// ordered regex, not a substring search that a comment can also
|
||||
// satisfy.
|
||||
// Campaign OVERHAUL S5 #469: the atmospheric pack may direct
|
||||
// shadow projection at a selected celestial body, but both receiver
|
||||
// vertex shaders keep retail's one authored, unnormalized base-light
|
||||
// direction in every gate state. Strip comments so prose cannot
|
||||
// satisfy any of these production-expression checks.
|
||||
string shaderRoot = Path.Combine(
|
||||
RepositoryRoot(),
|
||||
"src",
|
||||
|
|
@ -1259,9 +1246,12 @@ public sealed class AtmosphericPostProcessGraphTests
|
|||
string terrainModern = StripLineComments(
|
||||
File.ReadAllText(Path.Combine(shaderRoot, "terrain_modern.vert")));
|
||||
|
||||
const string flagTest = "(uShadowTextureAndFlags.w & 1u) == 0u";
|
||||
Assert.Contains(flagTest, meshAtmospheric, StringComparison.Ordinal);
|
||||
Assert.Contains(flagTest, terrainAtmospheric, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("shadowGatedOff", meshAtmospheric, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("shadowGatedOff", terrainAtmospheric, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("uShadowTextureAndFlags", meshAtmospheric, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("uShadowTextureAndFlags", terrainAtmospheric, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("uShadowLightDirectionAndSource", meshAtmospheric, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("uShadowLightDirectionAndSource", terrainAtmospheric, StringComparison.Ordinal);
|
||||
|
||||
// mesh: both files negate the per-light uniform inline (no
|
||||
// intermediate variable), so the literal expression matches
|
||||
|
|
@ -1271,10 +1261,9 @@ public sealed class AtmosphericPostProcessGraphTests
|
|||
Assert.Contains(meshPlainDirection, meshModern, StringComparison.Ordinal);
|
||||
Assert.Contains(meshPlainDirection, meshAtmospheric, StringComparison.Ordinal);
|
||||
|
||||
// terrain: terrain_modern.vert reads the uniform into `sunDir`
|
||||
// then negates that variable two lines later, so only the
|
||||
// shared uniform-access substring (not the exact negation
|
||||
// placement) is asserted identical between the two files.
|
||||
// terrain_modern.vert reads the same uniform into `sunDir` and then
|
||||
// negates it; the atmospheric receiver spells that identical value
|
||||
// inline so its split directional varying remains unchanged.
|
||||
const string terrainLightUniform = "uLights[0].dirAndRange.xyz";
|
||||
Assert.Contains(terrainLightUniform, terrainModern, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
|
|
@ -1282,24 +1271,19 @@ public sealed class AtmosphericPostProcessGraphTests
|
|||
terrainAtmospheric,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
// Branch-shape guard (N1): fails on a swapped ternary or a
|
||||
// deleted fallback even though the substrings above would still
|
||||
// be present somewhere in the (comment-stripped) file.
|
||||
var meshBranchShape = new Regex(
|
||||
@"shadowGatedOff\s*\?\s*-uLights\[i\]\.dirAndRange\.xyz\s*:\s*normalize\(uShadowLightDirectionAndSource\.xyz\)",
|
||||
var meshDirectionShape = new Regex(
|
||||
@"vec3\s+Ldir\s*=\s*-uLights\[i\]\.dirAndRange\.xyz\s*;\s*float\s+ndl\s*=\s*max\(0\.0,\s*dot\(N,\s*Ldir\)\)",
|
||||
RegexOptions.Singleline);
|
||||
Assert.True(
|
||||
meshBranchShape.IsMatch(meshAtmospheric),
|
||||
"mesh_atmospheric.vert must branch exactly: "
|
||||
+ "shadowGatedOff ? -uLights[i].dirAndRange.xyz : normalize(uShadowLightDirectionAndSource.xyz)");
|
||||
meshDirectionShape.IsMatch(meshAtmospheric),
|
||||
"mesh_atmospheric.vert must feed the exact unnormalized -uLights[i] direction directly to N dot L.");
|
||||
|
||||
var terrainBranchShape = new Regex(
|
||||
@"shadowGatedOff\s*\?\s*-uLights\[0\]\.dirAndRange\.xyz\s*:\s*normalize\(uShadowLightDirectionAndSource\.xyz\)",
|
||||
var terrainDirectionShape = new Regex(
|
||||
@"vec3\s+surfaceToLight\s*=\s*-uLights\[0\]\.dirAndRange\.xyz\s*;\s*vec3\s+sunCol[\s\S]*?float\s+L\s*=\s*max\(dot\(vWorldNormal,\s*surfaceToLight\),\s*MIN_FACTOR\)",
|
||||
RegexOptions.Singleline);
|
||||
Assert.True(
|
||||
terrainBranchShape.IsMatch(terrainAtmospheric),
|
||||
"terrain_atmospheric.vert must branch exactly: "
|
||||
+ "shadowGatedOff ? -uLights[0].dirAndRange.xyz : normalize(uShadowLightDirectionAndSource.xyz)");
|
||||
terrainDirectionShape.IsMatch(terrainAtmospheric),
|
||||
"terrain_atmospheric.vert must feed the exact unnormalized -uLights[0] direction directly to N dot L.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1495,6 +1479,12 @@ public sealed class AtmosphericPostProcessGraphTests
|
|||
StringComparison.Ordinal);
|
||||
Assert.Contains("pack-off does not build, select, upload, or draw shadow work", ia24,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("authored, unnormalized `uLights` direction remains the sole base-light direction", ia24,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("selected celestial source drives only the opt-in shadow and volumetric projection", ia24,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("light/shadow-angle separation", ia24, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("same celestial source instead", ia24, StringComparison.Ordinal);
|
||||
|
||||
static void AssertGraphUsesTypedSelection(string source, string methodName)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue