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 |
| `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)
| 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 uAlphaHandle;
uniform float uTexTiling[36];
#define uTerrain sampler2DArray(uTerrainHandle)
#define uAlpha sampler2DArray(uAlphaHandle)
@ -46,7 +47,12 @@ layout(std140, binding = 1) uniform SceneLighting {
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) {
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);
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) {
vec4 a = texture(uAlpha, vec3(pOverlay0.xy, pOverlay0.w));
t0.a = a.a;
}
}
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) {
vec4 a = texture(uAlpha, vec3(pOverlay1.xy, pOverlay1.w));
t1.a = a.a;
}
}
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) {
vec4 a = texture(uAlpha, vec3(pOverlay2.xy, pOverlay2.w));
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;
vec4 result = vec4(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) {
vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w));
result.a = 1.0 - a0.a;
@ -123,7 +129,7 @@ vec3 applyFog(vec3 lit, vec3 worldPos) {
void main() {
vec4 baseColor = vec4(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);

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 IReadOnlyDictionary<uint, uint> TerrainTypeToLayer { 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) ---
public uint GlAlphaTexture { get; }
@ -86,6 +92,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
GL gl,
Wb.BindlessSupport? bindless,
uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount,
IReadOnlyList<float> tilingByLayer,
uint glAlphaTexture, int alphaLayerCount,
IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers,
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes)
@ -95,6 +102,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
GlTexture = glTexture;
TerrainTypeToLayer = map;
LayerCount = layerCount;
TilingByLayer = tilingByLayer;
GlAlphaTexture = glAlphaTexture;
AlphaLayerCount = alphaLayerCount;
CornerAlphaLayers = cornerLayers;
@ -125,6 +133,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
// ---- Terrain atlas (unchanged Phase 2b logic) ----
var decodedByType = new Dictionary<uint, DecodedTexture>();
var tilingByType = new Dictionary<uint, uint>();
int maxW = 1, maxH = 1;
foreach (var tmtd in terrainDesc)
{
@ -132,6 +141,12 @@ public sealed unsafe class TerrainAtlas : IDisposable
if (decodedByType.ContainsKey(typeKey))
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 st = dats.Get<SurfaceTexture>(surfaceTextureId);
if (st is null || st.Textures.Count == 0)
@ -141,6 +156,10 @@ public sealed unsafe class TerrainAtlas : IDisposable
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]);
if (rs is null)
{
@ -183,6 +202,12 @@ public sealed unsafe class TerrainAtlas : IDisposable
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.
gl.GenerateMipmap(TextureTarget.Texture2DArray);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear);
@ -203,7 +228,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
return new TerrainAtlas(
gl,
bindless,
tex, map, layerCount,
tex, map, layerCount, tilingByLayer,
alphaBuild.gl, alphaBuild.layerCount,
alphaBuild.corner, alphaBuild.side, alphaBuild.road,
alphaBuild.cornerTCodes, alphaBuild.sideTCodes, alphaBuild.roadRCodes);
@ -410,6 +435,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
gl,
bindless,
tex, new Dictionary<uint, uint> { [0] = 0u }, 1,
TerrainTextureTilingTable.Build(Array.Empty<(uint Layer, uint RepeatCount)>()),
alphaTex, 1,
Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(),
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).
private int _uTerrainHandleLoc;
private int _uAlphaHandleLoc;
private int _uTexTilingLoc;
private bool _textureTilingUploaded;
// Reusable per-frame buffers.
private readonly List<int> _visibleSlots = new();
@ -90,6 +92,9 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle");
_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();
_globalVbo = _gl.GenBuffer();
@ -267,6 +272,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// pre-positions terrain to the outdoor landcell, but acdream uses the
// unified camera matrix everywhere, so no separate viewpoint divergence can occur.
_shader.Use();
UploadTextureTilingOnce();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
@ -331,6 +337,34 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// 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>
/// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared
/// <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);
}
}