acdream/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs
Erik 49a7e90652 probe(physics): ACDREAM_PROBE_SUPPORT + ACDREAM_WIRE_MESH — separate #337's three candidates
The user is wedged at the top of Neftet rock plateaus, jumps sink into the
mesh, and a corpse falls straight through. ACDREAM_PROBE_REACH already ruled
out its own domain: blocked=0, every candidate tested-ok. Three candidates
remain — terrain support, a collision mesh not where its visual is, or the
transition wedging on an unobstructed path.

ACDREAM_PROBE_RESOLVE alone cannot separate them. It prints a three-value
contact-plane token, no plane normal, no plane height, no terrain sample and
no plane provenance, so all three produce the same line. Two additions:

[support] — one line per resolve for EVERY body, not just the player. A corpse
is a plain physics body with no player-specific logic, so its fall-through is
the cheapest available control on "movement code vs geometry data", and it is
invisible to any player-filtered probe. The line samples the outdoor terrain
INDEPENDENTLY at the body's own out-XY and prints the contact plane's own
height at that same XY. Two heights at one point make support=terrain /
object / none a measurement rather than an inference, and cpSrc= names the
site that asserted the plane so provenance and classification cross-check.

[geom] — once per GfxObj that comes near a mover: the object's physics-BSP
vertex cloud against its visual mesh AABB in the same local frame, through the
same prepared accessors the resolver queries. verdict=coincident REFUTES the
working hypothesis for that object outright; no-physics-bsp / empty-physics-bsp
/ displaced / extent-mismatch each name a specific data defect. Built to
refute, not to confirm — two diagnoses on this defect's lineage have already
been refuted by measurement.

ACDREAM_WIRE_MESH upgrades the existing F2 overlay, which drew a broadphase
proxy cylinder for BSP objects and so could not answer the question at all, to
the real physics-BSP polygon edges (cyan) beside the visual mesh box (magenta)
and the terrain surface (yellow). Own class per code-structure rule 1.

The provenance latch lives on PhysicsDiagnostics, not on CollisionInfo. Two
fields there first — the obvious home — broke the flat/graph differential
referee and the scratch-reset poison test, both of which compare CollisionInfo
member-for-member. Teaching either to skip a member is a one-line green fix
that puts a permanent hole in a referee whose whole job is comparing
everything. Captured as feedback_probe_state_off_compared_types.

Seven tests cover the support classifier's boundaries: a wrong classifier does
not fail to answer, it answers confidently wrong.

Gates: Release build 0 errors; complete suite 11,225 passed / 4 skipped / 0
failed from a cleaned tree — baseline 11,218/4/0 plus exactly the seven new
tests, skips unchanged.

Issue #337 filed with the symptom set, what is ruled out, and a table of what
each possible output means. All of this is TEMPORARY and recorded for
stripping with the physics-probe family.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:49:59 +02:00

344 lines
12 KiB
C#

using System.Numerics;
using AcDream.App.Input;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
internal readonly record struct WorldSceneDiagnosticOutcome(
int VisibleLandblocks,
int TotalLandblocks);
internal interface IWorldSceneDiagnostics
{
CameraCellResolution CameraCellResolution { get; }
void EmitPViewInput(
PortalVisibilityFrame portalFrame,
Matrix4x4 viewProjection,
LoadedCell clipRoot,
Vector3 cameraPosition,
Vector3 playerPosition);
void EmitRenderSignature(
string branch,
LoadedCell? clipRoot,
LoadedCell? viewerRoot,
LoadedCell? playerRoot,
uint viewerCellId,
uint playerCellId,
bool playerIndoorGate,
bool cameraInsideCell,
bool renderSky,
bool drawSkyThisFrame,
bool terrainDrawn,
TerrainClipMode terrainClipMode,
bool skyDrawn,
bool depthClear,
bool outdoorSceneryDrawn,
int liveDynamicDrawnCount,
string sceneParticles,
RetailPViewFrameResult? pviewResult,
Vector3 cameraPosition,
Vector3 playerPosition);
WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera,
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds);
}
/// <summary>
/// Owns world-pass probes, collision wireframes, and the DebugVM facts derived
/// from one completed normal-world draw. None of these diagnostics influence
/// visibility or pass ordering.
/// </summary>
internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics
{
private readonly WorldRenderDiagnostics _diagnostics;
private readonly IWorldScenePViewDiagnosticSource _pview;
private readonly IWorldSceneDebugStateSource _state;
private readonly DebugLineRenderer? _lines;
private readonly PhysicsEngine _physics;
private readonly ILocalPlayerModeSource _mode;
private readonly IRuntimeLocalPlayerControllerSource _player;
private readonly DebugVmRenderFactsPublisher _debugVm;
private readonly bool _debugVmConsumerActive;
private int _debugDrawLogCount;
// #337 (TEMPORARY): built on first use so the ordinary overlay path and
// every headless/no-window host pay nothing for it.
private CollisionMeshWireframe? _meshWireframe;
private CollisionMeshWireframeStats _lastMeshStats = new(-1, -1, -1, -1, false);
public WorldSceneDiagnosticsController(
WorldRenderDiagnostics diagnostics,
IWorldScenePViewDiagnosticSource pview,
IWorldSceneDebugStateSource state,
DebugLineRenderer? lines,
PhysicsEngine physics,
ILocalPlayerModeSource mode,
IRuntimeLocalPlayerControllerSource player,
DebugVmRenderFactsPublisher debugVm,
bool debugVmConsumerActive)
{
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_pview = pview ?? throw new ArgumentNullException(nameof(pview));
_state = state ?? throw new ArgumentNullException(nameof(state));
_lines = lines;
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
_player = player ?? throw new ArgumentNullException(nameof(player));
_debugVm = debugVm ?? throw new ArgumentNullException(nameof(debugVm));
_debugVmConsumerActive = debugVmConsumerActive;
}
public CameraCellResolution CameraCellResolution => _pview.CameraCellResolution;
public void EmitPViewInput(
PortalVisibilityFrame portalFrame,
Matrix4x4 viewProjection,
LoadedCell clipRoot,
Vector3 cameraPosition,
Vector3 playerPosition)
{
if (!RenderingDiagnostics.ProbePvInputEnabled)
return;
_diagnostics.EmitPViewInput(
enabled: true,
portalFrame,
viewProjection,
clipRoot.IsOutdoorNode,
cameraPosition,
playerPosition,
_pview.RawPlayerPositionOr(playerPosition),
_pview.PlayerYaw,
_pview.SampleTerrainZ(cameraPosition.X, cameraPosition.Y));
}
public void EmitRenderSignature(
string branch,
LoadedCell? clipRoot,
LoadedCell? viewerRoot,
LoadedCell? playerRoot,
uint viewerCellId,
uint playerCellId,
bool playerIndoorGate,
bool cameraInsideCell,
bool renderSky,
bool drawSkyThisFrame,
bool terrainDrawn,
TerrainClipMode terrainClipMode,
bool skyDrawn,
bool depthClear,
bool outdoorSceneryDrawn,
int liveDynamicDrawnCount,
string sceneParticles,
RetailPViewFrameResult? pviewResult,
Vector3 cameraPosition,
Vector3 playerPosition)
{
_diagnostics.EmitRenderSignatureIfChanged(
RenderingDiagnostics.ProbeFlapEnabled,
branch,
clipRoot,
viewerRoot,
playerRoot,
viewerCellId,
playerCellId,
playerIndoorGate,
cameraInsideCell,
renderSky,
drawSkyThisFrame,
terrainDrawn,
terrainClipMode,
skyDrawn,
depthClear,
outdoorSceneryDrawn,
outdoorPortalDrawn: false,
outdoorRootObjectCount: 0,
liveDynamicDrawnCount,
sceneParticles,
pviewResult?.PortalFrame,
pviewResult?.ClipAssembly,
pviewResult?.DrawableCells,
pviewResult?.DiagnosticPartition,
exteriorPortalFrame: null,
exteriorClipAssembly: null,
exteriorDrawableCells: null,
exteriorPartition: null,
cameraPosition,
playerPosition);
}
public WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera,
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
{
ArgumentNullException.ThrowIfNull(bounds);
// Brackets the normal-world phase against the clear-phase tripwire: any
// GL state the world's geometry drew under but the next frame's clear
// no longer sees shows up as a difference between the two lines.
_diagnostics.EmitPostWorldGlStateIfChanged(
AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled);
DrawCollisionWireframes(in camera);
int visible = 0;
int total = bounds.Count;
for (int index = 0; index < bounds.Count; index++)
{
var entry = bounds[index];
if (FrustumCuller.IsAabbVisible(
camera.Frustum,
entry.AabbMin,
entry.AabbMax))
{
visible++;
}
}
// AllEntriesForDebug is a yield sequence. Do not even construct its
// iterator unless the developer UI is mounted and consumes the facts.
if (_debugVmConsumerActive)
{
Vector3 nearestOrigin =
_mode.IsPlayerMode && _player.Controller is { } controller
? controller.Position
: camera.Position;
_debugVm.PublishDebugVmFacts(
consumerActive: true,
visible,
total,
nearestOrigin,
_physics.ShadowObjects.AllEntriesForDebug());
}
return new WorldSceneDiagnosticOutcome(visible, total);
}
private void DrawCollisionWireframes(in WorldCameraFrame camera)
{
if (!_state.CollisionWireframesVisible || _lines is null)
return;
// #337 (2026-08-06 — TEMPORARY): ACDREAM_WIRE_MESH=1 replaces the
// broadphase-proxy overlay below with the objects' real physics-BSP
// polygon edges beside their visual mesh boxes and the terrain surface
// — see CollisionMeshWireframe for why the proxy cannot answer the
// question this mode exists for. Default off; F2 keeps its old
// behaviour otherwise.
if (RenderingDiagnostics.CollisionMeshWireframeEnabled)
{
DrawCollisionMesh(in camera);
return;
}
_lines.Begin();
int drawn = 0;
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
{
if (shadow.CollisionType == ShadowCollisionType.Cylinder)
{
float height = shadow.CylHeight > 0f
? shadow.CylHeight
: shadow.Radius * 2f;
_lines.AddCylinder(
shadow.Position,
shadow.Radius,
height,
new Vector3(0f, 1f, 0f));
}
else
{
_lines.AddCylinder(
shadow.Position - new Vector3(0f, 0f, shadow.Radius),
shadow.Radius,
shadow.Radius * 2f,
new Vector3(1f, 0.5f, 0f));
}
drawn++;
}
if (_mode.IsPlayerMode && _player.Controller is { } localPlayer)
{
Vector3 position = localPlayer.Position;
_lines.AddCylinder(
new Vector3(position.X, position.Y, position.Z),
DebugVmRenderFactsPublisher.PlayerCollisionRadius,
1.8f,
new Vector3(1f, 0f, 0f));
LogNearbyCollisionObjects(position, drawn);
}
_lines.Flush(camera.Camera.View, camera.Projection);
}
/// <summary>
/// #337 (2026-08-06 — TEMPORARY, strip with the probe family). Centres on
/// the player when there is one, else on the camera, so the fly-camera
/// mode can inspect geometry too.
/// </summary>
private void DrawCollisionMesh(in WorldCameraFrame camera)
{
Vector3 centre = _mode.IsPlayerMode && _player.Controller is { } controller
? controller.Position
: camera.Position;
_meshWireframe ??= new CollisionMeshWireframe(_physics);
_lines!.Begin();
CollisionMeshWireframeStats stats = _meshWireframe.Draw(_lines, centre);
if (_mode.IsPlayerMode && _player.Controller is { } player)
{
_lines.AddCylinder(
player.Position,
DebugVmRenderFactsPublisher.PlayerCollisionRadius,
1.8f,
new Vector3(1f, 0f, 0f));
}
_lines.Flush(camera.Camera.View, camera.Projection);
// A capped frame is showing PARTIAL geometry, which would otherwise be
// indistinguishable from an object that has none — exactly the
// misreading this overlay exists to prevent. Say so, throttled, rather
// than letting the picture lie.
if (stats != _lastMeshStats)
{
_lastMeshStats = stats;
Console.WriteLine(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"[wire-mesh] centre=({0:F2},{1:F2},{2:F2}) objects={3} polys={4} " +
"noPhysicsGeometry={5} lines={6} capped={7}",
centre.X, centre.Y, centre.Z,
stats.ObjectsConsidered, stats.PolygonsDrawn,
stats.ObjectsWithoutPhysicsGeometry, stats.LinesDrawn, stats.Capped));
}
}
private void LogNearbyCollisionObjects(Vector3 playerPosition, int drawn)
{
if (_debugDrawLogCount >= 5)
return;
Console.WriteLine(
$"debug frame {_debugDrawLogCount}: player=({playerPosition.X:F1},{playerPosition.Y:F1},{playerPosition.Z:F1}) drew={drawn} "
+ $"totalReg={_physics.ShadowObjects.TotalRegistered}");
int logged = 0;
foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug())
{
float dx = shadow.Position.X - playerPosition.X;
float dy = shadow.Position.Y - playerPosition.Y;
float horizontalDistance = MathF.Sqrt(dx * dx + dy * dy);
if (horizontalDistance >= 10f)
continue;
Console.WriteLine(
$" near id=0x{shadow.EntityId:X8} type={shadow.CollisionType} "
+ $"pos=({shadow.Position.X:F1},{shadow.Position.Y:F1},{shadow.Position.Z:F1}) "
+ $"r={shadow.Radius:F2} h={shadow.CylHeight:F2} dh={horizontalDistance:F2}");
if (++logged >= 5)
break;
}
_debugDrawLogCount++;
}
}