Skip to content

Latest commit

 

History

History
384 lines (299 loc) · 11.6 KB

File metadata and controls

384 lines (299 loc) · 11.6 KB
title View Smoothing
description Learn to create smooth animations by interpolating between model updates for better user experience

This tutorial demonstrates how to smooth the view so that objects move continually even if the model only updates intermittently. This technique is essential for creating smooth user experiences and handling connectivity issues gracefully.

<iframe src="https://codepen.io/multisynq/embed/gbbmdqR?height=512&theme-id=37190&default-tab=result&editable=true" style={{ width: '100%', height: '512px', border: '2px solid #ccc', borderRadius: '8px', marginBottom: '24px' }} title="View Smoothing" allowFullScreen ></iframe>

Try it out!

Click or scan the QR code above to launch a new CodePen instance. You'll see several moving colored dots - one for each device currently connected to the session. Some dots may even belong to other Multisynq developers reading this documentation!

Click or tap the screen to tell your dot where to go.

**The unsmoothed position of your dot is shown in gray.** Notice how it jumps forward every time the model performs an update. The view uses this information to calculate each dot's smoothed position.

In this example, the model updates only twice per second, but the dots move smoothly at 60 frames per second because the view interpolates their position between model updates.

What You'll Learn

Define constants that contribute to session synchronization Share utility functions safely between model and view Use `"oncePerFrame"` to limit view updates efficiently Handle infrequent model updates with smooth animations

Global Constants

Constants used by the model should be included in the session hash to ensure synchronization. Changing these constants will create a new session, preventing desynchronization issues.

const Q = Multisynq.Constants;
Q.TICK_MS = 500;    // milliseconds per actor tick
Q.SPEED = 0.15;     // dot movement speed in pixels per millisecond
Q.CLOSE = 0.1;      // minimum distance in pixels to a new destination
Q.SMOOTH = 0.05;    // weighting between old and new positions (0 < SMOOTH <= 1)
`Multisynq.Constants` contributes to the hash used to generate a session ID. Use a short alias like `Q` to make your code more readable.

Pure Functions

You can safely share utility functions between model and view as long as they are purely functional:

function add(a, b) {
    return { x: (a.x + b.x), y: (a.y + b.y) };
}

function subtract(a, b) {
    return { x: (a.x - b.x), y: (a.y - b.y) };
}

function magnitude(vector) {
    return Math.sqrt(vector.x * vector.x + vector.y * vector.y);
}

function normalize(vector) {
    const mag = magnitude(vector);
    return { x: vector.x / mag, y: vector.y / mag };
}

function scale(vector, factor) {
    return { x: vector.x * factor, y: vector.y * factor };
}

function dotProduct(a, b) {
    return a.x * b.x + a.y * b.y;
}

function lerp(a, b, t) {
    return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}

Pure Function Requirements

Function only uses parameters passed to it Function doesn't modify input parameters Function doesn't save state outside its execution scope Same inputs always produce same outputs Pure function code isn't included in the session ID hash. If you change these functions frequently, ensure all users have the same version to maintain synchronization.

Actor-Pawn Architecture

RootModel & RootView

The root classes handle spawning and managing Actor-Pawn pairs:

  • User joins: RootModel spawns an Actor, which tells RootView to spawn a Pawn
  • User exits: RootModel removes the Actor, which tells RootView to remove the Pawn

View Initialization Pattern

// In RootView constructor
model.actors.forEach(actor => this.addPawn(actor));
During initialization, the view should never assume the model's current state. Always read the model state and build accordingly, as the view might be joining a session in progress or restoring from a snapshot.

Actor Implementation

Movement Planning

goto(goal) {
    this.goal = goal;
    const delta = subtract(goal, this.position);
    if (magnitude(delta) < Q.CLOSE) {
        this.goto(randomPosition());
    } else {
        const unit = normalize(delta);
        this.velocity = scale(unit, Q.SPEED);
    }
}

The goto method calculates movement vectors:

  1. Check if already at destination (within Q.CLOSE distance)
  2. If too close, pick a new random destination
  3. Otherwise, calculate velocity vector toward the goal

Arrival Detection

arrived() {
    const delta = subtract(this.goal, this.position);
    return (dotProduct(this.velocity, delta) <= 0);
}

Since actors step forward fixed distances and usually overshoot goals, arrival is detected by checking if the direction to the goal has reversed (negative dot product).

Animation Loop

tick() {
    this.position = add(this.position, scale(this.velocity, Q.TICK_MS));
    if (this.arrived()) this.goto(this.randomPosition());
    this.publish(this.id, "moved", this.now());
    this.future(Q.TICK_MS).tick();
}

Each tick:

  1. Move forward by velocity × tick duration
  2. Check if arrived and pick new destination if needed
  3. Notify view that actor has moved
  4. Schedule next tick

Pawn Implementation

Constructor with Frame Limiting

constructor(actor) {
    super(actor);
    this.actor = actor;
    this.position = {...actor.position};
    this.actorMoved();
    this.subscribe(actor.id, {event: "moved", handling: "oncePerFrame"}, this.actorMoved);
}

Key features:

  • Copy initial position from actor
  • Subscribe to actor's movement events
  • Use "oncePerFrame" to optimize event handling
`"oncePerFrame"` discards all but the last event of this type during each frame. This is crucial for high-frequency updates where only the latest position matters.

Event Handling

actorMoved() {
    this.lastMoved = viewTime;
}

Simply timestamps when the actor last moved, enabling position extrapolation.

Smooth Animation Update

update() {
    // Special case for own pawn - show debug info
    if (this.actor.viewId === this.viewId) {
        this.draw(this.actor.goal, null, this.actor.color);
        this.draw(this.actor.position, "lightgrey");
    }

    // Calculate extrapolated position
    const delta = scale(this.actor.velocity, viewTime - this.lastMoved);
    const extrapolation = add(this.actor.position, delta);
    
    // Interpolate between current and extrapolated position
    this.position = lerp(this.position, extrapolation, Q.SMOOTH);
    this.draw(this.position, this.actor.color);
}

The smoothing algorithm:

  1. Extrapolate: Project actor's last known position forward using velocity
  2. Interpolate: Blend current pawn position with extrapolated position
  3. Render: Draw the smoothed position

Understanding the Smoothing Parameter

The Q.SMOOTH value (0 < SMOOTH ≤ 1) controls interpolation behavior:

No interpolation - instant position updates (jerky) Balanced smoothing - good responsiveness Heavy smoothing - very smooth but less responsive **Rule of thumb**: Tune `Q.SMOOTH` so the pawn spends about half its time behind the actor's position and half ahead. This provides optimal balance between smoothness and responsiveness.

Reflector Heartbeat Configuration

Setting Tick Rate

Multisynq.Session.join({
  apiKey: "your_api_key",
  appId: "io.codepen.multisynq.smooth",
  name: "public",
  password: "none",
  model: RootModel,
  view: RootView,
  tps: 1000/Q.TICK_MS,  // or simply: tps: 2
});

The tps (ticks per second) option controls reflector heartbeat frequency:

  • Purpose: Keeps model running when no user input is received
  • Default: 20 ticks per second
  • Range: 1-60 ticks per second
  • Best Practice: Match your model's internal tick rate
In this tutorial, `Q.TICK_MS = 500` means the reflector sends heartbeat ticks twice per second maximum. Set heartbeat rate to match your model's update frequency.

Heartbeat vs Responsiveness

Increasing heartbeat tick rate will **NOT** make your app more responsive. User input events are sent immediately and processed as soon as received. Heartbeat ticks only affect model updates when no other events are received.

Performance Optimization Techniques

Frame-Rate Optimization

Discards redundant events within single frame Only update pawns that have actually moved Predict position between model updates Smooth transitions prevent visual "popping"

Tuning Guidelines

Match heartbeat rate to your model's natural update frequency Balance between smoothness and responsiveness based on your use case Adjust based on model tick rate and expected latency Set arrival detection distance appropriate for your coordinate system

Advanced Concepts

Actor-Pawn Pattern Benefits

Models handle logic, views handle presentation Smooth 60fps animations from low-frequency model updates Gracefully handles network hiccups and connectivity issues Efficient event handling with frame-rate limiting

Common Pitfalls

**Avoid These Mistakes:** - Setting `Q.SMOOTH` to 0 (causes no movement) - Making functions impure (breaks synchronization) - Ignoring existing model state during view initialization - Using wall-clock time instead of simulation time

Next Steps

Apply smoothing techniques to Three.js 3D scenes See smoothing in action in a complete game

This tutorial demonstrates essential techniques for creating smooth, responsive user experiences in Multisynq applications. The Actor-Pawn pattern with interpolation is fundamental for professional-quality real-time applications.