Project

General

Profile

Bug #65 » 0002-fix-ui-sample-the-interpolation-at-the-wall-advanced.patch

knight8241, 08/07/2026 18:32

View differences:

androidApp/src/main/java/com/aether/mofe/viewmodel/HudViewModel.kt
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
......
// 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 {
......
// 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 }
}
}
}
......
* 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<MeshAimTracker.Track> {
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)
}
/**
......
*/
fun renderNodePositions(): Map<String, Vector3D> {
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-2/2)