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:
- Multiple simultaneous animations (rotate, scale, o2p, color)
- Per-frame SVG transforms and position calculations
- OSC message broadcasting
- Hit label repositioning
- Network synchronization
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
- Single RAF call per frame instead of N calls
- Consistent timing - all animations use the same delta-time
- Automatic cleanup - coordinator stops when no animations remain
- Reduced overhead - one scheduler instead of many
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
- On window resize
- On score zoom/scale change
- On page navigation
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:
- Long animation frames (>16ms)
- Excessive layout/reflow (forced sync layout)
- JavaScript execution bottlenecks
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
- Use the central coordinator - Don't create standalone RAF loops
- Cache expensive calculations - getBBox, getBoundingClientRect, matrix transforms
- Throttle network output - OSC, WebSocket broadcasts
- Tier update rates - Not everything needs 60fps
- Use spatial culling - Skip elements outside viewport
- Avoid forced layout - Batch DOM reads and writes
Version History
- v1.0 (2024-03) — Initial performance optimizations
- Central animation coordinator
- OSC throttling
- Hit label tiering
- Spatial culling
- SVG caching
Tip: use ← → or ↑ ↓ to navigate the docs