diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 575b4a60..36e7fd41 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -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 _preparedFilter = new(); + private bool _preparedFilterWasNull; + private (int? X, int? Y, int? Radius) _preparedTrim; + private long _preparedMeshVersion = -1; + private bool _hasPreparedSnapshot; + + /// Bumps once per visibility-snapshot rebuild (tests + diagnostics). + 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? 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? 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); + } + + /// + /// Pure half of the prepare gate's camera test (regression-tested without a + /// GL context, same pattern as ). + /// 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. + /// + 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( diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index d1f2956e..16ac2200 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -122,6 +122,12 @@ namespace AcDream.App.Rendering.Wb private bool _workersQuiesced; private bool _workSignalDisposed; private readonly ConcurrentDictionary _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; } + /// + /// Monotonic counter that changes whenever + /// can answer differently for any id. Consumers that bake availability + /// into a cached product (EnvCellRenderer's visibility snapshot) rebuild + /// when this moves. + /// + public long RenderDataAvailabilityVersion => Volatile.Read(ref _renderDataAvailabilityVersion); + + private void MarkRenderDataAvailabilityChanged() => + Interlocked.Increment(ref _renderDataAvailabilityVersion); + /// /// Increment reference count for an object (e.g. when a landblock starts using it). /// @@ -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(); diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs index b46ad87d..007cb531 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -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); + } }