acdream/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs
Erik 0afd741ea7 feat(A.5 T18): use cached WorldEntity AABB in dispatcher; populate at register
Per Phase A.5 spec §4.6 Change #2: WalkEntities's per-entity AABB
frustum cull was recomputing Position±5 per frame per entity. With
~10.7K entities (N1=4) at 240 FPS that is ~2.5M wasted Vector3
ops/sec.

Read the AABB from the WorldEntity cache (T8 schema) instead.
RefreshAabb runs lazily on AabbDirty=true. Populate at register time:

- LandblockLoader.BuildEntitiesFromInfo: RefreshAabb after each new
  WorldEntity construction (stabs + buildings). Refactored from
  inline object-initializer to named variable to enable the call.
- EntitySpawnAdapter.OnCreate: RefreshAabb after entity state init
  (position/rotation already set via the WorldEntity passed in).

Dynamic entities (NPCs, players) move every frame via direct
Position writes in GameWindow.cs. Migrated all three per-frame
write sites to SetPosition() (T8 mutator) so AabbDirty propagates:
  - line 5942: player entity render position update
  - line 6951: remote animated entity interpolated path
  - line 7279: remote animated entity landing/movement path

The lazy RefreshAabb in WalkEntities catches up on the next frame
after any SetPosition call — render thread only, no races.

Build green, 986 passed / 8 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:20:20 +02:00

194 lines
8.6 KiB
C#

using System;
using System.Collections.Generic;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Routes server-spawned (<c>CreateObject</c>) entities through the
/// per-instance rendering path. Server entities always carry per-instance
/// customizations (palette overrides, texture changes, part swaps) that
/// don't fit WB's atlas key, so they bypass the atlas and use the existing
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// path which already hash-keys overrides for caching.
///
/// <para>
/// Companion to <see cref="LandblockSpawnAdapter"/>: that adapter handles
/// atlas-tier (procedural) entities; this one handles per-instance-tier
/// (server-spawned). The boundary is <c>ServerGuid != 0</c> on
/// <see cref="WorldEntity"/>.
/// </para>
///
/// <para>
/// <b>Per-entity texture decode</b>: when <c>entity.PaletteOverride</c> is
/// non-null, the adapter calls
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// once per surface id that is known at spawn time (those on
/// <see cref="MeshRef.SurfaceOverrides"/>). Surfaces whose ids are only
/// discoverable by opening the GfxObj dat are decoded lazily by the draw
/// dispatcher (Task 22) on first use — that matches the existing
/// <c>StaticMeshRenderer</c> behavior.
/// </para>
///
/// <para>
/// <b>Sequencer factory</b>: the adapter is constructed with a
/// <c>Func&lt;WorldEntity, AnimationSequencer&gt;</c> factory so tests can
/// inject a stub without needing a live DatCollection or MotionTable.
/// Production callers supply a factory that fetches MotionTable from dats.
/// </para>
///
/// <para>
/// <b>Adjustment 6</b> (resolved Adjustment 4): <see cref="WorldEntity"/> now
/// carries <see cref="WorldEntity.PartOverrides"/> and
/// <see cref="WorldEntity.HiddenPartsMask"/>. <see cref="OnCreate"/> applies
/// both to the created <see cref="AnimatedEntityState"/>.
/// </para>
/// </summary>
public sealed class EntitySpawnAdapter
{
private readonly ITextureCachePerInstance _textureCache;
private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory;
private readonly IWbMeshAdapter? _meshAdapter;
// Per-server-guid state. Written on OnCreate, released on OnRemove.
// Single-threaded: called only from the render thread (same as GpuWorldState).
private readonly Dictionary<uint, AnimatedEntityState> _stateByGuid = new();
// Per-server-guid set of GfxObj ids registered with the mesh adapter,
// so OnRemove can decrement each. Per-instance entities don't go through
// LandblockSpawnAdapter, so without this their meshes would never load
// (WB doesn't know they exist).
private readonly Dictionary<uint, HashSet<ulong>> _meshIdsByGuid = new();
/// <param name="textureCache">
/// Per-instance texture decode path. In production this is the
/// <see cref="TextureCache"/> instance (which implements
/// <see cref="ITextureCachePerInstance"/>); in tests it is a capturing mock.
/// </param>
/// <param name="sequencerFactory">
/// Factory that builds an <see cref="AnimationSequencer"/> for a given
/// entity. Receives the full <see cref="WorldEntity"/> so it can look up
/// the Setup + MotionTable from the entity's <c>SourceGfxObjOrSetupId</c>
/// and server-supplied motion table override. Tests pass a lambda that
/// returns a stub sequencer.
/// </param>
/// <param name="meshAdapter">
/// Optional WB mesh adapter. When non-null, <see cref="OnCreate"/>
/// registers each unique <c>MeshRef.GfxObjId</c> with the adapter so WB
/// background-loads the mesh data; <see cref="OnRemove"/> decrements the
/// matching ref counts. When null, the adapter only tracks per-instance
/// state without driving WB lifecycle (test mode + flag-off mode).
/// </param>
public EntitySpawnAdapter(
ITextureCachePerInstance textureCache,
Func<WorldEntity, AnimationSequencer> sequencerFactory,
IWbMeshAdapter? meshAdapter = null)
{
ArgumentNullException.ThrowIfNull(textureCache);
ArgumentNullException.ThrowIfNull(sequencerFactory);
_textureCache = textureCache;
_sequencerFactory = sequencerFactory;
_meshAdapter = meshAdapter;
}
/// <summary>
/// Process a server-spawned entity. Returns the created
/// <see cref="AnimatedEntityState"/> for the entity, or <c>null</c> if
/// <paramref name="entity"/> is atlas-tier (<c>ServerGuid == 0</c>).
/// </summary>
public AnimatedEntityState? OnCreate(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
// Atlas-tier entities (procedural / dat-hydrated, ServerGuid == 0)
// are handled by LandblockSpawnAdapter, not here.
if (entity.ServerGuid == 0) return null;
// Pre-warm the per-instance texture cache for surfaces whose ids are
// already known at spawn time (those appearing as keys in
// MeshRef.SurfaceOverrides). GfxObj sub-mesh surface ids that aren't
// covered by SurfaceOverrides are decoded lazily by the draw
// dispatcher on first use — consistent with StaticMeshRenderer.
if (entity.PaletteOverride is { } paletteOverride)
{
foreach (var meshRef in entity.MeshRefs)
{
if (meshRef.SurfaceOverrides is null) continue;
// SurfaceOverrides maps surfaceId → origTextureOverride (may be 0
// meaning "no texture swap, just the palette override applies").
foreach (var (surfaceId, origTexOverride) in meshRef.SurfaceOverrides)
{
_textureCache.GetOrUploadWithPaletteOverride(
surfaceId,
origTexOverride == 0 ? null : origTexOverride,
paletteOverride);
}
}
}
// A.5 T18: populate cached AABB so WalkEntities reads from the cache
// rather than recomputing Position±5 per frame. Called here because
// all entity-state initialization (position, rotation) is complete
// by this point via the WorldEntity passed in.
entity.RefreshAabb();
// Build the per-entity AnimatedEntityState. The sequencer factory
// may return a stub (in tests) or a fully-constructed sequencer from
// the MotionTable (in production). Factory must not return null —
// if the entity has no motion table the factory should construct a
// no-op sequencer (Setup + empty MotionTable + NullAnimationLoader).
var sequencer = _sequencerFactory(entity);
var state = new AnimatedEntityState(sequencer);
// Adjustment 6: WorldEntity now carries PartOverrides + HiddenPartsMask.
state.HideParts(entity.HiddenPartsMask);
foreach (var po in entity.PartOverrides)
state.SetPartOverride(po.PartIndex, po.GfxObjId);
_stateByGuid[entity.ServerGuid] = state;
// Register each unique GfxObj id with WB so the meshes background-load.
// Includes both the entity's natural MeshRefs AND any server-sent
// PartOverride GfxObjs (weapons, clothing, helmets) — those replace the
// Setup default and need their own mesh data uploaded.
if (_meshAdapter is not null)
{
var unique = new HashSet<ulong>();
foreach (var meshRef in entity.MeshRefs)
unique.Add((ulong)meshRef.GfxObjId);
foreach (var po in entity.PartOverrides)
unique.Add((ulong)po.GfxObjId);
_meshIdsByGuid[entity.ServerGuid] = unique;
foreach (var id in unique) _meshAdapter.IncrementRefCount(id);
}
return state;
}
/// <summary>
/// Release the per-entity state for <paramref name="serverGuid"/>. Called
/// on <c>RemoveObject</c>. Unknown guids (never spawned, or already
/// removed) are silently ignored.
/// </summary>
public void OnRemove(uint serverGuid)
{
_stateByGuid.Remove(serverGuid);
if (_meshAdapter is not null && _meshIdsByGuid.TryGetValue(serverGuid, out var ids))
{
foreach (var id in ids) _meshAdapter.DecrementRefCount(id);
_meshIdsByGuid.Remove(serverGuid);
}
}
/// <summary>
/// Look up the <see cref="AnimatedEntityState"/> for a server guid.
/// Returns <c>null</c> if the entity was never spawned or has already
/// been removed.
/// </summary>
public AnimatedEntityState? GetState(uint serverGuid)
=> _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null;
}