refactor(rendering): isolate dispatcher candidate values

This commit is contained in:
Erik 2026-07-25 01:06:57 +02:00
parent accd402dd7
commit 58b712c6ec
5 changed files with 356 additions and 50 deletions

View file

@ -673,6 +673,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes = ~480 KB / frame
// of GC pressure on the render thread under the original T17 shape.
private readonly List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> _walkScratch = new();
// G2: the dispatcher consumes this acdream-owned value boundary, never
// WorldEntity. The current source translates its accepted walk into this
// retained list; G4 will replace only that producer with RenderFrameView.
private readonly List<RenderInstanceTuple> _candidateTupleScratch = new();
// Tier 1 cache (#53) — per-entity classification collector. Reused across
// frames; cleared at flush time when the per-entity loop crosses an entity
@ -1295,6 +1299,49 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
internal static uint ResolveCacheLandblockHint(WorldEntity entity, uint tupleLandblockId)
=> entity.ParentCellId is uint pc ? ((pc & 0xFFFF0000u) | 0xFFFFu) : tupleLandblockId;
internal static uint ResolveCacheLandblockHint(
in RenderInstanceCandidate entity) =>
entity.CacheLandblockId;
private static void BuildCurrentCandidateTuples(
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> source,
HashSet<uint>? animatedEntityIds,
List<RenderInstanceTuple> destination)
{
destination.Clear();
if (destination.Capacity < source.Count)
destination.Capacity = source.Count;
int index = 0;
while (index < source.Count)
{
(WorldEntity entity, _, uint tupleLandblockId) = source[index];
int end = index + 1;
while (end < source.Count
&& ReferenceEquals(source[end].Entity, entity)
&& source[end].LandblockId == tupleLandblockId)
{
end++;
}
int meshPartCount = end - index;
RenderInstanceCandidate candidate =
RenderInstanceCandidate.FromWorldEntity(
entity,
animatedEntityIds?.Contains(entity.Id) == true,
meshPartCount,
tupleLandblockId);
for (; index < end; index++)
{
int meshRefIndex = source[index].MeshRefIndex;
destination.Add(new RenderInstanceTuple(
candidate,
meshRefIndex,
entity.MeshRefs[meshRefIndex]));
}
}
}
/// <summary>
/// #119 decisive probe: rate-limited <c>[dump-entity] WALK-REJECT</c> line
/// for an <c>ACDREAM_DUMP_ENTITY</c>-targeted entity that the walk filtered
@ -1325,17 +1372,31 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// collapsed to ~zero / missing parts) from H-B (Tier-1 cache holding a
/// partial or stale batch set) from H-C (both healthy ⇒ draw-side compose).
/// </summary>
private void MaybeEmitEntityDump(WorldEntity entity, uint landblockId, bool culled)
private void MaybeEmitEntityDump(
in RenderInstanceCandidate entity,
uint landblockId,
bool culled,
IReadOnlyList<RenderInstanceTuple> tuples)
{
var targets = RenderingDiagnostics.DumpEntitySourceIds;
if (targets.Count == 0 || !targets.Contains(entity.SourceGfxObjOrSetupId)) return;
if (targets.Count == 0 || !targets.Contains(entity.SourceId))
return;
var refs = entity.MeshRefs;
int zeroT = 0;
int refsCount = 0;
float tzMin = float.MaxValue, tzMax = float.MinValue;
for (int i = 0; i < refs.Count; i++)
for (int i = 0; i < tuples.Count; i++)
{
var t = refs[i].PartTransform.Translation;
RenderInstanceTuple tuple = tuples[i];
if (tuple.Candidate.LocalEntityId != entity.LocalEntityId
|| tuple.Candidate.TupleLandblockId
!= entity.TupleLandblockId)
{
continue;
}
refsCount++;
Vector3 t = tuple.MeshRef.PartTransform.Translation;
if (t.LengthSquared() < 1e-9f) zeroT++;
if (t.Z < tzMin) tzMin = t.Z;
if (t.Z > tzMax) tzMax = t.Z;
@ -1356,7 +1417,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
}
var sig = (refs.Count, cacheBatches, zeroT, culled);
var sig = (refsCount, cacheBatches, zeroT, culled);
bool first = !_entityDumpSig.TryGetValue(entity.Id, out var prev);
if (!first && prev == sig) return;
_entityDumpSig[entity.Id] = sig;
@ -1365,20 +1426,29 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
? (_tier1CacheDisabled ? "disabled" : "miss")
: $"hit:{cacheBatches} restZero={restZero} restZ=[{rzMin:F2}..{rzMax:F2}]";
Console.WriteLine(
$"[dump-entity] DRAW{(first ? "" : "-CHANGED")} id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " +
$"lb=0x{landblockId:X8} cell=0x{(entity.ParentCellId ?? 0u):X8} " +
$"[dump-entity] DRAW{(first ? "" : "-CHANGED")} id=0x{entity.Id:X8} src=0x{entity.SourceId:X8} " +
$"lb=0x{landblockId:X8} cell=0x{entity.ParentCellId:X8} " +
$"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) scale={entity.Scale:F2} " +
$"meshRefs={refs.Count} tZero={zeroT} tZ=[{tzMin:F2}..{tzMax:F2}] cache={cacheStr} culled={culled}");
$"meshRefs={refsCount} tZero={zeroT} tZ=[{tzMin:F2}..{tzMax:F2}] cache={cacheStr} culled={culled}");
if (first)
{
for (int i = 0; i < refs.Count; i++)
for (int i = 0; i < tuples.Count; i++)
{
var mr = refs[i];
RenderInstanceTuple tuple = tuples[i];
if (tuple.Candidate.LocalEntityId
!= entity.LocalEntityId
|| tuple.Candidate.TupleLandblockId
!= entity.TupleLandblockId)
{
continue;
}
MeshRef mr = tuple.MeshRef;
var t = mr.PartTransform.Translation;
bool loaded = _meshAdapter.TryGetRenderData(mr.GfxObjId) is not null;
Console.WriteLine(
$"[dump-entity] part[{i:D2}] gfx=0x{mr.GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3}) loaded={loaded}");
$"[dump-entity] part[{tuple.MeshRefIndex:D2}] gfx=0x{mr.GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3}) loaded={loaded}");
}
}
}
@ -1485,6 +1555,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
set,
walkResult.EntitiesWalked,
_walkScratch);
BuildCurrentCandidateTuples(
_walkScratch,
animatedEntityIds,
_candidateTupleScratch);
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
@ -1537,8 +1611,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// a successful slow-path classification.
uint? prevTupleEntityId = null;
foreach (var (entity, partIdx, landblockId) in _walkScratch)
foreach (RenderInstanceTuple tuple in _candidateTupleScratch)
{
RenderInstanceCandidate entity = tuple.Candidate;
int partIdx = tuple.MeshRefIndex;
uint landblockId = entity.TupleLandblockId;
if (diag) _entitiesSeen++;
// Skip subsequent tuples of an entity that already cache-hit on
@ -1580,7 +1657,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// #119 root-cause fix: cache operations key on the entity's OWNING
// landblock, never the Draw call's tuple landblock (which is the
// PLAYER's landblock on the bucket path). See ResolveCacheLandblockHint.
uint cacheLb = ResolveCacheLandblockHint(entity, landblockId);
uint cacheLb = ResolveCacheLandblockHint(in entity);
bool isNewEntity = !prevTupleEntityId.HasValue || prevTupleEntityId.Value != entity.Id;
if (isNewEntity)
@ -1599,7 +1676,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// The whole decision (including the routing-active gate) lives in
// the pure ResolveSlotForFrame helper so it's unit-testable.
(_currentEntitySlot, _currentEntityCulled) = ResolveSlotForFrame(
_clipRoutingActive, entity.ServerGuid, entity.ParentCellId,
_clipRoutingActive, entity.ServerGuid, entity.ParentCell,
_cellIdToSlot, _outdoorSlot, _outdoorVisible);
if (_currentEntityCulled)
probeCulledEntities++;
@ -1619,14 +1696,18 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// #119 decisive probe: one-shot dump (+ change re-emission) for
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
// so a routed-out entity still reports its state.
MaybeEmitEntityDump(entity, cacheLb, _currentEntityCulled);
MaybeEmitEntityDump(
in entity,
cacheLb,
_currentEntityCulled,
_candidateTupleScratch);
// #176 seam-draw probe: any entity parented to a target cell reports
// its position + light set (a floor-coincident static/plate would be
// the z-fight's second draw; the player entity is the positive
// control). Before the culled-continue, like the dump above.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled
&& entity.ParentCellId is { } seamPc
&& entity.ParentCell is { } seamPc
&& AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(seamPc))
MaybeEmitSeamEnt(entity);
}
@ -1650,11 +1731,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_currentEntityCulled)
continue;
var entityWorld =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
Matrix4x4 entityWorld = entity.RootWorld;
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
bool isAnimated = entity.Animated;
// Cache-hit fast path (Task 10): static entity with a populated
// cache entry skips classification entirely. Walk the cached
@ -1689,7 +1768,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// the mesh adapter — cheap dict lookup, not a re-classify.
if (anyVao == 0)
{
var firstMeshRef = entity.MeshRefs[partIdx];
MeshRef firstMeshRef = tuple.MeshRef;
var firstRenderData = _meshAdapter.TryGetRenderData(firstMeshRef.GfxObjId);
if (firstRenderData is not null) anyVao = firstRenderData.VAO;
}
@ -1728,7 +1807,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// 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];
MeshRef meshRef = tuple.MeshRef;
ulong gfxObjId = meshRef.GfxObjId;
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
@ -1871,7 +1950,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (!ClassifyBatches(partData, model, entity, meshRef, paletteIdentity, restPose, opacityMultiplier, collector))
currentEntityIncomplete = true;
_selectionSink?.AddVisiblePart(
entity,
entity.ServerGuid,
entity.LocalEntityId,
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
(uint)partGfxObjId,
model);
@ -1901,7 +1981,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (!ClassifyBatches(renderData, model, entity, meshRef, paletteIdentity, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector))
currentEntityIncomplete = true;
_selectionSink?.AddVisiblePart(
entity,
entity.ServerGuid,
entity.LocalEntityId,
partIdx,
(uint)gfxObjId,
model);
@ -2354,13 +2435,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private void PublishCachedSelectionParts(
EntityCacheEntry cachedEntry,
WorldEntity entity,
in RenderInstanceCandidate entity,
Matrix4x4 entityWorld)
{
foreach (CachedSelectionPart part in cachedEntry.SelectionParts)
{
_selectionSink!.AddVisiblePart(
entity,
entity.ServerGuid,
entity.LocalEntityId,
part.PartIndex,
part.GfxObjId,
part.RestPose * entityWorld);
@ -3379,14 +3461,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// ever live in the target cells.
private readonly Dictionary<ulong, string> _seamEntSigs = new();
private void MaybeEmitSeamEnt(WorldEntity entity)
private void MaybeEmitSeamEnt(in RenderInstanceCandidate entity)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
var snap = _pointSnapshot;
var sb = new System.Text.StringBuilder(200);
sb.AppendFormat(ci,
"guid=0x{0:X8} cell=0x{1:X8} pos=({2:F2},{3:F2},{4:F3}) culled={5} slot={6} indoor={7} L=[",
entity.ServerGuid, entity.ParentCellId ?? 0u,
entity.ServerGuid, entity.ParentCellId,
entity.Position.X, entity.Position.Y, entity.Position.Z,
_currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0);
bool any = false;
@ -3409,12 +3491,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
Console.WriteLine($"[seam-ent] t={Environment.TickCount64} {sig}");
}
private void ComputeEntityLightSet(WorldEntity entity)
private void ComputeEntityLightSet(
in RenderInstanceCandidate entity)
{
// #142: set the indoor flag first so it's available even when the early-return
// fires below. Both the torch selection and the sun gate use the same predicate,
// so they can't disagree — one call, one truth.
_currentEntityIndoor = IndoorObjectReceivesTorches(entity.ParentCellId);
_currentEntityIndoor =
IndoorObjectReceivesTorches(entity.ParentCell);
_currentEntityLightSet = InstanceLightSet.Disabled;
var snap = _pointSnapshot;
@ -3423,9 +3507,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Retail useSunlight gate: outdoor objects receive no per-object torches.
if (!_currentEntityIndoor) return; // #142: reuse the cached flag (was: IndoorObjectReceivesTorches(...))
if (entity.AabbDirty) entity.RefreshAabb();
Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f;
float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f;
Vector3 center =
(entity.Bounds.Minimum + entity.Bounds.Maximum) * 0.5f;
float radius =
(entity.Bounds.Maximum - entity.Bounds.Minimum).Length() * 0.5f;
Array.Fill(_currentEntityLightSetScratch, -1);
LightManager.SelectForObject(snap, center, radius, _currentEntityLightSetScratch);
_currentEntityLightSet = InstanceLightSet.From(_currentEntityLightSetScratch);
@ -3461,7 +3546,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private bool ClassifyBatches(
ObjectRenderData renderData,
Matrix4x4 model,
WorldEntity entity,
in RenderInstanceCandidate entity,
MeshRef meshRef,
PaletteCompositeIdentity paletteIdentity,
Matrix4x4 restPose,
@ -3483,7 +3568,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
translucency = TranslucencyKind.AlphaBlend;
ResolvedTexture texture = ResolveTexture(
entity,
in entity,
meshRef,
batch,
paletteIdentity,
@ -3518,11 +3603,40 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly record struct ResolvedTexture(ulong Handle, uint Layer);
private ResolvedTexture ResolveTexture(
in RenderInstanceCandidate entity,
MeshRef meshRef,
ObjectRenderBatch batch,
PaletteCompositeIdentity paletteIdentity,
out bool compositePending) =>
ResolveTexture(
entity.LocalEntityId,
entity.PaletteOverride,
meshRef,
batch,
paletteIdentity,
out compositePending);
private ResolvedTexture ResolveTexture(
WorldEntity entity,
MeshRef meshRef,
ObjectRenderBatch batch,
PaletteCompositeIdentity paletteIdentity,
out bool compositePending) =>
ResolveTexture(
entity.Id,
entity.PaletteOverride,
meshRef,
batch,
paletteIdentity,
out compositePending);
private ResolvedTexture ResolveTexture(
uint localEntityId,
PaletteOverride? paletteOverride,
MeshRef meshRef,
ObjectRenderBatch batch,
PaletteCompositeIdentity paletteIdentity,
out bool compositePending)
{
compositePending = false;
@ -3536,11 +3650,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
&& overrideOrigTex != 0;
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
bool sourceIsPaletteIndexed = entity.PaletteOverride is not null
bool sourceIsPaletteIndexed = paletteOverride is not null
&& _textures.IsPaletteIndexed(surfaceId, origTexOverride);
WbTextureResolutionKind resolution = WbTextureResolutionPolicy.Select(
hasOrigTexOverride,
entity.PaletteOverride is not null,
paletteOverride is not null,
sourceIsPaletteIndexed);
switch (resolution)
@ -3549,10 +3663,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{
BindlessTextureLocation texture =
_textures.GetOrUploadWithPaletteOverrideBindless(
entity.Id,
localEntityId,
surfaceId,
origTexOverride,
entity.PaletteOverride!,
paletteOverride!,
paletteIdentity);
compositePending = texture.Handle == 0;
return new ResolvedTexture(texture.Handle, texture.Layer);
@ -3562,7 +3676,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{
BindlessTextureLocation texture =
_textures.GetOrUploadWithOrigTextureOverrideBindless(
entity.Id,
localEntityId,
surfaceId,
overrideOrigTex);
compositePending = texture.Handle == 0;