diff --git a/src/AcDream.App/Rendering/InteriorEntityPartition.cs b/src/AcDream.App/Rendering/InteriorEntityPartition.cs new file mode 100644 index 0000000..9d2ef05 --- /dev/null +++ b/src/AcDream.App/Rendering/InteriorEntityPartition.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Splits a frame's landblock entities into the three draw buckets the per-cell +/// needs, using the SAME precedence as +/// : +/// +/// ServerGuid != 0 (player / NPCs / items / doors) ⇒ +/// — drawn unclipped (depth only). These have no ParentCellId so they MUST be tested first. +/// ParentCellId in the visible set ⇒ [cell] — per-cell, portal-clipped. +/// ParentCellId == null (outdoor scenery / building shell) ⇒ +/// — drawn through the doorway, clipped to OutsideView. +/// +/// A static whose ParentCellId is NOT in is dropped (its cell +/// isn't drawn this frame). Entities with no MeshRefs are skipped. Pure; GL-free; unit-tested. +/// +public static class InteriorEntityPartition +{ + public sealed class Result + { + public Dictionary> ByCell { get; } = new(); + public List Outdoor { get; } = new(); + public List LiveDynamic { get; } = new(); + } + + public static Result Partition( + HashSet visibleCells, + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> landblockEntries) + { + var result = new Result(); + foreach (var entry in landblockEntries) + { + foreach (var e in entry.Entities) + { + if (e.MeshRefs.Count == 0) continue; + + if (e.ServerGuid != 0) // live-dynamic — precedence first (no ParentCellId) + { + result.LiveDynamic.Add(e); + } + else if (e.ParentCellId is uint cell) + { + if (!visibleCells.Contains(cell)) continue; // its cell isn't drawn this frame + if (!result.ByCell.TryGetValue(cell, out var list)) + result.ByCell[cell] = list = new List(); + list.Add(e); + } + else // outdoor scenery / building shell + { + result.Outdoor.Add(e); + } + } + } + return result; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs b/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs new file mode 100644 index 0000000..507f4d0 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class InteriorEntityPartitionTests +{ + private const uint CellA = 0xA9B40170; + private const uint CellB = 0xA9B40171; + + private static WorldEntity Ent(uint id, uint serverGuid, uint? parentCell) => new() + { + Id = id, + ServerGuid = serverGuid, + SourceGfxObjOrSetupId = 0x01000001, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = new[] { new MeshRef(0x01000001, Matrix4x4.Identity) }, + ParentCellId = parentCell, + }; + + private static IEnumerable<(uint, Vector3, Vector3, IReadOnlyList, + IReadOnlyDictionary?)> OneLb(uint lbId, params WorldEntity[] ents) + => new[] { (lbId, Vector3.Zero, Vector3.Zero, (IReadOnlyList)ents, + (IReadOnlyDictionary?)null) }; + + [Fact] + public void Partitions_ByServerGuidThenParentCell_IntoThreeBuckets() + { + var livePlayer = Ent(1, serverGuid: 0x5000000A, parentCell: null); // live-dynamic + var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); // live-dynamic WINS over cell + var staticA = Ent(3, serverGuid: 0, parentCell: CellA); // per-cell static + var staticB = Ent(4, serverGuid: 0, parentCell: CellB); // per-cell static + var scenery = Ent(5, serverGuid: 0, parentCell: null); // outdoor scenery + + var visible = new HashSet { CellA, CellB }; + var result = InteriorEntityPartition.Partition( + visible, OneLb(0xA9B4FFFF, livePlayer, liveNpcInCell, staticA, staticB, scenery)); + + Assert.Equal(2, result.LiveDynamic.Count); // player + npc (serverGuid != 0) + Assert.Contains(livePlayer, result.LiveDynamic); + Assert.Contains(liveNpcInCell, result.LiveDynamic); + + Assert.Single(result.ByCell[CellA]); // only staticA (npc went live-dynamic) + Assert.Contains(staticA, result.ByCell[CellA]); + Assert.Single(result.ByCell[CellB]); + Assert.Contains(staticB, result.ByCell[CellB]); + + Assert.Single(result.Outdoor); + Assert.Contains(scenery, result.Outdoor); + } + + [Fact] + public void Static_InNonVisibleCell_IsDropped() + { + var staticHidden = Ent(3, serverGuid: 0, parentCell: 0xA9B40199); // not in the visible set + var visible = new HashSet { CellA }; + var result = InteriorEntityPartition.Partition(visible, OneLb(0xA9B4FFFF, staticHidden)); + + Assert.False(result.ByCell.ContainsKey(0xA9B40199)); + Assert.Empty(result.Outdoor); + Assert.Empty(result.LiveDynamic); + } + + [Fact] + public void EntityWithNoMeshRefs_IsSkipped() + { + var noMesh = new WorldEntity + { + Id = 9, ServerGuid = 0, SourceGfxObjOrSetupId = 0x01000001, + Position = Vector3.Zero, Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), ParentCellId = CellA, + }; + var result = InteriorEntityPartition.Partition( + new HashSet { CellA }, OneLb(0xA9B4FFFF, noMesh)); + + Assert.False(result.ByCell.ContainsKey(CellA)); + } +}