feat(render): Phase A8 — WbDrawDispatcher.EntitySet partition
Adds EntitySet { All, IndoorOnly, OutdoorOnly } and a Draw parameter to
partition the per-entity walk by ParentCellId presence. EntitySet.All
preserves pre-A8 behavior; IndoorOnly drops null-ParentCellId entities;
OutdoorOnly drops ParentCellId.HasValue entities. The visibleCellIds
filter is still applied on top.
Used by Task 7 to split the render frame's single Draw call into two
(indoor stencil-OFF, outdoor stencil-gated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a1c393ee14
commit
dcf69a1feb
2 changed files with 170 additions and 3 deletions
|
|
@ -60,6 +60,26 @@ namespace AcDream.App.Rendering.Wb;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed unsafe class WbDrawDispatcher : IDisposable
|
public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Phase A8 — which subset of entities to walk in a single Draw call.
|
||||||
|
/// Used to split indoor entities (drawn first, stencil OFF) from outdoor
|
||||||
|
/// entities (drawn after, stencil-gated to portal silhouettes) when the
|
||||||
|
/// camera is inside an EnvCell.
|
||||||
|
/// </summary>
|
||||||
|
public enum EntitySet
|
||||||
|
{
|
||||||
|
/// <summary>Pre-A8 behavior: every entity walked, gated only by
|
||||||
|
/// the existing ParentCellId ∈ visibleCellIds filter.</summary>
|
||||||
|
All,
|
||||||
|
/// <summary>Only entities with <c>ParentCellId.HasValue</c> (indoor).
|
||||||
|
/// Existing visibleCellIds filter still applied on top.</summary>
|
||||||
|
IndoorOnly,
|
||||||
|
/// <summary>Only entities with <c>ParentCellId == null</c> (outdoor
|
||||||
|
/// stabs, scenery, live-spawned). visibleCellIds is ignored for
|
||||||
|
/// this set since outdoor entities never have a ParentCellId.</summary>
|
||||||
|
OutdoorOnly,
|
||||||
|
}
|
||||||
|
|
||||||
private readonly GL _gl;
|
private readonly GL _gl;
|
||||||
private readonly Shader _shader;
|
private readonly Shader _shader;
|
||||||
private readonly TextureCache _textures;
|
private readonly TextureCache _textures;
|
||||||
|
|
@ -315,7 +335,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
HashSet<uint>? animatedEntityIds,
|
HashSet<uint>? animatedEntityIds,
|
||||||
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
|
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
|
||||||
ref WalkResult result,
|
ref WalkResult result,
|
||||||
IndoorProbeState? indoorProbeState = null)
|
IndoorProbeState? indoorProbeState = null,
|
||||||
|
EntitySet set = EntitySet.All)
|
||||||
{
|
{
|
||||||
scratch.Clear();
|
scratch.Clear();
|
||||||
result.EntitiesWalked = 0;
|
result.EntitiesWalked = 0;
|
||||||
|
|
@ -336,6 +357,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
foreach (var animatedId in animatedEntityIds)
|
foreach (var animatedId in animatedEntityIds)
|
||||||
{
|
{
|
||||||
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
|
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
|
||||||
|
// Phase A8: EntitySet partition for indoor/outdoor split passes.
|
||||||
|
if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue) continue;
|
||||||
|
if (set == EntitySet.OutdoorOnly && entity.ParentCellId.HasValue) continue;
|
||||||
if (entity.MeshRefs.Count == 0) continue;
|
if (entity.MeshRefs.Count == 0) continue;
|
||||||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
|
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
|
||||||
|
|
@ -348,6 +372,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
|
|
||||||
foreach (var entity in entry.Entities)
|
foreach (var entity in entry.Entities)
|
||||||
{
|
{
|
||||||
|
// Phase A8: EntitySet partition for indoor/outdoor split passes.
|
||||||
|
if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue) continue;
|
||||||
|
if (set == EntitySet.OutdoorOnly && entity.ParentCellId.HasValue) continue;
|
||||||
if (entity.MeshRefs.Count == 0) continue;
|
if (entity.MeshRefs.Count == 0) continue;
|
||||||
|
|
||||||
// Detect cell entity for indoor probes — first MeshRef.GfxObjId
|
// Detect cell entity for indoor probes — first MeshRef.GfxObjId
|
||||||
|
|
@ -426,7 +453,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
FrustumPlanes? frustum = null,
|
FrustumPlanes? frustum = null,
|
||||||
uint? neverCullLandblockId = null,
|
uint? neverCullLandblockId = null,
|
||||||
HashSet<uint>? visibleCellIds = null,
|
HashSet<uint>? visibleCellIds = null,
|
||||||
HashSet<uint>? animatedEntityIds = null)
|
HashSet<uint>? animatedEntityIds = null,
|
||||||
|
EntitySet set = EntitySet.All)
|
||||||
{
|
{
|
||||||
_shader.Use();
|
_shader.Use();
|
||||||
_indoorProbeFrameCounter++;
|
_indoorProbeFrameCounter++;
|
||||||
|
|
@ -501,7 +529,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
animatedEntityIds,
|
animatedEntityIds,
|
||||||
_walkScratch,
|
_walkScratch,
|
||||||
ref walkResult,
|
ref walkResult,
|
||||||
probeState);
|
probeState,
|
||||||
|
set);
|
||||||
|
|
||||||
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
|
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
|
||||||
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
|
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
|
||||||
|
|
@ -1312,6 +1341,34 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
buf[offset + 12] = m.M41; buf[offset + 13] = m.M42; buf[offset + 14] = m.M43; buf[offset + 15] = m.M44;
|
buf[offset + 12] = m.M41; buf[offset + 13] = m.M42; buf[offset + 14] = m.M43; buf[offset + 15] = m.M44;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase A8 test helper: runs the EntitySet partition + visibleCellIds
|
||||||
|
/// gate against an in-memory entity list, returning the IDs that
|
||||||
|
/// survive both filters. Exists so the partition logic is unit-testable
|
||||||
|
/// without requiring a GL context or landblock-entries machinery.
|
||||||
|
/// </summary>
|
||||||
|
public static List<uint> WalkEntitiesForTest(
|
||||||
|
IReadOnlyList<AcDream.Core.World.WorldEntity> entities,
|
||||||
|
HashSet<uint>? visibleCellIds,
|
||||||
|
EntitySet set)
|
||||||
|
{
|
||||||
|
var output = new List<uint>();
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue) continue;
|
||||||
|
if (set == EntitySet.OutdoorOnly && entity.ParentCellId.HasValue) continue;
|
||||||
|
if (entity.MeshRefs.Count == 0) continue;
|
||||||
|
|
||||||
|
bool cellInVis = !(entity.ParentCellId.HasValue
|
||||||
|
&& visibleCellIds is not null
|
||||||
|
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
|
||||||
|
if (!cellInVis) continue;
|
||||||
|
|
||||||
|
output.Add(entity.Id);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
// Phase A8 — verify the WbDrawDispatcher EntitySet partition.
|
||||||
|
//
|
||||||
|
// The pure-data WalkEntitiesForTest helper iterates a flat entity list and
|
||||||
|
// returns the IDs that survive the EntitySet filter + visibleCellIds gate.
|
||||||
|
// EntitySet.IndoorOnly should include only entities with ParentCellId,
|
||||||
|
// EntitySet.OutdoorOnly only entities with null ParentCellId, and
|
||||||
|
// EntitySet.All (the default) should match the pre-A8 behavior.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Wb;
|
||||||
|
using AcDream.Core.World;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||||
|
|
||||||
|
public class WbDrawDispatcherEntitySetTests
|
||||||
|
{
|
||||||
|
private static WorldEntity Indoor(uint id, uint cellId) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
SourceGfxObjOrSetupId = 0x01000001u,
|
||||||
|
ParentCellId = cellId,
|
||||||
|
MeshRefs = new List<AcDream.Core.World.MeshRef>
|
||||||
|
{
|
||||||
|
new() { GfxObjId = 0x01000001u },
|
||||||
|
},
|
||||||
|
Position = Vector3.Zero,
|
||||||
|
Rotation = System.Numerics.Quaternion.Identity,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static WorldEntity Outdoor(uint id) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
SourceGfxObjOrSetupId = 0x01000001u,
|
||||||
|
ParentCellId = null,
|
||||||
|
MeshRefs = new List<AcDream.Core.World.MeshRef>
|
||||||
|
{
|
||||||
|
new() { GfxObjId = 0x01000001u },
|
||||||
|
},
|
||||||
|
Position = Vector3.Zero,
|
||||||
|
Rotation = System.Numerics.Quaternion.Identity,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EntitySet_IndoorOnly_DropsOutdoorEntities()
|
||||||
|
{
|
||||||
|
var entities = new List<WorldEntity>
|
||||||
|
{
|
||||||
|
Indoor(0x10000001, 0xA9B40143),
|
||||||
|
Outdoor(0x10000002),
|
||||||
|
Indoor(0x10000003, 0xA9B40144),
|
||||||
|
};
|
||||||
|
|
||||||
|
var visible = new HashSet<uint> { 0xA9B40143u, 0xA9B40144u };
|
||||||
|
var result = WbDrawDispatcher.WalkEntitiesForTest(
|
||||||
|
entities,
|
||||||
|
visibleCellIds: visible,
|
||||||
|
set: WbDrawDispatcher.EntitySet.IndoorOnly);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Contains(0x10000001u, result);
|
||||||
|
Assert.Contains(0x10000003u, result);
|
||||||
|
Assert.DoesNotContain(0x10000002u, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EntitySet_OutdoorOnly_KeepsOnlyNullParentCellId()
|
||||||
|
{
|
||||||
|
var entities = new List<WorldEntity>
|
||||||
|
{
|
||||||
|
Indoor(0x10000001, 0xA9B40143),
|
||||||
|
Outdoor(0x10000002),
|
||||||
|
Outdoor(0x10000003),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = WbDrawDispatcher.WalkEntitiesForTest(
|
||||||
|
entities,
|
||||||
|
visibleCellIds: null,
|
||||||
|
set: WbDrawDispatcher.EntitySet.OutdoorOnly);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Contains(0x10000002u, result);
|
||||||
|
Assert.Contains(0x10000003u, result);
|
||||||
|
Assert.DoesNotContain(0x10000001u, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EntitySet_All_MatchesPreA8Behavior()
|
||||||
|
{
|
||||||
|
var entities = new List<WorldEntity>
|
||||||
|
{
|
||||||
|
Indoor(0x10000001, 0xA9B40143),
|
||||||
|
Outdoor(0x10000002),
|
||||||
|
Indoor(0x10000003, 0xA9B40999), // not in visibleCellIds
|
||||||
|
};
|
||||||
|
|
||||||
|
var visible = new HashSet<uint> { 0xA9B40143u };
|
||||||
|
var result = WbDrawDispatcher.WalkEntitiesForTest(
|
||||||
|
entities,
|
||||||
|
visibleCellIds: visible,
|
||||||
|
set: WbDrawDispatcher.EntitySet.All);
|
||||||
|
|
||||||
|
// Pre-A8: visibleCellIds gates indoor entities, outdoor entities pass.
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Contains(0x10000001u, result);
|
||||||
|
Assert.Contains(0x10000002u, result);
|
||||||
|
Assert.DoesNotContain(0x10000003u, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue