Opus narrow re-review ofae651312: 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 since05970306. - 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>
698 lines
27 KiB
C#
698 lines
27 KiB
C#
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();
|
||
}
|
||
}
|