acdream/src/AcDream.App/Rendering/TerrainAtlas.cs
Erik dcdd102824 docs(vm1): closeout - AP-232 for the translucent detail blend weight; sampler test pins the production constant
Opus narrow re-review of ae651312: APPROVE. Closes its three residuals:
- AP-232 filed: retail's single-pass stage-1 OUTPUT alpha
  (MODULATE(TEXTURE, CURRENT) @0x0059c549) is the blend weight for a
  translucent subset; acdream's two-draw model is exact for opaque
  subsets (fog identity pinned) and a bounded weight difference on
  translucent ones. Distinct from AP-34 (queue order). Owed since
  05970306.
- TerrainAtlas.DetailSamplerDescription names the production sampler
  (WRAP/LINEAR x3 per ACRender::SetDetailSurfaceInternal @0x006b6280);
  the test now asserts that constant's properties instead of a
  test-local copy.
- Plan VM1 section: fragment now described as fogged; VM1 marked CLOSED
  with the Holtburg measurement (+2.17/+0.57/+0.16 vs predicted
  +2.2/+0.66/+0.16) and the detail-on cost (+0.3-0.5 ms CPU at Arwic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:50:06 +02:00

698 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AcDream.App.Rendering.Gpu;
using AcDream.Core.Textures;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatPixelFormat = DatReaderWriter.Enums.PixelFormat;
namespace AcDream.App.Rendering;
/// <summary>
/// Holds both texture arrays the terrain renderer samples from:
/// <list type="bullet">
/// <item><description>
/// <b>Terrain atlas</b> — one GL_TEXTURE_2D_ARRAY layer per terrain type
/// (grass, dirt, sand, forest...), sourced from
/// Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc.
/// </description></item>
/// <item><description>
/// <b>Alpha atlas</b> — one GL_TEXTURE_2D_ARRAY layer per blend mask,
/// sourced from CornerTerrainMaps / SideTerrainMaps / RoadMaps in the
/// same TexMerge. Used by the fragment shader to blend up to three
/// terrain overlays and two roads on top of a base cell texture.
/// </description></item>
/// </list>
/// The alpha atlas is built but not yet sampled by any shader — that wiring
/// lands in Phase 3c.4 along with the shader rewrite.
/// </summary>
public sealed class TerrainAtlas : IDisposable
{
/// <summary>
/// Retail's category-scoped detail surface, resolved from one
/// <c>TexMerge.TerrainDesc</c> entry. The texture-table slot samples a
/// one-layer 2-D array so it uses the same backend-neutral table contract
/// as every other world texture.
/// </summary>
/// <summary>
/// Retail's detail-stage sampler: <c>ACRender::SetDetailSurfaceInternal</c>
/// (0x006b6280) sets WRAP/WRAP addressing and LINEAR/LINEAR/LINEAR filtering
/// on the detail stage. The LINEAR mip chain is retail's only distance
/// attenuation for built meshes (VM1), so this constant is load-bearing and
/// pinned by <c>TerrainAtlasDetailTextureTests</c>.
/// </summary>
internal static readonly GpuSamplerDescription DetailSamplerDescription =
GpuSamplerDescription.WorldRepeat;
internal readonly record struct RetailDetailTextureBinding(
GpuTextureSlot TextureSlot,
float Tiling,
uint SurfaceTextureId,
uint RenderSurfaceId,
int Width,
int Height)
{
public bool IsAvailable =>
TextureSlot.IsAssigned
&& SurfaceTextureId != 0
&& RenderSurfaceId != 0;
}
private sealed record DetailTextureResource(
IGpuTexture Texture,
RetailDetailTextureBinding Binding);
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 int AlphaLayerCount { get; }
/// <summary>Layer indices in the alpha atlas for CornerTerrainMaps (typically 4 entries).</summary>
public IReadOnlyList<byte> CornerAlphaLayers { get; }
/// <summary>Layer indices in the alpha atlas for SideTerrainMaps (typically 4 entries).</summary>
public IReadOnlyList<byte> SideAlphaLayers { get; }
/// <summary>Layer indices in the alpha atlas for RoadMaps (variable count).</summary>
public IReadOnlyList<byte> RoadAlphaLayers { get; }
// --- Parallel TCode/RCode arrays (added in Phase 3c.4 for BuildSurface) ---
/// <summary>TCode for each CornerTerrainMap, parallel to <see cref="CornerAlphaLayers"/>.</summary>
public IReadOnlyList<uint> CornerAlphaTCodes { get; }
/// <summary>TCode for each SideTerrainMap, parallel to <see cref="SideAlphaLayers"/>.</summary>
public IReadOnlyList<uint> SideAlphaTCodes { get; }
/// <summary>RCode for each RoadMap, parallel to <see cref="RoadAlphaLayers"/>.</summary>
public IReadOnlyList<uint> RoadAlphaRCodes { get; }
/// <summary>
/// Retail detail category 1. <c>DrawBuilding</c> is the only live object
/// path that consumes it; ordinary scenery and terrain do not.
/// </summary>
internal RetailDetailTextureBinding BuildingDetailTexture { get; }
/// <summary>
/// Retail detail category 2. <c>DrawEnvCell</c> consumes it for interior
/// cell shells.
/// </summary>
internal RetailDetailTextureBinding EnvironmentDetailTexture { get; }
/// <summary>
/// Campaign V slice V6i-2: both arrays are <see cref="IGpuTexture"/>s the
/// device created, and both slots were registered at build time, so
/// <see cref="TextureSlots"/> is a field read rather than a residency
/// negotiation. The GL construction path this used to sit alongside is
/// deleted as of Campaign V slice V11.
/// </summary>
private sealed class RhiArrays(
IGpuDevice device,
IGpuTexture terrain,
IGpuTexture alpha,
IGpuSampler alphaSampler)
{
public IGpuDevice Device { get; } = device;
public IGpuTexture Terrain { get; } = terrain;
public IGpuTexture Alpha { get; } = alpha;
public IGpuSampler AlphaSampler { get; } = alphaSampler;
public IGpuSampler? TerrainSampler { get; set; }
public IGpuTexture? BuildingDetailTexture { get; set; }
public IGpuTexture? EnvironmentDetailTexture { get; set; }
public GpuTextureSlot TerrainSlot { get; set; } = GpuTextureSlot.Unassigned;
public GpuTextureSlot AlphaSlot { get; set; } = GpuTextureSlot.Unassigned;
}
private readonly RhiArrays _rhi;
/// <summary>
/// The device-table slots for the terrain and alpha arrays. Registered once
/// at build time and re-registered only by <see cref="SetAnisotropic"/>,
/// which changes the sampler.
/// </summary>
internal (GpuTextureSlot Terrain, GpuTextureSlot Alpha) TextureSlots =>
(_rhi.TerrainSlot, _rhi.AlphaSlot);
/// <summary>
/// Retail's terrain arrays are trilinear-filtered with the highest anisotropy
/// the quality preset allows; <see cref="SetAnisotropic"/> lowers it.
/// </summary>
private const float RetailMaxAnisotropy = 16f;
private TerrainAtlas(
IGpuDevice device,
IGpuTexture terrain,
IGpuTexture alpha,
IGpuSampler alphaSampler,
IReadOnlyDictionary<uint, uint> map,
int layerCount,
IReadOnlyList<float> tilingByLayer,
int alphaLayerCount,
IReadOnlyList<byte> cornerLayers,
IReadOnlyList<byte> sideLayers,
IReadOnlyList<byte> roadLayers,
IReadOnlyList<uint> cornerTCodes,
IReadOnlyList<uint> sideTCodes,
IReadOnlyList<uint> roadRCodes,
DetailTextureResource? buildingDetail,
DetailTextureResource? environmentDetail)
{
_rhi = new RhiArrays(device, terrain, alpha, alphaSampler);
TerrainTypeToLayer = map;
LayerCount = layerCount;
TilingByLayer = tilingByLayer;
AlphaLayerCount = alphaLayerCount;
CornerAlphaLayers = cornerLayers;
SideAlphaLayers = sideLayers;
RoadAlphaLayers = roadLayers;
CornerAlphaTCodes = cornerTCodes;
SideAlphaTCodes = sideTCodes;
RoadAlphaRCodes = roadRCodes;
_rhi.BuildingDetailTexture = buildingDetail?.Texture;
_rhi.EnvironmentDetailTexture = environmentDetail?.Texture;
BuildingDetailTexture = buildingDetail?.Binding ?? default;
EnvironmentDetailTexture = environmentDetail?.Binding ?? default;
_rhi.AlphaSlot = device.RegisterTexture(alpha, alphaSampler);
ApplyAnisotropic(RetailMaxAnisotropy);
}
/// <summary>
/// Campaign V slice V6i-2: the decode both construction paths share.
/// Splitting it out is what keeps the CPU logic single while the upload
/// forks — plan §3.1's rule. Nothing here touches a graphics API.
/// </summary>
private readonly record struct TerrainLayerDecode(
Dictionary<uint, DecodedTexture> DecodedByType,
Dictionary<uint, uint> TilingByType,
int MaxWidth,
int MaxHeight);
private static TerrainLayerDecode DecodeTerrainLayers(
IDatReaderWriter dats,
IReadOnlyList<DatReaderWriter.Types.TMTerrainDesc> terrainDesc)
{
var decodedByType = new Dictionary<uint, DecodedTexture>();
var tilingByType = new Dictionary<uint, uint>();
int maxW = 1, maxH = 1;
foreach (var tmtd in terrainDesc)
{
uint typeKey = (uint)tmtd.TerrainType;
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)
{
Console.WriteLine($"WARN: TerrainType {tmtd.TerrainType} SurfaceTexture 0x{surfaceTextureId:X8} missing");
decodedByType[typeKey] = DecodedTexture.Magenta;
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)
{
decodedByType[typeKey] = DecodedTexture.Magenta;
continue;
}
Palette? palette = rs.DefaultPaletteId != 0
? dats.Get<Palette>(rs.DefaultPaletteId)
: null;
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
decodedByType[typeKey] = decoded;
if (decoded.Width > maxW) maxW = decoded.Width;
if (decoded.Height > maxH) maxH = decoded.Height;
}
return new TerrainLayerDecode(decodedByType, tilingByType, maxW, maxH);
}
/// <summary>
/// Slice V6i-2: the alpha-map decode, shared by both construction paths for
/// the same reason <see cref="DecodeTerrainLayers"/> is.
/// </summary>
private sealed record AlphaLayerDecode(
List<DecodedTexture> Decoded,
List<byte> CornerLayers,
List<byte> SideLayers,
List<byte> RoadLayers,
List<uint> CornerTCodes,
List<uint> SideTCodes,
List<uint> RoadRCodes,
int MaxWidth,
int MaxHeight);
private static AlphaLayerDecode DecodeAlphaLayers(
IDatReaderWriter dats,
DatReaderWriter.Types.TexMerge texMerge)
{
var decoded = new List<DecodedTexture>();
var cornerLayers = new List<byte>();
var sideLayers = new List<byte>();
var roadLayers = new List<byte>();
var cornerTCodes = new List<uint>();
var sideTCodes = new List<uint>();
var roadRCodes = new List<uint>();
foreach (var entry in texMerge.CornerTerrainMaps)
{
if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex))
{
cornerLayers.Add((byte)decoded.Count);
cornerTCodes.Add(entry.TCode);
decoded.Add(dtex);
}
else
{
Console.WriteLine($"WARN: CornerTerrainMap TextureId 0x{(uint)entry.TextureId:X8} failed to decode");
}
}
foreach (var entry in texMerge.SideTerrainMaps)
{
if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex))
{
sideLayers.Add((byte)decoded.Count);
sideTCodes.Add(entry.TCode);
decoded.Add(dtex);
}
else
{
Console.WriteLine($"WARN: SideTerrainMap TextureId 0x{(uint)entry.TextureId:X8} failed to decode");
}
}
foreach (var entry in texMerge.RoadMaps)
{
if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex))
{
roadLayers.Add((byte)decoded.Count);
roadRCodes.Add(entry.RCode);
decoded.Add(dtex);
}
else
{
Console.WriteLine($"WARN: RoadMap TextureId 0x{(uint)entry.TextureId:X8} failed to decode");
}
}
// Alpha maps should all be uniform size (WorldBuilder asserts 512×512).
// Fall back to the max observed so a stray mismatch doesn't crash us.
int decodedMaxW = 1, decodedMaxH = 1;
foreach (var d in decoded)
{
if (d.Width > decodedMaxW) decodedMaxW = d.Width;
if (d.Height > decodedMaxH) decodedMaxH = d.Height;
}
return new AlphaLayerDecode(
decoded,
cornerLayers,
sideLayers,
roadLayers,
cornerTCodes,
sideTCodes,
roadRCodes,
decodedMaxW,
decodedMaxH);
}
/// <summary>
/// Builds both arrays through <see cref="IGpuDevice"/>. The terrain array
/// gets a full mip chain (<see cref="IGpuTexture.GenerateMipChain"/> blits
/// it, because RGBA8 is a legal blit destination) and a repeat/anisotropic
/// sampler; the alpha array is single-level and clamped, matching what the
/// deleted GL path's texture parameters said.
/// </summary>
internal static TerrainAtlas BuildBackendNeutral(IGpuDevice device, IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(device);
ArgumentNullException.ThrowIfNull(dats);
var region = dats.Get<Region>(0x13000000u)
?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
var texMerge = region.TerrainInfo?.LandSurfaces?.TexMerge;
var terrainDesc = texMerge?.TerrainDesc;
Dictionary<uint, DecodedTexture> decodedByType;
Dictionary<uint, uint> tilingByType;
int maxW, maxH;
if (terrainDesc is null || terrainDesc.Count == 0)
{
Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
decodedByType = new Dictionary<uint, DecodedTexture> { [0u] = WhitePixel() };
tilingByType = [];
maxW = 1;
maxH = 1;
}
else
{
TerrainLayerDecode decode = DecodeTerrainLayers(dats, terrainDesc);
decodedByType = decode.DecodedByType;
tilingByType = decode.TilingByType;
maxW = decode.MaxWidth;
maxH = decode.MaxHeight;
}
AlphaLayerDecode alpha = texMerge is null
? new AlphaLayerDecode([], [], [], [], [], [], [], 1, 1)
: DecodeAlphaLayers(dats, texMerge);
List<DecodedTexture> alphaDecoded = alpha.Decoded;
int alphaLayerCount = Math.Max(1, alphaDecoded.Count);
int alphaW = alphaDecoded.Count == 0 ? 1 : alpha.MaxWidth;
int alphaH = alphaDecoded.Count == 0 ? 1 : alpha.MaxHeight;
int layerCount = decodedByType.Count;
int mipLevels = Wb.RhiWorldTextureArray.MipLevelsFor(maxW, maxH);
IGpuTexture? terrainTexture = null;
IGpuTexture? alphaTexture = null;
IGpuSampler? detailSampler = null;
DetailTextureResource? buildingDetail = null;
DetailTextureResource? environmentDetail = null;
try
{
terrainTexture = device.CreateTexture(new GpuTextureDescription(
"terrain-atlas",
GpuTextureKind.Texture2DArray,
GpuTextureFormat.Rgba8Unorm,
maxW,
maxH,
layerCount,
mipLevels));
var map = new Dictionary<uint, uint>(layerCount);
int layerIdx = 0;
foreach (var kvp in decodedByType)
{
byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH);
terrainTexture.Upload(0, layerIdx, buffer);
map[kvp.Key] = (uint)layerIdx;
layerIdx++;
}
// A.5 T19's mip chain, built by the device rather than by
// glGenerateMipmap. RGBA8 blits, so this is the GPU path.
terrainTexture.GenerateMipChain();
var tilingByLayer = TerrainTextureTilingTable.Build(
map.Select(entry =>
(entry.Value, tilingByType.TryGetValue(entry.Key, out uint repeatCount)
? repeatCount
: 1u)));
alphaTexture = device.CreateTexture(new GpuTextureDescription(
"terrain-alpha-atlas",
GpuTextureKind.Texture2DArray,
GpuTextureFormat.Rgba8Unorm,
alphaW,
alphaH,
alphaLayerCount,
MipLevelCount: 1));
if (alphaDecoded.Count == 0)
{
Console.WriteLine("WARN: no alpha maps loaded; alpha atlas will be a 1x1 white fallback");
alphaTexture.Upload(0, 0, [0xFF, 0xFF, 0xFF, 0xFF]);
}
else
{
for (int i = 0; i < alphaDecoded.Count; i++)
alphaTexture.Upload(0, i, ResizeRgba8Nearest(alphaDecoded[i], alphaW, alphaH));
}
IGpuSampler alphaSampler = device.CreateSampler(GpuSamplerDescription.WorldClamp with
{
MipFilter = GpuMipFilter.None,
});
// Retail LScape::SetDetailTexturing category indices:
// 1 = building, 2 = environment/EnvCell.
// ChangeRegion and the only reachable SmartBox caller keep
// landscape (0) and object (3) disabled, so do not create or expose
// those categories here.
detailSampler = device.CreateSampler(DetailSamplerDescription);
if (terrainDesc is { Count: > 1 })
{
buildingDetail = TryCreateDetailTexture(
device,
dats,
detailSampler,
terrainDesc[1],
"building");
}
if (terrainDesc is { Count: > 2 })
{
environmentDetail = TryCreateDetailTexture(
device,
dats,
detailSampler,
terrainDesc[2],
"environment");
}
Console.WriteLine(
$"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} ({mipLevels} mip levels)");
Console.WriteLine(
$"AlphaAtlas: {alphaLayerCount} layers at {alphaW}x{alphaH} "
+ $"(corners={alpha.CornerLayers.Count}, sides={alpha.SideLayers.Count}, "
+ $"roads={alpha.RoadLayers.Count})");
return new TerrainAtlas(
device,
terrainTexture,
alphaTexture,
alphaSampler,
map,
layerCount,
tilingByLayer,
alphaLayerCount,
alpha.CornerLayers,
alpha.SideLayers,
alpha.RoadLayers,
alpha.CornerTCodes,
alpha.SideTCodes,
alpha.RoadRCodes,
buildingDetail,
environmentDetail);
}
catch
{
DisposeDetailTextureResource(device, environmentDetail);
DisposeDetailTextureResource(device, buildingDetail);
alphaTexture?.Dispose();
terrainTexture?.Dispose();
throw;
}
}
private static DetailTextureResource? TryCreateDetailTexture(
IGpuDevice device,
IDatReaderWriter dats,
IGpuSampler sampler,
DatReaderWriter.Types.TMTerrainDesc terrain,
string categoryName)
{
uint surfaceTextureId = (uint)terrain.TerrainTex.DetailTextureId;
if (surfaceTextureId == 0)
return null;
SurfaceTexture? surfaceTexture = dats.Get<SurfaceTexture>(surfaceTextureId);
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
{
Console.WriteLine(
$"WARN: retail {categoryName} detail SurfaceTexture "
+ $"0x{surfaceTextureId:X8} missing");
return null;
}
uint renderSurfaceId = (uint)surfaceTexture.Textures[0];
RenderSurface? renderSurface = dats.Get<RenderSurface>(renderSurfaceId);
if (renderSurface is null)
{
Console.WriteLine(
$"WARN: retail {categoryName} detail RenderSurface "
+ $"0x{renderSurfaceId:X8} missing");
return null;
}
Palette? palette = renderSurface.DefaultPaletteId != 0
? dats.Get<Palette>(renderSurface.DefaultPaletteId)
: null;
DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface(
renderSurface,
palette);
if (ReferenceEquals(decoded, DecodedTexture.Magenta))
{
Console.WriteLine(
$"WARN: retail {categoryName} detail RenderSurface "
+ $"0x{renderSurfaceId:X8} failed to decode");
return null;
}
int mipLevels = Wb.RhiWorldTextureArray.MipLevelsFor(
decoded.Width,
decoded.Height);
IGpuTexture? texture = null;
GpuTextureSlot slot = GpuTextureSlot.Unassigned;
try
{
texture = device.CreateTexture(new GpuTextureDescription(
$"retail-detail-{categoryName}",
GpuTextureKind.Texture2DArray,
GpuTextureFormat.Rgba8Unorm,
decoded.Width,
decoded.Height,
LayerCount: 1,
MipLevelCount: mipLevels));
texture.Upload(0, 0, decoded.Rgba8);
texture.GenerateMipChain();
slot = device.RegisterTexture(texture, sampler);
var binding = new RetailDetailTextureBinding(
slot,
terrain.TerrainTex.DetailTexTiling,
surfaceTextureId,
renderSurfaceId,
decoded.Width,
decoded.Height);
Console.WriteLine(
$"Retail detail {categoryName}: SurfaceTexture "
+ $"0x{surfaceTextureId:X8} -> RenderSurface "
+ $"0x{renderSurfaceId:X8}, {decoded.Width}x{decoded.Height}, "
+ $"tiling={binding.Tiling}");
return new DetailTextureResource(texture, binding);
}
catch
{
if (slot.IsAssigned)
device.ReleaseTextureSlot(slot);
texture?.Dispose();
throw;
}
}
private static void DisposeDetailTextureResource(
IGpuDevice device,
DetailTextureResource? resource)
{
if (resource is null)
return;
if (resource.Binding.IsAvailable)
device.ReleaseTextureSlot(resource.Binding.TextureSlot);
resource.Texture.Dispose();
}
private static DecodedTexture WhitePixel() =>
new([0xFF, 0xFF, 0xFF, 0xFF], 1, 1);
/// <summary>
/// Slice V6i-2: the backend-neutral anisotropy change. GL mutates a texture
/// parameter; Vulkan bakes filtering into the sampler, so the level change
/// is a new sampler and a re-registration of the terrain slot. The
/// superseded slot is released in the same step, exactly as the bindless arm
/// does when a handle changes.
/// </summary>
private void ApplyAnisotropic(float level)
{
RhiArrays rhi = _rhi!;
IGpuSampler sampler = rhi.Device.CreateSampler(GpuSamplerDescription.WorldRepeat with
{
MaxAnisotropy = Math.Max(1f, level),
});
if (ReferenceEquals(rhi.TerrainSampler, sampler) && rhi.TerrainSlot.IsAssigned)
return;
if (rhi.TerrainSlot.IsAssigned)
rhi.Device.ReleaseTextureSlot(rhi.TerrainSlot);
rhi.TerrainSampler = sampler;
rhi.TerrainSlot = rhi.Device.RegisterTexture(rhi.Terrain, sampler);
}
private static bool TryDecodeAlphaMap(IDatReaderWriter dats, uint surfaceTextureId, out DecodedTexture decoded)
{
decoded = DecodedTexture.Magenta;
var st = dats.Get<SurfaceTexture>(surfaceTextureId);
if (st is null || st.Textures.Count == 0)
return false;
var rs = dats.Get<RenderSurface>((uint)st.Textures[0]);
if (rs is null)
return false;
// Alpha maps ship as PFID_CUSTOM_LSCAPE_ALPHA (AC's landscape-alpha
// format) or the more generic PFID_A8; terrain blending alpha masks
// MUST use isAdditive=true so R=G=B=A=val — the terrain fragment shader
// reads .r for the blend weight. Palette is not used.
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true);
if (ReferenceEquals(d, DecodedTexture.Magenta))
return false;
decoded = d;
return true;
}
private static byte[] ResizeRgba8Nearest(DecodedTexture src, int dstW, int dstH)
{
if (src.Width == dstW && src.Height == dstH)
return src.Rgba8;
var dst = new byte[dstW * dstH * 4];
for (int y = 0; y < dstH; y++)
{
int srcY = y * src.Height / dstH;
for (int x = 0; x < dstW; x++)
{
int srcX = x * src.Width / dstW;
int si = (srcY * src.Width + srcX) * 4;
int di = (y * dstW + x) * 4;
dst[di + 0] = src.Rgba8[si + 0];
dst[di + 1] = src.Rgba8[si + 1];
dst[di + 2] = src.Rgba8[si + 2];
dst[di + 3] = src.Rgba8[si + 3];
}
}
return dst;
}
/// <summary>
/// Update terrain-array anisotropy at runtime (called by
/// <see cref="AcDream.App.Settings.RuntimeSettingsController.ReapplyQualityPreset"/> when
/// the user changes Quality preset mid-session). Idempotent — calling with
/// the same level as the current setting is safe and produces no visual
/// change.
/// </summary>
public void SetAnisotropic(int level)
{
ApplyAnisotropic(level);
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
}
private bool _disposed;
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
// Slice V4t's teardown rule remains load-bearing: GameWindowLifetime
// disposes the device before this atlas, so normal teardown must not
// call back into its texture table. Build/registration failure paths
// above still release detail slots while the device is known alive.
_rhi.EnvironmentDetailTexture?.Dispose();
_rhi.BuildingDetailTexture?.Dispose();
_rhi.Alpha.Dispose();
_rhi.Terrain.Dispose();
}
}