diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index cad6484d..59f0f83c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2744,6 +2744,7 @@ public sealed class GameWindow : IDisposable var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale); var meshRefs = new List(); + var liveBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); int dumpClothingTotalTris = 0; for (int partIdx = 0; partIdx < parts.Count; partIdx++) { @@ -2780,6 +2781,10 @@ public sealed class GameWindow : IDisposable // base anchor to end up below the ground ("sinks into foundry"). var transform = scale == 1.0f ? mr.PartTransform : mr.PartTransform * scaleMat; + // #119 follow-up: vertex-derived root-local bounds (see WorldEntity.RefreshAabb). + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) liveBounds.Add(transform, pb.Value); + meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, transform) { SurfaceOverrides = surfaceOverrides, @@ -2838,6 +2843,8 @@ public sealed class GameWindow : IDisposable PartOverrides = entityPartOverrides, ParentCellId = spawn.Position!.Value.LandblockId, }; + if (liveBounds.TryGet(out var liveBMin, out var liveBMax)) + entity.SetLocalBounds(liveBMin, liveBMax); var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( Id: entity.Id, @@ -5238,6 +5245,7 @@ public sealed class GameWindow : IDisposable foreach (var e in baseLoaded.Entities) { var meshRefs = new List(); + var stabBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) { @@ -5246,6 +5254,8 @@ public sealed class GameWindow : IDisposable if (gfx is not null) { _physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx); + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); meshRefs.Add(new AcDream.Core.World.MeshRef( e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity)); } @@ -5263,6 +5273,8 @@ public sealed class GameWindow : IDisposable var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value); meshRefs.Add(mr); } } @@ -5280,6 +5292,8 @@ public sealed class GameWindow : IDisposable IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag BuildingShellAnchorCellId = e.BuildingShellAnchorCellId, }; + if (stabBounds.TryGet(out var sbMin, out var sbMax)) + entity.SetLocalBounds(sbMin, sbMax); hydrated.Add(entity); } @@ -5356,6 +5370,7 @@ public sealed class GameWindow : IDisposable // Scale is baked into the root transform by wrapping each part's // transform with a scale matrix. var meshRefs = new List(); + var sceneryBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale); if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u) @@ -5366,6 +5381,8 @@ public sealed class GameWindow : IDisposable _physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx); // Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain. _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value); meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat)); } } @@ -5383,7 +5400,10 @@ public sealed class GameWindow : IDisposable _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); // Compose: part's own transform, then the spawn's scale. - meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform * scaleMat)); + var partXf = mr.PartTransform * scaleMat; + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) sceneryBounds.Add(partXf, pb.Value); + meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, partXf)); } } } @@ -5484,6 +5504,8 @@ public sealed class GameWindow : IDisposable MeshRefs = meshRefs, Scale = spawn.Scale, }; + if (sceneryBounds.TryGet(out var scbMin, out var scbMax)) + hydrated.SetLocalBounds(scbMin, scbMax); result.Add(hydrated); } @@ -5635,6 +5657,7 @@ public sealed class GameWindow : IDisposable int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0; var meshRefs = new List(); + var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); if ((stab.Id & 0xFF000000u) == 0x01000000u) { var gfx = _dats.Get(stab.Id); @@ -5642,6 +5665,8 @@ public sealed class GameWindow : IDisposable { _physicsDataCache.CacheGfxObj(stab.Id, gfx); _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); } else if (dumpStab) @@ -5674,6 +5699,8 @@ public sealed class GameWindow : IDisposable } _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); + if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value); meshRefs.Add(mr); } } @@ -5705,6 +5732,8 @@ public sealed class GameWindow : IDisposable MeshRefs = meshRefs, ParentCellId = envCellId, }; + if (interiorBounds.TryGet(out var ibMin, out var ibMax)) + hydrated.SetLocalBounds(ibMin, ibMax); if (dumpStab) { diff --git a/src/AcDream.Core/Meshing/GfxObjBounds.cs b/src/AcDream.Core/Meshing/GfxObjBounds.cs new file mode 100644 index 00000000..248a784c --- /dev/null +++ b/src/AcDream.Core/Meshing/GfxObjBounds.cs @@ -0,0 +1,95 @@ +using System.Collections.Concurrent; +using System.Numerics; +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.Meshing; + +/// +/// Per-GfxObj vertex-space AABB, cached by id. The ground-truth source for entity +/// visibility bounds (#119 follow-up, 2026-06-11): derived from the SAME dat vertex +/// data that gets drawn, so the bound can never disagree with the mesh — unlike the +/// previous synthetic constants (anchor ± 5 m, then ± max-part-offset) whose +/// containment was a promise nothing enforced. Retail needs no equivalent because it +/// culls per part with dat-authored spheres (CGfxObj.drawing_sphere, viewconeCheck at +/// 0x005a09a4); our per-ENTITY culling granularity is a deliberate batching-era +/// divergence and is visually safe exactly as long as the entity volume CONTAINS the +/// mesh — which vertex-derived bounds guarantee by construction. +/// +/// Thread-safe: hydration runs on the streaming worker AND live spawns on the render +/// thread. Parts repeat heavily (shared body parts, repeated stair steps, fence +/// segments), so the cache hit rate is high and the vertex scan runs once per +/// distinct GfxObj id per session. +/// +public static class GfxObjBounds +{ + private static readonly ConcurrentDictionary _cache = new(); + + /// + /// Vertex-space AABB of , or null for a vertex-less model + /// (the legitimate all-no-draw class — see Issue119UpNullGfxObjDumpTests). + /// + public static (Vector3 Min, Vector3 Max)? Get(GfxObj? gfx) + { + if (gfx is null) return null; + if (_cache.TryGetValue(gfx.Id, out var hit)) return hit; + + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var v in gfx.VertexArray.Vertices.Values) + { + var o = new Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z); + min = Vector3.Min(min, o); + max = Vector3.Max(max, o); + } + if (min.X == float.MaxValue) return null; + + _cache[gfx.Id] = (min, max); + return (min, max); + } +} + +/// +/// Accumulates an entity's ROOT-LOCAL geometry bounds during hydration: the union +/// over its MeshRefs of each part's vertex AABB transformed by the part's FINAL +/// MeshRef transform (placement frame, including any baked scenery scale). The +/// result feeds WorldEntity.SetLocalBounds; WorldEntity.RefreshAabb +/// rotates it into world axes per frame. Transforming the 8 corners and re-boxing +/// is conservative-correct under any affine transform (the re-box of transformed +/// corners contains the transformed contents). +/// +public struct LocalBoundsAccumulator +{ + private Vector3 _min; + private Vector3 _max; + private bool _any; + + public void Add(Matrix4x4 partTransform, (Vector3 Min, Vector3 Max) partBounds) + { + Vector3 lo = partBounds.Min, hi = partBounds.Max; + for (int c = 0; c < 8; c++) + { + var corner = new Vector3( + (c & 1) == 0 ? lo.X : hi.X, + (c & 2) == 0 ? lo.Y : hi.Y, + (c & 4) == 0 ? lo.Z : hi.Z); + var t = Vector3.Transform(corner, partTransform); + if (!_any) + { + _min = _max = t; + _any = true; + } + else + { + _min = Vector3.Min(_min, t); + _max = Vector3.Max(_max, t); + } + } + } + + public readonly bool TryGet(out Vector3 min, out Vector3 max) + { + min = _min; + max = _max; + return _any; + } +} diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index 08160ce9..b26e9a4d 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -111,6 +111,26 @@ public sealed class WorldEntity public Vector3 AabbMax { get; private set; } public bool AabbDirty { get; private set; } = true; + /// + /// Root-local geometry bounds: the union over MeshRefs of each part's dat + /// vertex AABB transformed by its part transform (see + /// Meshing.LocalBoundsAccumulator). Set at hydration from the same + /// vertex data that gets drawn — the every-case fix for the "#119 bounds + /// must cover the mesh" class. When absent (HasLocalBounds false), the + /// part-offset heuristic below is the fallback. + /// + public Vector3 LocalBoundMin { get; private set; } + public Vector3 LocalBoundMax { get; private set; } + public bool HasLocalBounds { get; private set; } + + public void SetLocalBounds(Vector3 min, Vector3 max) + { + LocalBoundMin = min; + LocalBoundMax = max; + HasLocalBounds = true; + AabbDirty = true; + } + private const float DefaultAabbRadius = 5.0f; public void RefreshAabb() @@ -118,18 +138,48 @@ public sealed class WorldEntity var p = Position; // #119 follow-up (2026-06-11): the box must cover the MESH, not just the - // anchor. A multi-part Setup's parts sit at root-relative offsets — the - // AAB3 tower's spiral staircase spans 15 m ABOVE its anchor — and BOTH - // visibility gates derive from this box: the dispatcher's per-entity - // frustum cull (WbDrawDispatcher.WalkEntitiesInto) and the viewcone - // sphere (RetailPViewRenderer.EntitySphere = this box's bounding - // sphere). A fixed ±5 m anchor box dropped the staircase whenever the - // gaze left the anchor's neighborhood: stairs visible looking down the - // spiral (anchor in view), gone looking up (anchor culled) — the - // user-reported direction/angle asymmetry. Expand by the largest part - // offset; using the offset MAGNITUDE keeps the box rotation-invariant, - // so entity.Rotation needs no handling here. Identity-part entities - // (1-part Setups, GfxObjs, scenery) get offset 0 — behavior unchanged. + // anchor. BOTH visibility gates derive from this box: the dispatcher's + // per-entity frustum cull (WbDrawDispatcher.WalkEntitiesInto) and the + // viewcone sphere (RetailPViewRenderer.EntitySphere = this box's + // bounding sphere). The original fixed ±5 m anchor box dropped the AAB3 + // tower staircase (parts spiralling 15 m above the anchor) whenever the + // gaze left the anchor's neighborhood — stairs visible looking down, + // gone looking up. + // + // Preferred path: dat-vertex-derived root-local bounds (SetLocalBounds + // at hydration), rotated into world axes — re-boxing the 8 rotated + // corners contains the rotated contents, so this is correct for EVERY + // shape including a single tall part at identity transform (which the + // offset heuristic below cannot see). DefaultAabbRadius stays as a + // margin: it absorbs animated-pose drift (MeshRefs are swapped per + // frame for animated entities while local bounds are rest-pose) and + // keeps small objects at their historical box size. + if (HasLocalBounds) + { + Vector3 lo = LocalBoundMin, hi = LocalBoundMax; + var rot = Rotation; + Vector3 min = default, max = default; + for (int c = 0; c < 8; c++) + { + var corner = new Vector3( + (c & 1) == 0 ? lo.X : hi.X, + (c & 2) == 0 ? lo.Y : hi.Y, + (c & 4) == 0 ? lo.Z : hi.Z); + var t = Vector3.Transform(corner, rot); + if (c == 0) { min = max = t; } + else { min = Vector3.Min(min, t); max = Vector3.Max(max, t); } + } + AabbMin = p + min - new Vector3(DefaultAabbRadius); + AabbMax = p + max + new Vector3(DefaultAabbRadius); + AabbDirty = false; + return; + } + + // Fallback (no hydration bounds — e.g. tests, minimal fixtures): anchor + // box expanded by the largest part-translation magnitude. Rotation- + // invariant; covers multi-part spreads but NOT a single part whose mesh + // extends >5 m from its own origin — which is why hydrated entities use + // the vertex-derived path above. float radius = DefaultAabbRadius; var refs = MeshRefs; if (refs is not null) diff --git a/tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs b/tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs index 2f8e9d68..61c339bc 100644 --- a/tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs +++ b/tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs @@ -52,6 +52,67 @@ public class WorldEntityAabbTests Assert.True(entity.AabbMin.Z <= 107f && entity.AabbMax.X >= 305f); } + [Fact] + public void Aabb_LocalBounds_TallSinglePart_Covered() + { + // The every-case fix (#119 follow-up 2): a SINGLE part at identity transform + // whose MESH is 12 m tall. The part-offset heuristic cannot see this (offset 0 + // -> box ±5 m, mesh sticks 7 m out); vertex-derived local bounds must cover it. + var entity = new WorldEntity + { + Id = 1, + SourceGfxObjOrSetupId = 0x01000001, + Position = new Vector3(100, 100, 50), + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = new[] { new MeshRef(0x01000001, Matrix4x4.Identity) }, + }; + entity.SetLocalBounds(new Vector3(-1, -1, 0), new Vector3(1, 1, 12)); + entity.RefreshAabb(); + + // Mesh top = position.Z (50) + local 12 = 62; the old ±5 anchor box topped out at 55. + Assert.True(entity.AabbMax.Z >= 62f, $"box top {entity.AabbMax.Z} must cover mesh top (z=62)"); + Assert.True(entity.AabbMin.Z <= 50f); + } + + [Fact] + public void Aabb_LocalBounds_FollowRotation() + { + // A mesh extending 12 m along +Y, entity rotated 90° about Z -> the extent + // now points along -X (or +X depending on sign); the world box must follow. + var entity = new WorldEntity + { + Id = 1, + SourceGfxObjOrSetupId = 0x01000001, + Position = Vector3.Zero, + Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, System.MathF.PI / 2f), + MeshRefs = new[] { new MeshRef(0x01000001, Matrix4x4.Identity) }, + }; + entity.SetLocalBounds(new Vector3(-1, 0, -1), new Vector3(1, 12, 1)); + entity.RefreshAabb(); + + // Rotating +Y by +90° about Z lands on -X: the box must extend ≥12 m on X + // (one side), and no longer require 12 m on Y. + bool coversX = entity.AabbMin.X <= -12f || entity.AabbMax.X >= 12f; + Assert.True(coversX, $"rotated extent must show on X axis (box X [{entity.AabbMin.X},{entity.AabbMax.X}])"); + Assert.True(entity.AabbMax.Y < 12f, "the unrotated +Y extent must not persist after rotation"); + } + + [Fact] + public void Aabb_LocalBoundsAccumulator_UnionsTransformedParts() + { + var acc = new AcDream.Core.Meshing.LocalBoundsAccumulator(); + Assert.False(acc.TryGet(out _, out _)); + + // Part 1: unit cube at origin. Part 2: unit cube translated to z=15. + acc.Add(Matrix4x4.Identity, (new Vector3(-0.5f), new Vector3(0.5f))); + acc.Add(Matrix4x4.CreateTranslation(3, 3, 15), (new Vector3(-0.5f), new Vector3(0.5f))); + + Assert.True(acc.TryGet(out var min, out var max)); + Assert.Equal(-0.5f, min.Z, 3); + Assert.Equal(15.5f, max.Z, 3); + Assert.Equal(3.5f, max.X, 3); + } + [Fact] public void Aabb_DirtyFlag_SetByMutator_ClearedByRefresh() {