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:
parent
84b7d2d7cd
commit
bb5acab9e6
7 changed files with 297 additions and 7 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
40
src/AcDream.App/Rendering/TerrainTextureTilingTable.cs
Normal file
40
src/AcDream.App/Rendering/TerrainTextureTilingTable.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue