Project

General

Profile

Bug #65 » 0001-fix-engine-RenderClock-advance-the-interp-query-cloc.patch

knight8241, 08/07/2026 18:32

View differences:

common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/RenderClock.kt
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
}
}
common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/RenderClockTest.kt
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)",
)
}
}
(1-1/2)