The1ca412dpart-offset expansion fixed the staircase but still rested on the 5 m promise one level down: a SINGLE part whose mesh extends more than 5 m from its own origin (offset 0 -> box +-5 m) keeps the gaze-dependent vanish. Per the user's mandate ("it must work for every case"), the bound now derives from the dat VERTEX data - the same vertices that get drawn - so no synthetic containment promise remains. Oracle context (read this session): retail has NO whole-entity visibility volume - CPhysicsPart::Draw (0x0050d7a0) viewcone-checks each part's dat-authored CGfxObj.drawing_sphere at the part's own world position (RenderDeviceD3D::DrawMesh 0x005a0860). Retail's bound IS data; ours was a promise. Our per-ENTITY granularity stays (a deliberate batching-era choice, WB-owned per the inventory) but the volume is now data-derived and conservative: visually identical by construction, never culls what retail would draw. - GfxObjBounds: per-GfxObj vertex AABB, cached by id (parts repeat heavily); LocalBoundsAccumulator: union of part-transformed AABB corners (conservative-correct under any affine transform). - WorldEntity.SetLocalBounds + RefreshAabb preferred path: rotate the root-local bounds' 8 corners into world axes + DefaultAabbRadius margin (absorbs animated-pose drift vs the rest-pose bounds; keeps small objects at their historical box size). Offset heuristic stays as the fallback for boundless fixtures. - All four hydration sites wired (outdoor stabs, scenery incl. baked scale, interior cell statics, server live spawns). Tests: tall-single-part coverage (the case1ca412dcould not see), rotation-following, accumulator union. Suites: App 246+1skip / Core 1434+2skip / UI 420 / Net 294. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
95 lines
3.6 KiB
C#
95 lines
3.6 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Numerics;
|
|
using DatReaderWriter.DBObjs;
|
|
|
|
namespace AcDream.Core.Meshing;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static class GfxObjBounds
|
|
{
|
|
private static readonly ConcurrentDictionary<uint, (Vector3 Min, Vector3 Max)> _cache = new();
|
|
|
|
/// <summary>
|
|
/// Vertex-space AABB of <paramref name="gfx"/>, or null for a vertex-less model
|
|
/// (the legitimate all-no-draw class — see Issue119UpNullGfxObjDumpTests).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>WorldEntity.SetLocalBounds</c>; <c>WorldEntity.RefreshAabb</c>
|
|
/// 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).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|