phase(N.4) Task 17: EntitySpawnAdapter for server-spawned per-instance content
Routes server-spawned (CreateObject) entities through the per-instance rendering path. Filter: ServerGuid != 0. Atlas-tier entities (procedural, ServerGuid == 0) flow through LandblockSpawnAdapter (Task 11) instead. For entities with PaletteOverride set, walks each MeshRef.SurfaceOverrides map and calls TextureCache.GetOrUploadWithPaletteOverride to pre-warm the palette-composed GL texture before the first draw. Surfaces not in the SurfaceOverrides map (i.e. whose ids are only known after opening the GfxObj dat) are decoded lazily by the draw dispatcher on first use, consistent with StaticMeshRenderer. Builds AnimatedEntityState per server-guid via injected sequencer factory (Func<WorldEntity, AnimationSequencer>). The factory decouples the adapter from DatCollection so tests pass a stub lambda without a GL context. OnRemove releases per-entity state. Unknown guids no-op. Introduces ITextureCachePerInstance: thin seam interface over the palette decode path so EntitySpawnAdapter tests can use a CapturingTextureCache mock without constructing a GL context. TextureCache implements it. Adjustment 4 documented in source comments: WorldEntity does not currently expose HiddenPartsMask or AnimPartChanges (they are consumed upstream in the network layer before the WorldEntity is built). HideParts / SetPartOverride calls are placeholder TODO'd for when those fields are promoted. Wired into GpuWorldState.AppendLiveEntity (OnCreate) and RemoveEntityByServerGuid (OnRemove). Constructed in GameWindow under the ACDREAM_USE_WB_FOUNDATION flag alongside LandblockSpawnAdapter. Sequencer factory captures _dats + _animLoader at construction time; falls back to an empty Setup + MotionTable via NullAnimLoader when dats are unavailable. 10 new tests: server-spawn routing, atlas-tier skip, palette decode pre-warm (with and without surface overrides), OnRemove lifecycle, unknown-guid noop, multi-entity isolation. All pass; 8 pre-existing failures unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ce72c574e9
commit
c02c307bee
6 changed files with 499 additions and 3 deletions
152
src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs
Normal file
152
src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
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<WorldEntity, AnimationSequencer></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 4</b>: <see cref="WorldEntity"/> does not currently expose
|
||||
/// <c>HiddenPartsMask</c> or <c>AnimPartChanges</c> as direct fields (those
|
||||
/// live on the network-layer spawn record and are consumed upstream before
|
||||
/// the <see cref="WorldEntity"/> is built). When those fields are promoted to
|
||||
/// <see cref="WorldEntity"/>, <see cref="OnCreate"/> should call
|
||||
/// <see cref="AnimatedEntityState.HideParts"/> and
|
||||
/// <see cref="AnimatedEntityState.SetPartOverride"/> here. For now the mask
|
||||
/// stays at 0 (no parts hidden) and no part overrides are set — the draw
|
||||
/// dispatcher falls through to Setup defaults for every part.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EntitySpawnAdapter
|
||||
{
|
||||
private readonly ITextureCachePerInstance _textureCache;
|
||||
private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory;
|
||||
|
||||
// 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();
|
||||
|
||||
/// <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>
|
||||
public EntitySpawnAdapter(
|
||||
ITextureCachePerInstance textureCache,
|
||||
Func<WorldEntity, AnimationSequencer> sequencerFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(textureCache);
|
||||
ArgumentNullException.ThrowIfNull(sequencerFactory);
|
||||
_textureCache = textureCache;
|
||||
_sequencerFactory = sequencerFactory;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 4 placeholder: when WorldEntity gains HiddenPartsMask +
|
||||
// AnimPartChanges fields, apply them here:
|
||||
// state.HideParts(entity.HiddenPartsMask);
|
||||
// foreach (var apc in entity.AnimPartChanges)
|
||||
// state.SetPartOverride(apc.PartIndex, apc.NewModelId);
|
||||
|
||||
_stateByGuid[entity.ServerGuid] = state;
|
||||
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);
|
||||
|
||||
/// <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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue