From 0683b19d83ee5f6059a8a19a796ded9d75bb497f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 02:16:48 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix(engine):=20RenderClock=20=E2=80=94=20ad?= =?UTF-8?q?vance=20the=20interp=20query=20clock=20by=20real=20wall-time=20?= =?UTF-8?q?(F1=20smoothness)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-device the 3-D nodes stayed jumpy at ~10 Hz despite F1's interpolation being wired end to end. Root cause: the view sampled NetcodeSession.renderPositions / MeshAimTracker at the latest SAMPLE's mesh-time, which only advances when a solve lands (~10 Hz). Between solves that query clock is FROZEN, so the 60 Hz view re-reads one interpolated point ~6x then jumps — a 10 Hz step function, and the interpolation buffer buys nothing. (The existing smoothness test passed only because it sampled at 60 distinct wall-clock instants; production passed one frozen instant.) RenderClock.queryMicros advances the query clock by the REAL wall-time elapsed since the latest sample: queryMesh = latestSampleMesh + (nowWall - latestSampleWall), clamped to [0, maxAdvance] so a clock glitch can't rewind and a stalled solve can't run away. Mesh- and wall-time both run at real-time rate, so it sweeps smoothly between solves while the interp delay keeps render-time between two buffered samples. Pure common (caller supplies both clocks). 4 jvmTests incl. an end-to-end proof that sampling the SAME session with the frozen clock steps (~0.2 m/frame jumps) while the wall clock stays smooth (<0.05 m/frame, <0.34x the frozen jump). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../aether/mofe/engine/netcode/RenderClock.kt | 50 ++++++++++ .../mofe/engine/netcode/RenderClockTest.kt | 99 +++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/RenderClock.kt create mode 100644 common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/RenderClockTest.kt diff --git a/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/RenderClock.kt b/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/RenderClock.kt new file mode 100644 index 0000000..2f11b11 --- /dev/null +++ b/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/RenderClock.kt @@ -0,0 +1,50 @@ +package com.aether.mofe.engine.netcode + +/** + * Chooses the mesh-time at which a high-rate (~60 Hz) view should SAMPLE the entity interpolation + * ([NetcodeSession.renderPositions] / [MeshAimTracker.tracks]) so the drawn motion is actually smooth. + * + * WHY THIS EXISTS (F1 · smoothness, on-device defect). The fused solve lands at ~10 Hz. If the view + * samples the interpolation at the latest SAMPLE's mesh-time, the query clock FREEZES between solves: + * the same interpolated point is re-read for the ~6 frames of a 100 ms window, then jumps when the + * next solve advances the clock by ~100 ms. The output is still a 10 Hz STEP FUNCTION and the whole + * interpolation buffer buys nothing — nodes look as jumpy as the raw solve. (The engine's own + * smoothness test passes only because it samples renderPositions at 60 DISTINCT wall-clock instants; + * the on-device caller was passing one frozen instant.) + * + * THE FIX. Advance the query clock by the REAL wall-time elapsed since the latest sample was recorded: + * + * queryMesh = latestSampleMesh + (nowWall − latestSampleWall) + * + * Mesh-time and a monotonic wall clock both run at real-time rate, so between solves this sweeps + * forward continuously; [NetcodeSession]'s own interpolation delay then keeps `queryMesh − delay` + * between two buffered samples (choose a base delay ≥ one solve interval so it always straddles a + * pair). The advance is clamped to `[0, maxAdvanceMicros]`: a backwards clock glitch can't rewind the + * view, and a stalled solve can't run the query away — past the buffer's extrapolation window it + * simply clamps to the newest pose instead of drifting off. + * + * Pure and platform-agnostic: the caller supplies both clocks (the mesh timestamp of the newest + * recorded sample, and monotonic wall-time readings for when it arrived vs now), so this stays in + * `common` and is unit-testable without a real clock. + */ +object RenderClock { + /** Default cap on how far past the latest sample the query may advance: ~2 nominal 10 Hz intervals. */ + const val DEFAULT_MAX_ADVANCE_MICROS: Long = 200_000L + + /** + * @param latestSampleMesh mesh-time (µs) of the newest recorded fused sample + * @param latestSampleWall monotonic wall-time (µs) captured when that sample was recorded + * @param nowWall monotonic wall-time (µs) now + * @param maxAdvanceMicros cap on the forward advance past [latestSampleMesh] (see class KDoc) + * @return the mesh-time to pass to [NetcodeSession.renderPositions] / [MeshAimTracker.tracks] + */ + fun queryMicros( + latestSampleMesh: Long, + latestSampleWall: Long, + nowWall: Long, + maxAdvanceMicros: Long = DEFAULT_MAX_ADVANCE_MICROS, + ): Long { + val advance = (nowWall - latestSampleWall).coerceIn(0L, maxAdvanceMicros) + return latestSampleMesh + advance + } +} diff --git a/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/RenderClockTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/RenderClockTest.kt new file mode 100644 index 0000000..64eb800 --- /dev/null +++ b/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/RenderClockTest.kt @@ -0,0 +1,99 @@ +package com.aether.mofe.engine.netcode + +import com.aether.mofe.model.DeviceId +import com.aether.mofe.model.Quaternion +import com.aether.mofe.model.Timestamp +import com.aether.mofe.model.Vector3D +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * F1 · smoothness (the on-device defect). [RenderClock] picks the mesh-time a ~60 Hz view samples the + * entity interpolation at. These tests pin the arithmetic AND — the whole point of the fix — prove that + * sampling the SAME [NetcodeSession] with a frozen "latest sample" clock yields a 10 Hz STEP function + * (jumpy nodes), while the wall-advanced clock traces a smooth per-frame path. + */ +class RenderClockTest { + + // ── queryMicros arithmetic ──────────────────────────────────────────────────────────────── + + @Test + fun advances_one_for_one_with_wall_time_between_samples() { + // 40 ms of real time after the sample landed ⇒ query 40 ms past that sample's mesh-time. + assertEquals(1_040_000L, RenderClock.queryMicros(1_000_000L, 500_000L, 540_000L)) + } + + @Test + fun never_runs_backwards_on_a_clock_glitch() { + // nowWall < latestSampleWall ⇒ advance clamps to 0; the query holds at the sample, never rewinds. + assertEquals(1_000_000L, RenderClock.queryMicros(1_000_000L, 800_000L, 500_000L)) + } + + @Test + fun caps_the_forward_advance_on_a_stalled_solve() { + assertEquals( + 1_000_000L + RenderClock.DEFAULT_MAX_ADVANCE_MICROS, + RenderClock.queryMicros(1_000_000L, 0L, 5_000_000L), + ) + assertEquals( + 1_050_000L, + RenderClock.queryMicros(1_000_000L, 0L, 999_000L, maxAdvanceMicros = 50_000L), + ) + } + + // ── end to end: the frozen clock steps, the wall clock is smooth ────────────────────────── + + private fun pose(x: Double) = TemporalPose(Vector3D(x, 0.0, 0.0), Vector3D.ZERO, Quaternion.IDENTITY) + + @Test + fun frozen_latest_sample_clock_jumps_while_wall_clock_stays_smooth() { + // A device moving +X at a constant 2 m/s, solved at 10 Hz with sample mesh-time == arrival + // wall-time (µs). No jitter — so ANY frame-to-frame jump is purely the sampling clock's fault. + val s = NetcodeSession() // default 100 ms interp delay + s.observeTimeSync(t1 = 0, t2 = 100, t3 = 100, t4 = 200) // GOOD clock ⇒ stable 100 ms delay + val dev = DeviceId("t") + val vMps = 2.0 + fun posAt(meshMicros: Long) = vMps * (meshMicros / 1_000_000.0) + var i = 0L + while (i * 100_000L <= 1_000_000L) { // samples 0..1000 ms @ 10 Hz + s.recordSample(dev, Timestamp(i * 100_000L), pose(posAt(i * 100_000L))) + i++ + } + + // 60 Hz render sweep across a steady-state window where BOTH strategies stay strictly + // interpolating (render-at = query − 100 ms lands inside the buffered 0..1000 ms samples). + var maxFrozenJump = 0.0 + var maxWallJump = 0.0 + var prevFrozen: Double? = null + var prevWall: Double? = null + var w = 300_000L + while (w <= 700_000L) { + val latestMesh = (w / 100_000L) * 100_000L // newest sample arrived by wall w + val latestWall = latestMesh // aligned in this model + + // BUG: sample at the frozen latest-sample mesh-time — constant across each 100 ms window. + val frozen = s.renderPositions(Timestamp(latestMesh)).getValue(dev).x + // FIX: sample at the wall-advanced query clock. + val wall = s.renderPositions( + Timestamp(RenderClock.queryMicros(latestMesh, latestWall, w)), + ).getValue(dev).x + + prevFrozen?.let { maxFrozenJump = maxOf(maxFrozenJump, abs(frozen - it)) } + prevWall?.let { maxWallJump = maxOf(maxWallJump, abs(wall - it)) } + prevFrozen = frozen + prevWall = wall + w += 16_666L // ~60 Hz + } + + // The frozen clock holds flat for ~6 frames then jumps a full solve step (~v·100 ms = 0.2 m). + assertTrue(maxFrozenJump > 0.15, "control: frozen-clock scene really steps ($maxFrozenJump m/frame)") + // The wall clock advances ~v·frame (~0.033 m) each frame — a fraction of the step, no jumps. + assertTrue(maxWallJump < 0.05, "wall-clock scene is smooth ($maxWallJump m/frame)") + assertTrue( + maxWallJump < 0.34 * maxFrozenJump, + "wall clock is far smoother than the frozen clock (wall=$maxWallJump frozen=$maxFrozenJump)", + ) + } +} -- 2.43.0