Bug #65 » 0001-fix-engine-RenderClock-advance-the-interp-query-cloc.patch
| 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)",
|
||
|
)
|
||
|
}
|
||
|
}
|
||