acdream/src/AcDream.Core/Vfx/EmitterDescLoader.cs
Erik 749e8ceeb1 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>
2026-07-18 21:35:16 +02:00

240 lines
8.7 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.Lib.IO;
using AcDream.Core.Content;
using DatParticleEmitter = DatReaderWriter.DBObjs.ParticleEmitter;
using DatGfxObj = DatReaderWriter.DBObjs.GfxObj;
using DatGfxObjDegradeInfo = DatReaderWriter.DBObjs.GfxObjDegradeInfo;
using DatEmitterType = DatReaderWriter.Enums.EmitterType;
using DatParticleType = DatReaderWriter.Enums.ParticleType;
using DatGfxObjFlags = DatReaderWriter.Enums.GfxObjFlags;
namespace AcDream.Core.Vfx;
/// <summary>
/// Resolves retail <c>ParticleEmitterInfo</c> dat records
/// (<c>0x32xxxxxx</c>) into acdream runtime descriptors.
/// </summary>
public sealed class EmitterDescRegistry
{
private readonly Func<uint, DatParticleEmitter?>? _resolver;
private readonly Func<DatParticleEmitter, float?>? _degradeDistanceResolver;
private readonly ConcurrentDictionary<uint, EmitterDesc> _byId = new();
public EmitterDescRegistry()
: this((Func<uint, DatParticleEmitter?>?)null)
{
}
public EmitterDescRegistry(IDatObjectSource dats)
{
ArgumentNullException.ThrowIfNull(dats);
_resolver = id => SafeGet<DatParticleEmitter>(dats, id);
_degradeDistanceResolver = emitter => ResolveMaxDegradeDistance(dats, emitter);
}
public EmitterDescRegistry(Func<uint, DatParticleEmitter?>? resolver)
{
_resolver = resolver;
}
public void Register(EmitterDesc desc)
{
ArgumentNullException.ThrowIfNull(desc);
_byId[desc.DatId] = desc;
}
public EmitterDesc Get(uint emitterId)
{
if (TryGet(emitterId, out EmitterDesc? desc))
return desc;
throw new KeyNotFoundException(
$"ParticleEmitterInfo 0x{emitterId:X8} was not found.");
}
public bool TryGet(uint emitterId, out EmitterDesc desc)
{
if (_byId.TryGetValue(emitterId, out desc!))
return true;
if (_resolver is not null)
{
var dat = _resolver(emitterId);
if (dat is not null)
{
float? resolvedDistance = _degradeDistanceResolver?.Invoke(dat);
if (_degradeDistanceResolver is not null && !resolvedDistance.HasValue)
{
// ParticleEmitter::SetInfo (0x0051CE90) fails when the
// authored hardware GfxObj cannot be loaded. It never
// substitutes the software GfxObj.
desc = null!;
return false;
}
float maxDegradeDistance = resolvedDistance
?? RetailParticleDegradeDistance.DefaultDistance;
desc = FromDat(emitterId, dat, maxDegradeDistance);
_byId[emitterId] = desc;
return true;
}
}
desc = null!;
return false;
}
public int Count => _byId.Count;
public static EmitterDesc FromDat(
uint emitterId,
DatParticleEmitter dat,
float maxDegradeDistance = RetailParticleDegradeDistance.DefaultDistance)
{
ArgumentNullException.ThrowIfNull(dat);
float birthrate = MathF.Max(0f, (float)dat.Birthrate);
float lifespan = MathF.Max(0f, (float)dat.Lifespan);
float lifespanRand = MathF.Abs((float)dat.LifespanRand);
float lifetimeMin = MathF.Max(0f, lifespan - lifespanRand);
float lifetimeMax = MathF.Max(lifetimeMin, lifespan + lifespanRand);
// ParticleEmitterInfo has no "additive" field; retail derives blend
// state from the particle GfxObj surface material.
var flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera;
if (dat.IsParentLocal)
flags |= EmitterFlags.AttachLocal;
// ParticleEmitterInfo stores translucency, not opacity. Retail feeds
// StartTrans/FinalTrans to PhysicsPart::SetTranslucency; the GL path
// uses the complement as source alpha.
float startOpacity = 1f - Math.Clamp((float)dat.StartTrans, 0f, 1f);
float endOpacity = 1f - Math.Clamp((float)dat.FinalTrans, 0f, 1f);
return new EmitterDesc
{
MaxDegradeDistance = maxDegradeDistance,
DatId = emitterId,
Type = MapParticleType(dat.ParticleType),
EmitterKind = MapEmitterKind(dat.EmitterType),
Flags = flags,
GfxObjId = dat.GfxObjId.DataId,
HwGfxObjId = dat.HwGfxObjId.DataId,
Birthrate = birthrate,
EmitRate = dat.EmitterType == DatEmitterType.BirthratePerSec && birthrate > 0f
? 1f / birthrate
: 0f,
MaxParticles = Math.Max(1, dat.MaxParticles),
InitialParticles = Math.Max(0, dat.InitialParticles),
TotalParticles = Math.Max(0, dat.TotalParticles),
TotalDuration = MathF.Max(0f, (float)dat.TotalSeconds),
Lifespan = lifespan,
LifespanRand = lifespanRand,
LifetimeMin = lifetimeMin,
LifetimeMax = lifetimeMax,
OffsetDir = dat.OffsetDir,
MinOffset = dat.MinOffset,
MaxOffset = dat.MaxOffset,
SpawnDiskRadius = dat.MaxOffset,
InitialVelocity = dat.A,
Gravity = dat.B,
A = dat.A,
MinA = dat.MinA,
MaxA = dat.MaxA,
B = dat.B,
MinB = dat.MinB,
MaxB = dat.MaxB,
C = dat.C,
MinC = dat.MinC,
MaxC = dat.MaxC,
StartSize = dat.StartScale,
EndSize = dat.FinalScale,
ScaleRand = dat.ScaleRand,
StartAlpha = startOpacity,
EndAlpha = endOpacity,
TransRand = dat.TransRand,
};
}
private static float? ResolveMaxDegradeDistance(
IDatObjectSource dats,
DatParticleEmitter emitter)
{
uint gfxObjId = GetRetailHardwareGfxObjId(emitter);
if (gfxObjId == 0)
return null;
DatGfxObj? gfx = SafeGet<DatGfxObj>(dats, gfxObjId);
if (gfx is null)
return null;
if (!gfx.Flags.HasFlag(DatGfxObjFlags.HasDIDDegrade)
|| gfx.DIDDegrade == 0)
{
return RetailParticleDegradeDistance.DefaultDistance;
}
DatGfxObjDegradeInfo? degrade = SafeGet<DatGfxObjDegradeInfo>(dats, gfx.DIDDegrade);
return RetailParticleDegradeDistance.FromEntries(degrade?.Degrades);
}
internal static uint GetRetailHardwareGfxObjId(DatParticleEmitter emitter)
=> emitter.HwGfxObjId.DataId;
private static T? SafeGet<T>(IDatObjectSource dats, uint id) where T : class, IDBObj
{
if (dats is null)
return null;
try
{
return dats.Get<T>(id);
}
catch
{
return null;
}
}
private static ParticleEmitterKind MapEmitterKind(DatEmitterType type) => type switch
{
DatEmitterType.BirthratePerSec => ParticleEmitterKind.BirthratePerSec,
DatEmitterType.BirthratePerMeter => ParticleEmitterKind.BirthratePerMeter,
_ => ParticleEmitterKind.Unknown,
};
private static ParticleType MapParticleType(DatParticleType type) => type switch
{
DatParticleType.Still => ParticleType.Still,
DatParticleType.LocalVelocity => ParticleType.LocalVelocity,
DatParticleType.ParabolicLVGA => ParticleType.ParabolicLVGA,
DatParticleType.ParabolicLVGAGR => ParticleType.ParabolicLVGAGR,
DatParticleType.Swarm => ParticleType.Swarm,
DatParticleType.Explode => ParticleType.Explode,
DatParticleType.Implode => ParticleType.Implode,
DatParticleType.ParabolicLVLA => ParticleType.ParabolicLVLA,
DatParticleType.ParabolicLVLALR => ParticleType.ParabolicLVLALR,
DatParticleType.ParabolicGVGA => ParticleType.ParabolicGVGA,
DatParticleType.ParabolicGVGAGR => ParticleType.ParabolicGVGAGR,
DatParticleType.GlobalVelocity => ParticleType.GlobalVelocity,
_ => ParticleType.Unknown,
};
}
/// <summary>
/// Exact retail maximum-distance selection for a particle GfxObj.
/// <c>CPhysicsPart::GetMaxDegradeDistance</c> (0x0050D510) supplies the
/// 100-unit default; <c>GfxObjDegradeInfo::get_max_degrade_distance</c>
/// (0x0051E2D0) selects entry zero for one/two-entry tables and the
/// second-to-last entry for larger tables.
/// </summary>
public static class RetailParticleDegradeDistance
{
public const float DefaultDistance = 100f;
public static float FromEntries(IReadOnlyList<DatReaderWriter.Types.GfxObjInfo>? entries)
{
if (entries is null || entries.Count == 0)
return DefaultDistance;
int index = entries.Count <= 2 ? 0 : entries.Count - 2;
return entries[index].MaxDist;
}
}