Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The user reported wielded items subtly hiding particle effects, as if a translucent texture were missing. Root cause verified in source: the DAT authors a per-surface Translucency float, and the shared-atlas extraction honors it by baking (1 - Translucency) into the texture alpha (MeshExtractor). But a surface with an appearance override - ObjDesc subpalettes or texture changes, which wielded loot typically carries - routes through the per-instance composite paths instead (WbDrawDispatcher.ResolveTexture -> TextureCache GetOrUploadWithPaletteOverrideBindless / GetOrUploadWithOrigTextureOverrideBindless -> DecodeFromDats), and the textured decode there never saw the authored value: only the Base1Solid branch passed it (SurfaceDecoder.DecodeSolidColor); DecodeRenderSurface has no translucency input at all. Consequence: the part still classified translucent, still sorted in the RetailAlphaQueue, still drew with depth writes off - but with texture alpha = 1 it overwrote everything already composited behind it. Particles behind the part vanished; particles in front survived. The same GfxObj without overrides (atlas path) rendered correctly, which is why the loss was so selective and subtle. Fix: SurfaceDecoder.ApplyAuthoredTranslucency mirrors the atlas bake (in-place alpha scale, caller-owned buffers, Magenta sentinel guarded), and DecodeFromDats applies it behind an opt-in flag set by exactly the two world composite paths. The sky path stays unbaked (its shader applies the authored opacity separately - baking would double-apply, the AP-89 compounding class) and particle sheets stay unbaked (emitter-driven alpha, no authored-translucency consumer). Composite cache keys already include the surface id, so the baked alpha is cache-coherent. This closes an unregistered divergence (no register row existed; the fix restores parity with the shipped atlas mechanism, so none is added). Investigation evidence: equipped children and world objects share the same classification chain (ClassifyPackedBatches/GroupKey), so the gap was override-driven, not attachment-driven - a dropped item with the same ObjDesc was equally affected. Core SurfaceDecoder tests 22/22 (3 new); App Release suite 3,968 / 3 skips. Visual gate: a wielded item with authored-translucent parts must let its particle effects show through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1017 lines
46 KiB
C#
1017 lines
46 KiB
C#
// src/AcDream.App/Rendering/TextureCache.cs
|
|
using AcDream.Core.Textures;
|
|
using AcDream.Core.World;
|
|
using AcDream.Content;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using System.Linq;
|
|
using PixelFormatId = DatReaderWriter.Enums.PixelFormat;
|
|
using SurfaceType = DatReaderWriter.Enums.SurfaceType;
|
|
using AcDream.App.Rendering.Residency;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
public sealed class TextureCache
|
|
: Wb.IEntityTextureLifetime,
|
|
IDisposable
|
|
{
|
|
private readonly IGpuDevice _device;
|
|
private readonly IDatReaderWriter _dats;
|
|
private readonly string _diagnosticsDirectory;
|
|
private readonly Dictionary<(uint SurfaceId, uint OrigTextureId), (int Width, int Height)>
|
|
_decodedDimensionsByTexture = new();
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V4a: one registered <see cref="IGpuTexture"/> plus its
|
|
/// device texture-table <see cref="GpuTextureSlot"/>, decoded pixel size,
|
|
/// and the raw GL name <see cref="TextRenderer.DrawSprite"/>'s classic
|
|
/// texture-unit binding path still needs (see that class's remarks).
|
|
/// </summary>
|
|
private readonly record struct GpuUiTextureEntry(
|
|
IGpuTexture Texture,
|
|
GpuTextureSlot Slot,
|
|
uint GlName,
|
|
int Width,
|
|
int Height);
|
|
|
|
// Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids
|
|
// decoded directly (Portal/HighRes → DecodeRenderSurface), bypassing the
|
|
// Surface→SurfaceTexture chain that GetOrUpload uses for world materials.
|
|
private readonly Dictionary<uint, GpuUiTextureEntry> _renderSurfaceGpuTextures = new();
|
|
|
|
// Ad-hoc textures produced by the public UploadRgba8(byte[],int,int,bool) wrapper
|
|
// (used by IconComposer for composited item icons). These are NOT stored in any
|
|
// of the keyed caches above, so Dispose must sweep this list to avoid leaking
|
|
// GPU texture objects/slots until process exit.
|
|
private readonly List<GpuUiTextureEntry> _adhocGpuTextures = new();
|
|
|
|
private readonly CompositeTextureArrayCache? _compositeTextures;
|
|
private bool _destinationRevealUploadPriority;
|
|
|
|
// Standalone Texture2DArray caches. Shared world surfaces use WB's atlas;
|
|
// this base cache remains for consumers such as particle rendering.
|
|
// Per-entity override composites are owner-scoped but share pooled array
|
|
// storage. Retail CSurface ownership releases immediately while ImgTex
|
|
// residency remains separately purgeable; CompositeTextureArrayCache
|
|
// mirrors that split without one GL object per material composite.
|
|
private readonly StandaloneBindlessTextureCache? _particleTextures;
|
|
private readonly Dictionary<(uint surfaceId, uint origTexOverride), bool> _paletteIndexedByTexture = new();
|
|
|
|
internal int OwnedBindlessTextureCount => _compositeTextures?.ActiveResourceCount ?? 0;
|
|
internal int TextureOwnerCount => _compositeTextures?.OwnerCount ?? 0;
|
|
internal int CachedCompositeTextureCount => _compositeTextures?.CachedEntryCount ?? 0;
|
|
internal int CachedUnownedCompositeCount => _compositeTextures?.UnownedEntryCount ?? 0;
|
|
internal long CachedUnownedCompositeBytes => _compositeTextures?.UnownedBytes ?? 0;
|
|
internal int CompositeAtlasCount => _compositeTextures?.AtlasCount ?? 0;
|
|
internal long CompositeAtlasBytes => _compositeTextures?.AllocatedBytes ?? 0;
|
|
internal int CompositeFrameUploadCount => _compositeTextures?.FrameUploadCount ?? 0;
|
|
internal long CompositeFrameUploadBytes => _compositeTextures?.FrameUploadBytes ?? 0;
|
|
internal bool CanStartCompositeUpload => _compositeTextures?.CanStartUpload == true;
|
|
internal int CachedParticleTextureCount => _particleTextures?.EntryCount ?? 0;
|
|
internal int ActiveParticleTextureCount => _particleTextures?.ActiveResourceCount ?? 0;
|
|
internal int ParticleTextureOwnerCount => _particleTextures?.OwnerCount ?? 0;
|
|
internal int CachedUnownedParticleTextureCount => _particleTextures?.UnownedEntryCount ?? 0;
|
|
internal long CachedUnownedParticleTextureBytes => _particleTextures?.UnownedBytes ?? 0;
|
|
|
|
internal void SetDestinationRevealUploadPriority(bool enabled) =>
|
|
_destinationRevealUploadPriority = enabled;
|
|
|
|
// Phase N.6 slice 1 (2026-05-11): per-upload metadata for the
|
|
// ACDREAM_DUMP_SURFACES=1 histogram dump path. Populated at upload
|
|
// time so the dump method doesn't have to query GL state. Keyed by
|
|
// GL texture name (same key used in cache value tuples). Format
|
|
// label is "RGBA8_DECODED" for the post-decode upload (all uploads
|
|
// currently land as RGBA8 regardless of source format).
|
|
private readonly Dictionary<uint, (int Width, int Height, string Format)> _uploadMetadata = new();
|
|
|
|
// Frame counter for the one-shot ACDREAM_DUMP_SURFACES=1 trigger.
|
|
// Increments per Tick call; fires the dump once at frame index 600
|
|
// and never again for the session. See spec §5.
|
|
private int _dumpFrameCounter;
|
|
private bool _surfaceHistogramAlreadyDumped;
|
|
|
|
// internal, not public: IGpuDevice is an internal type (the pinned RHI
|
|
// contract), and this convenience overload has no real caller today (both
|
|
// production construction sites already target the internal overload
|
|
// below) — kept internal rather than deleted to preserve its shape.
|
|
internal TextureCache(IGpuDevice device, IDatReaderWriter dats)
|
|
: this(
|
|
device,
|
|
dats,
|
|
ImmediateGpuResourceRetirementQueue.Instance,
|
|
Path.Combine(
|
|
Path.GetTempPath(),
|
|
"acdream",
|
|
"diagnostics"))
|
|
{
|
|
}
|
|
|
|
internal TextureCache(
|
|
IGpuDevice device,
|
|
IDatReaderWriter dats,
|
|
IGpuResourceRetirementQueue retirementQueue,
|
|
string diagnosticsDirectory,
|
|
ResidencyBudgetOptions? budgets = null)
|
|
{
|
|
budgets ??= ResidencyBudgetOptions.Default;
|
|
_device = device ?? throw new ArgumentNullException(nameof(device));
|
|
_dats = dats;
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(diagnosticsDirectory);
|
|
_diagnosticsDirectory = diagnosticsDirectory;
|
|
ArgumentNullException.ThrowIfNull(retirementQueue);
|
|
|
|
// Campaign V slice V6l: both owner-scoped caches exist on both arms.
|
|
//
|
|
// Everything about them that matters — sharing equivalent surfaces
|
|
// between owners, the bounded unowned LRU, the metered upload budget,
|
|
// and retirement behind the frame-flight fence — is already
|
|
// backend-neutral; only how one entry is created and destroyed
|
|
// differs, which is exactly what the two backend interfaces are for.
|
|
var resources = new ResourceCleanupGroup();
|
|
CompositeTextureArrayCache? composite = null;
|
|
StandaloneBindlessTextureCache? particles = null;
|
|
try
|
|
{
|
|
composite = new CompositeTextureArrayCache(
|
|
new RhiCompositeTextureArrayBackend(device),
|
|
retirementQueue,
|
|
budgets.CompositeUnownedBytes,
|
|
budgets.CompositePhysicalBytes);
|
|
resources.Add("composite texture cache", composite.Dispose);
|
|
particles = new StandaloneBindlessTextureCache(
|
|
new ParticleRhiTextureBackend(this),
|
|
retirementQueue,
|
|
budgets.StandaloneUnownedBytes,
|
|
budgets.StandaloneUnownedEntries);
|
|
resources.Add("particle texture cache", particles.Dispose);
|
|
resources.TransferAll();
|
|
}
|
|
catch (Exception constructionFailure)
|
|
{
|
|
resources.RollbackConstructionAndThrow(
|
|
"TextureCache construction failed and its child-cache prefix did not cleanly roll back.",
|
|
constructionFailure);
|
|
}
|
|
|
|
_compositeTextures = composite;
|
|
_particleTextures = particles;
|
|
}
|
|
|
|
internal void RegisterResidencySources(ResidencyManager manager)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(manager);
|
|
if (_compositeTextures is not null)
|
|
{
|
|
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
|
|
ResidencyDomain.CompositeTextures,
|
|
_compositeTextures.CaptureResidency));
|
|
}
|
|
if (_particleTextures is not null)
|
|
{
|
|
manager.RegisterDomainSource(new DelegateResidencyDomainSource(
|
|
ResidencyDomain.StandaloneTextures,
|
|
CaptureStandaloneResidency));
|
|
}
|
|
}
|
|
|
|
private ResidencyDomainSnapshot CaptureStandaloneResidency()
|
|
{
|
|
StandaloneBindlessTextureCache textures = EnsureParticleTexturesAvailable();
|
|
return new ResidencyDomainSnapshot(
|
|
ResidencyDomain.StandaloneTextures,
|
|
EntryCount: textures.EntryCount,
|
|
OwnerCount: textures.OwnerCount,
|
|
Charges: new ResidencyCharges(
|
|
GpuResidentBytes: checked(
|
|
textures.AllocatedBytes - textures.RetiringBytes),
|
|
RetiringBytes: textures.RetiringBytes),
|
|
BudgetBytes: textures.BudgetBytes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded
|
|
/// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the
|
|
/// Surface→SurfaceTexture chain that <see cref="GetOrUpload(uint)"/> uses
|
|
/// for world-geometry materials. This is the correct path for retail UI
|
|
/// chrome + font glyph sheets, which reference RenderSurface directly.
|
|
/// Paletted (PFID_P8 / PFID_INDEX16) UI sprites — e.g. the selected-object
|
|
/// health-bar track 0x0600193E — are decoded against the RenderSurface's own
|
|
/// <c>DefaultPaletteId</c> (same starting palette <see cref="DecodeFromDats"/>
|
|
/// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns
|
|
/// a 1x1 magenta handle on miss.
|
|
///
|
|
/// <para>Campaign V slice V6d: the returned value is a
|
|
/// <see cref="UiTextureTableHandle"/> — a one-based index into the device's
|
|
/// global texture table — not a raw GL texture name. Every caller passes it
|
|
/// straight to <see cref="TextRenderer.DrawSprite"/>, which samples the
|
|
/// table; nothing reads it as a GL name, and on Vulkan there is no GL name.
|
|
/// Zero still means "no texture", which is what every widget guards on.</para>
|
|
/// </summary>
|
|
public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false)
|
|
{
|
|
if (_renderSurfaceGpuTextures.TryGetValue(renderSurfaceId, out GpuUiTextureEntry existing))
|
|
{
|
|
width = existing.Width; height = existing.Height;
|
|
return UiTextureTableHandle.FromSlot(existing.Slot);
|
|
}
|
|
|
|
DecodedTexture decoded;
|
|
if (_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
|
|
|| _dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
|
{
|
|
// Resolve the surface's own default palette so paletted UI sprites decode
|
|
// correctly instead of the magenta fallback (the back-track 0x0600193E behind
|
|
// the selected-object health bar is PFID_P8/INDEX16). Non-paletted formats
|
|
// (DefaultPaletteId==0) keep the previous null-palette behaviour unchanged.
|
|
Palette? palette = rs.DefaultPaletteId != 0
|
|
? _dats.Get<Palette>(rs.DefaultPaletteId)
|
|
: null;
|
|
decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
|
|
}
|
|
else
|
|
{
|
|
decoded = DecodedTexture.Magenta;
|
|
}
|
|
|
|
GpuUiTextureEntry entry = UploadUiTexture(decoded, nearest, $"ui-rendersurface-0x{renderSurfaceId:X8}");
|
|
_renderSurfaceGpuTextures[renderSurfaceId] = entry;
|
|
width = decoded.Width; height = decoded.Height;
|
|
return UiTextureTableHandle.FromSlot(entry.Slot);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V4a: creates an <see cref="IGpuTexture"/> for one
|
|
/// decoded UI sprite/atlas and registers it into the device's global
|
|
/// texture table. Every UI-path texture uses REPEAT addressing (existing
|
|
/// behaviour — panel fills and tiled chrome sample UVs greater than 1) and
|
|
/// a single mip level (UI sprites never mip).
|
|
///
|
|
/// <para>Campaign V slice V6d: the sampler is now what filtering actually
|
|
/// comes from. Before this slice the draw bound the texture object directly,
|
|
/// so filtering lived on the texture and <paramref name="nearest"/> was
|
|
/// applied with a raw <c>glTexParameter</c> before the bindless handle was
|
|
/// made resident. Sampling through the table means a bound sampler object
|
|
/// overrides those parameters, so a nearest-requested sprite has to be
|
|
/// registered with a nearest SAMPLER or every retail icon and dat-font
|
|
/// glyph would silently become bilinear.</para>
|
|
/// </summary>
|
|
/// <summary>
|
|
/// Campaign V slice V6k: one world Surface as a device texture-table slot,
|
|
/// sampled with the wrap mode the caller needs.
|
|
///
|
|
/// <para>The sky's RHI arm is the only consumer, and it exists because that
|
|
/// arm has no GL texture name to intern a bindless handle from — a Vulkan
|
|
/// draw cannot sample a GL handle. The decode is the same
|
|
/// <see cref="DecodeFromDats"/> the GL path uses, so the pixels are
|
|
/// identical; what differs is that the image is created through
|
|
/// <see cref="IGpuDevice.CreateTexture"/> and paired with a real sampler
|
|
/// object rather than baked into a handle.</para>
|
|
///
|
|
/// <para>Keyed by (surface, wrap) for the same reason the GL arm keys its
|
|
/// handles that way: a table entry is a combined image sampler, so the dome
|
|
/// sampled CLAMP_TO_EDGE and a scrolling cloud sheet sampled REPEAT are two
|
|
/// entries even when they name one decoded texture.</para>
|
|
/// </summary>
|
|
internal GpuTextureSlot RegisterWorldSurface(uint surfaceId, bool repeat)
|
|
{
|
|
var key = (surfaceId, repeat);
|
|
if (_worldSurfaceGpuTextures.TryGetValue(key, out GpuUiTextureEntry existing))
|
|
return existing.Slot;
|
|
|
|
DecodedTexture decoded = DecodeFromDats(
|
|
surfaceId,
|
|
origTextureOverride: null,
|
|
paletteOverride: null);
|
|
GpuUiTextureEntry entry = UploadWorldSurfaceTexture(
|
|
decoded,
|
|
repeat,
|
|
$"world-surface-0x{surfaceId:X8}{(repeat ? "-repeat" : "-clamp")}");
|
|
_worldSurfaceGpuTextures[key] = entry;
|
|
return entry.Slot;
|
|
}
|
|
|
|
private readonly Dictionary<(uint SurfaceId, bool Repeat), GpuUiTextureEntry>
|
|
_worldSurfaceGpuTextures = new();
|
|
|
|
private GpuUiTextureEntry UploadWorldSurfaceTexture(
|
|
DecodedTexture decoded,
|
|
bool repeat,
|
|
string debugName)
|
|
{
|
|
IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription(
|
|
debugName,
|
|
GpuTextureKind.Texture2D,
|
|
GpuTextureFormat.Rgba8Unorm,
|
|
Width: decoded.Width,
|
|
Height: decoded.Height,
|
|
LayerCount: 1,
|
|
MipLevelCount: 1));
|
|
try
|
|
{
|
|
texture.Upload(0, 0, decoded.Rgba8);
|
|
uint glName = UploadAccountingName(texture);
|
|
TrackUploadedTexture(glName, decoded.Width, decoded.Height);
|
|
|
|
// Linear/linear with a single level — the filtering
|
|
// TextureCache's own GL uploads have always used for sky surfaces,
|
|
// and the wrap mode SamplerCache's two objects express on GL.
|
|
IGpuSampler sampler = _device.CreateSampler(
|
|
repeat ? GpuSamplerDescription.WorldRepeat : GpuSamplerDescription.WorldClamp);
|
|
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
|
|
return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height);
|
|
}
|
|
catch
|
|
{
|
|
texture.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private GpuUiTextureEntry UploadUiTexture(DecodedTexture decoded, bool nearest, string debugName)
|
|
{
|
|
IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription(
|
|
debugName,
|
|
GpuTextureKind.Texture2D,
|
|
GpuTextureFormat.Rgba8Unorm,
|
|
Width: decoded.Width,
|
|
Height: decoded.Height,
|
|
LayerCount: 1,
|
|
MipLevelCount: 1));
|
|
try
|
|
{
|
|
texture.Upload(0, 0, decoded.Rgba8);
|
|
uint glName = UploadAccountingName(texture);
|
|
TrackUploadedTexture(glName, decoded.Width, decoded.Height);
|
|
|
|
IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat);
|
|
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
|
|
return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height);
|
|
}
|
|
catch
|
|
{
|
|
texture.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The identity a UI upload is accounted under. There is no GL name on the
|
|
/// Vulkan-only backend, so a descending synthetic counter supplies one; the
|
|
/// value is a dictionary key and a dedup token only — Campaign V slice V6d
|
|
/// removed the last draw-time consumer of a raw GL name, so nothing binds it.
|
|
/// </summary>
|
|
private uint UploadAccountingName(IGpuTexture texture) => _nextSyntheticUploadName--;
|
|
|
|
private uint _nextSyntheticUploadName = uint.MaxValue;
|
|
|
|
/// <summary>
|
|
/// Point sampling with REPEAT addressing — pixel-exact retail UI art that is
|
|
/// still tiled by nine-slice chrome and meter tracks. Neither stock preset
|
|
/// fits: <c>UiNearest</c> clamps, <c>WorldRepeat</c> filters.
|
|
/// </summary>
|
|
private static readonly GpuSamplerDescription UiNearestRepeat = new(
|
|
GpuFilter.Nearest,
|
|
GpuFilter.Nearest,
|
|
GpuMipFilter.None,
|
|
GpuAddressMode.Repeat,
|
|
GpuAddressMode.Repeat,
|
|
MaxAnisotropy: 1f);
|
|
|
|
/// <summary>
|
|
/// Acquires the exact DAT-decoded one-layer texture array for a live
|
|
/// particle emitter. Equivalent surfaces are shared; the cache ownership
|
|
/// ends with <see cref="ReleaseParticleTextureOwner"/>.
|
|
///
|
|
/// <para>Campaign V slice V4t: returns the device texture-table
|
|
/// <see cref="GpuTextureSlot"/> rather than the raw bindless handle. The
|
|
/// handle is still created, made resident and destroyed here — only the
|
|
/// table entry belongs to the device.</para>
|
|
/// </summary>
|
|
internal GpuTextureSlot AcquireParticleTexture(int emitterHandle, uint surfaceId)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(emitterHandle);
|
|
ArgumentOutOfRangeException.ThrowIfZero(surfaceId);
|
|
StandaloneBindlessTextureCache textures = EnsureParticleTexturesAvailable();
|
|
uint ownerId = checked((uint)emitterHandle);
|
|
if (textures.TryAcquire(
|
|
ownerId,
|
|
surfaceId,
|
|
out StandaloneBindlessTextureResource? existing))
|
|
{
|
|
return existing.Slot;
|
|
}
|
|
|
|
DecodedTexture decoded = DecodeFromDats(
|
|
surfaceId,
|
|
origTextureOverride: null,
|
|
paletteOverride: null);
|
|
|
|
return AcquireParticleTextureRhi(textures, ownerId, surfaceId, decoded);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6l: one particle surface as a device texture-table
|
|
/// slot, owned by the same emitter-scoped cache.
|
|
///
|
|
/// <para>Linear/clamped matches the filtering the deleted GL arm's own
|
|
/// one-layer array upload set on itself, and a particle sheet's UVs never
|
|
/// leave [0,1] — the quad's own texcoords are the unit square — so the
|
|
/// wrap mode is not a visible choice, it is just the safe one.</para>
|
|
/// </summary>
|
|
private GpuTextureSlot AcquireParticleTextureRhi(
|
|
StandaloneBindlessTextureCache textures,
|
|
uint ownerId,
|
|
uint surfaceId,
|
|
DecodedTexture decoded)
|
|
{
|
|
IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription(
|
|
$"particle-surface-0x{surfaceId:X8}",
|
|
GpuTextureKind.Texture2D,
|
|
GpuTextureFormat.Rgba8Unorm,
|
|
Width: decoded.Width,
|
|
Height: decoded.Height,
|
|
LayerCount: 1,
|
|
MipLevelCount: 1));
|
|
try
|
|
{
|
|
texture.Upload(0, 0, decoded.Rgba8);
|
|
uint accountingName = UploadAccountingName(texture);
|
|
TrackUploadedTexture(accountingName, decoded.Width, decoded.Height);
|
|
|
|
IGpuSampler sampler = _device.CreateSampler(GpuSamplerDescription.WorldClamp);
|
|
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
|
|
textures.AddAndAcquire(ownerId, new StandaloneBindlessTextureResource
|
|
{
|
|
SurfaceId = surfaceId,
|
|
Name = accountingName,
|
|
Texture = texture,
|
|
Slot = slot,
|
|
Bytes = checked((long)decoded.Width * decoded.Height * 4L),
|
|
});
|
|
return slot;
|
|
}
|
|
catch
|
|
{
|
|
texture.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
internal void ReleaseParticleTextureOwner(int emitterHandle)
|
|
{
|
|
if (emitterHandle <= 0 || _particleTextures is null)
|
|
return;
|
|
_particleTextures.ReleaseOwner(checked((uint)emitterHandle));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owner-scoped bindless variant for a server-supplied original-texture
|
|
/// replacement. Stores compatible composites in a pooled Texture2DArray
|
|
/// and returns its resident handle plus the assigned layer. Equivalent
|
|
/// composites are shared until their final live owner leaves. Returns
|
|
/// <see langword="default"/> (an empty location) if a composite upload
|
|
/// can't start or the decoded size can't be prepared this frame.
|
|
/// </summary>
|
|
internal BindlessTextureLocation GetOrUploadWithOrigTextureOverrideBindless(
|
|
uint ownerLocalId,
|
|
uint surfaceId,
|
|
uint overrideOrigTextureId)
|
|
{
|
|
CompositeTextureArrayCache composites = EnsureCompositeTexturesAvailable();
|
|
var key = new CompositeTextureKey(
|
|
CompositeTextureKind.OriginalTextureOverride,
|
|
surfaceId,
|
|
overrideOrigTextureId,
|
|
Palette: default);
|
|
if (composites.TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing))
|
|
return existing;
|
|
if (!composites.CanStartUpload)
|
|
return default;
|
|
(int width, int height) = ResolveDecodedDimensions(surfaceId, overrideOrigTextureId);
|
|
if (!composites.CanPrepareUpload(width, height))
|
|
return default;
|
|
|
|
DecodedTexture decoded = DecodeFromDats(
|
|
surfaceId,
|
|
origTextureOverride: overrideOrigTextureId,
|
|
paletteOverride: null,
|
|
bakeAuthoredTranslucency: true);
|
|
return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added)
|
|
? added
|
|
: default;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owner-scoped bindless palette composite. Applies the palette override on
|
|
/// top of the texture's default palette before decoding, stores compatible
|
|
/// composites in a pooled Texture2DArray, and returns its resident handle
|
|
/// plus the assigned layer. Structural identity is computed once per entity.
|
|
/// Returns <see langword="default"/> (an empty location) if a composite
|
|
/// upload can't start or the decoded size can't be prepared this frame.
|
|
/// </summary>
|
|
internal BindlessTextureLocation GetOrUploadWithPaletteOverrideBindless(
|
|
uint ownerLocalId,
|
|
uint surfaceId,
|
|
uint? overrideOrigTextureId,
|
|
PaletteOverride paletteOverride,
|
|
PaletteCompositeIdentity paletteIdentity)
|
|
{
|
|
CompositeTextureArrayCache composites = EnsureCompositeTexturesAvailable();
|
|
uint origTexKey = overrideOrigTextureId ?? 0;
|
|
var key = new CompositeTextureKey(
|
|
CompositeTextureKind.PaletteComposite,
|
|
surfaceId,
|
|
origTexKey,
|
|
paletteIdentity);
|
|
if (composites.TryAcquire(ownerLocalId, key, out BindlessTextureLocation existing))
|
|
return existing;
|
|
if (!composites.CanStartUpload)
|
|
return default;
|
|
(int width, int height) = ResolveDecodedDimensions(surfaceId, overrideOrigTextureId);
|
|
if (!composites.CanPrepareUpload(width, height))
|
|
return default;
|
|
|
|
DecodedTexture decoded = DecodeFromDats(
|
|
surfaceId,
|
|
origTextureOverride: overrideOrigTextureId,
|
|
paletteOverride: paletteOverride,
|
|
bakeAuthoredTranslucency: true);
|
|
return composites.TryAddAndAcquire(ownerLocalId, key, decoded, out BindlessTextureLocation added)
|
|
? added
|
|
: default;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail applies a palette composite only to P8/INDEX16 image data.
|
|
/// Cache the resolved source format so animated entities do not reopen the
|
|
/// DAT chain every frame.
|
|
/// </summary>
|
|
internal bool IsPaletteIndexed(uint surfaceId, uint? overrideOrigTextureId)
|
|
{
|
|
uint origTexKey = overrideOrigTextureId ?? 0;
|
|
var key = (surfaceId, origTexKey);
|
|
if (_paletteIndexedByTexture.TryGetValue(key, out bool indexed))
|
|
return indexed;
|
|
|
|
Surface? surface = _dats.Get<Surface>(surfaceId);
|
|
if (surface is null || surface.Type.HasFlag(SurfaceType.Base1Solid))
|
|
return _paletteIndexedByTexture[key] = false;
|
|
|
|
uint surfaceTextureId = overrideOrigTextureId ?? (uint)surface.OrigTextureId;
|
|
SurfaceTexture? texture = _dats.Get<SurfaceTexture>(surfaceTextureId);
|
|
if (texture is null || texture.Textures.Count == 0)
|
|
return _paletteIndexedByTexture[key] = false;
|
|
|
|
uint renderSurfaceId = (uint)texture.Textures[0];
|
|
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out RenderSurface? renderSurface)
|
|
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out renderSurface))
|
|
return _paletteIndexedByTexture[key] = false;
|
|
|
|
indexed = renderSurface.Format is PixelFormatId.PFID_P8 or PixelFormatId.PFID_INDEX16;
|
|
_paletteIndexedByTexture[key] = indexed;
|
|
return indexed;
|
|
}
|
|
|
|
private (int Width, int Height) ResolveDecodedDimensions(
|
|
uint surfaceId,
|
|
uint? overrideOrigTextureId)
|
|
{
|
|
var key = (surfaceId, overrideOrigTextureId ?? 0);
|
|
if (_decodedDimensionsByTexture.TryGetValue(key, out var cached))
|
|
return cached;
|
|
|
|
Surface? surface = _dats.Get<Surface>(surfaceId);
|
|
if (surface is null
|
|
|| surface.Type.HasFlag(SurfaceType.Base1Solid)
|
|
|| (uint)surface.OrigTextureId == 0)
|
|
return _decodedDimensionsByTexture[key] = (1, 1);
|
|
|
|
uint surfaceTextureId = overrideOrigTextureId ?? (uint)surface.OrigTextureId;
|
|
SurfaceTexture? texture = _dats.Get<SurfaceTexture>(surfaceTextureId);
|
|
if (texture is null || texture.Textures.Count == 0)
|
|
return _decodedDimensionsByTexture[key] = (1, 1);
|
|
|
|
uint renderSurfaceId = (uint)texture.Textures[0];
|
|
if ((!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out RenderSurface? renderSurface)
|
|
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out renderSurface))
|
|
|| renderSurface.Width <= 0
|
|
|| renderSurface.Height <= 0
|
|
|| renderSurface.SourceData is null)
|
|
return _decodedDimensionsByTexture[key] = (1, 1);
|
|
|
|
return _decodedDimensionsByTexture[key] = (renderSurface.Width, renderSurface.Height);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>CSurface::Destroy</c> (0x005361F0) releases its current
|
|
/// <c>ImgTex</c>. Mirror that ownership boundary for per-entity composites.
|
|
/// </summary>
|
|
public void ReleaseOwner(uint localEntityId)
|
|
{
|
|
EnsureCompositeTexturesAvailable().ReleaseOwner(localEntityId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6l: no longer gated on bindless. The composite cache is
|
|
/// constructed on both arms — V6i-2's RHI backend is what serves the one
|
|
/// without a GL context — so the only failure left is a cache that was never
|
|
/// built at all.
|
|
/// </summary>
|
|
private CompositeTextureArrayCache EnsureCompositeTexturesAvailable() =>
|
|
_compositeTextures ?? throw new InvalidOperationException(
|
|
"This TextureCache owns no composite texture array cache.");
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6l: no longer gated on bindless. The particle cache is
|
|
/// constructed on both arms, so the only failure left is a cache that was
|
|
/// never built at all.
|
|
/// </summary>
|
|
private StandaloneBindlessTextureCache EnsureParticleTexturesAvailable() =>
|
|
_particleTextures ?? throw new InvalidOperationException(
|
|
"This TextureCache owns no standalone particle texture cache.");
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6l: the table slot is released first and the image
|
|
/// second — a submitted-but-unretired frame may still sample the slot, and
|
|
/// <see cref="IGpuDevice.ReleaseTextureSlot"/> is what defers its reuse.
|
|
/// </summary>
|
|
private sealed class ParticleRhiTextureBackend(TextureCache owner)
|
|
: IStandaloneBindlessTextureBackend
|
|
{
|
|
public void MakeNonResident(StandaloneBindlessTextureResource resource)
|
|
{
|
|
if (resource.Slot.IsAssigned)
|
|
owner._device.ReleaseTextureSlot(resource.Slot);
|
|
}
|
|
|
|
public void Delete(StandaloneBindlessTextureResource resource)
|
|
{
|
|
resource.Texture?.Dispose();
|
|
owner.UntrackUploadedTexture(resource.Name);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advances bounded composite-cache maintenance once per render frame.
|
|
/// Logical owner release is immediate; at most one over-budget layer and
|
|
/// one empty backing array are physically retired in this call.
|
|
/// </summary>
|
|
public void TickCompositeTextureCache() => _compositeTextures?.Tick();
|
|
|
|
/// <summary>
|
|
/// Retires at most one over-budget standalone particle texture per render
|
|
/// frame. Keeping this separate from owner release avoids portal-time GPU
|
|
/// destruction bursts without changing live particle range or quality.
|
|
/// </summary>
|
|
public void TickParticleTextureCache() => _particleTextures?.Tick();
|
|
|
|
public void BeginCompositeTextureFrame() =>
|
|
_compositeTextures?.BeginFrame(_destinationRevealUploadPriority);
|
|
|
|
/// <summary>
|
|
/// Cheap 64-bit hash over a palette override's identity so two
|
|
/// entities with the same palette setup share a decode. Internal so
|
|
/// the WB dispatcher can compute it once per entity.
|
|
/// </summary>
|
|
internal static ulong HashPaletteOverride(PaletteOverride p)
|
|
{
|
|
// Not cryptographic — just needs to distinguish override setups
|
|
// for caching. Start with base palette id, fold in each entry.
|
|
ulong h = 0xCBF29CE484222325UL; // FNV-1a offset basis
|
|
const ulong prime = 0x100000001B3UL;
|
|
h = (h ^ p.BasePaletteId) * prime;
|
|
foreach (var sp in p.SubPalettes)
|
|
{
|
|
h = (h ^ sp.SubPaletteId) * prime;
|
|
h = (h ^ sp.Offset) * prime;
|
|
h = (h ^ sp.Length) * prime;
|
|
}
|
|
return h;
|
|
}
|
|
|
|
internal static PaletteCompositeIdentity GetPaletteIdentity(PaletteOverride palette) =>
|
|
new(palette, HashPaletteOverride(palette));
|
|
|
|
/// <summary>
|
|
/// Phase N.6 slice 1: one-shot surface-format histogram dump for the
|
|
/// atlas-opportunity audit. Activated by ACDREAM_DUMP_SURFACES=1; fires
|
|
/// once after BOTH gates pass:
|
|
/// 1. <c>_dumpFrameCounter >= 600</c> — at least 600 OnRender ticks
|
|
/// have elapsed (catches the "we're already past startup boilerplate"
|
|
/// bound; ~10s at 60fps, ~3s at 200fps).
|
|
/// 2. <c>_uploadMetadata.Count >= 100</c> — the cache contains at
|
|
/// least 100 uploaded textures, indicating streaming has actually
|
|
/// pulled in world content (not just sky/UI/font). The original
|
|
/// frame-only gate fired during the login/handshake phase where
|
|
/// OnRender ticks at GUI rates but no world has streamed in.
|
|
/// Output goes to the host-provided portable diagnostics directory.
|
|
/// Zero cost
|
|
/// when off. See spec §5 in
|
|
/// docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md.
|
|
/// </summary>
|
|
public void TickSurfaceHistogramDumpIfEnabled()
|
|
{
|
|
if (_surfaceHistogramAlreadyDumped) return;
|
|
if (!string.Equals(System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SURFACES"), "1", StringComparison.Ordinal)) return;
|
|
_dumpFrameCounter++;
|
|
if (_dumpFrameCounter < 600) return;
|
|
if (_uploadMetadata.Count < 100) return;
|
|
|
|
DumpSurfaceHistogram();
|
|
_surfaceHistogramAlreadyDumped = true;
|
|
}
|
|
|
|
private void DumpSurfaceHistogram()
|
|
{
|
|
try
|
|
{
|
|
DumpSurfaceHistogramCore();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Diagnostic-only path. If the dump file can't be written
|
|
// (disk full, permission denied, antivirus lock, path too
|
|
// long) we must NOT crash OnRender — that would invalidate
|
|
// the very measurement pass this diagnostic is meant to
|
|
// support. Log to stderr and let the caller mark the dump
|
|
// as "already done" so it doesn't retry every frame.
|
|
Console.Error.WriteLine($"[N6-DUMP] Failed to write surface histogram: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void DumpSurfaceHistogramCore()
|
|
{
|
|
System.IO.Directory.CreateDirectory(_diagnosticsDirectory);
|
|
var outPath = System.IO.Path.Combine(
|
|
_diagnosticsDirectory,
|
|
"n6-surfaces.txt");
|
|
|
|
var sb = new System.Text.StringBuilder();
|
|
sb.AppendLine($"# acdream surface-format histogram — generated {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}");
|
|
sb.AppendLine("# Per-entry: surfaceId(hex), width, height, format, byteCount");
|
|
sb.AppendLine();
|
|
|
|
// Walk every cached entry across the 6 caches, dedupe by GL name.
|
|
var seen = new HashSet<uint>();
|
|
long totalBytes = 0;
|
|
var bucketsByDim = new Dictionary<(int W, int H), int>();
|
|
var bucketsByFormat = new Dictionary<string, int>();
|
|
var bucketsByTriple = new Dictionary<(int W, int H, string F), int>();
|
|
|
|
void Emit(uint surfaceId, uint name)
|
|
{
|
|
if (!seen.Add(name)) return;
|
|
if (!_uploadMetadata.TryGetValue(name, out var meta)) return;
|
|
int bytes = meta.Width * meta.Height * 4;
|
|
totalBytes += bytes;
|
|
sb.AppendLine($"0x{surfaceId:X8}, {meta.Width}, {meta.Height}, {meta.Format}, {bytes}");
|
|
|
|
var dimKey = (meta.Width, meta.Height);
|
|
bucketsByDim[dimKey] = bucketsByDim.GetValueOrDefault(dimKey) + 1;
|
|
bucketsByFormat[meta.Format] = bucketsByFormat.GetValueOrDefault(meta.Format) + 1;
|
|
var tripleKey = (meta.Width, meta.Height, meta.Format);
|
|
bucketsByTriple[tripleKey] = bucketsByTriple.GetValueOrDefault(tripleKey) + 1;
|
|
}
|
|
|
|
_particleTextures?.VisitEntries(resource => Emit(resource.SurfaceId, resource.Name));
|
|
_compositeTextures?.VisitEntries((surfaceId, width, height) =>
|
|
{
|
|
int bytes = checked(width * height * 4);
|
|
totalBytes += bytes;
|
|
sb.AppendLine($"0x{surfaceId:X8}, {width}, {height}, RGBA8_COMPOSITE_LAYER, {bytes}");
|
|
bucketsByDim[(width, height)] = bucketsByDim.GetValueOrDefault((width, height)) + 1;
|
|
bucketsByFormat["RGBA8_COMPOSITE_LAYER"] =
|
|
bucketsByFormat.GetValueOrDefault("RGBA8_COMPOSITE_LAYER") + 1;
|
|
bucketsByTriple[(width, height, "RGBA8_COMPOSITE_LAYER")] =
|
|
bucketsByTriple.GetValueOrDefault((width, height, "RGBA8_COMPOSITE_LAYER")) + 1;
|
|
});
|
|
|
|
sb.AppendLine();
|
|
sb.AppendLine("# Rollups");
|
|
sb.AppendLine($"# Total unique GL textures: {seen.Count}");
|
|
sb.AppendLine($"# Total bytes (sum of W*H*4): {totalBytes}");
|
|
|
|
sb.AppendLine("# Top 10 (W,H) dimension buckets:");
|
|
foreach (var kv in bucketsByDim.OrderByDescending(kv => kv.Value).Take(10))
|
|
sb.AppendLine($"# {kv.Key.W}x{kv.Key.H}: {kv.Value}");
|
|
|
|
sb.AppendLine("# Format buckets:");
|
|
foreach (var kv in bucketsByFormat.OrderByDescending(kv => kv.Value))
|
|
sb.AppendLine($"# {kv.Key}: {kv.Value}");
|
|
|
|
sb.AppendLine("# Top 10 (W,H,format) triples — atlas-opportunity input:");
|
|
foreach (var kv in bucketsByTriple.OrderByDescending(kv => kv.Value).Take(10))
|
|
sb.AppendLine($"# {kv.Key.W}x{kv.Key.H} {kv.Key.F}: {kv.Value}");
|
|
|
|
System.IO.File.WriteAllText(outPath, sb.ToString());
|
|
Console.WriteLine($"[N6-DUMP] Surface histogram written to {outPath} ({seen.Count} textures, {totalBytes} bytes)");
|
|
}
|
|
|
|
/// <param name="bakeAuthoredTranslucency">
|
|
/// Apply the surface's authored <c>Translucency</c> to the decoded alpha, the same
|
|
/// bake the shared-atlas extraction performs. TRUE for the world composite paths
|
|
/// (palette / original-texture overrides) — without it an override-carrying item's
|
|
/// translucent part paints alpha=1: it still sorts as see-through in the alpha
|
|
/// queue but erases the particles composited behind it. FALSE for the sky (its
|
|
/// shader applies the authored opacity separately — baking would double-apply)
|
|
/// and for particle sheets (emitter-driven alpha, no authored-translucency
|
|
/// consumer today).
|
|
/// </param>
|
|
private DecodedTexture DecodeFromDats(
|
|
uint surfaceId,
|
|
uint? origTextureOverride,
|
|
PaletteOverride? paletteOverride,
|
|
bool bakeAuthoredTranslucency = false)
|
|
{
|
|
var surface = _dats.Get<Surface>(surfaceId);
|
|
if (surface is null)
|
|
{
|
|
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
|
Console.WriteLine($"[tex-miss] Surface 0x{surfaceId:X8} -> magenta (thread={System.Environment.CurrentManagedThreadId})");
|
|
return DecodedTexture.Magenta;
|
|
}
|
|
|
|
// Base1Solid surfaces (and any with OrigTextureId==0) carry a ColorValue
|
|
// instead of a texture chain. Overrides are irrelevant here — there's
|
|
// no texture chain to swap — so the override is ignored for solid-color
|
|
// surfaces. Translucency is honored so Base1Solid|Translucent surfaces
|
|
// with Translucency=1.0 become alpha=0, which the mesh shader's discard
|
|
// cutout makes invisible.
|
|
if (surface.Type.HasFlag(SurfaceType.Base1Solid) || (uint)surface.OrigTextureId == 0)
|
|
return SurfaceDecoder.DecodeSolidColor(surface.ColorValue, surface.Translucency);
|
|
|
|
// Use the override SurfaceTexture id when present, otherwise the
|
|
// Surface's native OrigTextureId.
|
|
uint surfaceTextureId = origTextureOverride ?? (uint)surface.OrigTextureId;
|
|
var surfaceTexture = _dats.Get<SurfaceTexture>(surfaceTextureId);
|
|
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
|
|
{
|
|
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
|
Console.WriteLine($"[tex-miss] SurfaceTexture 0x{surfaceTextureId:X8} (surface 0x{surfaceId:X8}) -> magenta (thread={System.Environment.CurrentManagedThreadId})");
|
|
return DecodedTexture.Magenta;
|
|
}
|
|
|
|
uint renderSurfaceId = (uint)surfaceTexture.Textures[0];
|
|
if (!_dats.Portal.TryGet<RenderSurface>(renderSurfaceId, out var rs)
|
|
&& !_dats.HighRes.TryGet<RenderSurface>(renderSurfaceId, out rs))
|
|
{
|
|
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix)
|
|
Console.WriteLine($"[tex-miss] RenderSurface 0x{renderSurfaceId:X8} (surface 0x{surfaceId:X8}) -> magenta (thread={System.Environment.CurrentManagedThreadId})");
|
|
return DecodedTexture.Magenta;
|
|
}
|
|
|
|
// Start with the texture's default palette, then apply overlays.
|
|
// ACViewer's Render/TextureCache.IndexToColor does the same and never
|
|
// consults ObjDesc.BasePaletteId for palette-indexed textures — the
|
|
// RenderSurface's own default palette is the starting point.
|
|
Palette? basePalette = rs.DefaultPaletteId != 0
|
|
? _dats.Get<Palette>(rs.DefaultPaletteId)
|
|
: null;
|
|
|
|
Palette? effectivePalette = basePalette;
|
|
if (paletteOverride is not null && basePalette is not null && paletteOverride.SubPalettes.Count > 0)
|
|
{
|
|
effectivePalette = ComposePalette(basePalette, paletteOverride);
|
|
}
|
|
|
|
// Clipmap surfaces use palette indices 0..7 as transparent sentinels.
|
|
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
|
|
bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive);
|
|
|
|
DecodedTexture decoded =
|
|
SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive);
|
|
|
|
// The decoders return the shared Magenta sentinel on failure; it must never
|
|
// be scaled in place. Fresh decodes are caller-owned, so the in-place bake
|
|
// is safe.
|
|
if (bakeAuthoredTranslucency
|
|
&& surface.Translucency > 0.0f
|
|
&& !ReferenceEquals(decoded, DecodedTexture.Magenta))
|
|
{
|
|
decoded = SurfaceDecoder.ApplyAuthoredTranslucency(decoded, surface.Translucency);
|
|
}
|
|
|
|
return decoded;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a composite palette by copying subpalette ranges into a
|
|
/// mutable copy of the base. Ported from ACViewer's
|
|
/// Render/TextureCache.IndexToColor, with network-side Offset/Length
|
|
/// multiplied by 8 to recover the raw palette-index units (ACE's
|
|
/// writer divides by 8 before writing).
|
|
/// </summary>
|
|
private Palette ComposePalette(Palette basePalette, PaletteOverride paletteOverride)
|
|
{
|
|
var composed = new Palette();
|
|
composed.Colors.AddRange(basePalette.Colors);
|
|
|
|
foreach (var sp in paletteOverride.SubPalettes)
|
|
{
|
|
var subPal = _dats.Get<Palette>(sp.SubPaletteId);
|
|
if (subPal is null) continue;
|
|
|
|
int startIdx = sp.Offset * 8;
|
|
// Length == 0 is the sentinel for "entire palette" per
|
|
// Chorizite.ACProtocol.Types.Subpalette docs. Use a value
|
|
// large enough to cover any real palette; we clamp below.
|
|
int count = sp.Length == 0 ? 2048 : sp.Length * 8;
|
|
|
|
for (int j = 0; j < count; j++)
|
|
{
|
|
int idx = startIdx + j;
|
|
if (idx >= composed.Colors.Count || idx >= subPal.Colors.Count)
|
|
break;
|
|
composed.Colors[idx] = subPal.Colors[idx];
|
|
}
|
|
}
|
|
|
|
return composed;
|
|
}
|
|
|
|
/// <summary>Uploads a raw RGBA8 byte array as a Texture2D. Used by
|
|
/// <see cref="AcDream.App.UI.IconComposer"/> to upload CPU-composited icon layers.
|
|
/// The texture is tracked in <see cref="_adhocGpuTextures"/> and deleted by
|
|
/// <see cref="Dispose"/>. Callers must NOT also store the returned handle in any
|
|
/// of the keyed caches — that would cause a double-delete on Dispose.
|
|
///
|
|
/// <para>Campaign V slice V6d: returns a <see cref="UiTextureTableHandle"/>
|
|
/// rather than a GL texture name, for the reason given on
|
|
/// <see cref="GetOrUploadRenderSurface"/>.</para>
|
|
/// </summary>
|
|
public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false)
|
|
{
|
|
GpuUiTextureEntry entry = UploadUiTexture(
|
|
new DecodedTexture(rgba, width, height), nearest, "ui-adhoc-rgba8");
|
|
_adhocGpuTextures.Add(entry);
|
|
return UiTextureTableHandle.FromSlot(entry.Slot);
|
|
}
|
|
|
|
private void TrackUploadedTexture(uint name, int width, int height)
|
|
{
|
|
_uploadMetadata[name] = (width, height, "RGBA8_DECODED");
|
|
long bytes = checked((long)width * height * 4L);
|
|
Wb.GpuMemoryTracker.TrackResourceAllocation(Wb.GpuResourceType.Texture);
|
|
Wb.GpuMemoryTracker.TrackAllocation(bytes, Wb.GpuResourceType.Texture);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Memory-tracking bookkeeping only — used for every <see cref="IGpuTexture"/>
|
|
/// entry, whose GPU resource is released by <see cref="IGpuTexture.Dispose"/>
|
|
/// through the device's own retirement queue.
|
|
/// </summary>
|
|
private void UntrackUploadedTexture(uint name)
|
|
{
|
|
if (_uploadMetadata.Remove(name, out var metadata))
|
|
{
|
|
long bytes = checked((long)metadata.Width * metadata.Height * 4L);
|
|
Wb.GpuMemoryTracker.TrackDeallocation(bytes, Wb.GpuResourceType.Texture);
|
|
Wb.GpuMemoryTracker.TrackResourceDeallocation(Wb.GpuResourceType.Texture);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
// GameWindow drains frame-flight fences before this teardown. The
|
|
// bindless caches make every handle non-resident before deleting
|
|
// their backing storage.
|
|
_particleTextures?.Dispose();
|
|
_compositeTextures?.Dispose();
|
|
|
|
_paletteIndexedByTexture.Clear();
|
|
|
|
// RenderSurface (UI sprite) textures — Campaign V slice V4a: each
|
|
// entry's IGpuTexture.Dispose() releases the underlying GL name
|
|
// through the device's own retirement queue, so only the memory-
|
|
// tracking bookkeeping and the registered slot need releasing here.
|
|
foreach (GpuUiTextureEntry entry in _renderSurfaceGpuTextures.Values)
|
|
{
|
|
entry.Texture.Dispose();
|
|
_device.ReleaseTextureSlot(entry.Slot);
|
|
UntrackUploadedTexture(entry.GlName);
|
|
}
|
|
_renderSurfaceGpuTextures.Clear();
|
|
|
|
// Campaign V slice V6k: world Surface textures created through the RHI
|
|
// for the sky's backend-neutral arm. Same ownership shape as the UI
|
|
// entries above — the device retires the image, this releases the slot.
|
|
foreach (GpuUiTextureEntry entry in _worldSurfaceGpuTextures.Values)
|
|
{
|
|
entry.Texture.Dispose();
|
|
_device.ReleaseTextureSlot(entry.Slot);
|
|
UntrackUploadedTexture(entry.GlName);
|
|
}
|
|
_worldSurfaceGpuTextures.Clear();
|
|
|
|
// Ad-hoc textures from the public UploadRgba8(byte[],int,int,bool) wrapper
|
|
// (IconComposer composited icons). Not stored in any keyed cache.
|
|
foreach (GpuUiTextureEntry entry in _adhocGpuTextures)
|
|
{
|
|
entry.Texture.Dispose();
|
|
_device.ReleaseTextureSlot(entry.Slot);
|
|
UntrackUploadedTexture(entry.GlName);
|
|
}
|
|
_adhocGpuTextures.Clear();
|
|
}
|
|
}
|