feat(A.5 T17): WbDrawDispatcher Change #1 — animated-walk fix + WalkEntities helper
Per Phase A.5 spec §4.6 Change #1: when an LB is invisible AND animatedEntityIds is non-empty, the inner loop walked every entity in the LB just to find the few animated ones. At ~10.7K entities (N1=4) that is wasted iteration cost per frame. Extracted a pure-CPU internal static WalkEntities helper. When LB is invisible: iterate animatedEntityIds directly and look each up in a per-LB AnimatedById dictionary (typically <50 animated vs ~10K total). When LB is visible: walk all entities as before. GpuWorldState.LandblockEntries now yields an AnimatedById map as a 5th tuple field alongside the AABB tuple. Dictionary is built on each yield (cheap — ~132 entities/LB max). A caching layer is out of A.5 scope. WbDrawDispatcher.Draw signature updated to consume the 5-tuple. GameWindow.cs call site passes _worldState.LandblockEntries which now yields the 5-tuple — no change needed there. 8 new tests in WbDrawDispatcherBucketingTests cover T17 Change #1 (invisible LB / animated set / neverCull / null frustum) and T18 Change #2 guard tests (cached AABB / dirty flag / animated bypass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0de6bc9c96
commit
003443cd1a
3 changed files with 546 additions and 90 deletions
|
|
@ -157,9 +157,113 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
Matrix4x4 restPose)
|
||||
=> restPose * animOverride * entityWorld;
|
||||
|
||||
/// <summary>
|
||||
/// Entry for <see cref="WalkEntities"/> per-landblock iteration.
|
||||
/// Mirrors the shape yielded by <c>GpuWorldState.LandblockEntries</c>.
|
||||
/// </summary>
|
||||
public readonly record struct LandblockEntry(
|
||||
uint LandblockId,
|
||||
Vector3 AabbMin,
|
||||
Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById);
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="WalkEntities"/> — the list of (entity, meshRef index)
|
||||
/// pairs that passed all visibility filters, plus a diagnostic walk count.
|
||||
/// </summary>
|
||||
public struct WalkResult
|
||||
{
|
||||
public int EntitiesWalked;
|
||||
public List<(WorldEntity Entity, int MeshRefIndex)> ToDraw;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure-CPU visibility filter over <paramref name="landblockEntries"/>.
|
||||
/// Separated from <see cref="Draw"/> so tests can exercise it without GL state.
|
||||
///
|
||||
/// <para>
|
||||
/// A.5 T17 Change #1: when an LB is frustum-culled AND
|
||||
/// <paramref name="animatedEntityIds"/> is non-empty, the OLD path walked
|
||||
/// every entity in the LB just to find the few animated ones. This helper
|
||||
/// fixes that: if the LB is invisible, we iterate
|
||||
/// <paramref name="animatedEntityIds"/> directly and look each up in
|
||||
/// <c>entry.AnimatedById</c> (typically <50 animated, up to ~10K total).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A.5 T18 Change #2: per-entity AABB cull reads from the cached
|
||||
/// <see cref="WorldEntity.AabbMin"/>/<see cref="WorldEntity.AabbMax"/>
|
||||
/// (refreshed lazily if <see cref="WorldEntity.AabbDirty"/>), instead of
|
||||
/// recomputing Position±5 each frame.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static WalkResult WalkEntities(
|
||||
IEnumerable<LandblockEntry> landblockEntries,
|
||||
FrustumPlanes? frustum,
|
||||
uint? neverCullLandblockId,
|
||||
HashSet<uint>? visibleCellIds,
|
||||
HashSet<uint>? animatedEntityIds)
|
||||
{
|
||||
var result = new WalkResult { ToDraw = new List<(WorldEntity, int)>() };
|
||||
|
||||
foreach (var entry in landblockEntries)
|
||||
{
|
||||
bool landblockVisible = frustum is null
|
||||
|| entry.LandblockId == neverCullLandblockId
|
||||
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
|
||||
|
||||
if (!landblockVisible)
|
||||
{
|
||||
// A.5 T17 Change #1: walk only animated entities, not all entities.
|
||||
// Avoids O(N_entities) scan when only O(N_animated) work is needed.
|
||||
if (animatedEntityIds is null || animatedEntityIds.Count == 0) continue;
|
||||
if (entry.AnimatedById is null) continue;
|
||||
foreach (var animatedId in animatedEntityIds)
|
||||
{
|
||||
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
|
||||
result.EntitiesWalked++;
|
||||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||||
result.ToDraw.Add((entity, i));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entity in entry.Entities)
|
||||
{
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
||||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
|
||||
continue;
|
||||
|
||||
// Per-entity AABB frustum cull (perf #3). Animated entities bypass —
|
||||
// they're tracked at landblock level + need per-frame work regardless.
|
||||
// A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty.
|
||||
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
|
||||
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
|
||||
{
|
||||
if (entity.AabbDirty) entity.RefreshAabb();
|
||||
if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax))
|
||||
continue;
|
||||
}
|
||||
|
||||
result.EntitiesWalked++;
|
||||
for (int i = 0; i < entity.MeshRefs.Count; i++)
|
||||
result.ToDraw.Add((entity, i));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Draw(
|
||||
ICamera camera,
|
||||
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
|
||||
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
|
||||
FrustumPlanes? frustum = null,
|
||||
uint? neverCullLandblockId = null,
|
||||
HashSet<uint>? visibleCellIds = null,
|
||||
|
|
@ -194,97 +298,79 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
var metaTable = _meshAdapter.MetadataTable;
|
||||
uint anyVao = 0;
|
||||
|
||||
foreach (var entry in landblockEntries)
|
||||
// Project the 5-tuple enumerable into LandblockEntry records for WalkEntities.
|
||||
static IEnumerable<LandblockEntry> ToEntries(
|
||||
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> src)
|
||||
{
|
||||
bool landblockVisible = frustum is null
|
||||
|| entry.LandblockId == neverCullLandblockId
|
||||
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
|
||||
foreach (var e in src)
|
||||
yield return new LandblockEntry(e.LandblockId, e.AabbMin, e.AabbMax, e.Entities, e.AnimatedById);
|
||||
}
|
||||
|
||||
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
|
||||
continue;
|
||||
var walkResult = WalkEntities(
|
||||
ToEntries(landblockEntries),
|
||||
frustum,
|
||||
neverCullLandblockId,
|
||||
visibleCellIds,
|
||||
animatedEntityIds);
|
||||
|
||||
foreach (var entity in entry.Entities)
|
||||
foreach (var (entity, partIdx) in walkResult.ToDraw)
|
||||
{
|
||||
if (diag) _entitiesSeen++;
|
||||
|
||||
var entityWorld =
|
||||
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
||||
Matrix4x4.CreateTranslation(entity.Position);
|
||||
|
||||
// Compute palette-override hash ONCE per entity (perf #4).
|
||||
// Reused across every (part, batch) lookup so the FNV-1a fold
|
||||
// over SubPalettes runs once instead of N times. Zero when the
|
||||
// entity has no palette override (trees, scenery).
|
||||
ulong palHash = 0;
|
||||
if (entity.PaletteOverride is not null)
|
||||
palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
|
||||
|
||||
// Note: GameWindow's spawn path already applies
|
||||
// AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
|
||||
// close-detail mesh swap for humanoids) to MeshRefs. We
|
||||
// trust MeshRefs as the source of truth here. AnimatedEntityState's
|
||||
// overrides become relevant only for hot-swap (0xF625
|
||||
// ObjDescEvent) which today rebuilds MeshRefs anyway.
|
||||
var meshRef = entity.MeshRefs[partIdx];
|
||||
ulong gfxObjId = meshRef.GfxObjId;
|
||||
|
||||
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
|
||||
if (renderData is null)
|
||||
{
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
||||
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
|
||||
if (!landblockVisible && !isAnimated) continue;
|
||||
|
||||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
|
||||
continue;
|
||||
|
||||
// Per-entity AABB frustum cull (perf #3). Skips work for distant
|
||||
// entities even when their landblock is visible. Animated
|
||||
// entities bypass — they're tracked at landblock level + need
|
||||
// per-frame work for animation regardless. Conservative 5m
|
||||
// radius covers typical entity bounds.
|
||||
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
|
||||
{
|
||||
var p = entity.Position;
|
||||
var aMin = new Vector3(p.X - PerEntityCullRadius, p.Y - PerEntityCullRadius, p.Z - PerEntityCullRadius);
|
||||
var aMax = new Vector3(p.X + PerEntityCullRadius, p.Y + PerEntityCullRadius, p.Z + PerEntityCullRadius);
|
||||
if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (diag) _entitiesSeen++;
|
||||
|
||||
var entityWorld =
|
||||
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
||||
Matrix4x4.CreateTranslation(entity.Position);
|
||||
|
||||
// Compute palette-override hash ONCE per entity (perf #4).
|
||||
// Reused across every (part, batch) lookup so the FNV-1a fold
|
||||
// over SubPalettes runs once instead of N times. Zero when the
|
||||
// entity has no palette override (trees, scenery).
|
||||
ulong palHash = 0;
|
||||
if (entity.PaletteOverride is not null)
|
||||
palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
|
||||
|
||||
bool drewAny = false;
|
||||
for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++)
|
||||
{
|
||||
// Note: GameWindow's spawn path already applies
|
||||
// AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
|
||||
// close-detail mesh swap for humanoids) to MeshRefs. We
|
||||
// trust MeshRefs as the source of truth here. AnimatedEntityState's
|
||||
// overrides become relevant only for hot-swap (0xF625
|
||||
// ObjDescEvent) which today rebuilds MeshRefs anyway.
|
||||
var meshRef = entity.MeshRefs[partIdx];
|
||||
ulong gfxObjId = meshRef.GfxObjId;
|
||||
|
||||
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
|
||||
if (renderData is null)
|
||||
{
|
||||
if (diag) _meshesMissing++;
|
||||
continue;
|
||||
}
|
||||
drewAny = true;
|
||||
if (anyVao == 0) anyVao = renderData.VAO;
|
||||
|
||||
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
|
||||
{
|
||||
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
|
||||
{
|
||||
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
|
||||
if (partData is null) continue;
|
||||
|
||||
var model = ComposePartWorldMatrix(
|
||||
entityWorld, meshRef.PartTransform, partTransform);
|
||||
|
||||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var model = meshRef.PartTransform * entityWorld;
|
||||
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
|
||||
}
|
||||
}
|
||||
|
||||
if (diag && drewAny) _entitiesDrawn++;
|
||||
if (diag) _meshesMissing++;
|
||||
continue;
|
||||
}
|
||||
if (anyVao == 0) anyVao = renderData.VAO;
|
||||
|
||||
bool drewAny = false;
|
||||
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
|
||||
{
|
||||
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
|
||||
{
|
||||
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
|
||||
if (partData is null) continue;
|
||||
|
||||
var model = ComposePartWorldMatrix(
|
||||
entityWorld, meshRef.PartTransform, partTransform);
|
||||
|
||||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
|
||||
drewAny = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var model = meshRef.PartTransform * entityWorld;
|
||||
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
|
||||
drewAny = true;
|
||||
}
|
||||
|
||||
if (diag && drewAny) _entitiesDrawn++;
|
||||
}
|
||||
|
||||
// Nothing visible — skip the GL pass entirely.
|
||||
|
|
|
|||
|
|
@ -106,17 +106,33 @@ public sealed class GpuWorldState
|
|||
/// Per-landblock iteration with AABB data for use by the frustum-culling
|
||||
/// draw path. Landblocks without a stored AABB yield <see cref="Vector3.Zero"/>
|
||||
/// for both corners, which the culler will conservatively treat as visible.
|
||||
///
|
||||
/// <para>
|
||||
/// A.5 T17: also yields an <c>AnimatedById</c> dictionary built on the fly
|
||||
/// from the landblock's entity list. This lets <see cref="WbDrawDispatcher"/>
|
||||
/// skip the full entity walk when the landblock is frustum-culled but animated
|
||||
/// entities inside it must still be processed (Change #1).
|
||||
/// Building the dict per-yield is cheap (~132 entities/LB max). A caching
|
||||
/// layer is out of A.5 scope.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> LandblockEntries
|
||||
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var kvp in _loaded)
|
||||
{
|
||||
// Build AnimatedById on the fly — cheap (~132 entities/LB max).
|
||||
var byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
|
||||
foreach (var e in kvp.Value.Entities)
|
||||
byId[e.Id] = e;
|
||||
|
||||
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
|
||||
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities);
|
||||
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
|
||||
else
|
||||
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities);
|
||||
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue