Animation Performance Optimization

This document describes the performance optimizations implemented in Oscilla's animation system. These optimizations are critical for maintaining smooth 60fps playback, especially on older hardware or when running many simultaneous animations.


Overview

Oscilla animations can be CPU-intensive due to:

The following optimizations address these bottlenecks.


1. Central Animation Coordinator

File: cues/cueAnimLoop.js

Instead of each animation running its own requestAnimationFrame loop, all animations register with a central coordinator that runs a single RAF loop.

Architecture

┌─────────────────────────────────────────────────┐
│         Central Animation Coordinator           │
│                                                 │
│   Single RAF loop → iterates _activeAnimations  │
└─────────────────────────────────────────────────┘
          │
          ├── rotate animation 1
          ├── rotate animation 2
          ├── scale animation 1
          ├── o2p animation 1
          └── o2p animation 2

Benefits

Usage

import { createAnimationLoop } from '../cues/cueAnimLoop.js';

const loop = createAnimationLoop({
    dur: 2,
    loop: 0,  // 0 = infinite
    getSpeed: () => cfg.speedMultiplier || 1,
    onTick: (phase, dt) => {
        // Update animation state
        applyRotation(el, phase * 360);
    },
    onLoopComplete: () => { /* ... */ },
    onComplete: () => { /* ... */ }
});

loop.start();  // Registers with coordinator
loop.stop();   // Unregisters

Debug

// Browser console
window._oscillaAnimCoordinator.getCount()  // Active animation count
window._oscillaAnimCoordinator.isRunning() // Coordinator running?

2. OSC Throttling

File: system/oscillaOSCClient.js

OSC messages are throttled to ~30 sends/second per address to avoid overwhelming the network and receiving applications.

Implementation

const OSC_THROTTLE_MS = 33;  // ~30 sends/sec max
const throttleState = new Map();  // Per-address state

export function sendOSC(payload, options = {}) {
    const key = payload.addr || payload.uid || 'default';
    const now = performance.now();
    let state = throttleState.get(key);

    // If too soon since last send, queue the update
    if (now - state.lastSent < OSC_THROTTLE_MS) {
        state.pending = payload;  // Store latest value
        // Timer will send it when throttle expires
        return;
    }

    // Send immediately
    _doSend(payload);
    state.lastSent = now;
}

Per-Cue Throttle Override

Individual cues can specify custom throttle rates:

o2p(path:slider, trig:touch, osc:50)  // 50ms throttle (20/sec)
o2p(path:slider, trig:touch, osc:1)   // Default throttle (~30/sec)

Bypass for Events

Use immediate: true for event-based messages (like vertex crossings):

sendOSC(payload, { immediate: true });

3. Hit Label Update Tiering

File: cues/animOverlays.js

Hit labels (interactive touch targets) are updated at different rates based on their state, reducing per-frame work.

Tiered Update Rates

State Update Rate Rationale
running / paused ~30fps Element is moving or may be dragged
touch mode ~30fps Active user interaction expected
armed ~10fps Waiting for start, may be on rotating parent
registered Skip Not yet interactive

Implementation

const HIT_LABEL_MIN_INTERVAL = 33;  // ~30fps base rate
const ARMED_UPDATE_DIVISOR = 3;      // Armed updates every 3rd call

let _armedUpdateCounter = 0;

function _doRepositionAllHitLabels() {
    _armedUpdateCounter++;
    const updateArmedThisFrame = (_armedUpdateCounter % ARMED_UPDATE_DIVISOR) === 0;

    for (const rec of window._oscillaHitLabels) {
        const state = rec.groupEl?._oscillaCfg?._armState;

        if (rec.isTouchMode || state === "running" || state === "paused") {
            updateHitCircle(rec);  // Always update
        } else if (state === "armed" && updateArmedThisFrame) {
            updateHitCircle(rec);  // Update every 3rd frame
        }
        // Skip 'registered' and other states
    }
}

Why This Matters

With 24 hit labels on rotating polygons, updating all at 60fps caused significant frame drops. Tiered updates reduced hit label overhead by ~70% while maintaining visual quality.


4. Spatial Culling for Cue Triggers

File: cues/cueDispatcher.js

The playhead trigger system only evaluates cues within a viewport-based culling distance, skipping distant cues entirely.

Implementation

export async function checkCueTriggers() {
    const playheadX = window.getPlayheadX();
    const cullDistance = window.innerWidth * 1.5;

    for (const cue of window.cues) {
        // Quick reject: cue is far from playhead
        if (cue._cachedWorldX != null) {
            const dist = Math.abs(cue._cachedWorldX - playheadX);
            if (dist > cullDistance) {
                continue;  // Skip expensive getBoundingClientRect
            }
        }

        // Full intersection check
        const cueRect = cue.element.getBoundingClientRect();
        // ... check intersection with playhead
    }
}

World X Caching

Cue world X positions are cached after first calculation:

if (cue._cachedWorldX == null) {
    const svg = cue.element.ownerSVGElement;
    const point = svg.createSVGPoint();
    point.x = cueRect.left;
    const ctm = svg.getScreenCTM().inverse();
    cue._cachedWorldX = point.matrixTransform(ctm).x;
}

5. SVG Operation Caching

Expensive SVG operations are cached to avoid redundant calculations.

getBBox Caching

File: cues/o2p.js

// Cache path bbox - expensive call
if (!cfg._pathBBox) {
    cfg._pathBBox = path.getBBox();
}
const bbox = cfg._pathBBox;

SVG Width Caching

File: cues/o2p.js

// Cache SVG width for normalized coordinates
if (!cfg._svgWidth) {
    const svg = el.ownerSVGElement;
    cfg._svgWidth = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width;
}

When to Invalidate Cache


6. Signal Publishing Throttle

File: control/paramBinding.js

The control plane signal publishing is throttled to ~60 updates/second per source.

const PUBLISH_THROTTLE_MS = 16;  // ~60fps
const publishTimestamps = new Map();

export function publish(sourceType, uid, channels, meta = {}) {
    const now = performance.now();
    const throttleKey = `${sourceType}:${uid}`;

    const lastTime = publishTimestamps.get(throttleKey) || 0;
    if (now - lastTime < PUBLISH_THROTTLE_MS) {
        return;  // Skip this update
    }
    publishTimestamps.set(throttleKey, now);

    // Publish channels to paramBus
    // ...
}

Performance Debugging

Animation Count

// Browser console
window._oscillaAnimCoordinator.getCount()

Frame Rate Monitoring

Use browser DevTools Performance panel to identify:

Common Performance Issues

Symptom Likely Cause Solution
Choppy animations Too many SVG transforms Use will-change: transform
OSC flooding Missing throttle Add osc:30 parameter
Hit labels lag All updating at 60fps Check tiered update logic
Playhead jank Too many cue checks Verify spatial culling

Best Practices for Animation Cues

  1. Use the central coordinator - Don't create standalone RAF loops
  2. Cache expensive calculations - getBBox, getBoundingClientRect, matrix transforms
  3. Throttle network output - OSC, WebSocket broadcasts
  4. Tier update rates - Not everything needs 60fps
  5. Use spatial culling - Skip elements outside viewport
  6. Avoid forced layout - Batch DOM reads and writes

Version History

Tip: use ← → or ↑ ↓ to navigate the docs