feat(render): add integer vertex attributes and the terrain tiling binding

A scouting pass over V4d stopped before writing code and reported three gaps between terrain and the pinned contract. All three verified against source.

The load-bearing one: terrain_modern.vert declares locations 2-5 as uvec4 and TerrainModernRenderer feeds them with glVertexAttribIPointer, but GpuVertexFormat had no integer format and the encoder only issued glVertexAttribPointer. GL leaves an integer shader input undefined if it arrives through the float path, and Vulkan needs the format named as R8G8B8A8_UINT rather than _UNORM, so UByte4Normalized cannot stand in for it. Those packed bytes carry terrain-type, road and split-direction codes that drive every blend decision, so normalising them would have produced garbage rather than an approximation. Adds GpuVertexFormat.UByte4UInt and an integer branch in the encoder.

Also adds a uniform binding for terrain's 36-float per-layer tiling array, which at 144 bytes cannot ride in the 96-byte push-constant block or Vulkan's guaranteed 128-byte ceiling, and has no uniform-array verb to reach it otherwise.

Corrects two V4d plan rows: TerrainAtlas belongs to V4t with the rest of the texture stack, and terrain has no GPU timer to port since its diagnostics use a CPU stopwatch. The uView/uProjection convergence gets its own pixel-gated sub-commit because it moves a matrix product from per-vertex GPU evaluation to a CPU multiply, and that rounding effect should be attributable on its own.

Files #250: two zero-allocation tests fail about one run in three on an unchanged tree, independent of this campaign. That noise trains everyone to re-run until green, which is how a real regression gets waved through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 21:11:48 +02:00
parent cb0182a06c
commit c7f5f251f8
7 changed files with 136 additions and 9 deletions

View file

@ -86,6 +86,34 @@ public sealed class GpuContractTests
Assert.Contains(GpuBlendMode.InverseAlpha, Enum.GetValues<GpuBlendMode>());
}
[Fact]
public void IntegerVertexAttributesAreRepresentableDistinctlyFromNormalizedOnes()
{
// terrain_modern.vert declares locations 2-5 as uvec4 and the CPU feeds
// them with glVertexAttribIPointer. GL leaves an integer shader input
// undefined if it arrives through the float path, and Vulkan needs the
// format named as R8G8B8A8_UINT rather than _UNORM — so the two cannot be
// the same contract value. Those packed bytes carry terrain-type, road and
// split-direction codes, so normalising them would produce garbage, not an
// approximation.
Assert.NotEqual(GpuVertexFormat.UByte4Normalized, GpuVertexFormat.UByte4UInt);
Assert.Contains(GpuVertexFormat.UByte4UInt, Enum.GetValues<GpuVertexFormat>());
}
[Fact]
public void UniformBindingsDoNotCollide()
{
uint[] uniformBindings =
[
GpuBindingModel.UniformSceneLighting,
GpuBindingModel.UniformTerrainTiling,
];
Assert.Equal(uniformBindings.Length, uniformBindings.Distinct().Count());
// Binding 2 is the terrain clip block; the tiling array must not take it.
Assert.NotEqual(2u, GpuBindingModel.UniformTerrainTiling);
}
[Fact]
public void UnassignedTextureSlotIsNeverAValidIndex()
{