PAJAMA / BUILD
← Engineering series

08 / 12 · Connect a learned model

Make time an explicit rendering input

Evaluate motion and temporal opacity, then keep camera, sort and playback synchronized.

Pressing play should change Gaussian parameters before projection. That is different from putting prerecorded frames onto a rectangle. You can freeze time and still move the camera.

1. Separate seconds from model time

The UI owns elapsed seconds. The renderer receives normalized time t in [0,1]. A ten-second loop maps seconds/10 into that interval. The file does not magically define the playback duration. Capture time range, loop behavior and synchronization belong in explicit metadata or host policy.

t=fract⁡(s/T),Δt=t−τt=\operatorname{fract}(s/T),\qquad\Delta t=t-\tau

2. Move the center before projecting it

In stage 6, the red Gaussian uses x += 2.5Δt and y += 1.5Δt², while blue moves in the opposite x direction. At t = 0.5, Δt = 0 and both return to their base centers. At t = 0.9, the red displacement is (1.0, 0.24, 0). The real STG model extends this to a cubic vector polynomial and time-dependent quaternion.

μ(t)=μ0+m1Δt+m2Δt2+m3Δt3\mu(t)=\mu_0+m_1\Delta t+m_2\Delta t^2+m_3\Delta t^3

3. Fade a Gaussian through its lifetime

Multiply base opacity by exp(−(Δt/ρ)²). At Δt = 0, the factor is 1; at Δt = ρ, it is e⁻¹ ≈ 0.3679. This STG convention does not put 1/2 in the exponent. A spatial Gaussian and this temporal envelope have similar shapes but different parameter conventions.

o(t)=sigmoid⁡(β)exp⁡ ⁣[−(t−τρ)2]o(t)=\operatorname{sigmoid}(\beta)\exp\!\left[-\left(\frac{t-\tau}{\rho}\right)^2\right]

4. A frame must use one time consistently

Evaluate the current centers, compute camera-space depths, sort, upload, draw and submit using the same t. Sorting base centers and drawing moved centers produces incorrect overlap. Pausing freezes t, not the camera controls. Returning from a background browser tab resets the wall-clock delta so the animation does not leap unexpectedly.

A renderer can evaluate intermediate times without proving those times are accurate reconstructions. A viewer’s smooth playback and a trained model’s temporal quality are different claims. Check held-out times and views when evaluating the model.

RUN → OBSERVE

Make this step concrete

Two splats move and one fades. Pause at t = 0.9 and orbit: time stays fixed while the viewpoint changes.

Open GPU milestone 6 →
Terminal · repository root
cargo run --locked --example workshop -- 6
# Space pauses; arrow keys still orbit. Online, scrub t before pressing Play.

Commands use the reference repository. Rust/WGSL changes need a native rebuild or npm run build:wasm, followed by a browser reload.

IDEA → IMPLEMENTATION

Follow the running code

Excerpts are extracted from the repository at site build time. Expand only the part you are studying; full files include context and line numbers.

src/workshop.rs · L111–125

Motion is evaluated before depth sorting.

Actual repository code
    if frame.stage == 6 {
        let dt = frame.time - 0.5;
        values[0].center_alpha[0] += 2.5 * dt;
        values[0].center_alpha[1] += 1.5 * dt * dt;
        values[0].center_alpha[3] *= (-(dt / 0.45).powi(2)).exp();
        values[1].center_alpha[0] -= 1.5 * dt;
    }
    // Sorting happens AFTER evaluating the current time and camera.
    values.sort_by(|a, b| {
        let depth = |s: &Instance| -(view * Vec3::from_slice(&s.center_alpha[..3]).extend(1.0)).z;
        depth(b).total_cmp(&depth(a))
    });
    if frame.reverse_order {
        values.reverse();
    }
Open full source with line numbers ↗
public/build/lab.mjs · L91–120

The host maps real time to the renderer’s normalized input.

Actual repository code
function animate(now) {
  if (failed) return;
  const dt = last ? Math.min((now - last) / 1000, 0.1) : 0; last = now;
  if (!document.hidden && renderer) {
    // Physical pixels, capped for this lesson. CSS dimensions stay in CSS pixels.
    const bounds = canvas.getBoundingClientRect(), dpr = Math.min(devicePixelRatio || 1, 1.5);
    const width = Math.max(1, Math.min(2048, Math.round(bounds.width * dpr)));
    const height = Math.max(1, Math.min(2048, Math.round(bounds.height * dpr)));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width; canvas.height = height; renderer.resize(width, height);
    }
    if (playing) clock = (clock + dt / 10) % 1;
    slider.value = clock;
    const n = Number(stage.value);
    if (n !== 7 || loaded) {
      try {
        const count = renderer.render(n, clock, yaw, pitch, distance, byId('reverse').checked);
        if (count >= 0) { frames++; byId('draw-count').textContent = count.toLocaleString(); }
      } catch (error) { fatal(error); return; }
    } else {
      renderer.render(0, clock, yaw, pitch, distance, false);
      byId('draw-count').textContent = '—';
    }
    byId('time-value').textContent = clock.toFixed(3);
    byId('resolution').textContent = `${width} × ${height}`;
    byId('presented').textContent = frames.toLocaleString();
    byId('camera-value').textContent = `${yaw.toFixed(2)} / ${pitch.toFixed(2)} / ${distance.toFixed(1)}`;
  }
  requestAnimationFrame(animate);
}
Open full source with line numbers ↗

Real-model render pass ↗ · Real-model shader ↗

Predict it, change it, explain it

Freeze at t = 0.9. Predict the red opacity multiplier when ρ = 0.45, then compute it.

Check your reasoning

Δt = 0.4. exp(−(0.4/0.45)²) ≈ 0.4538, so the base 0.5 opacity becomes about 0.2269. The motion and fade use the same temporal offset.

Keep the theory beside the code

Open the related derivation or lab ↗

Spacetime Gaussian Feature Splatting · representation ↗