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); + } +}