acdream/src/AcDream.Core/World/WorldEntity.cs
Erik 6a9b529113 #119: entity bounds from dat vertex data - works for every case, not just multi-part
The 1ca412d part-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 case 1ca412d could 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>
2026-06-11 22:39:05 +02:00

213 lines
9.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
namespace AcDream.Core.World;
public sealed class WorldEntity
{
public required uint Id { get; init; }
/// <summary>
/// Server-assigned GUID (from CreateObject). Zero for dat-hydrated
/// scenery/static entities that don't come from the server.
/// Used by GpuWorldState for persistent-entity rescue on landblock unload.
/// </summary>
public uint ServerGuid { get; init; }
public required uint SourceGfxObjOrSetupId { get; init; }
/// <summary>
/// World-space position. Settable so Phase 6.7 position-update events
/// can reseat an existing entity without rebuilding its meshes.
/// </summary>
public required Vector3 Position { get; set; }
/// <summary>Settable for the same reason as <see cref="Position"/>.</summary>
public required Quaternion Rotation { get; set; }
/// <summary>
/// Per-part mesh references with their root-relative transforms.
/// Mutable so the animation tick can replace it each frame for
/// entities that play a cycle (Phase 6.4); static entities set it
/// once at hydration and never touch it again.
/// </summary>
public required IReadOnlyList<MeshRef> MeshRefs { get; set; }
/// <summary>
/// Optional per-entity palette override (server-specified base +
/// subpalette overlays). When non-null, applies to every palette-
/// indexed texture on this entity. Used for character skin/hair
/// colors, creature recolors (e.g. stone-colored drudge statue),
/// and team colors. Non-palette-indexed textures ignore this field.
/// </summary>
public PaletteOverride? PaletteOverride { get; init; }
/// <summary>
/// EnvCell or outdoor cell ID that owns this entity (room geometry, static
/// object, or live object inside/outside a cell).
/// the cell). Used by portal visibility to filter interior entities — only
/// entities whose ParentCellId appears in the visible set are rendered.
/// Null for outdoor dat scenery/building stabs or unresolved live entities.
/// </summary>
public uint? ParentCellId { get; set; }
/// <summary>
/// True when this entity originates from <c>LandBlockInfo.Buildings[]</c>
/// (the dat array that carries building shells: cottage walls, smithy walls,
/// inn walls — every solid building enclosure). False for entities from
/// <c>LandBlockInfo.Objects[]</c> (rocks, fences, lampposts, tree clusters —
/// outdoor scenery placeholders). The two arrays are conflated through
/// hydration today but the dat itself carries the distinction; retail
/// (<c>CLandBlock::init_buildings</c>) and WorldBuilder
/// (<c>SceneryInstance.IsBuilding</c>) both preserve it.
///
/// <para>
/// Read at draw time by <c>WbDrawDispatcher</c>'s <c>IndoorPass</c>
/// partition so building shells can render when the camera is inside their
/// own building (they ARE the indoor walls), not stencil-gated as outdoor
/// scenery would be.
/// </para>
/// </summary>
public bool IsBuildingShell { get; init; }
/// <summary>
/// Dat-derived EnvCell anchor for a building shell. Building shells are
/// top-level landblock stabs, so they do not have a real ParentCellId, but
/// the LandBlockInfo.Buildings[] portal list names cells owned by the same
/// building. The indoor renderer uses this anchor only for draw scoping:
/// a shell renders in IndoorPass when its anchor belongs to the camera
/// building's EnvCell set. Collision still treats the shell as an outdoor
/// stab unless ParentCellId is explicitly set.
/// </summary>
public uint? BuildingShellAnchorCellId { get; init; }
/// <summary>
/// Uniform scale applied to this entity's mesh by the scenery pipeline.
/// For scenery objects this is spawn.Scale (typically 0.81.3). For stabs
/// and interior static objects this is 1.0 (no scaling).
///
/// Used by the collision registration path to scale CylSphere / Sphere /
/// Setup.Radius shapes so they match the visually-scaled mesh. Without
/// this, scaled scenery has a collision cylinder that's smaller than the
/// visible trunk, producing "partial passthrough" bugs.
/// </summary>
public float Scale { get; init; } = 1.0f;
/// <summary>
/// Server-sent part-swap overrides from <c>AnimPartChange</c>. Each entry
/// replaces a Setup part's GfxObj with an alternate model (clothing, weapons,
/// helmets). Carried on the entity so <c>EntitySpawnAdapter</c> can populate
/// <c>AnimatedEntityState</c>'s override map at spawn time. Empty for atlas-
/// tier entities.
/// </summary>
public IReadOnlyList<PartOverride> PartOverrides { get; init; } = Array.Empty<PartOverride>();
/// <summary>
/// Bitmask of hidden Setup parts. Bit <c>i</c> set hides part <c>i</c> at
/// draw time. Sourced from the server's <c>CreateObject</c> record when
/// present. Zero (no parts hidden) is the default.
/// </summary>
public ulong HiddenPartsMask { get; init; }
// Per Phase A.5 spec §4.6 Change #2 — cache per-entity AABB so the
// dispatcher's frustum cull is a memory read, not a per-frame recompute.
// AabbDirty starts true so the dispatcher calls RefreshAabb on first read
// (AabbMin/AabbMax are Vector3.Zero until refreshed).
public Vector3 AabbMin { get; private set; }
public Vector3 AabbMax { get; private set; }
public bool AabbDirty { get; private set; } = true;
/// <summary>
/// Root-local geometry bounds: the union over MeshRefs of each part's dat
/// vertex AABB transformed by its part transform (see
/// <c>Meshing.LocalBoundsAccumulator</c>). 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.
/// </summary>
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()
{
var p = Position;
// #119 follow-up (2026-06-11): the box must cover the MESH, not just the
// 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)
{
float maxOffset = 0f;
for (int i = 0; i < refs.Count; i++)
{
float len = refs[i].PartTransform.Translation.Length();
if (len > maxOffset) maxOffset = len;
}
radius += maxOffset;
}
AabbMin = new Vector3(p.X - radius, p.Y - radius, p.Z - radius);
AabbMax = new Vector3(p.X + radius, p.Y + radius, p.Z + radius);
AabbDirty = false;
}
public void SetPosition(Vector3 pos)
{
Position = pos;
AabbDirty = true;
}
}
/// <summary>
/// Lightweight value type for a server-sent <c>AnimPartChange</c> (part index
/// → replacement GfxObj id). Decouples <c>WorldEntity</c> (Core) from the
/// network-layer <c>CreateObject.AnimPartChange</c> type.
/// </summary>
public readonly record struct PartOverride(byte PartIndex, uint GfxObjId);