Fixes the three-lens review blockers against 671eb3ad4 (S3 section 9.6 F1-F6).
F1 - slot key (blocking, retail). TerrainModernRenderer.DrawLandCells
normalizes every incoming landblockId to (id & 0xFFFF0000u) | 0xFFFFu
before the _idToSlot lookup: the walk hands 0xXXYY0000
(WalkLandBlock.LandblockId) but AddLandblock stores under the DAT id
0xXXYYFFFF (LandblockRenderPublisher.LandblockId) - every walk lookup
was missing and the walk path drew NO terrain. Unit-tested end-to-end
through a real RecordingGpuDevice-backed TerrainModernRenderer
(TerrainWalkSlotKeyNormalizationTests): AddLandblock(0xA9B4FFFF, ...)
is found by a 0xA9B40000 lookup, an unknown landblock is a silent
per-entry no-op, and a batch mixing a known and unknown entry submits
only the known one.
F2 - deferred cross-block batching (blocking, driver). Retail's
DrawSortCell always follows DrawLandCell (LC/SC strictly alternate,
never two LC in a row - S3 section 9 R1), so chunk 3's "merge
consecutive same-landblock LandCell events" rule never actually
merged anything; the driver review flagged batching as inert.
WalkFrameDriver.Replay now keeps ONE pending terrain batch across
landblocks ((landblockId, side, cellIndex) entries, cleared at
Replay's own start); a LandCell event only appends; every OTHER event
kind that will itself submit GPU work (StreamMark, Sky, CellShell,
PunchFan, AlphaBarrier, LandscapeFlush, ClearInteriorDepth,
ExitSeals) flushes the pending batch first; a StaticParticles/
CellParticles turn asks the new ParticleSystem.
HasRenderableEmittersInCell (an allocation-free sibling of
CopyRenderableEmittersInCell) and, when the cell has no renderable
emitter, submits nothing and does NOT flush either - the whole point
of the deferred rule. The end of Replay flushes the remainder. This
is order-preserving by construction: a flush always lands at the
exact point the unbatched draw would have, so GPU submission order -
and therefore pixels - is identical to the unbatched baseline; only
the number of small terrain draw calls shrinks.
TerrainModernRenderer.DrawLandCellRuns becomes DrawLandCells(
viewProjection, IReadOnlyList<(uint LandblockId, int SideCellCount,
int CellIndex)>) - one MultiDrawIndexedIndirect over every entry's
runs, unknown slots skipped per-entry. IWalkFrameLeafRenderer.
DrawLandCellBatch drops its separate landblockId parameter to match
(a batch can span several landblocks now) and gains
HasRenderableEmittersInCell.
Batch-count demonstration: driven through a real WalkFrameDriver
Replay (OnLandCellTurn_MergesAcrossLandblocksOverAnEmptyParticleTurn_
RealSubmissionsSplit), 4 LandCell turns across 3 distinct landblocks,
separated only by an empty particle turn, a real StreamMark, and a
building's alpha barrier, submit as exactly 3 DrawLandCellBatch calls
(2+1+1) instead of 4 - the empty particle turn's non-flush merges two
otherwise-separate cross-landblock entries. At production scale the
same mechanism is expected to cut the terrace-edge frame's ~578
individual DrawLandCell events (S3 section 9's captured transcript
count) to "tens" of submitted batches, per the contract's own
expectation: most terrain cells have no particle owner nearby, so the
strict LC/[empty-SC]/LC/[empty-SC]/... run collapses into one batch
per region bounded by real content (a building, a StreamMark-worthy
cell, or a genuine emitter) rather than per cell.
F3 - per-frame terrain diagnostic (blocking, build/test). The walk
leaf no longer brackets each batch with TerrainDrawDiagnosticsController
.Begin()/Complete() (a per-batch Stopwatch Restart/Stop pair that was
pushing one timing SAMPLE per batch, not per frame).
RetailPViewPassExecutor.DrawWalkLandCellBatch instead times its own
call with a raw Stopwatch.GetTimestamp() delta (no allocation) and
hands the ticks to the controller's new AccumulateWalkBatch;
RetailPViewRenderer.DrawWalkDrivenStatics calls the new
CompleteWalkTerrainFrame() exactly once, immediately after
driver.Replay finishes - "the end of the walk replay", where the
deleted whole-stage terrain leaf's own Begin()/Complete() bracket
used to close - which pushes ONE elapsed-time sample (even a
zero-batch frame pushes a zero sample: one sample per frame, not per
landscape turn) and publishes on the existing 5-second cadence.
TerrainRenderDiagnosticFacts gains a Draws field alongside
VisibleSlots (both were the same field before); TerrainModernRenderer
tracks its own per-frame WalkVisibleSlotCount/WalkDrawCount (a
HashSet<int>/int cleared in BeginFrame, populated by DrawLandCells),
and the diagnostics source reports those whenever the walk drew at
least one batch this frame, falling back to the non-walk Draw()
path's VisibleSlots otherwise (the two paths never both run in the
same frame). The [TERRAIN-DIAG] line's meaning (cpu_us per frame) is
unchanged, so the S3 section 9.5 before/after compare stays valid.
F4 - driver pins for the LandCell position (major). Three RunFrame-
level pins replace the deleted TERRAIN:0 pins: an outdoor-root
sequence (SKY, then one LANDCELL, driving RetailFrameWalk.
DrawLandscape directly with a one-view/zero-vertex WalkPortalView so
WalkLandscape.CheckBlocks' admission stays the same deterministic
"CY-only" test RetailFrameWalkTests already relies on, while still
satisfying WalkFrameDriver's real >=1-active-view fail-loud guard);
an interior-root test with one real exit view and one populated
block (SKY, LANDCELL, LFLUSH, SEALS, SHELL...) built on the existing
RunFrame_InteriorFloodWithExitView_... fixture; and the T4 batching
pin re-expressed for the F2 rule (OnLandCellTurn_
MergesAcrossLandblocksOverAnEmptyParticleTurn_RealSubmissionsSplit,
described above). The fake leaf's DrawLandCellBatch now logs
LANDCELL:<lb>:<side>:<idx>[,...] per batch and gains
HasRenderableEmittersInCell backed by an opt-out CellsWithoutEmitters
set (default true - has-emitters - so every pre-existing pin in the
file keeps its old unconditional-submission behavior unchanged).
F5 - no code change: the walk's in-view gate is unchanged; no
whole-block terrain re-added.
F6 - minor/notes: DrawLandCells' own comment now states the walk's
CheckBlocks/landcell_check admission is the sole terrain culling
authority (retail has no separate terrain frustum test); the
HandleLandscapeTurn comment's inverted claim is corrected (a FARTHER
building's punch survived because NEARER terrain was drawn BEFORE
it, not after - the interleave now draws it after, matching retail);
the "flat/directional-shadow paths" claim is corrected to the one
actual caller, WorldScenePassExecutor.DrawFlatTerrain (a directional-
shadow receiver selects its pipeline inside the SAME DrawRhi call,
not through a second caller); the cathedral order-trace token gains
the LOD side/index (":LC<lb>/<side>:<idx>"); T2's vacuous "no
TERRAIN event" assertion in RetailFrameWalkTests is replaced by a
comment pointing at the F4 driver-level pins; and the stale
"Confirmed OH5 defect" row in oh1-construction-landscape-contract.md
is retired with "FIXED by S3 chunk 3 (commit 671eb3ad4 + fix round
1)".
App hermetic lane: 6,795/6,795 (up from 671eb3ad4's 6,786 baseline -
net +9 tests: 3 F1 slot-key tests, 2 F4a/b driver RunFrame pins, 3
TerrainDrawDiagnosticsController walk-frame tests, plus the T4->F4c
rewrite and the RetailPViewPassExecutorTests split are net neutral).
InstalledDat lane: 241 passed, the same 3 accepted failures (2
pre-existing #383 layout fixture-drift tests, 1 TowerAscent
Status=KnownFailure) - unchanged from baseline. Core Vfx tests:
109/109 (108 baseline + 1 new HasRenderableEmittersInCell lifecycle
pin mirroring CopyRenderableEmittersInCell's own add/move/remove
test).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
546 lines
21 KiB
C#
546 lines
21 KiB
C#
using System.Numerics;
|
|
using System.Diagnostics;
|
|
using System.Text;
|
|
using AcDream.Core.Vfx;
|
|
using AcDream.Core.World;
|
|
|
|
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 Draws,
|
|
int LoadedSlots,
|
|
int CapacitySlots);
|
|
|
|
internal interface IRenderGlStateReader
|
|
{
|
|
RenderGlStateSnapshot CaptureState();
|
|
|
|
RenderGlScissorSnapshot CaptureScissor();
|
|
}
|
|
|
|
/// <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 Stopwatch _terrainStopwatch = new();
|
|
private readonly RollingTimingSampleWindow _terrainSamples = new(256);
|
|
private string? _lastRenderSignature;
|
|
private int _renderSignatureFrame;
|
|
private int _renderSignatureStableFrames;
|
|
private string? _lastGlStateSignature;
|
|
private long _glStateFrame;
|
|
private long _glStateStableFrames;
|
|
private string? _lastPostWorldGlStateSignature;
|
|
private long _postWorldGlStateFrame;
|
|
private long _postWorldGlStateStableFrames;
|
|
private string? _lastScissorSignature;
|
|
private long _scissorSequence;
|
|
private string? _lastClipRouteSignature;
|
|
private long _clipRouteSequence;
|
|
private readonly List<uint> _clipRouteCellKeys = [];
|
|
|
|
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));
|
|
}
|
|
|
|
/// <summary>S3 chunk 3 fix round 1 (F3): the walk path's per-frame
|
|
/// analogue of <see cref="EndTerrainDraw"/> — pushes ONE precomputed
|
|
/// elapsed-time sample (the sum of every land-cell batch's own
|
|
/// Stopwatch.GetTimestamp() delta this frame, converted by the caller)
|
|
/// instead of stopping this owner's own stopwatch, since the walk path
|
|
/// times many small batches per frame rather than one bracketed call.</summary>
|
|
public void PushTerrainSample(long elapsedHundredthsMicroseconds) =>
|
|
_terrainSamples.PushHundredthsMicroseconds(elapsedHundredthsMicroseconds);
|
|
|
|
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.Draws}/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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Second sample of the same snapshot, taken at the END of the normal-world
|
|
/// phase instead of at the frame clear.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="EmitGlStateTripwireIfChanged"/> samples immediately after the
|
|
/// clear phase has run <c>RestoreFrameDefaults</c>, so it can only observe
|
|
/// state that survives from one frame into the next. State that a world pass
|
|
/// establishes and something in private presentation puts back before the
|
|
/// next clear is invisible to it — including the draw framebuffer, which no
|
|
/// frame-global restore touches. Sampling here as well brackets the world
|
|
/// phase, so a binding that the world's geometry drew into but the retained
|
|
/// UI did not shows up as a difference between the two lines rather than as
|
|
/// no line at all.
|
|
/// </remarks>
|
|
public void EmitPostWorldGlStateIfChanged(bool enabled)
|
|
{
|
|
if (!enabled)
|
|
return;
|
|
|
|
_postWorldGlStateFrame++;
|
|
string signature = FormatGlState(_gl.CaptureState());
|
|
if (signature == _lastPostWorldGlStateSignature)
|
|
{
|
|
_postWorldGlStateStableFrames++;
|
|
return;
|
|
}
|
|
|
|
_log.WriteLine(
|
|
$"[gl-state-postworld] frame={_postWorldGlStateFrame} "
|
|
+ $"stable={_postWorldGlStateStableFrames} {signature}");
|
|
_lastPostWorldGlStateSignature = signature;
|
|
_postWorldGlStateStableFrames = 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 EmitClipRouteProbe(
|
|
bool enabled,
|
|
ClipFrame clipFrame,
|
|
ClipFrameAssembly clipAssembly,
|
|
ClipViewSlice slice,
|
|
int sliceIndex)
|
|
{
|
|
if (!enabled)
|
|
return;
|
|
|
|
var text = new StringBuilder(256);
|
|
text.Append(FormattableString.Invariant(
|
|
$"slice={sliceIndex}/{clipAssembly.OutsideViewSlices.Length} slot={slice.Slot}"));
|
|
text.Append(FormattableString.Invariant(
|
|
$" ndc=({slice.NdcAabb.X:F3},{slice.NdcAabb.Y:F3},{slice.NdcAabb.Z:F3},{slice.NdcAabb.W:F3})"));
|
|
text.Append(FormattableString.Invariant($" planes={slice.Planes.Length}["));
|
|
for (int i = 0; i < slice.Planes.Length; i++)
|
|
{
|
|
Vector4 plane = slice.Planes[i];
|
|
if (i > 0)
|
|
text.Append(' ');
|
|
text.Append(FormattableString.Invariant(
|
|
$"({plane.X:F3},{plane.Y:F3},{plane.Z:F3},{plane.W:F3})"));
|
|
}
|
|
|
|
text.Append("] cells={");
|
|
_clipRouteCellKeys.Clear();
|
|
foreach (uint key in clipAssembly.CellIdToSlot.Keys)
|
|
_clipRouteCellKeys.Add(key);
|
|
_clipRouteCellKeys.Sort();
|
|
for (int i = 0; i < _clipRouteCellKeys.Count; i++)
|
|
{
|
|
if (i > 0)
|
|
text.Append(',');
|
|
text.Append(FormattableString.Invariant(
|
|
$"0x{_clipRouteCellKeys[i]:X8}:{clipAssembly.CellIdToSlot[_clipRouteCellKeys[i]]}"));
|
|
}
|
|
text.Append('}');
|
|
|
|
ReadOnlySpan<byte> regionBytes = clipFrame.RegionBytesForTest;
|
|
int offset = slice.Slot * ClipFrame.CellClipStrideBytes;
|
|
if (offset >= 0 && offset + ClipFrame.CellClipStrideBytes <= regionBytes.Length)
|
|
{
|
|
uint count = BitConverter.ToUInt32(regionBytes.Slice(offset, 4));
|
|
text.Append(FormattableString.Invariant($" ssbo[{slice.Slot}]: n={count}"));
|
|
int planeCount = (int)Math.Min(count, (uint)ClipFrame.MaxPlanes);
|
|
for (int i = 0; i < planeCount; i++)
|
|
{
|
|
int planeOffset = offset + ClipFrame.CellClipPlanesOffset + i * 16;
|
|
float x = BitConverter.ToSingle(regionBytes.Slice(planeOffset, 4));
|
|
float y = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 4, 4));
|
|
float z = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 8, 4));
|
|
float w = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 12, 4));
|
|
text.Append(FormattableString.Invariant($" ({x:F3},{y:F3},{z:F3},{w:F3})"));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
text.Append(FormattableString.Invariant(
|
|
$" ssbo[{slice.Slot}]: OUT-OF-RANGE len={regionBytes.Length}"));
|
|
}
|
|
|
|
ReadOnlySpan<byte> terrainBytes = clipFrame.TerrainBytesForTest;
|
|
int terrainCount = BitConverter.ToInt32(terrainBytes[..4]);
|
|
float p0 = BitConverter.ToSingle(terrainBytes.Slice(16, 4));
|
|
float p1 = BitConverter.ToSingle(terrainBytes.Slice(20, 4));
|
|
float p2 = BitConverter.ToSingle(terrainBytes.Slice(24, 4));
|
|
float p3 = BitConverter.ToSingle(terrainBytes.Slice(28, 4));
|
|
text.Append(FormattableString.Invariant(
|
|
$" ubo: n={terrainCount} p0=({p0:F3},{p1:F3},{p2:F3},{p3:F3})"));
|
|
|
|
string signature = text.ToString();
|
|
_clipRouteSequence++;
|
|
if (signature == _lastClipRouteSignature)
|
|
return;
|
|
_lastClipRouteSignature = signature;
|
|
_log.WriteLine($"[clip-route] n={_clipRouteSequence} {signature}");
|
|
}
|
|
|
|
public void EmitSeamMask(
|
|
bool enabled,
|
|
IReadOnlySet<uint> targetCells,
|
|
uint cellId,
|
|
int portalIndex,
|
|
bool forceFarZ,
|
|
ReadOnlySpan<Vector3> vertices)
|
|
{
|
|
if (!enabled || !targetCells.Contains(cellId))
|
|
return;
|
|
|
|
float minimumZ = float.MaxValue;
|
|
float maximumZ = float.MinValue;
|
|
foreach (Vector3 vertex in vertices)
|
|
{
|
|
minimumZ = Math.Min(minimumZ, vertex.Z);
|
|
maximumZ = Math.Max(maximumZ, vertex.Z);
|
|
}
|
|
|
|
_log.WriteLine(FormattableString.Invariant(
|
|
$"[seam-mask] t={Environment.TickCount64} cell=0x{cellId:X8} portal={portalIndex} far={forceFarZ} n={vertices.Length} z=[{minimumZ:F3},{maximumZ:F3}]"));
|
|
}
|
|
|
|
public void EmitPViewInput(
|
|
bool enabled,
|
|
IReadOnlySet<uint> visibleCells,
|
|
int outsideViewCount,
|
|
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} visible={visibleCells.Count} outsideViews={outsideViewCount} 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 EmitRetailPViewDiagnostics(
|
|
bool visibilityEnabled,
|
|
bool flapEnabled,
|
|
RetailPViewFrameResult result,
|
|
LoadedCell clipRoot,
|
|
uint viewerCellId,
|
|
uint playerCellId,
|
|
Vector3 cameraPosition,
|
|
Vector3 playerPosition,
|
|
CameraCellResolution cameraCellResolution)
|
|
{
|
|
if (visibilityEnabled)
|
|
{
|
|
AcDream.Core.Rendering.RenderingDiagnostics.EmitVis(
|
|
clipRoot.CellId,
|
|
result.VisibleCells.OrderBy(static id => id).ToArray(),
|
|
result.ClipAssembly.OutsideViewSlices.Length,
|
|
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,
|
|
int liveDynamicDrawnCount,
|
|
string sceneParticles,
|
|
IReadOnlySet<uint>? visibleCells,
|
|
ClipFrameAssembly? clipAssembly,
|
|
IReadOnlySet<uint>? drawableCells,
|
|
InteriorEntityPartition.Result? partition,
|
|
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(clipAssembly.OutsideViewSlices.Length);
|
|
text.Append(" outMode=").Append(clipAssembly.TerrainMode);
|
|
}
|
|
else
|
|
{
|
|
text.Append(" outSlices=0 outPolys=0 outMode=none");
|
|
}
|
|
|
|
text.Append(" ids=").Append(FormatIds(visibleCells, false));
|
|
text.Append(" draw=").Append(FormatIds(drawableCells, false));
|
|
text.Append(" miss=").Append(FormatMissingDrawableCells(visibleCells, drawableCells));
|
|
text.Append(" obj=").Append(FormatPartitionCounts(partition));
|
|
text.Append(" outdoorDoor=").Append(outdoorSceneryDrawn ? 'Y' : 'n');
|
|
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);
|
|
}
|
|
|
|
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 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(
|
|
IReadOnlySet<uint>? visibleCells,
|
|
IReadOnlySet<uint>? drawableCells)
|
|
{
|
|
if (visibleCells is null || drawableCells is null)
|
|
return "[]";
|
|
|
|
var text = new StringBuilder(96).Append('[');
|
|
int written = 0;
|
|
const int MaximumCells = 8;
|
|
foreach (uint id in visibleCells.OrderBy(static id => id))
|
|
{
|
|
if (drawableCells.Contains(id))
|
|
continue;
|
|
if (written > 0)
|
|
text.Append(',');
|
|
text.Append("0x").Append(id.ToString("X8"));
|
|
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();
|
|
}
|
|
}
|