acdream/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs

593 lines
22 KiB
C#

using System.Numerics;
using System.Diagnostics;
using System.Text;
using AcDream.Core.Vfx;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
internal readonly record struct IntRenderRectangle(int X, int Y, int Width, int Height);
internal readonly record struct RenderGlStateSnapshot(
bool DepthTest,
bool DepthWrite,
int DepthFunction,
bool Blend,
int BlendSource,
int BlendDestination,
bool CullFace,
int CullMode,
int FrontFace,
bool Scissor,
IntRenderRectangle ScissorBox,
IntRenderRectangle Viewport,
int DrawFramebuffer,
bool AlphaToCoverage,
bool Stencil,
int ClipBits,
int Error);
internal readonly record struct RenderGlScissorSnapshot(
bool Enabled,
IntRenderRectangle Box);
internal readonly record struct TerrainRenderDiagnosticFacts(
int VisibleSlots,
int LoadedSlots,
int CapacitySlots);
internal interface IRenderGlStateReader
{
RenderGlStateSnapshot CaptureState();
RenderGlScissorSnapshot CaptureScissor();
}
/// <summary>Render-thread GL state reader used only by explicitly enabled probes.</summary>
internal sealed class SilkRenderGlStateReader : IRenderGlStateReader
{
private readonly GL _gl;
public SilkRenderGlStateReader(GL gl) =>
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
public RenderGlStateSnapshot CaptureState()
{
Span<int> scissor = stackalloc int[4];
Span<int> viewport = stackalloc int[4];
_gl.GetInteger(GetPName.ScissorBox, scissor);
_gl.GetInteger(GetPName.Viewport, viewport);
int clipBits = 0;
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
{
if (_gl.IsEnabled(EnableCap.ClipDistance0 + index))
clipBits |= 1 << index;
}
// Preserve the old tripwire boundary: consume the error that existed
// after the scissor/viewport/clip reads, before any of the state reads
// below can produce a probe-owned error.
int error = (int)_gl.GetError();
return new RenderGlStateSnapshot(
_gl.IsEnabled(EnableCap.DepthTest),
_gl.GetBoolean(GetPName.DepthWritemask),
_gl.GetInteger(GetPName.DepthFunc),
_gl.IsEnabled(EnableCap.Blend),
_gl.GetInteger(GetPName.BlendSrcRgb),
_gl.GetInteger(GetPName.BlendDstRgb),
_gl.IsEnabled(EnableCap.CullFace),
_gl.GetInteger(GetPName.CullFaceMode),
_gl.GetInteger(GetPName.FrontFace),
_gl.IsEnabled(EnableCap.ScissorTest),
new IntRenderRectangle(scissor[0], scissor[1], scissor[2], scissor[3]),
new IntRenderRectangle(viewport[0], viewport[1], viewport[2], viewport[3]),
_gl.GetInteger(GetPName.DrawFramebufferBinding),
_gl.IsEnabled(EnableCap.SampleAlphaToCoverage),
_gl.IsEnabled(EnableCap.StencilTest),
clipBits,
error);
}
public RenderGlScissorSnapshot CaptureScissor()
{
Span<int> box = stackalloc int[4];
_gl.GetInteger(GetPName.ScissorBox, box);
return new RenderGlScissorSnapshot(
_gl.IsEnabled(EnableCap.ScissorTest),
new IntRenderRectangle(box[0], box[1], box[2], box[3]));
}
}
/// <summary>
/// Owns print-on-change world-render probes and their reusable scratch. Inputs
/// are borrowed for one call; the owner retains only copied signatures and IDs.
/// </summary>
internal sealed class WorldRenderDiagnostics
{
private readonly IRenderGlStateReader _gl;
private readonly IRenderFrameDiagnosticLog _log;
private readonly HashSet<uint> _lastViewerFloodCells = [];
private readonly HashSet<uint> _outStageUnmatched = [];
private readonly HashSet<uint> _outStageMatched = [];
private readonly Stopwatch _terrainStopwatch = new();
private readonly RollingTimingSampleWindow _terrainSamples = new(256);
private string? _lastRenderSignature;
private int _renderSignatureFrame;
private int _renderSignatureStableFrames;
private string? _lastViewerSignature;
private string? _lastGlStateSignature;
private long _glStateFrame;
private long _glStateStableFrames;
private string? _lastScissorSignature;
private long _scissorSequence;
private string? _lastOutStageSignature;
public WorldRenderDiagnostics(
IRenderGlStateReader gl,
IRenderFrameDiagnosticLog log)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_log = log ?? throw new ArgumentNullException(nameof(log));
}
public void BeginTerrainDraw() => _terrainStopwatch.Restart();
public void EndTerrainDraw()
{
_terrainStopwatch.Stop();
_terrainSamples.PushHundredthsMicroseconds(
(long)(_terrainStopwatch.Elapsed.TotalMicroseconds * 100.0));
}
public void PublishTerrainDiagnostics(TerrainRenderDiagnosticFacts facts)
{
RollingTimingPercentiles timing = _terrainSamples.Snapshot();
double medianMicroseconds = timing.MedianHundredthsMicroseconds / 100.0;
double p95Microseconds = timing.Percentile95HundredthsMicroseconds / 100.0;
string budget = medianMicroseconds > 1000.0 ? " BUDGET_OVER" : string.Empty;
_log.WriteLine(
$"[TERRAIN-DIAG]{budget} cpu_us={medianMicroseconds:F2}m/"
+ $"{p95Microseconds:F2}p95 draws={facts.VisibleSlots}/frame "
+ $"visible={facts.VisibleSlots} loaded={facts.LoadedSlots} "
+ $"capacity={facts.CapacitySlots}");
}
public void EmitGlStateTripwireIfChanged(bool enabled)
{
if (!enabled)
return;
_glStateFrame++;
string signature = FormatGlState(_gl.CaptureState());
if (signature == _lastGlStateSignature)
{
_glStateStableFrames++;
return;
}
_log.WriteLine(
$"[gl-state] frame={_glStateFrame} stable={_glStateStableFrames} {signature}");
_lastGlStateSignature = signature;
_glStateStableFrames = 0;
}
public void EmitClipRouteScissorProbe(
bool enabled,
bool applied,
Vector4 ndcAabb)
{
if (!enabled)
return;
RenderGlScissorSnapshot snapshot = _gl.CaptureScissor();
string signature = FormattableString.Invariant(
$"applied={(applied ? 1 : 0)} scis={(snapshot.Enabled ? 1 : 0)} box=({snapshot.Box.X},{snapshot.Box.Y},{snapshot.Box.Width},{snapshot.Box.Height}) ndc=({ndcAabb.X:F3},{ndcAabb.Y:F3},{ndcAabb.Z:F3},{ndcAabb.W:F3})");
_scissorSequence++;
if (signature == _lastScissorSignature)
return;
_lastScissorSignature = signature;
_log.WriteLine($"[clip-route-scis] n={_scissorSequence} {signature}");
}
public void EmitPViewInput(
bool enabled,
PortalVisibilityFrame portalFrame,
Matrix4x4 viewProjection,
bool outdoorRoot,
Vector3 eye,
Vector3 player,
Vector3 rawPlayer,
float yaw,
float? terrainHeight)
{
if (!enabled)
return;
string terrain = terrainHeight is { } height
? FormattableString.Invariant(
$"terrZ={height:F3} eyeAbove={eye.Z - height:F3}")
: "terrZ=n/a eyeAbove=n/a";
char root = outdoorRoot ? 'Y' : 'n';
Matrix4x4 vp = viewProjection;
_log.WriteLine(FormattableString.Invariant(
$"[pv-input] outRoot={root} flood={portalFrame.OrderedVisibleCells.Count} eye=({eye.X:F6},{eye.Y:F6},{eye.Z:F6}) player=({player.X:F6},{player.Y:F6},{player.Z:F6}) rawPlayer=({rawPlayer.X:F6},{rawPlayer.Y:F6},{rawPlayer.Z:F6}) yaw={yaw:F8} {terrain} vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]"));
}
public void EmitOutStageParticles(
bool enabled,
ParticleSystem? particles,
IReadOnlySet<uint> ownerIds)
{
if (!enabled || particles is null)
return;
int matched = 0;
int attached = 0;
int unattached = 0;
_outStageUnmatched.Clear();
_outStageMatched.Clear();
foreach (var (emitter, _) in particles.EnumerateLive())
{
if (emitter.AttachedObjectId == 0)
{
unattached++;
continue;
}
attached++;
if (ownerIds.Contains(emitter.AttachedObjectId))
{
matched++;
if (_outStageMatched.Count < 48)
_outStageMatched.Add(emitter.AttachedObjectId);
}
else if (_outStageUnmatched.Count < 12)
{
_outStageUnmatched.Add(emitter.AttachedObjectId);
}
}
var unmatched = new StringBuilder(96);
foreach (uint id in _outStageUnmatched)
unmatched.Append(FormattableString.Invariant($" 0x{id:X8}"));
var matchedIds = new StringBuilder(192);
foreach (uint id in _outStageMatched)
matchedIds.Append(FormattableString.Invariant($" 0x{id:X8}"));
string signature = FormattableString.Invariant(
$"ids={ownerIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} matchedIds=[{matchedIds}] unmatchedIds=[{unmatched}]");
if (signature == _lastOutStageSignature)
return;
_lastOutStageSignature = signature;
_log.WriteLine("[outstage-pt] " + signature);
}
public void EmitRetailPViewDiagnostics(
bool viewerEnabled,
bool visibilityEnabled,
bool flapEnabled,
RetailPViewFrameResult result,
LoadedCell clipRoot,
uint viewerCellId,
uint playerCellId,
Vector3 cameraPosition,
Vector3 playerPosition,
Matrix4x4 cameraView,
CameraCellResolution cameraCellResolution)
{
if (viewerEnabled)
{
string signature = FormattableString.Invariant(
$"root=0x{clipRoot.CellId:X8}{(clipRoot.IsOutdoorNode ? "(OUT)" : string.Empty)} flood={result.PortalFrame.OrderedVisibleCells.Count} outPolys={result.PortalFrame.OutsideView.Polygons.Count} pCell=0x{playerCellId:X8}");
if (signature != _lastViewerSignature)
{
_lastViewerSignature = signature;
_log.WriteLine(FormattableString.Invariant(
$"[viewer] {signature} eye=({cameraPosition.X:F3},{cameraPosition.Y:F3},{cameraPosition.Z:F3}) fwd=({-cameraView.M13:F4},{-cameraView.M23:F4},{-cameraView.M33:F4}) viewerCell=0x{viewerCellId:X8}"));
EmitViewerDiff(result.PortalFrame.OrderedVisibleCells);
}
}
if (visibilityEnabled)
{
AcDream.Core.Rendering.RenderingDiagnostics.EmitVis(
clipRoot.CellId,
result.PortalFrame.OrderedVisibleCells,
result.PortalFrame.OutsideView.Polygons.Count,
result.ClipAssembly.OutsidePlaneCount,
result.ClipAssembly.PerCellPlaneCounts,
result.ClipAssembly.ScissorFallbacks);
}
if (flapEnabled)
{
bool eyeInRoot = CellVisibility.PointInCell(cameraPosition, clipRoot);
bool playerInRoot = CellVisibility.PointInCell(playerPosition, clipRoot);
_log.WriteLine(
$"[flap-cam] root=0x{clipRoot.CellId:X8} "
+ $"viewerCell=0x{viewerCellId:X8} playerCell=0x{playerCellId:X8} "
+ $"res={cameraCellResolution} "
+ $"eyeInRoot={(eyeInRoot ? "Y" : "n")} "
+ $"playerInRoot={(playerInRoot ? "Y" : "n")} "
+ $"eye=({cameraPosition.X:F2},{cameraPosition.Y:F2},{cameraPosition.Z:F2}) "
+ $"player=({playerPosition.X:F2},{playerPosition.Y:F2},{playerPosition.Z:F2}) "
+ $"terrain={result.ClipAssembly.TerrainMode} "
+ $"outVisible={result.ClipAssembly.OutdoorVisible}");
}
}
public void EmitRenderSignatureIfChanged(
bool enabled,
string branch,
LoadedCell? clipRoot,
LoadedCell? viewerRoot,
LoadedCell? playerRoot,
uint viewerCellId,
uint playerCellId,
bool playerIndoorGate,
bool cameraInsideCell,
bool renderSkyGate,
bool drawSkyThisFrame,
bool terrainDrawn,
TerrainClipMode terrainClipMode,
bool skyDrawn,
bool depthClear,
bool outdoorSceneryDrawn,
bool outdoorPortalDrawn,
int outdoorRootObjectCount,
int liveDynamicDrawnCount,
string sceneParticles,
PortalVisibilityFrame? portalFrame,
ClipFrameAssembly? clipAssembly,
IReadOnlySet<uint>? drawableCells,
InteriorEntityPartition.Result? partition,
PortalVisibilityFrame? exteriorPortalFrame,
ClipFrameAssembly? exteriorClipAssembly,
IReadOnlySet<uint>? exteriorDrawableCells,
InteriorEntityPartition.Result? exteriorPartition,
Vector3 cameraPosition,
Vector3 playerPosition)
{
if (!enabled)
return;
_renderSignatureFrame++;
bool eyeInRoot = clipRoot is not null
&& CellVisibility.PointInCell(cameraPosition, clipRoot);
bool playerInRoot = clipRoot is not null
&& CellVisibility.PointInCell(playerPosition, clipRoot);
var text = new StringBuilder(512);
text.Append("branch=").Append(branch);
text.Append(" root=0x").Append((clipRoot?.CellId ?? 0u).ToString("X8"));
text.Append(" viewerRoot=0x").Append((viewerRoot?.CellId ?? 0u).ToString("X8"));
text.Append(" playerRoot=0x").Append((playerRoot?.CellId ?? 0u).ToString("X8"));
text.Append(" viewerCell=0x").Append(viewerCellId.ToString("X8"));
text.Append(" playerCell=0x").Append(playerCellId.ToString("X8"));
text.Append(" gate=").Append(playerIndoorGate ? "in" : "out");
text.Append(" camIn=").Append(cameraInsideCell ? 'Y' : 'n');
text.Append(" eyeInRoot=").Append(eyeInRoot ? 'Y' : 'n');
text.Append(" playerInRoot=").Append(playerInRoot ? 'Y' : 'n');
text.Append(" eye=").Append(FormatVector(cameraPosition));
text.Append(" player=").Append(FormatVector(playerPosition));
text.Append(" terrain=").Append(terrainClipMode);
text.Append('/').Append(terrainDrawn ? "draw" : "skip");
text.Append(" skyGate=").Append(renderSkyGate ? 'Y' : 'n');
text.Append(" sky=").Append(skyDrawn ? 'Y' : 'n');
text.Append(" skyFrame=").Append(drawSkyThisFrame ? 'Y' : 'n');
text.Append(" zclear=").Append(depthClear ? 'Y' : 'n');
text.Append(" sceneParticles=").Append(sceneParticles);
if (clipAssembly is not null)
{
text.Append(" outSlices=").Append(clipAssembly.OutsideViewSlices.Length);
text.Append(" outPolys=").Append(portalFrame?.OutsideView.Polygons.Count ?? 0);
text.Append(" outMode=").Append(clipAssembly.TerrainMode);
}
else
{
text.Append(" outSlices=0 outPolys=0 outMode=none");
}
text.Append(" ids=").Append(FormatIds(portalFrame?.OrderedVisibleCells, true));
text.Append(" draw=").Append(FormatIds(drawableCells, false));
text.Append(" miss=").Append(FormatMissingDrawableCells(portalFrame, drawableCells));
text.Append(" obj=").Append(FormatPartitionCounts(partition));
text.Append(" outdoorDoor=").Append(outdoorSceneryDrawn ? 'Y' : 'n');
text.Append(" outdoorRootObjs=").Append(outdoorRootObjectCount);
text.Append(" liveDynDraw=").Append(liveDynamicDrawnCount);
text.Append(" outRoot=").Append(clipRoot is { IsOutdoorNode: true } ? 'Y' : 'n');
if (partition is not null)
{
int totalShells = 0;
int shellsWithMeshes = 0;
foreach (var entity in partition.OutdoorStatic)
{
if (!entity.IsBuildingShell)
continue;
totalShells++;
if (entity.MeshRefs.Count > 0)
shellsWithMeshes++;
}
text.Append(" bshell=").Append(totalShells).Append('/').Append(shellsWithMeshes);
}
if (outdoorPortalDrawn || exteriorPortalFrame is not null
|| exteriorClipAssembly is not null)
{
text.Append(" extPortal=").Append(outdoorPortalDrawn ? 'Y' : 'n');
text.Append(" extSlices=").Append(exteriorClipAssembly?.OutsideViewSlices.Length ?? 0);
text.Append(" extIds=").Append(FormatIds(
exteriorPortalFrame?.OrderedVisibleCells,
true));
text.Append(" extDraw=").Append(FormatIds(exteriorDrawableCells, false));
text.Append(" extMiss=").Append(FormatMissingDrawableCells(
exteriorPortalFrame,
exteriorDrawableCells));
text.Append(" extObj=").Append(FormatPartitionCounts(exteriorPartition));
}
string signature = text.ToString();
if (signature == _lastRenderSignature)
{
_renderSignatureStableFrames++;
return;
}
_log.WriteLine(
$"[render-sig] frame={_renderSignatureFrame} "
+ $"stable={_renderSignatureStableFrames} {signature}");
_lastRenderSignature = signature;
_renderSignatureStableFrames = 0;
}
internal static string FormatGlState(RenderGlStateSnapshot state) =>
$"depth={(state.DepthTest ? 1 : 0)} "
+ $"dmask={(state.DepthWrite ? 1 : 0)} "
+ $"dfunc=0x{state.DepthFunction:X} "
+ $"blend={(state.Blend ? 1 : 0)} "
+ $"bsrc=0x{state.BlendSource:X} bdst=0x{state.BlendDestination:X} "
+ $"cull={(state.CullFace ? 1 : 0)} cmode=0x{state.CullMode:X} "
+ $"fface=0x{state.FrontFace:X} "
+ $"scis={(state.Scissor ? 1 : 0)} "
+ $"sbox=({state.ScissorBox.X},{state.ScissorBox.Y},"
+ $"{state.ScissorBox.Width},{state.ScissorBox.Height}) "
+ $"vp=({state.Viewport.X},{state.Viewport.Y},"
+ $"{state.Viewport.Width},{state.Viewport.Height}) "
+ $"fbo={state.DrawFramebuffer} "
+ $"a2c={(state.AlphaToCoverage ? 1 : 0)} "
+ $"stencil={(state.Stencil ? 1 : 0)} "
+ $"clip=0x{state.ClipBits:X2} err=0x{state.Error:X}";
private void EmitViewerDiff(IReadOnlyList<uint> current)
{
var text = new StringBuilder(96);
text.Append("[viewer-diff] added=[");
bool first = true;
foreach (uint cell in current)
{
if (_lastViewerFloodCells.Contains(cell))
continue;
if (!first)
text.Append(',');
text.Append("0x").Append(cell.ToString("X8"));
first = false;
}
text.Append("] removed=[");
first = true;
foreach (uint cell in _lastViewerFloodCells)
{
bool present = false;
for (int index = 0; index < current.Count; index++)
{
if (current[index] == cell)
{
present = true;
break;
}
}
if (present)
continue;
if (!first)
text.Append(',');
text.Append("0x").Append(cell.ToString("X8"));
first = false;
}
text.Append(']');
_log.WriteLine(text.ToString());
_lastViewerFloodCells.Clear();
foreach (uint cell in current)
_lastViewerFloodCells.Add(cell);
}
private static string FormatVector(Vector3 value)
{
static float Quantize(float component) => MathF.Round(component * 20f) / 20f;
return $"({Quantize(value.X):F2},{Quantize(value.Y):F2},{Quantize(value.Z):F2})";
}
private static string FormatIds(IEnumerable<uint>? ids, bool preserveOrder)
{
if (ids is null)
return "[]";
var values = new List<uint>(ids);
if (!preserveOrder)
values.Sort();
var text = new StringBuilder(96).Append('[');
const int MaximumIds = 12;
for (int index = 0; index < values.Count && index < MaximumIds; index++)
{
if (index > 0)
text.Append(',');
text.Append("0x").Append(values[index].ToString("X8"));
}
if (values.Count > MaximumIds)
text.Append(",...");
return text.Append(']').ToString();
}
private static string FormatMissingDrawableCells(
PortalVisibilityFrame? portalFrame,
IReadOnlySet<uint>? drawableCells)
{
if (portalFrame is null || drawableCells is null)
return "[]";
var text = new StringBuilder(96).Append('[');
int written = 0;
const int MaximumCells = 8;
foreach (uint id in portalFrame.OrderedVisibleCells)
{
if (drawableCells.Contains(id))
continue;
if (written > 0)
text.Append(',');
text.Append("0x").Append(id.ToString("X8"));
if (portalFrame.CellViews.TryGetValue(id, out var view))
{
text.Append(":p").Append(view.Polygons.Count);
if (view.IsEmpty)
text.Append(":empty");
}
else
{
text.Append(":noView");
}
if (++written >= MaximumCells)
{
text.Append(",...");
break;
}
}
return text.Append(']').ToString();
}
private static string FormatPartitionCounts(InteriorEntityPartition.Result? partition)
{
if (partition is null)
return "cell=[] out=0 live=0";
var keys = new List<uint>(partition.ByCell.Keys);
keys.Sort();
var text = new StringBuilder(128).Append("cell=[");
const int MaximumCells = 10;
for (int index = 0; index < keys.Count && index < MaximumCells; index++)
{
uint id = keys[index];
if (index > 0)
text.Append(',');
text.Append("0x").Append(id.ToString("X8"))
.Append(':').Append(partition.ByCell[id].Count);
}
if (keys.Count > MaximumCells)
text.Append(",...");
return text.Append("] out=").Append(partition.OutdoorStatic.Count)
.Append(" live=").Append(partition.Dynamics.Count)
.ToString();
}
}