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>
344 lines
12 KiB
C#
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++;
|
|
}
|
|
}
|