diff --git a/src/AcDream.App/Rendering/Scene/RenderInstanceCandidate.cs b/src/AcDream.App/Rendering/Scene/RenderInstanceCandidate.cs
new file mode 100644
index 00000000..844bcc43
--- /dev/null
+++ b/src/AcDream.App/Rendering/Scene/RenderInstanceCandidate.cs
@@ -0,0 +1,83 @@
+using System.Numerics;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering.Scene;
+
+///
+/// The complete acdream-owned value boundary consumed by the object
+/// dispatcher. It contains presentation facts only; the dispatcher must not
+/// call back into or any gameplay owner after this
+/// value has been produced.
+///
+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);
+ }
+}
+
+///
+/// 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.
+///
+internal readonly record struct RenderInstanceTuple(
+ RenderInstanceCandidate Candidate,
+ int MeshRefIndex,
+ MeshRef MeshRef);
diff --git a/src/AcDream.App/Rendering/Selection/IRetailSelectionRenderSink.cs b/src/AcDream.App/Rendering/Selection/IRetailSelectionRenderSink.cs
index 68bfacf2..9a9aebfd 100644
--- a/src/AcDream.App/Rendering/Selection/IRetailSelectionRenderSink.cs
+++ b/src/AcDream.App/Rendering/Selection/IRetailSelectionRenderSink.cs
@@ -1,6 +1,4 @@
using System.Numerics;
-using AcDream.Core.World;
-
namespace AcDream.App.Rendering.Selection;
///
@@ -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);
diff --git a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs
index 963b3e85..ea9b7072 100644
--- a/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs
+++ b/src/AcDream.App/Rendering/Selection/RetailSelectionScene.cs
@@ -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,
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index c709a25b..7cda7523 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -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 _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? animatedEntityIds,
+ List 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]));
+ }
+ }
+ }
+
///
/// #119 decisive probe: rate-limited [dump-entity] WALK-REJECT line
/// for an ACDREAM_DUMP_ENTITY-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).
///
- private void MaybeEmitEntityDump(WorldEntity entity, uint landblockId, bool culled)
+ private void MaybeEmitEntityDump(
+ in RenderInstanceCandidate entity,
+ uint landblockId,
+ bool culled,
+ IReadOnlyList 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 _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;
diff --git a/tests/AcDream.App.Tests/Rendering/RenderInstanceCandidateTests.cs b/tests/AcDream.App.Tests/Rendering/RenderInstanceCandidateTests.cs
new file mode 100644
index 00000000..4a3db6ec
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/RenderInstanceCandidateTests.cs
@@ -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);
+ }
+}