feat(render): R1 — InteriorEntityPartition (3-bucket per-cell entity split)

Pure helper splitting a frame's entities into live-dynamic / per-cell statics /
outdoor scenery, by the same precedence as WbDrawDispatcher.ResolveEntitySlot
(serverGuid first — live entities have no ParentCellId). Feeds the per-cell
DrawInside loop. 3 unit tests, GL-free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 19:53:42 +02:00
parent ce7404b92b
commit cf85ea4e17
2 changed files with 144 additions and 0 deletions

View file

@ -0,0 +1,62 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Splits a frame's landblock entities into the three draw buckets the per-cell
/// <see cref="InteriorRenderer"/> needs, using the SAME precedence as
/// <see cref="Wb.WbDrawDispatcher.ResolveEntitySlot"/>:
/// <list type="number">
/// <item><b>ServerGuid != 0</b> (player / NPCs / items / doors) ⇒ <see cref="Result.LiveDynamic"/>
/// — drawn unclipped (depth only). These have no <c>ParentCellId</c> so they MUST be tested first.</item>
/// <item><b>ParentCellId</b> in the visible set ⇒ <see cref="Result.ByCell"/>[cell] — per-cell, portal-clipped.</item>
/// <item><b>ParentCellId == null</b> (outdoor scenery / building shell) ⇒ <see cref="Result.Outdoor"/>
/// — drawn through the doorway, clipped to OutsideView.</item>
/// </list>
/// A static whose <c>ParentCellId</c> is NOT in <paramref name="visibleCells"/> is dropped (its cell
/// isn't drawn this frame). Entities with no <c>MeshRefs</c> are skipped. Pure; GL-free; unit-tested.
/// </summary>
public static class InteriorEntityPartition
{
public sealed class Result
{
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
public List<WorldEntity> Outdoor { get; } = new();
public List<WorldEntity> LiveDynamic { get; } = new();
}
public static Result Partition(
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? 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<WorldEntity>();
list.Add(e);
}
else // outdoor scenery / building shell
{
result.Outdoor.Add(e);
}
}
}
return result;
}
}

View file

@ -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<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)> OneLb(uint lbId, params WorldEntity[] ents)
=> new[] { (lbId, Vector3.Zero, Vector3.Zero, (IReadOnlyList<WorldEntity>)ents,
(IReadOnlyDictionary<uint, WorldEntity>?)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<uint> { 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<uint> { 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<MeshRef>(), ParentCellId = CellA,
};
var result = InteriorEntityPartition.Partition(
new HashSet<uint> { CellA }, OneLb(0xA9B4FFFF, noMesh));
Assert.False(result.ByCell.ContainsKey(CellA));
}
}