fix(rendering): bound portal resource lifetime

Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -9,6 +9,10 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Content.Tests" />
</ItemGroup>
<ItemGroup>
<!-- MP1a: the inline DXT decode moved from ObjectMeshManager uses these
exact versions (mirror AcDream.App.csproj — keep in lockstep). -->

View file

@ -0,0 +1,161 @@
using DatReaderWriter.DBObjs;
using DatReaderWriter.Lib.IO;
using System.Diagnostics.CodeAnalysis;
namespace AcDream.Content;
/// <summary>
/// Thread-safe LRU for unpacked DAT objects retained by
/// <see cref="DatDatabaseWrapper"/>.
/// </summary>
/// <remarks>
/// The underlying <see cref="DatReaderWriter.DatCollection"/> remains the sole
/// reader and owner of the DAT databases. Entries here are managed object
/// references only; eviction therefore releases the adapter's reference and
/// never disposes or mutates an object.
///
/// DatReaderWriter objects do not expose an exact retained-size contract. The
/// byte budget is consequently conservative accounting rather than a hard
/// managed-heap limit: known raw texture payloads are charged exactly, while
/// other object graphs receive a fixed floor. The independent entry ceiling
/// provides the hard bound even when a generated DAT type grows an unmeasured
/// child graph.
/// </remarks>
internal sealed class BoundedDatObjectCache
{
internal const int DefaultEntryLimit = 256;
internal const long DefaultEstimatedByteLimit = 64L * 1024L * 1024L;
private const long UnknownObjectEstimate = 128L * 1024L;
private readonly record struct CacheKey(Type ObjectType, uint FileId);
private sealed record CacheEntry(
CacheKey Key,
IDBObj Value,
long EstimatedBytes);
private readonly int _entryLimit;
private readonly long _estimatedByteLimit;
private readonly Func<IDBObj, long> _estimateRetainedBytes;
private readonly Dictionary<CacheKey, LinkedListNode<CacheEntry>> _byKey = new();
private readonly LinkedList<CacheEntry> _leastRecentlyUsed = new();
private readonly object _gate = new();
private long _estimatedBytes;
internal BoundedDatObjectCache(
int entryLimit = DefaultEntryLimit,
long estimatedByteLimit = DefaultEstimatedByteLimit,
Func<IDBObj, long>? estimateRetainedBytes = null)
{
ArgumentOutOfRangeException.ThrowIfLessThan(entryLimit, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(estimatedByteLimit, 1);
_entryLimit = entryLimit;
_estimatedByteLimit = estimatedByteLimit;
_estimateRetainedBytes = estimateRetainedBytes ?? EstimateRetainedBytes;
}
internal int Count
{
get
{
lock (_gate)
return _byKey.Count;
}
}
internal long EstimatedBytes
{
get
{
lock (_gate)
return _estimatedBytes;
}
}
internal bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value)
where T : IDBObj
{
lock (_gate)
{
if (!_byKey.TryGetValue(new CacheKey(typeof(T), fileId), out var node))
{
value = default;
return false;
}
MarkMostRecentlyUsed(node);
value = (T)node.Value.Value;
return true;
}
}
/// <summary>
/// Returns the existing canonical object for a key, or admits
/// <paramref name="candidate"/> and returns it. An object larger than the
/// complete byte budget is served to the caller but is not retained.
/// </summary>
internal T GetOrAdd<T>(uint fileId, T candidate)
where T : IDBObj
{
ArgumentNullException.ThrowIfNull(candidate);
var key = new CacheKey(typeof(T), fileId);
long estimatedBytes = Math.Max(1L, _estimateRetainedBytes(candidate));
lock (_gate)
{
if (_byKey.TryGetValue(key, out var existing))
{
MarkMostRecentlyUsed(existing);
return (T)existing.Value.Value;
}
if (estimatedBytes > _estimatedByteLimit)
return candidate;
while (_byKey.Count >= _entryLimit
|| _estimatedBytes > _estimatedByteLimit - estimatedBytes)
{
EvictLeastRecentlyUsed();
}
var entry = new CacheEntry(key, candidate, estimatedBytes);
var node = _leastRecentlyUsed.AddLast(entry);
_byKey.Add(key, node);
_estimatedBytes += estimatedBytes;
return candidate;
}
}
private void MarkMostRecentlyUsed(LinkedListNode<CacheEntry> node)
{
if (!ReferenceEquals(node, _leastRecentlyUsed.Last))
{
_leastRecentlyUsed.Remove(node);
_leastRecentlyUsed.AddLast(node);
}
}
private void EvictLeastRecentlyUsed()
{
LinkedListNode<CacheEntry>? node = _leastRecentlyUsed.First;
if (node is null)
throw new InvalidOperationException("DAT object cache accounting is inconsistent.");
_leastRecentlyUsed.RemoveFirst();
_byKey.Remove(node.Value.Key);
_estimatedBytes -= node.Value.EstimatedBytes;
}
private static long EstimateRetainedBytes(IDBObj value)
{
// RenderSurface is the dominant byte-bearing object on the mesh path.
// Charge its unpacked source payload exactly, plus the conservative
// floor for the generated object and array overhead.
if (value is RenderSurface renderSurface)
return Math.Max(UnknownObjectEstimate, renderSurface.SourceData.LongLength + 256L);
return UnknownObjectEstimate;
}
}

View file

@ -2,7 +2,6 @@ using DatReaderWriter;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib.IO;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
@ -58,9 +57,44 @@ public sealed class DatCollectionAdapter : IDatReaderWriter {
public string SourceDirectory => _dats.Options.DatDirectory ?? string.Empty;
public IDatDatabase Portal => _portal;
public IDatDatabase Cell => _cell;
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions => _cellRegions;
public IDatDatabase HighRes => _highRes;
public IDatDatabase Language => _language;
public IDatDatabase Local => _language;
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj =>
TryGet<T>(fileId, out var value) ? value : default;
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj {
if (typeof(T) == typeof(DatReaderWriter.DBObjs.Iteration)) {
throw new Exception(
"Iteration is not a valid type to get from a dat file collection since it is used in all dat files. Use a specific dat like datCollection.Portal.Get<Iteration>()");
}
switch (_dats.TypeToDatFileType<T>()) {
case DatFileType.Cell:
return _cell.TryGet(fileId, out value);
case DatFileType.Portal:
return _portal.TryGet(fileId, out value)
|| _highRes.TryGet(fileId, out value);
case DatFileType.Local:
return _language.TryGet(fileId, out value);
default:
value = default;
return false;
}
}
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
_dats.TypeToDatFileType<T>() switch {
DatFileType.Cell => _cell.GetAllIdsOfType<T>(),
DatFileType.Portal => _portal.GetAllIdsOfType<T>()
.Concat(_highRes.GetAllIdsOfType<T>()),
DatFileType.Local => _language.GetAllIdsOfType<T>(),
_ => Array.Empty<uint>(),
};
// RegionFileMap is used by some WB internals but not by any acdream consumer.
public ReadOnlyDictionary<uint, uint> RegionFileMap =>
@ -122,8 +156,11 @@ public sealed class DatCollectionAdapter : IDatReaderWriter {
/// </summary>
public sealed class DatDatabaseWrapper : IDatDatabase {
private readonly DatDatabase _db;
private readonly ConcurrentDictionary<(Type, uint), IDBObj> _cache = new();
private readonly object _lock = new();
// One cache per database: a DatCollectionAdapter therefore retains at
// most 4 * 256 decoded entries and 4 * 64 MiB of estimated payload. The
// cache itself documents why estimated bytes are not a hard heap bound.
private readonly BoundedDatObjectCache _cache = new();
private readonly object _databaseLock = new();
public DatDatabaseWrapper(DatDatabase db) {
ArgumentNullException.ThrowIfNull(db);
@ -140,14 +177,19 @@ public sealed class DatDatabaseWrapper : IDatDatabase {
_db.GetAllIdsOfType<T>();
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj {
if (_cache.TryGetValue((typeof(T), fileId), out var cached)) {
value = (T)cached;
if (_cache.TryGet(fileId, out value)) {
return true;
}
lock (_lock) {
lock (_databaseLock) {
// A different reader may have populated the cache while this
// caller waited for the serialized DatDatabase read path.
if (_cache.TryGet(fileId, out value)) {
return true;
}
if (_db.TryGet<T>(fileId, out value)) {
_cache.TryAdd((typeof(T), fileId), value);
value = _cache.GetOrAdd(fileId, value);
return true;
}
@ -168,13 +210,13 @@ public sealed class DatDatabaseWrapper : IDatDatabase {
}
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) {
lock (_lock) {
lock (_databaseLock) {
return _db.TryGetFileBytes(fileId, out value);
}
}
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) {
lock (_lock) {
lock (_databaseLock) {
return _db.TryGetFileBytes(fileId, ref bytes, out bytesRead);
}
}

View file

@ -0,0 +1,145 @@
using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Threading;
namespace AcDream.Content;
/// <summary>
/// Thread-safe, byte-bounded residency for canonical decoded texture pixels.
/// Mesh extraction may run on several workers, so cached arrays are immutable
/// after admission and callers clone them before applying surface-local alpha.
/// </summary>
internal sealed class DecodedTextureCache {
private sealed record Entry(DecodedTextureKey Key, byte[] Pixels);
private readonly object _gate = new();
private readonly Dictionary<DecodedTextureKey, LinkedListNode<Entry>> _entries = new();
private readonly LinkedList<Entry> _lru = new();
private readonly ConcurrentDictionary<DecodedTextureKey, Lazy<byte[]>> _inflight = new();
private readonly long _maximumBytes;
private readonly int _maximumEntries;
private long _residentBytes;
public DecodedTextureCache(long maximumBytes, int maximumEntries) {
ArgumentOutOfRangeException.ThrowIfNegative(maximumBytes);
ArgumentOutOfRangeException.ThrowIfNegative(maximumEntries);
_maximumBytes = maximumBytes;
_maximumEntries = maximumEntries;
}
public int Count {
get {
lock (_gate) {
return _entries.Count;
}
}
}
public long ResidentBytes {
get {
lock (_gate) {
return _residentBytes;
}
}
}
public bool TryGet(DecodedTextureKey key, out byte[] pixels) {
lock (_gate) {
if (!_entries.TryGetValue(key, out var node)) {
pixels = null!;
return false;
}
Touch(node);
pixels = node.Value.Pixels;
return true;
}
}
/// <summary>
/// Returns the canonical pixels for <paramref name="key"/>. If another
/// worker admitted the same key first, its array wins. <paramref name="isCached"/>
/// reports whether the returned array is retained and therefore immutable.
/// </summary>
public byte[] RetainOrUse(DecodedTextureKey key, byte[] pixels, out bool isCached) {
ArgumentNullException.ThrowIfNull(pixels);
lock (_gate) {
if (_entries.TryGetValue(key, out var existing)) {
Touch(existing);
isCached = true;
return existing.Value.Pixels;
}
if (_maximumEntries == 0 || pixels.LongLength > _maximumBytes) {
isCached = false;
return pixels;
}
while (_lru.First is { } oldest
&& (_entries.Count >= _maximumEntries
|| _residentBytes + pixels.LongLength > _maximumBytes)) {
Remove(oldest);
}
var node = _lru.AddLast(new Entry(key, pixels));
_entries.Add(key, node);
_residentBytes += pixels.LongLength;
isCached = true;
return pixels;
}
}
/// <summary>
/// Decode a missing key once across concurrent mesh workers. The expensive
/// factory runs outside the LRU lock and unrelated keys proceed in parallel.
/// A failed or oversized decode is not stranded as permanent in-flight state.
/// </summary>
public byte[] GetOrCreate(
DecodedTextureKey key,
Func<byte[]> factory,
out bool isCached) {
ArgumentNullException.ThrowIfNull(factory);
if (TryGet(key, out byte[] cached)) {
isCached = true;
return cached;
}
var candidate = new Lazy<byte[]>(
factory,
LazyThreadSafetyMode.ExecutionAndPublication);
Lazy<byte[]> shared = _inflight.GetOrAdd(key, candidate);
try {
byte[] decoded = shared.Value;
return RetainOrUse(key, decoded, out isCached);
}
finally {
_inflight.TryRemove(
new KeyValuePair<DecodedTextureKey, Lazy<byte[]>>(key, shared));
}
}
private void Touch(LinkedListNode<Entry> node) {
if (node != _lru.Last) {
_lru.Remove(node);
_lru.AddLast(node);
}
}
private void Remove(LinkedListNode<Entry> node) {
_lru.Remove(node);
_entries.Remove(node.Value.Key);
_residentBytes -= node.Value.Pixels.LongLength;
}
}
/// <summary>
/// Every input that can change decoded RGBA belongs in the cache identity.
/// INDEX16/P8 clip-map conversion and A8 additive conversion are surface
/// dependent even when they reference the same RenderSurface DID.
/// </summary>
internal readonly record struct DecodedTextureKey(
uint RenderSurfaceId,
bool IsClipMap,
bool IsAdditive);

View file

@ -1,6 +1,7 @@
using DatReaderWriter;
using DatReaderWriter.Enums;
using DatReaderWriter.Lib.IO;
using AcDream.Core.Content;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
@ -20,7 +21,7 @@ namespace AcDream.Content;
/// <summary>
/// Interface for the dat reader/writer
/// </summary>
public interface IDatReaderWriter : IDisposable {
public interface IDatReaderWriter : IDisposable, IDatObjectSource {
/// <summary>
/// Gets the source directory of the DAT files.
/// </summary>
@ -36,6 +37,9 @@ public interface IDatReaderWriter : IDisposable {
/// </summary>
IDatDatabase Portal { get; }
/// <summary>The single cell database exposed by DatCollection.</summary>
IDatDatabase Cell { get; }
/// <summary>
/// The cell region databases. Each key is a cell region ID
/// </summary>
@ -51,6 +55,12 @@ public interface IDatReaderWriter : IDisposable {
/// </summary>
IDatDatabase Language { get; }
/// <summary>Alias matching DatCollection's Local database name.</summary>
IDatDatabase Local { get; }
/// <summary>Enumerates typed ids across the database selected for the type.</summary>
IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj;
/// <summary>
/// A mapping of region ids to region dat file entry ids. key: region id, value: region dat file entry
/// </summary>

View file

@ -10,7 +10,6 @@ using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
@ -34,10 +33,11 @@ public sealed class MeshExtractor {
private readonly ILogger _logger;
private readonly RetailPhysicsScriptLoader _physicsScripts;
// Cache for decoded textures to avoid redundant BCn decoding
private readonly ConcurrentQueue<uint> _decodedTextureLru = new();
private readonly ConcurrentDictionary<uint, byte[]> _decodedTextureCache = new();
private const int MaxDecodedTextures = 128;
// Canonical decoded pixels are immutable and bounded by bytes as well as
// count. A count-only cache can retain hundreds of MiB of large RGBA data.
private readonly DecodedTextureCache _decodedTextureCache = new(
maximumBytes: 64L * 1024 * 1024,
maximumEntries: 128);
private readonly ThreadLocal<BcDecoder> _bcDecoder = new(() => new BcDecoder());
/// <summary>
@ -370,6 +370,7 @@ public sealed class MeshExtractor {
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
uint paletteId = 0;
bool isDxt3or5 = false;
bool textureDataIsCached = false;
DatReaderWriter.Enums.PixelFormat? sourceFormat = null;
var isAdditive = false;
var isTransparent = false;
@ -395,37 +396,33 @@ public sealed class MeshExtractor {
texHeight = renderSurface.Height;
paletteId = renderSurface.DefaultPaletteId;
sourceFormat = renderSurface.Format;
isDxt3or5 = renderSurface.Format is
DatReaderWriter.Enums.PixelFormat.PFID_DXT3 or
DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
var decodedTextureKey = CreateDecodedTextureKey(
renderSurfaceId,
renderSurface.Format,
isClipMap,
surface.Type.HasFlag(SurfaceType.Additive));
if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) {
isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
textureFormat = TextureFormat.RGBA8;
uploadPixelFormat = UploadPixelFormat.Rgba;
if (_decodedTextureCache.TryGetValue(renderSurfaceId, out textureData!)) {
// use cached data
}
else {
textureData = new byte[texWidth * texHeight * 4];
CompressionFormat compressionFormat = renderSurface.Format switch {
DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1,
DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2,
DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3,
_ => throw new NotSupportedException($"Unsupported compressed format: {renderSurface.Format}")
};
using (var image = _bcDecoder.Value!.DecodeRawToImageRgba32(renderSurface.SourceData, texWidth, texHeight, compressionFormat)) {
image.CopyPixelDataTo(textureData);
}
_decodedTextureCache.TryAdd(renderSurfaceId, textureData);
}
textureData = _decodedTextureCache.GetOrCreate(
decodedTextureKey,
() => DecodeCompressedTexture(renderSurface, texWidth, texHeight),
out textureDataIsCached);
if (isClipMap && textureData != null) {
// If we got this from the cache, we need to clone it so we don't scale the cached raw data
if (_decodedTextureCache.ContainsKey(renderSurfaceId)) {
// Cached pixels are canonical and immutable. Surface-local
// alpha processing must never modify the shared array.
if (textureDataIsCached) {
var clonedData = new byte[textureData.Length];
System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length);
textureData = clonedData;
textureDataIsCached = false;
}
for (int i = 0; i < textureData.Length; i += 4) {
@ -437,8 +434,16 @@ public sealed class MeshExtractor {
}
else {
textureFormat = TextureFormat.RGBA8;
textureData = renderSurface.SourceData;
switch (renderSurface.Format) {
uploadPixelFormat = UploadPixelFormat.Rgba;
if (_decodedTextureCache.TryGet(
decodedTextureKey,
out byte[] cachedPixels)) {
textureData = cachedPixels;
textureDataIsCached = true;
}
else {
textureData = renderSurface.SourceData;
switch (renderSurface.Format) {
case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8:
textureData = new byte[texWidth * texHeight * 4];
TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight);
@ -486,12 +491,18 @@ public sealed class MeshExtractor {
break;
default:
throw new NotSupportedException($"Unsupported surface format: {renderSurface.Format}");
}
textureData = _decodedTextureCache.RetainOrUse(
decodedTextureKey,
textureData,
out textureDataIsCached);
}
}
if (surface.Translucency > 0.0f && textureData != null) {
// If we got this from the cache, we need to clone it so we don't scale the cached raw data
if (sourceFormat.HasValue && TextureHelpers.IsCompressedFormat(sourceFormat.Value) && _decodedTextureCache.ContainsKey(renderSurfaceId)) {
if (textureDataIsCached) {
var clonedData = new byte[textureData.Length];
System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length);
textureData = clonedData;
@ -749,8 +760,16 @@ public sealed class MeshExtractor {
texHeight = renderSurface.Height;
paletteId = renderSurface.DefaultPaletteId;
sourceFormat = renderSurface.Format;
isDxt3or5 = renderSurface.Format is
DatReaderWriter.Enums.PixelFormat.PFID_DXT3 or
DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
var decodedTextureKey = CreateDecodedTextureKey(
renderSurfaceId,
renderSurface.Format,
isClipMap,
surface.Type.HasFlag(SurfaceType.Additive));
if (_decodedTextureCache.TryGetValue(renderSurfaceId, out var cachedData)) {
if (_decodedTextureCache.TryGet(decodedTextureKey, out var cachedData)) {
textureData = cachedData;
textureFormat = TextureFormat.RGBA8;
uploadPixelFormat = UploadPixelFormat.Rgba;
@ -761,17 +780,10 @@ public sealed class MeshExtractor {
textureFormat = TextureFormat.RGBA8;
uploadPixelFormat = UploadPixelFormat.Rgba;
textureData = new byte[texWidth * texHeight * 4];
CompressionFormat compressionFormat = renderSurface.Format switch {
DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1,
DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2,
DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3,
_ => throw new NotSupportedException($"Unsupported compressed format: {renderSurface.Format}")
};
using (var image = _bcDecoder.Value!.DecodeRawToImageRgba32(renderSurface.SourceData, texWidth, texHeight, compressionFormat)) {
image.CopyPixelDataTo(textureData);
}
textureData = _decodedTextureCache.GetOrCreate(
decodedTextureKey,
() => DecodeCompressedTexture(renderSurface, texWidth, texHeight),
out _);
}
else {
textureFormat = TextureFormat.RGBA8;
@ -824,14 +836,12 @@ public sealed class MeshExtractor {
}
}
// Add to cache with LRU logic
if (textureData != null && _decodedTextureCache.TryAdd(renderSurfaceId, textureData)) {
_decodedTextureLru.Enqueue(renderSurfaceId);
if (_decodedTextureCache.Count > MaxDecodedTextures) {
if (_decodedTextureLru.TryDequeue(out var evictedId)) {
_decodedTextureCache.TryRemove(evictedId, out _);
}
}
if (!TextureHelpers.IsCompressedFormat(renderSurface.Format))
{
textureData = _decodedTextureCache.RetainOrUse(
decodedTextureKey,
textureData,
out _);
}
}
@ -996,6 +1006,48 @@ public sealed class MeshExtractor {
}
}
private static DecodedTextureKey CreateDecodedTextureKey(
uint renderSurfaceId,
DatReaderWriter.Enums.PixelFormat format,
bool isClipMap,
bool isAdditive)
{
bool clipAffectsDecode = format is
DatReaderWriter.Enums.PixelFormat.PFID_INDEX16 or
DatReaderWriter.Enums.PixelFormat.PFID_P8;
bool additiveAffectsDecode = format is
DatReaderWriter.Enums.PixelFormat.PFID_A8 or
DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA;
return new DecodedTextureKey(
renderSurfaceId,
clipAffectsDecode && isClipMap,
additiveAffectsDecode && isAdditive);
}
private byte[] DecodeCompressedTexture(
RenderSurface renderSurface,
int width,
int height)
{
var textureData = new byte[width * height * 4];
CompressionFormat compressionFormat = renderSurface.Format switch
{
DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1,
DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2,
DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3,
_ => throw new NotSupportedException(
$"Unsupported compressed format: {renderSurface.Format}"),
};
using var image = _bcDecoder.Value!.DecodeRawToImageRgba32(
renderSurface.SourceData,
width,
height,
compressionFormat);
image.CopyPixelDataTo(textureData);
return textureData;
}
private void BuildPolygonIndices(Polygon poly, GfxObj gfxObj, Vector3 scale,
Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup,
List<VertexPositionNormalTexture> vertices, List<ushort> indices, bool useNegSurface, ref bool hasWrappingUVs) {

View file

@ -42,6 +42,7 @@ public struct StagedEmitter {
/// Contains vertex data and per-batch index/texture info, but NO GPU resources.
/// </summary>
public class ObjectMeshData {
private long _estimatedUploadBytes = -1;
public ulong ObjectId { get; set; }
public bool IsSetup { get; set; }
public VertexPositionNormalTexture[] Vertices { get; set; } = Array.Empty<VertexPositionNormalTexture>();
@ -92,6 +93,36 @@ public class ObjectMeshData {
/// <summary>Edge line vertices for Environment wireframe rendering.</summary>
public Vector3[] EdgeLines { get; set; } = Array.Empty<Vector3>();
/// <summary>
/// Returns the immutable prepared payload's CPU-to-GPU work estimate.
/// Mesh extraction finishes populating the object before publishing it to
/// App, so this value can be cached once and reused by the CPU cache,
/// staging queue, retry path, and frame-budget planner without repeatedly
/// walking every material batch under their locks.
/// </summary>
public long GetEstimatedUploadBytes()
{
long cached = System.Threading.Volatile.Read(ref _estimatedUploadBytes);
if (cached >= 0)
return cached;
long bytes = IsSetup ? 1024L : 0L;
bytes = checked(bytes + (long)Vertices.Length * VertexPositionNormalTexture.Size);
foreach (List<TextureBatchData> batches in TextureBatches.Values)
{
foreach (TextureBatchData batch in batches)
{
bytes = checked(bytes + batch.TextureData.LongLength);
bytes = checked(bytes + (long)batch.Indices.Count * sizeof(ushort));
}
}
if (EnvCellGeometry is not null)
bytes = checked(bytes + EnvCellGeometry.GetEstimatedUploadBytes());
System.Threading.Interlocked.CompareExchange(ref _estimatedUploadBytes, bytes, -1);
return System.Threading.Volatile.Read(ref _estimatedUploadBytes);
}
}
/// <summary>

View file

@ -22,10 +22,10 @@ public sealed class RetailAnimationLoader : IAnimationLoader
private readonly DatDatabase? _database;
private readonly ConcurrentDictionary<uint, Lazy<Animation?>> _cache = new();
public RetailAnimationLoader(DatCollection dats)
public RetailAnimationLoader(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
_database = dats.Portal;
_database = dats.Portal.Db;
_readRaw = id => dats.Portal.TryGetFileBytes(id, out byte[]? bytes)
? bytes
: null;

View file

@ -21,10 +21,10 @@ public sealed class RetailPhysicsScriptLoader
private readonly DatDatabase? _database;
private readonly ConcurrentDictionary<uint, Lazy<DatPhysicsScript?>> _cache = new();
public RetailPhysicsScriptLoader(DatCollection dats)
public RetailPhysicsScriptLoader(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
_database = dats.Portal;
_database = dats.Portal.Db;
_readRaw = id => dats.Portal.TryGetFileBytes(id, out byte[]? bytes)
? bytes
: null;