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:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -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). -->
|
||||
|
|
|
|||
161
src/AcDream.Content/BoundedDatObjectCache.cs
Normal file
161
src/AcDream.Content/BoundedDatObjectCache.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
145
src/AcDream.Content/DecodedTextureCache.cs
Normal file
145
src/AcDream.Content/DecodedTextureCache.cs
Normal 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);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue