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

@ -0,0 +1,83 @@
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Scene;
/// <summary>
/// The complete acdream-owned value boundary consumed by the object
/// dispatcher. It contains presentation facts only; the dispatcher must not
/// call back into <see cref="WorldEntity"/> or any gameplay owner after this
/// value has been produced.
/// </summary>
internal readonly record struct RenderInstanceCandidate(
RenderProjectionId ProjectionId,
uint LocalEntityId,
uint ServerGuid,
uint SourceId,
uint ParentCellId,
Matrix4x4 RootWorld,
Vector3 Position,
Quaternion Rotation,
float Scale,
RenderWorldBounds Bounds,
PaletteOverride? PaletteOverride,
bool IsBuildingShell,
bool Animated,
int MeshPartCount,
uint TupleLandblockId)
{
internal uint Id => LocalEntityId;
internal uint? ParentCell =>
ParentCellId == 0 ? null : ParentCellId;
internal uint CacheLandblockId =>
ParentCellId != 0
? (ParentCellId & 0xFFFF0000u) | 0xFFFFu
: TupleLandblockId;
internal static RenderInstanceCandidate FromWorldEntity(
WorldEntity entity,
bool animated,
int meshPartCount,
uint tupleLandblockId)
{
ArgumentNullException.ThrowIfNull(entity);
if (meshPartCount < 0)
throw new ArgumentOutOfRangeException(nameof(meshPartCount));
if (entity.AabbDirty)
entity.RefreshAabb();
return new RenderInstanceCandidate(
ProjectionId: default,
LocalEntityId: entity.Id,
ServerGuid: entity.ServerGuid,
SourceId: entity.SourceGfxObjOrSetupId,
ParentCellId: entity.ParentCellId ?? 0u,
RootWorld:
Matrix4x4.CreateFromQuaternion(entity.Rotation)
* Matrix4x4.CreateTranslation(entity.Position),
Position: entity.Position,
Rotation: entity.Rotation,
Scale: entity.Scale,
Bounds: new RenderWorldBounds(
entity.AabbMin,
entity.AabbMax),
PaletteOverride: entity.PaletteOverride,
IsBuildingShell: entity.IsBuildingShell,
Animated: animated,
MeshPartCount: meshPartCount,
TupleLandblockId: tupleLandblockId);
}
}
/// <summary>
/// One ordered mesh reference belonging to a dispatcher candidate. Candidate
/// values repeat across the entity's tuples only in the transitional current
/// source; the scene frame product supplies the same facts from retained arena
/// storage.
/// </summary>
internal readonly record struct RenderInstanceTuple(
RenderInstanceCandidate Candidate,
int MeshRefIndex,
MeshRef MeshRef);

View file

@ -1,6 +1,4 @@
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering.Selection;
/// <summary>
@ -10,7 +8,8 @@ namespace AcDream.App.Rendering.Selection;
internal interface IRetailSelectionRenderSink
{
void AddVisiblePart(
WorldEntity entity,
uint serverGuid,
uint localEntityId,
int partIndex,
uint gfxObjId,
Matrix4x4 partWorld);

View file

@ -103,6 +103,22 @@ internal sealed class RetailSelectionScene :
int partIndex,
uint gfxObjId,
Matrix4x4 partWorld)
{
ArgumentNullException.ThrowIfNull(entity);
AddVisiblePart(
entity.ServerGuid,
entity.Id,
partIndex,
gfxObjId,
partWorld);
}
public void AddVisiblePart(
uint serverGuid,
uint localEntityId,
int partIndex,
uint gfxObjId,
Matrix4x4 partWorld)
{
// WbDrawDispatcher is also shared by sealed CreatureMode-style UI
// viewports (paperdoll and appraisal). Those draws occur after the
@ -110,10 +126,13 @@ internal sealed class RetailSelectionScene :
// enter the world picking product.
if (!_frameOpen)
return;
if (entity.ServerGuid == 0u)
if (serverGuid == 0u)
return;
if (!_buildingKeys.Add(new PartKey(entity.Id, partIndex, gfxObjId)))
if (!_buildingKeys.Add(new PartKey(
localEntityId,
partIndex,
gfxObjId)))
return;
RetailSelectionMesh? mesh = _geometry.Resolve(gfxObjId);
if (mesh is null)
@ -123,14 +142,14 @@ internal sealed class RetailSelectionScene :
return;
_building.Add(new RetailSelectionPart(
entity.ServerGuid,
entity.Id,
serverGuid,
localEntityId,
partIndex,
partWorld,
mesh));
_currentRenderSceneObserver?.ObserveSelectionPart(
entity.ServerGuid,
entity.Id,
serverGuid,
localEntityId,
partIndex,
gfxObjId,
partWorld,

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;

View file

@ -0,0 +1,91 @@
using System.Numerics;
using System.Reflection;
using AcDream.App.Rendering.Scene;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class RenderInstanceCandidateTests
{
[Fact]
public void FromWorldEntity_CopiesDispatcherFactsWithoutRetainingEntity()
{
Quaternion rotation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
MathF.PI / 2f);
var entity = new WorldEntity
{
Id = 41,
ServerGuid = 0x8000_0041,
SourceGfxObjOrSetupId = 0x0200_0041,
Position = new Vector3(1f, 2f, 3f),
Rotation = rotation,
MeshRefs =
[
new MeshRef(0x0100_0001, Matrix4x4.Identity),
new MeshRef(
0x0100_0002,
Matrix4x4.CreateTranslation(4f, 0f, 0f)),
],
ParentCellId = 0xA9B4_0170,
Scale = 1.25f,
IsBuildingShell = true,
};
RenderInstanceCandidate candidate =
RenderInstanceCandidate.FromWorldEntity(
entity,
animated: true,
meshPartCount: 2,
tupleLandblockId: 0x1122_FFFF);
Assert.Equal(entity.Id, candidate.LocalEntityId);
Assert.Equal(entity.ServerGuid, candidate.ServerGuid);
Assert.Equal(entity.SourceGfxObjOrSetupId, candidate.SourceId);
Assert.Equal(entity.ParentCellId, candidate.ParentCell);
Assert.Equal(
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(entity.Position),
candidate.RootWorld);
Assert.Equal(entity.AabbMin, candidate.Bounds.Minimum);
Assert.Equal(entity.AabbMax, candidate.Bounds.Maximum);
Assert.Equal(1.25f, candidate.Scale);
Assert.True(candidate.IsBuildingShell);
Assert.True(candidate.Animated);
Assert.Equal(2, candidate.MeshPartCount);
Assert.Equal(0xA9B4_FFFFu, candidate.CacheLandblockId);
Assert.DoesNotContain(
typeof(RenderInstanceCandidate).GetFields(
BindingFlags.Instance
| BindingFlags.Public
| BindingFlags.NonPublic),
field => field.FieldType == typeof(WorldEntity));
}
[Fact]
public void OutdoorCandidate_KeepsTupleCacheHintSeparate()
{
var entity = new WorldEntity
{
Id = 42,
ServerGuid = 0,
SourceGfxObjOrSetupId = 0x0100_0042,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs =
[
new MeshRef(0x0100_0042, Matrix4x4.Identity),
],
};
RenderInstanceCandidate candidate =
RenderInstanceCandidate.FromWorldEntity(
entity,
animated: false,
meshPartCount: 1,
tupleLandblockId: 0xA9B5_FFFF);
Assert.Null(candidate.ParentCell);
Assert.Equal(0xA9B5_FFFFu, candidate.CacheLandblockId);
}
}