From 541e09281deb5efbac3f50928bd0e237ad1cc206 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 02:16:58 +0000 Subject: [PATCH 2/5] fix(ui): sample the interpolation at the wall-advanced clock; interp delay >= one solve (F1) HudViewModel fed the FROZEN latest-sample mesh-time into renderNodePositions() and aimTracks(), so the smoothing never actually smoothed on-device (see RenderClock). Now both query at smoothQueryMicros() = RenderClock.queryMicros(latestMeshMicros, lastSampleWallMicros, nowWallMicros()), pairing each solve with a monotonic wall stamp so the query clock advances continuously between the ~10 Hz solves. Also raise the interp delay 60ms -> 120ms: 60ms was below one solve interval, so even a correct clock couldn't straddle two samples; 120ms gives ~20ms jitter slack at ~20ms extra latency. Fixes jumpy nodes in BOTH orbit and FPV on the Filament home view. androidApp (not compilable here); the query-clock arithmetic + smoothness are jvmTest-verified in the engine RenderClock change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../com/aether/mofe/viewmodel/HudViewModel.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/androidApp/src/main/java/com/aether/mofe/viewmodel/HudViewModel.kt b/androidApp/src/main/java/com/aether/mofe/viewmodel/HudViewModel.kt index 370baff..fea7616 100644 --- a/androidApp/src/main/java/com/aether/mofe/viewmodel/HudViewModel.kt +++ b/androidApp/src/main/java/com/aether/mofe/viewmodel/HudViewModel.kt @@ -7,6 +7,7 @@ import com.aether.mofe.AetherApp import com.aether.mofe.data.* import com.aether.mofe.engine.netcode.MeshAimTracker import com.aether.mofe.engine.netcode.NetcodeSession +import com.aether.mofe.engine.netcode.RenderClock import com.aether.mofe.engine.netcode.toTemporalPose import com.aether.mofe.model.DeviceId import com.aether.mofe.model.Timestamp @@ -66,9 +67,21 @@ class HudViewModel(application: Application) : AndroidViewModel(application) { // the reticle from entity-interpolated poses, so the aim reads self-consistent regardless of // absolute-heading accuracy. Local single-observer ⇒ the mesh clock is identity, so each // FusedState's own timestamp is its mesh time. - private val netcode = NetcodeSession(baseInterpDelayMicros = 60_000L) + // Interp delay ≥ one ~10 Hz solve interval (was 60 ms — BELOW one interval, so even a correctly + // advancing query clock couldn't straddle two samples). 120 ms keeps render-time between the two + // newest samples with ~20 ms of slack for solve jitter, at ~20 ms extra visual latency. + private val netcode = NetcodeSession(baseInterpDelayMicros = 120_000L) private val aim = MeshAimTracker(netcode) @Volatile private var latestMeshMicros: Long = 0L + // Monotonic wall-time (µs) captured when the newest fused sample was recorded. Paired with + // [latestMeshMicros] so [smoothQueryMicros] can advance the interpolation query clock by REAL + // elapsed time between the ~10 Hz solves — otherwise the query freezes between solves and the 60 Hz + // view re-reads one point ~6× then jumps (nodes look as jumpy as the raw solve). See RenderClock. + @Volatile private var lastSampleWallMicros: Long = 0L + private fun nowWallMicros(): Long = System.nanoTime() / 1_000L + /** Mesh-time to sample the interpolation at NOW, advanced smoothly off the wall clock (F1). */ + private fun smoothQueryMicros(): Long = + RenderClock.queryMicros(latestMeshMicros, lastSampleWallMicros, nowWallMicros()) init { viewModelScope.launch { @@ -85,10 +98,11 @@ class HudViewModel(application: Application) : AndroidViewModel(application) { // Feed the aim tracker from the engine's fused world poses (~10 Hz solve). viewModelScope.launch { app.mofeEngineHost.fusedStates.collect { states -> + val arrivedWall = nowWallMicros() // one wall stamp for this ~10 Hz solve batch for ((id, state) in states) { netcode.recordSample(DeviceId(id), state.timestamp, state.toTemporalPose()) val us = state.timestamp.microseconds - if (us > latestMeshMicros) latestMeshMicros = us + if (us > latestMeshMicros) { latestMeshMicros = us; lastSampleWallMicros = arrivedWall } } } } @@ -167,14 +181,14 @@ class HudViewModel(application: Application) : AndroidViewModel(application) { * mirroring [latestFacing]; empty until a self pose + facing exist. Observer position is the * engine's WORLD-frame self pose (the same frame as the recorded fused poses), so a track's * (0,0) reticle offset coincides with the crosshair by construction. [coneRadians] is the - * on-reticle "locked" half-angle. Queried at the freshest fused mesh time, so renderPose - * interpolates one interp-delay behind the latest solve. + * on-reticle "locked" half-angle. Queried at [smoothQueryMicros] (wall-advanced, F1), so the + * markers interpolate smoothly between the ~10 Hz solves instead of stepping with them. */ fun aimTracks(coneRadians: Double): List { val observer = app.mofeEngineHost.latestSelfPose()?.position ?: return emptyList() val facing = latestFacing() ?: return emptyList() val up = latestTopEdge() ?: Vector3D(0.0, 1.0, 0.0) - return aim.tracks(observer, facing, up, Timestamp(latestMeshMicros), coneRadians) + return aim.tracks(observer, facing, up, Timestamp(smoothQueryMicros()), coneRadians) } /** @@ -190,7 +204,7 @@ class HudViewModel(application: Application) : AndroidViewModel(application) { */ fun renderNodePositions(): Map { val selfWorld = app.mofeEngineHost.latestSelfPose()?.position ?: return emptyMap() - return netcode.renderPositions(Timestamp(latestMeshMicros)) + return netcode.renderPositions(Timestamp(smoothQueryMicros())) .entries.associate { (id, p) -> id.value to (p - selfWorld) } } -- 2.43.0