perf(render): gate EnvCellRenderer visibility-snapshot rebuilds on real input changes (2026-07-24 audit review)
PrepareRenderBatches rebuilt the full EnvCell visibility snapshot every frame any indoor/building root was resolved: a Parallel.ForEach dispatch over every GpuReady EnvCell landblock (with per-landblock locks), a fresh outer dictionary plus one fresh inner dictionary per visible cell, a new snapshot object, and a complete transparency rescan - all while standing perfectly still. The NeedsPrepare flag existed since Phase A8 as the intended rebuild gate but was never read by production code. This wires the gate on the snapshot's actual inputs: - landblock commits/removals (the existing NeedsPrepare flag), - the visible-cell filter (content-compared; the caller reuses one scratch HashSet across frames), - the trim window (center/radius), - mesh render-data availability - new ObjectMeshManager.RenderDataAvailabilityVersion, bumped at publish, pending-release hide, release completion, and teardown, because the snapshot bakes per-cell transparency from TryGetRenderData and a late-arriving transparent shell must reclassify its cell, - the camera: eye position under a 1 mm ABSOLUTE epsilon (swallows the documented ~36 um rest jitter, dirties on any real movement; the VP translation row scales with AC's ~5e4 world coordinates where a relative tolerance would mask sub-meter motion) plus rows 1-3 of view*projection (position-independent rotation x projection) under relative 1e-5. Skipping is pool-safe: RenderCore re-anchors _poolIndex to the active snapshot's PostPreparePoolIndex on every call, so consecutive Renders without an intervening Prepare reuse scratch lists past the snapshot's owned region exactly as within-frame passes already do. The empty-filter branch is now also idempotent instead of allocating a fresh empty snapshot per frame. Render still receives the current frame's view-projection every frame (the U.4 stale-matrix rule) - only the snapshot rebuild is gated. Pixels must be identical; needs the standard user visual pass (dungeon + town-near-buildings) before the change is considered accepted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
38e14c6a71
commit
c2cb83d11f
3 changed files with 208 additions and 0 deletions
|
|
@ -182,6 +182,26 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
private static CullMode? _currentCullMode;
|
||||
|
||||
public bool NeedsPrepare { get; private set; } = true;
|
||||
|
||||
// --- Prepare gate (2026-07-24) -------------------------------------------
|
||||
// PrepareRenderBatches rebuilds the visibility snapshot only when one of its
|
||||
// inputs changed: landblock commits/removals (NeedsPrepare), the visible-cell
|
||||
// filter, the trim window, mesh render-data availability (the snapshot bakes
|
||||
// per-cell transparency from TryGetRenderData), or the view-projection.
|
||||
// NeedsPrepare existed since A8 but was never read — this wires it. The VP
|
||||
// tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer
|
||||
// R-A2 note) while any real camera motion crosses it in the same frame.
|
||||
private Matrix4x4 _preparedViewProjection;
|
||||
private Vector3 _preparedCameraPosition;
|
||||
private readonly HashSet<uint> _preparedFilter = new();
|
||||
private bool _preparedFilterWasNull;
|
||||
private (int? X, int? Y, int? Radius) _preparedTrim;
|
||||
private long _preparedMeshVersion = -1;
|
||||
private bool _hasPreparedSnapshot;
|
||||
|
||||
/// <summary>Bumps once per visibility-snapshot rebuild (tests + diagnostics).</summary>
|
||||
internal int SnapshotGeneration { get; private set; }
|
||||
|
||||
private RetryableResourceReleaseLedger? _disposeResources;
|
||||
private bool _disposing;
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
|
@ -458,13 +478,23 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
int? renderRadius = null)
|
||||
{
|
||||
// Phase U.4 fix: stash the view-projection so Render() can upload it itself.
|
||||
// Stashed even when the gate below skips the rebuild — Render must always
|
||||
// project with the CURRENT frame's matrix (the U.4 stale-matrix root cause).
|
||||
_lastViewProjection = viewProjection;
|
||||
|
||||
// WB EnvCellRenderManager.cs:249-250:
|
||||
if (!_initialized || cameraPosition.Z > 4000) return;
|
||||
|
||||
long meshVersion = _meshManager is null ? 0L : _meshManager.RenderDataAvailabilityVersion;
|
||||
|
||||
if (filter is { Count: 0 })
|
||||
{
|
||||
// Idempotent: an empty filter always produces the empty snapshot, so
|
||||
// skip once it is already active. Landblock/content changes cannot
|
||||
// affect an empty result; NeedsPrepare stays observable for the next
|
||||
// non-empty prepare.
|
||||
if (_hasPreparedSnapshot && !_preparedFilterWasNull && _preparedFilter.Count == 0)
|
||||
return;
|
||||
lock (_renderLock)
|
||||
{
|
||||
_poolIndex = 0;
|
||||
|
|
@ -472,6 +502,22 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_transparentCellIds.Clear();
|
||||
NeedsPrepare = false;
|
||||
}
|
||||
RecordPreparedInputs(viewProjection, cameraPosition, filter, centerLbX, centerLbY, renderRadius, meshVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare gate: every snapshot input unchanged → keep the active snapshot.
|
||||
// (Same-thread discipline makes the version sample exact: publish, release
|
||||
// tickets, and this method all run on the render thread.)
|
||||
if (_hasPreparedSnapshot
|
||||
&& !NeedsPrepare
|
||||
&& meshVersion == _preparedMeshVersion
|
||||
&& _preparedTrim == (centerLbX, centerLbY, renderRadius)
|
||||
&& FilterUnchanged(filter)
|
||||
&& CameraApproximatelyEqual(
|
||||
viewProjection, cameraPosition,
|
||||
_preparedViewProjection, _preparedCameraPosition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -614,6 +660,67 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_poolIndex = 0;
|
||||
NeedsPrepare = false;
|
||||
}
|
||||
RecordPreparedInputs(viewProjection, cameraPosition, filter, centerLbX, centerLbY, renderRadius, meshVersion);
|
||||
}
|
||||
|
||||
private void RecordPreparedInputs(
|
||||
in Matrix4x4 viewProjection,
|
||||
Vector3 cameraPosition,
|
||||
HashSet<uint>? filter,
|
||||
int? centerLbX,
|
||||
int? centerLbY,
|
||||
int? renderRadius,
|
||||
long meshVersion)
|
||||
{
|
||||
_preparedViewProjection = viewProjection;
|
||||
_preparedCameraPosition = cameraPosition;
|
||||
_preparedFilterWasNull = filter is null;
|
||||
_preparedFilter.Clear();
|
||||
if (filter is not null)
|
||||
_preparedFilter.UnionWith(filter);
|
||||
_preparedTrim = (centerLbX, centerLbY, renderRadius);
|
||||
_preparedMeshVersion = meshVersion;
|
||||
_hasPreparedSnapshot = true;
|
||||
SnapshotGeneration++;
|
||||
}
|
||||
|
||||
private bool FilterUnchanged(HashSet<uint>? filter)
|
||||
{
|
||||
if (filter is null) return _preparedFilterWasNull;
|
||||
if (_preparedFilterWasNull) return false;
|
||||
// The caller reuses one scratch HashSet across frames, so compare by
|
||||
// content against our recorded copy, never by reference.
|
||||
return filter.Count == _preparedFilter.Count && _preparedFilter.SetEquals(filter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure half of the prepare gate's camera test (regression-tested without a
|
||||
/// GL context, same pattern as <see cref="CreateCommittedSnapshot"/>).
|
||||
/// Eye position uses a 1 mm ABSOLUTE epsilon: it swallows the ~36 µm rest
|
||||
/// jitter but dirties on any real movement (a slow walk moves 20+ mm/frame).
|
||||
/// Position must not be tested through the matrix — the view-projection's
|
||||
/// translation row scales with world coordinates (~5e4 in AC), where a
|
||||
/// relative tolerance would mask sub-meter motion. Rows 1–3 of
|
||||
/// view × projection are position-independent (rotation × projection), so a
|
||||
/// relative 1e-5 there dirties at ≈0.001° of rotation and on any
|
||||
/// projection (FOV/aspect/near/far) change.
|
||||
/// </summary>
|
||||
internal static bool CameraApproximatelyEqual(
|
||||
in Matrix4x4 vpA, Vector3 eyeA,
|
||||
in Matrix4x4 vpB, Vector3 eyeB)
|
||||
{
|
||||
const float EyeEpsilonSq = 1e-3f * 1e-3f;
|
||||
if (Vector3.DistanceSquared(eyeA, eyeB) > EyeEpsilonSq) return false;
|
||||
|
||||
return Close(vpA.M11, vpB.M11) && Close(vpA.M12, vpB.M12) && Close(vpA.M13, vpB.M13) && Close(vpA.M14, vpB.M14)
|
||||
&& Close(vpA.M21, vpB.M21) && Close(vpA.M22, vpB.M22) && Close(vpA.M23, vpB.M23) && Close(vpA.M24, vpB.M24)
|
||||
&& Close(vpA.M31, vpB.M31) && Close(vpA.M32, vpB.M32) && Close(vpA.M33, vpB.M33) && Close(vpA.M34, vpB.M34);
|
||||
|
||||
static bool Close(float x, float y)
|
||||
{
|
||||
const float Rel = 1e-5f;
|
||||
return MathF.Abs(x - y) <= Rel * MathF.Max(1f, MathF.Max(MathF.Abs(x), MathF.Abs(y)));
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildTransparentCellIndex(
|
||||
|
|
|
|||
|
|
@ -122,6 +122,12 @@ namespace AcDream.App.Rendering.Wb
|
|||
private bool _workersQuiesced;
|
||||
private bool _workSignalDisposed;
|
||||
private readonly ConcurrentDictionary<ulong, ObjectRenderData> _renderData = new();
|
||||
// Bumped whenever TryGetRenderData's answer can change for any id
|
||||
// (publish, pending-release hide, release completion, teardown).
|
||||
// EnvCellRenderer's prepare gate keys its visibility snapshot on this:
|
||||
// the snapshot bakes per-cell transparency from TryGetRenderData, so a
|
||||
// skipped rebuild must be provably input-identical.
|
||||
private long _renderDataAvailabilityVersion;
|
||||
// A render-data entry remains published until every one of its physical
|
||||
// resources has either released or reported a committed exceptional
|
||||
// outcome. Accessors hide entries in this map because a partially
|
||||
|
|
@ -454,6 +460,17 @@ namespace AcDream.App.Rendering.Wb
|
|||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monotonic counter that changes whenever <see cref="TryGetRenderData"/>
|
||||
/// can answer differently for any id. Consumers that bake availability
|
||||
/// into a cached product (EnvCellRenderer's visibility snapshot) rebuild
|
||||
/// when this moves.
|
||||
/// </summary>
|
||||
public long RenderDataAvailabilityVersion => Volatile.Read(ref _renderDataAvailabilityVersion);
|
||||
|
||||
private void MarkRenderDataAvailabilityChanged() =>
|
||||
Interlocked.Increment(ref _renderDataAvailabilityVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Increment reference count for an object (e.g. when a landblock starts using it).
|
||||
/// </summary>
|
||||
|
|
@ -1324,6 +1341,7 @@ namespace AcDream.App.Rendering.Wb
|
|||
if (!_renderData.TryAdd(meshData.ObjectId, data))
|
||||
throw new InvalidOperationException(
|
||||
$"Setup 0x{meshData.ObjectId:X10} was published concurrently.");
|
||||
MarkRenderDataAvailabilityChanged();
|
||||
_currentNonArenaGpuMemory = checked(
|
||||
_currentNonArenaGpuMemory + data.NonArenaGpuBytes);
|
||||
}
|
||||
|
|
@ -1372,6 +1390,7 @@ namespace AcDream.App.Rendering.Wb
|
|||
renderData.DIDDegrade = meshData.DIDDegrade;
|
||||
renderData.SelectionSphere = meshData.SelectionSphere;
|
||||
_renderData.TryAdd(meshData.ObjectId, renderData);
|
||||
MarkRenderDataAvailabilityChanged();
|
||||
_currentNonArenaGpuMemory = checked(
|
||||
_currentNonArenaGpuMemory + renderData.NonArenaGpuBytes);
|
||||
UpdateLruAfterUpload(meshData.ObjectId);
|
||||
|
|
@ -2315,6 +2334,7 @@ namespace AcDream.App.Rendering.Wb
|
|||
GetReclaimableBytes(data),
|
||||
new RetryableResourceReleaseLedger(releases));
|
||||
_objectReleases.Add(key, ticket);
|
||||
MarkRenderDataAvailabilityChanged();
|
||||
return ticket;
|
||||
}
|
||||
|
||||
|
|
@ -2351,6 +2371,7 @@ namespace AcDream.App.Rendering.Wb
|
|||
_renderData.TryRemove(key, out _);
|
||||
}
|
||||
_objectReleases.Remove(key);
|
||||
MarkRenderDataAvailabilityChanged();
|
||||
if (!_ownership.IsOwned(key))
|
||||
_ownership.Remove(key);
|
||||
lock (_lruList)
|
||||
|
|
@ -2635,6 +2656,7 @@ namespace AcDream.App.Rendering.Wb
|
|||
|
||||
_renderData.Clear();
|
||||
_objectReleases.Clear();
|
||||
MarkRenderDataAvailabilityChanged();
|
||||
_objectReleaseQueue.Clear();
|
||||
_uploadRollbacks.Clear();
|
||||
_uploadRollbackQueue.Clear();
|
||||
|
|
|
|||
|
|
@ -262,4 +262,83 @@ public class EnvCellRendererTests
|
|||
Assert.Empty(a);
|
||||
Assert.Empty(b);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Prepare gate (2026-07-24) — pure camera-tolerance half.
|
||||
// The tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer
|
||||
// R-A2 note) but never survive real camera motion.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static Matrix4x4 ViewProjectionFor(Vector3 eye, Vector3 forward)
|
||||
{
|
||||
var view = Matrix4x4.CreateLookAt(eye, eye + forward, Vector3.UnitZ);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
|
||||
fieldOfView: 1.2f, aspectRatio: 16f / 9f, nearPlaneDistance: 0.1f, farPlaneDistance: 2000f);
|
||||
return view * proj;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CameraApproximatelyEqual_IdenticalCamera_True()
|
||||
{
|
||||
var eye = new Vector3(120f, 80f, 10f);
|
||||
var vp = ViewProjectionFor(eye, Vector3.UnitX);
|
||||
Assert.True(EnvCellRenderer.CameraApproximatelyEqual(vp, eye, vp, eye));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CameraApproximatelyEqual_RestJitter_True()
|
||||
{
|
||||
// The ~36 µm eye rest jitter must NOT dirty the gate.
|
||||
var eye = new Vector3(120.34f, 87.91f, 10.2f);
|
||||
var jittered = eye + new Vector3(36e-6f, -36e-6f, 36e-6f);
|
||||
var a = ViewProjectionFor(eye, Vector3.UnitX);
|
||||
var b = ViewProjectionFor(jittered, Vector3.UnitX);
|
||||
Assert.True(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, jittered));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CameraApproximatelyEqual_SmallRealRotation_False()
|
||||
{
|
||||
// 0.05° of yaw — far below one frame of real mouse motion — must dirty.
|
||||
var eye = new Vector3(120.34f, 87.91f, 10.2f);
|
||||
float yaw = 0.05f * MathF.PI / 180f;
|
||||
var a = ViewProjectionFor(eye, Vector3.UnitX);
|
||||
var b = ViewProjectionFor(eye, new Vector3(MathF.Cos(yaw), MathF.Sin(yaw), 0f));
|
||||
Assert.False(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, eye));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CameraApproximatelyEqual_SmallRealTranslation_False()
|
||||
{
|
||||
// 5 cm of movement must dirty the gate.
|
||||
var eye = new Vector3(120.34f, 87.91f, 10.2f);
|
||||
var moved = eye + new Vector3(0.05f, 0f, 0f);
|
||||
var a = ViewProjectionFor(eye, Vector3.UnitX);
|
||||
var b = ViewProjectionFor(moved, Vector3.UnitX);
|
||||
Assert.False(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, moved));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CameraApproximatelyEqual_GlobalScaleCoordinates_TranslationStillDirties()
|
||||
{
|
||||
// AC world coordinates reach ~5e4. A relative tolerance applied to the
|
||||
// matrix translation row would mask sub-meter motion at that scale —
|
||||
// the eye epsilon is absolute precisely so this case stays sharp.
|
||||
var eye = new Vector3(40120.34f, 45087.91f, 110.2f);
|
||||
var moved = eye + new Vector3(0.07f, 0f, 0f); // one walking frame
|
||||
var a = ViewProjectionFor(eye, Vector3.UnitX);
|
||||
var b = ViewProjectionFor(moved, Vector3.UnitX);
|
||||
Assert.False(EnvCellRenderer.CameraApproximatelyEqual(a, eye, b, moved));
|
||||
|
||||
var jittered = eye + new Vector3(36e-6f, 0f, 0f);
|
||||
var c = ViewProjectionFor(jittered, Vector3.UnitX);
|
||||
Assert.True(EnvCellRenderer.CameraApproximatelyEqual(a, eye, c, jittered));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewRenderer_SnapshotGenerationStartsAtZero()
|
||||
{
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
Assert.Equal(0, r.SnapshotGeneration);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue