fix(rendering): restore retail terrain texture tiling

Carry TerrainTex.TexTiling through the terrain atlas and upload a layer-indexed table to the modern bindless shader so base, overlay, and road textures repeat at retail scale. Keep alpha masks cell-scaled and preserve retail's verified source-level-zero high-detail selection.

Add the named-retail pseudocode, WorldBuilder/ACE/ACME cross-reference, adapter conformance tests, and inventory documentation so a future renderer migration keeps the contract.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-13 21:04:22 +02:00
parent 84b7d2d7cd
commit bb5acab9e6
7 changed files with 297 additions and 7 deletions

View file

@ -153,6 +153,15 @@ use from WB rather than re-implement.
| `TextureAtlasManager` | Texture atlas builder | | `TextureAtlasManager` | Texture atlas builder |
| `VertexLandscape` | Terrain vertex format | | `VertexLandscape` | Terrain vertex format |
**Modern terrain adapter:** acdream's bindless path uses `TerrainAtlas` plus
`TerrainModernRenderer` rather than WB's draw manager, but retains
`LandSurfaceManager`'s layer-indexed `TerrainTex.TexTiling` contract. The
36-entry table is uploaded to `terrain_modern.frag`; base, overlay, and road
layers each use their owning repeat count while alpha masks stay at cell scale.
Retail `TexMerge::CopyAndTile` (`0x00503580`) and `TexMerge::Merge`
(`0x005038C0`) are the behavior oracle; see
`docs/research/2026-07-13-retail-terrain-texture-tiling-pseudocode.md`.
### Scenery (procedural placement: trees, bushes, rocks, fences) ### Scenery (procedural placement: trees, bushes, rocks, fences)
| Component | What it does | | Component | What it does |

View file

@ -0,0 +1,109 @@
# Retail terrain texture tiling
## Question
Why do acdream's grass, dirt, and road textures look lower-detail than the
September 2013 retail client even though the modern terrain atlas loads 512 x
512 source images, generates mipmaps, and enables anisotropic filtering?
## Retail oracle
Named retail symbols in
`docs/research/named-retail/acclient_2013_pseudo_c.txt`:
- `TexMerge::CopyAndTile` at `0x00503580`
- `TexMerge::Merge` at `0x005038C0`
- `ImgTex::TileCSI` at `0x0053E740`
- `ImgTex::GetSurfaceDID` at `0x0053F0E0`
- `Render::ShouldDropHighDetail` at `0x0054C700`
The verbatim `TerrainTex` declaration in
`docs/research/named-retail/acclient.h` contains `unsigned int tex_tiling`.
## Readable pseudocode
```text
CopyAndTile(destination, destinationSize, terrainTexture):
baseTexture = terrainTexture.base_texture
if baseTexture is null:
if terrainTexture.tex_gid is valid:
terrainTexture.base_texture = load ImgTex(terrainTexture.tex_gid)
baseTexture = terrainTexture.base_texture
if baseTexture is null:
CopyCSI(destination, destinationSize, destinationSize, null, 1)
return false
CopyCSI(
destination,
destinationSize,
destinationSize,
baseTexture,
terrainTexture.tex_tiling)
return true
Merge(destination, destinationSize, alphaTexture, rotation, terrainTexture):
if alphaTexture is null:
return false
if terrainTexture.base_texture is null:
if not terrainTexture.InitEnd():
return false
MergeTexture(
destination,
destinationSize,
destinationSize,
terrainTexture.base_texture,
terrainTexture.tex_tiling,
alphaTexture,
rotation)
return true
GetSurfaceDID(surfaceTexture):
if surfaceTexture has one source level:
return sourceLevels[0]
if surfaceTexture has two source levels:
if ShouldDropHighDetail():
return sourceLevels[1]
return sourceLevels[0]
report invalid source-level count
```
`ImgTex::TileCSI` repeats the source image `tex_tiling` times in both axes.
`TexMerge::Merge` applies the same repeat count to every terrain layer before
the cell-scale alpha mask is merged. Therefore the modern shader must multiply
the terrain UV independently for the base layer, each overlay layer, and each
road layer. The alpha-map UV must not be multiplied.
The source image selection is a separate policy. Retail uses source level zero
unless its quality policy explicitly drops high detail, so changing acdream's
atlas from `SurfaceTexture.Textures[0]` would invert the verified retail rule.
## Cross-references
- Extracted WorldBuilder lineage: `LandSurfaceManager` stores a 36-entry
layer-indexed tiling table populated from `TerrainTex.TexTiling`, and its
terrain shader multiplies each texture layer's UV by that value. The pinned
WorldBuilder change introducing the path is commit `0d1405af65`.
- ACE: `ACE.Server/Physics/Common/TexMerge.cs` forwards
`TerrainTex.TexTiling` from both `CopyAndTile` and `Merge`; `ImgTex.cs`
resolves the normal source as `Textures[0]`.
- ACME-derived `tests/AcDream.Core.Tests/Terrain/ClientReference.cs` covers
terrain split, pal/road codes, and blend selection, but not image tiling or
source-level selection. It supplies no competing behavior for this path.
Retail remains the oracle where a reference differs: the pinned WorldBuilder
reader selects the last surface source, while retail's `GetSurfaceDID` proves
that the normal/high-detail source is index zero.
## acdream port shape
1. Capture `TerrainTex.TexTiling` while `TerrainAtlas` assigns each terrain
type to an array layer.
2. Convert the type-keyed values into the renderer's 36-entry layer-indexed
uniform table. Unused layers default to `1.0`; mapped values are preserved
verbatim.
3. Upload the table after binding `terrain_modern.frag`.
4. Sample base, overlay, and road textures with
`cellUv * uTexTiling[layer]`; sample alpha masks with their original UV.

View file

@ -29,6 +29,7 @@ out vec4 fragColor;
uniform uvec2 uTerrainHandle; uniform uvec2 uTerrainHandle;
uniform uvec2 uAlphaHandle; uniform uvec2 uAlphaHandle;
uniform float uTexTiling[36];
#define uTerrain sampler2DArray(uTerrainHandle) #define uTerrain sampler2DArray(uTerrainHandle)
#define uAlpha sampler2DArray(uAlphaHandle) #define uAlpha sampler2DArray(uAlphaHandle)
@ -46,7 +47,12 @@ layout(std140, binding = 1) uniform SceneLighting {
vec4 uCameraAndTime; vec4 uCameraAndTime;
}; };
const float TILE = 1.0; // Retail TexMerge::CopyAndTile (0x00503580) and TexMerge::Merge
// (0x005038C0) pass TerrainTex::tex_tiling to every terrain source before
// the cell-scale alpha mask is applied. The atlas stores that value by layer.
float terrainTiling(float layer) {
return uTexTiling[int(layer)];
}
vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) { vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) {
float a0 = h0 == 0.0 ? 1.0 : t0.a; float a0 = h0 == 0.0 ? 1.0 : t0.a;
@ -68,21 +74,21 @@ vec4 combineOverlays(vec2 baseUV, vec4 pOverlay0, vec4 pOverlay1, vec4 pOverlay2
vec4 t0 = vec4(0.0), t1 = vec4(0.0), t2 = vec4(0.0); vec4 t0 = vec4(0.0), t1 = vec4(0.0), t2 = vec4(0.0);
if (h0 > 0.0) { if (h0 > 0.0) {
t0 = texture(uTerrain, vec3(baseUV * TILE, pOverlay0.z)); t0 = texture(uTerrain, vec3(baseUV * terrainTiling(pOverlay0.z), pOverlay0.z));
if (pOverlay0.w >= 0.0) { if (pOverlay0.w >= 0.0) {
vec4 a = texture(uAlpha, vec3(pOverlay0.xy, pOverlay0.w)); vec4 a = texture(uAlpha, vec3(pOverlay0.xy, pOverlay0.w));
t0.a = a.a; t0.a = a.a;
} }
} }
if (h1 > 0.0) { if (h1 > 0.0) {
t1 = texture(uTerrain, vec3(baseUV * TILE, pOverlay1.z)); t1 = texture(uTerrain, vec3(baseUV * terrainTiling(pOverlay1.z), pOverlay1.z));
if (pOverlay1.w >= 0.0) { if (pOverlay1.w >= 0.0) {
vec4 a = texture(uAlpha, vec3(pOverlay1.xy, pOverlay1.w)); vec4 a = texture(uAlpha, vec3(pOverlay1.xy, pOverlay1.w));
t1.a = a.a; t1.a = a.a;
} }
} }
if (h2 > 0.0) { if (h2 > 0.0) {
t2 = texture(uTerrain, vec3(baseUV * TILE, pOverlay2.z)); t2 = texture(uTerrain, vec3(baseUV * terrainTiling(pOverlay2.z), pOverlay2.z));
if (pOverlay2.w >= 0.0) { if (pOverlay2.w >= 0.0) {
vec4 a = texture(uAlpha, vec3(pOverlay2.xy, pOverlay2.w)); vec4 a = texture(uAlpha, vec3(pOverlay2.xy, pOverlay2.w));
t2.a = a.a; t2.a = a.a;
@ -96,7 +102,7 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
float h1 = pRoad1.z < 0.0 ? 0.0 : 1.0; float h1 = pRoad1.z < 0.0 ? 0.0 : 1.0;
vec4 result = vec4(0.0); vec4 result = vec4(0.0);
if (h0 > 0.0) { if (h0 > 0.0) {
result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z)); result = texture(uTerrain, vec3(baseUV * terrainTiling(pRoad0.z), pRoad0.z));
if (pRoad0.w >= 0.0) { if (pRoad0.w >= 0.0) {
vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w)); vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w));
result.a = 1.0 - a0.a; result.a = 1.0 - a0.a;
@ -123,7 +129,7 @@ vec3 applyFog(vec3 lit, vec3 worldPos) {
void main() { void main() {
vec4 baseColor = vec4(0.0); vec4 baseColor = vec4(0.0);
if (vBaseTexIdx >= 0.0) { if (vBaseTexIdx >= 0.0) {
baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx)); baseColor = texture(uTerrain, vec3(vBaseUV * terrainTiling(vBaseTexIdx), vBaseTexIdx));
} }
vec4 overlays = vec4(0.0); vec4 overlays = vec4(0.0);

View file

@ -34,6 +34,12 @@ public sealed unsafe class TerrainAtlas : IDisposable
public uint GlTexture { get; } // terrain atlas, kept as GlTexture for back-compat with TerrainRenderer public uint GlTexture { get; } // terrain atlas, kept as GlTexture for back-compat with TerrainRenderer
public IReadOnlyDictionary<uint, uint> TerrainTypeToLayer { get; } public IReadOnlyDictionary<uint, uint> TerrainTypeToLayer { get; }
public int LayerCount { get; } public int LayerCount { get; }
/// <summary>
/// UV repeat count for each terrain-array layer. Retail forwards
/// <c>TerrainTex::tex_tiling</c> to both base and merged terrain textures
/// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0).
/// </summary>
public IReadOnlyList<float> TilingByLayer { get; }
// --- Alpha atlas (new in Phase 3c.2) --- // --- Alpha atlas (new in Phase 3c.2) ---
public uint GlAlphaTexture { get; } public uint GlAlphaTexture { get; }
@ -86,6 +92,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
GL gl, GL gl,
Wb.BindlessSupport? bindless, Wb.BindlessSupport? bindless,
uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount, uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount,
IReadOnlyList<float> tilingByLayer,
uint glAlphaTexture, int alphaLayerCount, uint glAlphaTexture, int alphaLayerCount,
IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers, IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers,
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes) IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes)
@ -95,6 +102,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
GlTexture = glTexture; GlTexture = glTexture;
TerrainTypeToLayer = map; TerrainTypeToLayer = map;
LayerCount = layerCount; LayerCount = layerCount;
TilingByLayer = tilingByLayer;
GlAlphaTexture = glAlphaTexture; GlAlphaTexture = glAlphaTexture;
AlphaLayerCount = alphaLayerCount; AlphaLayerCount = alphaLayerCount;
CornerAlphaLayers = cornerLayers; CornerAlphaLayers = cornerLayers;
@ -125,6 +133,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
// ---- Terrain atlas (unchanged Phase 2b logic) ---- // ---- Terrain atlas (unchanged Phase 2b logic) ----
var decodedByType = new Dictionary<uint, DecodedTexture>(); var decodedByType = new Dictionary<uint, DecodedTexture>();
var tilingByType = new Dictionary<uint, uint>();
int maxW = 1, maxH = 1; int maxW = 1, maxH = 1;
foreach (var tmtd in terrainDesc) foreach (var tmtd in terrainDesc)
{ {
@ -132,6 +141,12 @@ public sealed unsafe class TerrainAtlas : IDisposable
if (decodedByType.ContainsKey(typeKey)) if (decodedByType.ContainsKey(typeKey))
continue; continue;
// Retail terrain composition repeats this surface exactly
// TerrainTex::tex_tiling times in each axis before applying the
// cell-scale alpha mask. Preserve the dat field alongside the
// decoded surface while layer assignment is still type-keyed.
tilingByType[typeKey] = tmtd.TerrainTex.TexTiling;
var surfaceTextureId = (uint)tmtd.TerrainTex.TextureId; var surfaceTextureId = (uint)tmtd.TerrainTex.TextureId;
var st = dats.Get<SurfaceTexture>(surfaceTextureId); var st = dats.Get<SurfaceTexture>(surfaceTextureId);
if (st is null || st.Textures.Count == 0) if (st is null || st.Textures.Count == 0)
@ -141,6 +156,10 @@ public sealed unsafe class TerrainAtlas : IDisposable
continue; continue;
} }
// Retail ImgTex::GetSurfaceDID (0x0053F0E0) returns source level
// zero unless Render::ShouldDropHighDetail explicitly selects
// level one. acdream currently has no low-detail quality mode, so
// index zero is the retail high-detail source.
var rs = dats.Get<RenderSurface>((uint)st.Textures[0]); var rs = dats.Get<RenderSurface>((uint)st.Textures[0]);
if (rs is null) if (rs is null)
{ {
@ -183,6 +202,12 @@ public sealed unsafe class TerrainAtlas : IDisposable
layerIdx++; layerIdx++;
} }
var tilingByLayer = TerrainTextureTilingTable.Build(
map.Select(entry =>
(entry.Value, tilingByType.TryGetValue(entry.Key, out uint repeatCount)
? repeatCount
: 1u)));
// A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality. // A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality.
gl.GenerateMipmap(TextureTarget.Texture2DArray); gl.GenerateMipmap(TextureTarget.Texture2DArray);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear); gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear);
@ -203,7 +228,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
return new TerrainAtlas( return new TerrainAtlas(
gl, gl,
bindless, bindless,
tex, map, layerCount, tex, map, layerCount, tilingByLayer,
alphaBuild.gl, alphaBuild.layerCount, alphaBuild.gl, alphaBuild.layerCount,
alphaBuild.corner, alphaBuild.side, alphaBuild.road, alphaBuild.corner, alphaBuild.side, alphaBuild.road,
alphaBuild.cornerTCodes, alphaBuild.sideTCodes, alphaBuild.roadRCodes); alphaBuild.cornerTCodes, alphaBuild.sideTCodes, alphaBuild.roadRCodes);
@ -410,6 +435,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
gl, gl,
bindless, bindless,
tex, new Dictionary<uint, uint> { [0] = 0u }, 1, tex, new Dictionary<uint, uint> { [0] = 0u }, 1,
TerrainTextureTilingTable.Build(Array.Empty<(uint Layer, uint RepeatCount)>()),
alphaTex, 1, alphaTex, 1,
Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(),
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>()); Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>());

View file

@ -64,6 +64,8 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4). // Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4).
private int _uTerrainHandleLoc; private int _uTerrainHandleLoc;
private int _uAlphaHandleLoc; private int _uAlphaHandleLoc;
private int _uTexTilingLoc;
private bool _textureTilingUploaded;
// Reusable per-frame buffers. // Reusable per-frame buffers.
private readonly List<int> _visibleSlots = new(); private readonly List<int> _visibleSlots = new();
@ -90,6 +92,9 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle"); _uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle");
_uAlphaHandleLoc = _gl.GetUniformLocation(_shader.Program, "uAlphaHandle"); _uAlphaHandleLoc = _gl.GetUniformLocation(_shader.Program, "uAlphaHandle");
_uTexTilingLoc = _gl.GetUniformLocation(_shader.Program, "uTexTiling[0]");
if (_uTexTilingLoc < 0)
throw new InvalidOperationException("terrain_modern.frag is missing the required uTexTiling uniform.");
_globalVao = _gl.GenVertexArray(); _globalVao = _gl.GenVertexArray();
_globalVbo = _gl.GenBuffer(); _globalVbo = _gl.GenBuffer();
@ -267,6 +272,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// pre-positions terrain to the outdoor landcell, but acdream uses the // pre-positions terrain to the outdoor landcell, but acdream uses the
// unified camera matrix everywhere, so no separate viewpoint divergence can occur. // unified camera matrix everywhere, so no separate viewpoint divergence can occur.
_shader.Use(); _shader.Use();
UploadTextureTilingOnce();
_shader.SetMatrix4("uView", camera.View); _shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection); _shader.SetMatrix4("uProjection", camera.Projection);
@ -331,6 +337,34 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// Private helpers // Private helpers
// ---------------------------------------------------------------- // ----------------------------------------------------------------
/// <summary>
/// Upload the texture-array adapter for retail's per-surface repeat count.
/// Retail passes <c>TerrainTex::tex_tiling</c> directly to
/// <c>ImgTex::TileCSI</c> / <c>ImgTex::MergeTexture</c>
/// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0).
/// Uniform values persist for the lifetime of this linked shader program,
/// so the immutable atlas table is uploaded on its first bound draw.
/// </summary>
private void UploadTextureTilingOnce()
{
if (_textureTilingUploaded)
return;
if (_atlas.TilingByLayer.Count != TerrainTextureTilingTable.LayerCapacity)
{
throw new InvalidOperationException(
$"Terrain tiling table has {_atlas.TilingByLayer.Count} entries; " +
$"expected {TerrainTextureTilingTable.LayerCapacity}.");
}
Span<float> values = stackalloc float[TerrainTextureTilingTable.LayerCapacity];
for (int i = 0; i < values.Length; i++)
values[i] = _atlas.TilingByLayer[i];
_gl.Uniform1(_uTexTilingLoc, values);
_textureTilingUploaded = true;
}
/// <summary> /// <summary>
/// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared /// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared
/// <see cref="ClipFrame"/> UBO (<see cref="SetClipUbo"/>); otherwise lazily /// <see cref="ClipFrame"/> UBO (<see cref="SetClipUbo"/>); otherwise lazily

View file

@ -0,0 +1,40 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Builds the layer-indexed adapter table consumed by
/// <c>terrain_modern.frag</c>. Retail passes <c>TerrainTex::tex_tiling</c>
/// directly to <c>ImgTex::TileCSI</c> / <c>ImgTex::MergeTexture</c>
/// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0).
/// The table is the modern texture-array equivalent of that per-texture
/// argument.
/// </summary>
internal static class TerrainTextureTilingTable
{
// WorldBuilder's extracted terrain-array path uses the same 36-layer
// contract. Packed terrain layer indices are validated against this
// capacity before the shader table is built.
internal const int LayerCapacity = 36;
internal static float[] Build(IEnumerable<(uint Layer, uint RepeatCount)> entries)
{
ArgumentNullException.ThrowIfNull(entries);
var table = new float[LayerCapacity];
Array.Fill(table, 1f);
foreach (var (layer, repeatCount) in entries)
{
if (layer >= LayerCapacity)
{
throw new InvalidOperationException(
$"Terrain atlas layer {layer} exceeds the shader capacity of {LayerCapacity} layers.");
}
// Preserve the dat value exactly. Retail forwards the unsigned
// field without normalizing it in both CopyAndTile and Merge.
table[layer] = repeatCount;
}
return table;
}
}

View file

@ -0,0 +1,66 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class TerrainTextureTilingTableTests
{
[Fact]
public void Build_MapsDatRepeatCountsToAtlasLayers()
{
var table = TerrainTextureTilingTable.Build(
[
(Layer: 0u, RepeatCount: 4u),
(Layer: 7u, RepeatCount: 2u),
(Layer: 32u, RepeatCount: 8u),
]);
Assert.Equal(TerrainTextureTilingTable.LayerCapacity, table.Length);
Assert.Equal(4f, table[0]);
Assert.Equal(2f, table[7]);
Assert.Equal(8f, table[32]);
}
[Fact]
public void Build_DefaultsOnlyUnusedLayersToOne()
{
var table = TerrainTextureTilingTable.Build(
[
(Layer: 3u, RepeatCount: 0u),
]);
Assert.Equal(1f, table[2]);
Assert.Equal(0f, table[3]);
Assert.Equal(1f, table[4]);
}
[Fact]
public void Build_RejectsLayersTheShaderCannotAddress()
{
var ex = Assert.Throws<InvalidOperationException>(() =>
TerrainTextureTilingTable.Build(
[
(Layer: (uint)TerrainTextureTilingTable.LayerCapacity, RepeatCount: 1u),
]));
Assert.Contains("exceeds the shader capacity", ex.Message);
}
[Fact]
public void ModernShader_AppliesTheOwningLayersRepeatCountToEveryTerrainSample()
{
string shaderPath = Path.Combine(
AppContext.BaseDirectory,
"Rendering",
"Shaders",
"terrain_modern.frag");
string shader = File.ReadAllText(shaderPath);
Assert.Contains("uniform float uTexTiling[36];", shader);
Assert.Contains("baseUV * terrainTiling(pOverlay0.z)", shader);
Assert.Contains("baseUV * terrainTiling(pOverlay1.z)", shader);
Assert.Contains("baseUV * terrainTiling(pOverlay2.z)", shader);
Assert.Contains("baseUV * terrainTiling(pRoad0.z)", shader);
Assert.Contains("vBaseUV * terrainTiling(vBaseTexIdx)", shader);
Assert.DoesNotContain("const float TILE", shader);
}
}