User Story #61 » 0005-feat-netcode-batched-interpolated-renderPositions-th.patch
| common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/NetcodeSession.kt | ||
|---|---|---|
|
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
|
||
| ... | ... | |
|
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<DeviceId, Vector3D> {
|
||
|
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
|
||
| common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/NetcodeSessionTest.kt | ||
|---|---|---|
|
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
|
||
| ... | ... | |
|
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()
|
||