perf(vfx): port retail particle visibility degradation

Resolve DAT-authored particle ranges from the hardware GfxObj, apply retail distance and completed-cell visibility gates, and preserve the exact finite/infinite off-view update semantics. This removes dense-world simulation work without shortening terrain, entity, fog, or streaming distance.

Publish doorway-clipped outdoor cells through a focused frame controller, retain effect cell identity for outdoor statics, reject hidden emitters before particle-slot scans, and offer an explicit opt-in Extended particle range.

Release build succeeds and all 5,857 tests pass with five intentional skips. Retail-conformance, architecture, and adversarial review cycles are clean; connected Aerlinthe visual/performance gate pending.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 15:27:36 +02:00
parent 82789eea88
commit f1ba147ac5
29 changed files with 1810 additions and 125 deletions

View file

@ -2,9 +2,13 @@ using System;
using System.Collections.Concurrent;
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.Lib.IO;
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;
@ -15,6 +19,7 @@ namespace AcDream.Core.Vfx;
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()
@ -23,8 +28,10 @@ public sealed class EmitterDescRegistry
}
public EmitterDescRegistry(DatCollection dats)
: this(id => SafeGet(dats, id))
{
ArgumentNullException.ThrowIfNull(dats);
_resolver = id => SafeGet<DatParticleEmitter>(dats, id);
_degradeDistanceResolver = emitter => ResolveMaxDegradeDistance(dats, emitter);
}
public EmitterDescRegistry(Func<uint, DatParticleEmitter?>? resolver)
@ -55,7 +62,18 @@ public sealed class EmitterDescRegistry
var dat = _resolver(emitterId);
if (dat is not null)
{
desc = FromDat(emitterId, dat);
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;
}
@ -66,7 +84,10 @@ public sealed class EmitterDescRegistry
public int Count => _byId.Count;
public static EmitterDesc FromDat(uint emitterId, DatParticleEmitter dat)
public static EmitterDesc FromDat(
uint emitterId,
DatParticleEmitter dat,
float maxDegradeDistance = RetailParticleDegradeDistance.DefaultDistance)
{
ArgumentNullException.ThrowIfNull(dat);
@ -90,6 +111,7 @@ public sealed class EmitterDescRegistry
return new EmitterDesc
{
MaxDegradeDistance = maxDegradeDistance,
DatId = emitterId,
Type = MapParticleType(dat.ParticleType),
EmitterKind = MapEmitterKind(dat.EmitterType),
@ -132,13 +154,37 @@ public sealed class EmitterDescRegistry
};
}
private static DatParticleEmitter? SafeGet(DatCollection dats, uint id)
private static float? ResolveMaxDegradeDistance(
DatCollection 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>(DatCollection dats, uint id) where T : class, IDBObj
{
if (dats is null)
return null;
try
{
return dats.Get<DatParticleEmitter>(id);
return dats.Get<T>(id);
}
catch
{
@ -170,3 +216,24 @@ public sealed class EmitterDescRegistry
_ => 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;
}
}