From 6ab5ddc58aa0af1eae8790b5113b715feae54112 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 12:10:08 +0000 Subject: [PATCH 05/10] =?UTF-8?q?feat(netcode):=20batched=20interpolated?= =?UTF-8?q?=20renderPositions=20=E2=80=94=20the=20smooth=20source=20for=20?= =?UTF-8?q?the=203-D=20scene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (real-time FPV milestone), engine half. The 3-D scene renders device NODES from the raw ~10 Hz fused solve, so they jitter/jump; the netcode already interpolates the same buffers for the aim overlay (MeshAimTracker/renderPose) but the nodes don't consume it. Add NetcodeSession.renderPositions(nowMesh): the batched, entity-interpolated world position of every tracked device at one consistent render instant — the position analogue of MeshAimTracker.tracks(), the source the scene should draw. A device without interpolable history yet is simply absent (no origin ghost). Two tests: correctness (per-device interpolated positions + empty-buffer skip) and the smoothness premise — renderPositions sampled at ~60 Hz traces <0.4x the per-frame jump of the raw 10 Hz stream a naive scene draws. The UI half (route MeshSceneView's retained nodes through this on the frame clock) follows as a reviewed androidApp patch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../mofe/engine/netcode/NetcodeSession.kt | 19 +++++ .../mofe/engine/netcode/NetcodeSessionTest.kt | 70 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/NetcodeSession.kt b/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/NetcodeSession.kt index 8e01d91..a81e886 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/NetcodeSession.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/NetcodeSession.kt @@ -3,6 +3,7 @@ package com.aether.mofe.engine.netcode import com.aether.mofe.math.Shape import com.aether.mofe.model.DeviceId import com.aether.mofe.model.Timestamp +import com.aether.mofe.model.Vector3D /** * The netcode integration seam — the stateful holder the wiring drives, keeping the @@ -58,6 +59,24 @@ class NetcodeSession( return poseAt(deviceId, Timestamp(nowMesh.microseconds - delay)) } + /** + * Entity-interpolated render POSITIONS for every device the session has history for, + * at [nowMesh] — the smooth, jitter-free source for the 3-D scene NODES, the position + * analogue of [MeshAimTracker.tracks]. Same buffers and the same quality-scaled + * interpolation delay as [renderPose]; a device without interpolable history yet is + * simply absent from the map. Reading this each render frame (≈60 Hz) resamples the + * smooth trajectory *between* the ~10 Hz solves, so the scene draws continuous motion + * instead of the raw per-solve jitter it draws today. The delay is computed once here, + * so every node in a frame shares one consistent render instant. + */ + fun renderPositions(nowMesh: Timestamp): Map { + val delay = MeshQuality.interpDelayMicros(quality(nowMesh), baseInterpDelayMicros) + val renderAt = Timestamp(nowMesh.microseconds - delay) + return buffers.keys.mapNotNull { id -> + poseAt(id, renderAt)?.let { id to it.position } + }.toMap() + } + /** * Leader-side arbitration: rewind the subject's authoritative history to the claimed * instant, re-detect the transition of [shape] there, and decide. No history for the diff --git a/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/NetcodeSessionTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/NetcodeSessionTest.kt index 09fcfb3..be6a1bf 100644 --- a/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/NetcodeSessionTest.kt +++ b/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/NetcodeSessionTest.kt @@ -5,6 +5,8 @@ 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.cos +import kotlin.math.sin import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -96,6 +98,74 @@ class NetcodeSessionTest { assertEquals(EventVerdict.Reason.NO_TRANSITION, verdict.reason) } + // ── renderPositions: the batched, interpolated source for the 3-D scene NODES (F1) ────────── + + @Test + fun renderPositionsInterpolatesEveryTrackedDeviceAndSkipsEmptyBuffers() { + val s = syncedSession() + val a = DeviceId("a") + val b = DeviceId("b") + val empty = DeviceId("empty") + s.recordSample(a, Timestamp(0), pose(0.0)) + s.recordSample(a, Timestamp(1_000_000), pose(10.0)) + s.recordSample(b, Timestamp(0), TemporalPose(Vector3D.ZERO, Vector3D.ZERO, Quaternion.IDENTITY)) + s.recordSample(b, Timestamp(1_000_000), TemporalPose(Vector3D(0.0, 4.0, 0.0), Vector3D.ZERO, Quaternion.IDENTITY)) + s.bufferFor(empty) // buffer exists but holds no samples + + // GOOD clock ⇒ 100 ms delay ⇒ render at 900 ms: a→x=9, b→y=3.6, all in one consistent instant. + val positions = s.renderPositions(Timestamp(1_000_000)) + assertEquals(9.0, positions.getValue(a).x, 1e-9) + assertEquals(3.6, positions.getValue(b).y, 1e-9) + assertFalse(positions.containsKey(empty), "an empty buffer yields no ghost node at the origin") + assertEquals(2, positions.size) + } + + @Test + fun renderPositionsGiveTheSceneASmoothStreamVsRawJitter() { + // The F1 premise: the 3-D scene must consume interpolated positions, not the raw ~10 Hz solve. + // A target ~10 m out drifting +Y at 1 m/s, sampled at 10 Hz with deterministic ±5 cm / ±3 cm + // jitter — the same shape the AoA solve delivers. renderPositions read at ~60 Hz must trace a + // far smoother path than the raw per-solve stream a naive scene would draw. + val s = syncedSession() + val dev = DeviceId("t") + fun sampleAt(t: Long) = Vector3D(10.0, 1.0 * (t / 1_000_000.0), 0.0) + + Vector3D(0.0, 0.05 * sin(t / 60_000.0), 0.03 * cos(t / 71_000.0)) + var t = 0L + while (t <= 900_000) { + s.recordSample(dev, Timestamp(t), TemporalPose(sampleAt(t), Vector3D(0.0, 1.0, 0.0), Quaternion.IDENTITY)) + t += 100_000 // 10 Hz + } + + // Control: the frame-to-frame jump a raw-snapshot scene would draw across the read span. + var maxRaw = 0.0 + var prevRaw: Vector3D? = null + var rt = 200_000L + while (rt <= 700_000) { // the samples the render read (now−100 ms) traverses below + val p = sampleAt(rt) + prevRaw?.let { maxRaw = maxOf(maxRaw, it.distanceTo(p)) } + prevRaw = p + rt += 100_000 + } + + // The scene stream: renderPositions at ~60 Hz. GOOD clock ⇒ 100 ms delay, so now∈[300k,800k] + // reads at [200k,700k] — strictly interpolating between samples, never clamped to an edge. + var maxRender = 0.0 + var prevRender: Vector3D? = null + var now = 300_000L + while (now <= 800_000) { + val p = s.renderPositions(Timestamp(now)).getValue(dev) + prevRender?.let { maxRender = maxOf(maxRender, it.distanceTo(p)) } + prevRender = p + now += 16_666 // ~60 Hz + } + + assertTrue(maxRaw > 0.08, "control: the raw 10 Hz stream really does jump ($maxRaw m/frame)") + assertTrue( + maxRender < 0.4 * maxRaw, + "interpolated scene stream is far smoother — render ${maxRender} m/frame vs raw $maxRaw m/frame", + ) + } + @Test fun clockConversionsDelegateToMeshClock() { val s = NetcodeSession() -- 2.43.0