Task 26 visual verification surfaced three bugs in the dispatcher. Two are fixed here; the third is documented as a remaining issue. 1. WB's IncrementRefCount only bumps a usage counter — it does NOT trigger mesh loading. Fixed in WbMeshAdapter.IncrementRefCount: call PrepareMeshDataAsync(id, isSetup: false) on first registration. Result auto-enqueues to _stagedMeshData (line 510 of WB's ObjectMeshManager) which Tick() drains onto the GPU. 2. EntitySpawnAdapter never registered per-instance entity meshes with WB. LandblockSpawnAdapter only registers atlas-tier (ServerGuid == 0); per-instance entities fell through. Fixed by adding optional IWbMeshAdapter constructor param + tracking unique GfxObj ids per server-guid for IncrementRefCount on OnCreate / DecrementRefCount on OnRemove. 3. WbDrawDispatcher.ResolveTexture used batch.SurfaceId which WB never populates (line 1746 of ObjectMeshManager only sets batch.Key — the TextureKey struct that has SurfaceId). Switched to batch.Key.SurfaceId. Plus diagnostic counters (ACDREAM_WB_DIAG=1) for entity-seen / drawn / mesh-missing / draws-issued counts. Status: with these fixes the dispatcher now issues real draw calls (~16K/frame, validated via diagnostic). However visual verification shows characters appear "exploded" (parts spaced too far apart) and scenery (trees/rocks/fences/buildings) does not appear. Root cause analysis pending — Adjustment 7 in the plan documents the deferred work. Flag stays default-off; legacy renderer remains the production path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
188 lines
8.3 KiB
C#
188 lines
8.3 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<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 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|