https://github.com/escapace/snaproll
Fixed‑timestep update loop with independent draw rate and quantized interpolation.
https://github.com/escapace/snaproll
Last synced: 3 months ago
JSON representation
Fixed‑timestep update loop with independent draw rate and quantized interpolation.
- Host: GitHub
- URL: https://github.com/escapace/snaproll
- Owner: escapace
- License: mpl-2.0
- Created: 2022-10-23T23:14:00.000Z (over 3 years ago)
- Default Branch: trunk
- Last Pushed: 2026-04-08T19:15:08.000Z (3 months ago)
- Last Synced: 2026-04-08T21:19:39.483Z (3 months ago)
- Language: TypeScript
- Homepage: https://escapace.github.io/snaproll/
- Size: 1.54 MB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 8
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# snaproll
Fixed‑timestep update loop with independent draw rate and quantized interpolation.
## Installation
```bash
pnpm add snaproll
```
## Getting Started
```js
import { Snaproll, SnaprollActionType } from 'snaproll'
// Create with custom rates and context
const snaproll = new Snaproll({
updateRate: 60, // Fixed timestep updates at 60 Hz
drawRate: 60, // Draw calls at 60 Hz
context: { score: 0 },
})
// Subscribe to the animation loop
const subscription = snaproll.subscribe((context) => {
switch (context.action) {
case SnaprollActionType.Begin:
// Frame initialization - check if ready to proceed
const skipFrame = !document.hasFocus()
// Return true to skip this entire frame
return skipFrame
case SnaprollActionType.Update:
// Fixed timestep animation logic
const coalesce = context.updateStep >= 100
const totalTime = coalesce ? context.updateStep * context.timestep : context.timestep
// Update animation state
context.score += coalesce ? context.updateStep : 1
// Return true to skip remaining updates this frame
return coalesce
case SnaprollActionType.Draw:
// Interpolated drawing with quantized alpha
draw(context.alpha)
break
}
})
// Start the animation loop
snaproll.resume()
// Dynamic rate adjustment
snaproll.updateRate = 30 // Reduce to 30 Hz updates
// Manual control
subscription.pause() // Pause this subscription
subscription.resume() // Resume after
// Reset with new configuration
snaproll.reset({
updateRate: 120,
drawRate: 60,
context: { score: 0 },
keepSubscriptions: true,
})
```
### Two independent rates
- **updateRate** — how often animation logic runs (defaults to 60 Hz).
- **drawRate** — how often frames draw to screen (defaults to 60 Hz).
### Timing system
Snaproll uses a bang-bang digital PLL (phase-locked loop) that captures requestAnimationFrame edges nearest to the target cadence. The PLL uses `performance.now()` for high-precision timing and computes phase error in target-frame units with a symmetric dead-zone (epsilon = 3e-3 ≈ 50µs at 60Hz) to determine when to advance the target timestamp. This keeps timing error bounded to within ±0.5 target frame periods while decimating 120→60 Hz, 144→48 Hz, 60→30 Hz cleanly without drift.
### Quantized interpolation
During the Draw phase, snaproll provides an `alpha` value \[0, 1) representing fractional progress toward the next update. The alpha value is quantized to a power-of-two grid based on the draw rate. The quantization grid is calculated as `2^⌈log₂(drawRate * 2)⌉`. For example, a 60 Hz draw rate uses a 128-step quantization grid. The alpha is calculated as `((alpha * grid + 0.5) | 0) / grid` clamped to `(grid-1)/grid`, which rounds to the nearest grid step. This controlled quantization improves visual consistency at the cost of temporal precision.
### Configuration
Both rates accept any positive number and can be changed while running:
```js
const snaproll = new Snaproll({
updateRate: 60, // animation logic frequency
drawRate: 30, // drawing frequency
})
// Change rates dynamically
snaproll.updateRate = 120 // higher precision
snaproll.drawRate = 60 // smoother drawing
```
## Animation loop structure
The subscription function receives the same context object during each phase of the animation loop. Each phase populates different fields within this shared context object.
### Phase sequence
Each frame follows this order:
1. **Begin** — Frame initialization with current timestamp
2. **Update** — Animation calculations (may repeat)
3. **Draw** — Render using interpolated values
### Multiple updates per frame
Multiple Update phases may run before Draw. The `updateStep` field counts down the remaining updates in the current frame, starting from the total number needed and decrementing to 1 on the final update. For example, if 5 updates are needed in a frame, `updateStep` will be 5, then 4, then 3, then 2, then 1 across the five Update calls. This allows subscription functions to make optimization decisions based on update backlog.
```js
case SnaprollActionType.Update:
console.log(`${context.updateStep} updates remaining this frame`)
updateAnimation(context.timestep)
break
```
## Configuration
### Initial setup
```js
const snaproll = new Snaproll({
updateRate: 120, // Higher precision updates
drawRate: 60, // Standard display refresh
context: { score: 0 }, // Initial application state
})
```
### Runtime rate changes
Update rates while the animation runs. The accumulator is preserved during rate changes:
```js
// Rates take effect immediately, accumulator unchanged
snaproll.updateRate = 30 // Slower animation calculations
snaproll.drawRate = 120 // Higher refresh rate
```
### Resetting the loop
Use `reset()` to clear accumulator state and optionally reconfigure:
```js
// Reset with new configuration
snaproll.reset({
updateRate: 60,
drawRate: 30,
keepSubscriptions: true, // Keep existing subscriptions (default)
keepContext: true, // Preserve context object (default)
})
// Reset accumulator only
snaproll.reset()
```
Reset behavior:
- Clears internal accumulator state
- Preserves subscriptions and context by default
- Resumes automatically if the loop was running
- Keeps current rates unless new ones provided
### Context management
Control what happens to the context when calling `reset()`:
```js
// Use provided context object, merge existing context into it
snaproll.reset({
context: { newField: 'value' },
keepContext: true,
})
// Result: { newField: 'value', ...existingContextProperties }
// Note: existing properties overwrite conflicting new ones
// Use provided context object
snaproll.reset({
context: { onlyField: 'value' },
keepContext: false,
})
// Result: { onlyField: 'value' }
// Keep existing context unchanged
snaproll.reset({ keepContext: true })
// Start with empty context
snaproll.reset({ keepContext: false })
```
## Advanced Usage
Return `true` from a subscription to control when expensive operations run.
### Skip frames when needed
Return `true` from Begin to skip the entire frame. The accumulator remains untouched when skipping frames:
```js
case SnaprollActionType.Begin:
if (!assetsLoaded) {
return true // Skip this frame entirely, accumulator unchanged
}
break
```
### Handle update backlog
Use `updateStep` to detect multiple queued updates and optimize accordingly. Zeroing the accumulator prevents spiral of death scenarios where update costs exceed frame budget:
```js
case SnaprollActionType.Update: {
const coalesce = context.updateStep >= 100
// Physics substep merge: combine multiple fixed timesteps into one larger step
const totalTime = coalesce ? context.updateStep * context.timestep : context.timestep
updateAnimation(totalTime)
// Returning true zeros the accumulator and skips remaining updates and Draw phase for all subscribers
return coalesce
}
```
### Execution order
- Subscriptions run in the order they were added
- All subscriptions process each phase before moving to the next
- Returning `true` from Update affects the current frame for all subscribers
### Subscription management
```js
const subscription = snaproll.subscribe((context) => {
// Handle animation phases
})
// Control individual subscriptions
subscription.pause()
subscription.resume()
subscription.unsubscribe()
snaproll.pause() // Stop animation, keep subscriptions
snaproll.resume() // Resume animation
```
Loop states:
- **active** — Running animation frames (has active subscriptions)
- **idle** — Not running (no active subscriptions)
- **paused** — Stopped by `snaproll.pause()` (subscriptions remain)
Unless paused, snaproll starts automatically when subscriptions are added and becomes idle when all subscriptions are paused or removed.
### Shared context
Use TypeScript declaration merging to add type safety for shared state:
```ts
declare module 'snaproll' {
interface SnaprollUserContext {
score?: number
}
}
// Now available in all context handlers
snaproll.subscribe((context) => {
if (context.action === SnaprollActionType.Update) {
context.score += 10
}
})
```
## SnaprollDrawRateAdvisor
Measures frame timing to detect display's refresh rate and recommend compatible draw rates.
```js
import { SnaprollDrawRateAdvisor } from 'snaproll'
// Create advisor with optional configuration
const advisor = new SnaprollDrawRateAdvisor({
samples: 90, // Collect 90 frame intervals
warmup: 10, // Skip first 10 frames
minDraw: 10, // Minimum recommended rate
})
// Subscribe to recommendations
const unsubscribe = advisor.subscribe((response) => {
console.log(`Quality score: ${response.score.toFixed(3)}`)
console.log(`Recommended rates: ${response.values.join(', ')} Hz`)
})
// Start analysis
advisor.trigger()
```
The advisor provides quality scores \[0-1] using RF (Robustness × Fit) formula and recommended draw rates sorted descending. Scores ≥0.85 indicate healthy timing with mild jitter; scores <0.60 show strong evidence of blocking/jitter or regime split and recommend re-running.
## Examples
View examples at or see the `examples/` directory:
- **Bouncing Balls** (`canvas-2d-bouncing-balls.vue`) — Canvas animation with interpolated movement
- **Moving Rectangles** (`css-transform-rectangles.vue`) — CSS transform animation with smooth transitions
Each example demonstrates different aspects of snaproll:
- Frame rate independence
- Smooth interpolation using alpha values
- Performance optimization techniques
- Multiple subscription management
## Timing precision and HTTP headers
For higher timer precision in simulation or measurement workloads, configure the top-level response with:
```http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
`Cross-Origin-Embedder-Policy: credentialless` is a possible alternative in some deployments, but support is not universal (notably unavailable in Safari and Safari on iOS).
## API
### class Snaproll [↗](src/snaproll.ts#L460-L700 'Snaproll')
Fixed-timestep animation loop with independent draw and update rates.
```typescript
export declare class Snaproll
```
#### new Snaproll
Creates animation loop instance with specified configuration.
```typescript
constructor(options?: Partial);
```
##### Parameters
| Parameter | Type | Description |
| --------- | ---------------------------------------------------------------------------------------------- | -------------------------------------- |
| `options` |
Partial<[SnaprollOptions](#interface-snaprolloptions- 'interface SnaprollOptions')>
| Options applied during initialization. |
##### Remarks
Uses the default [updateRate](#snaprolloptionsupdaterate) of 60 Hz, [drawRate](#snaprolloptionsdrawrate) of 60 Hz, and a new shared context object when those entries are not provided.
#### Snaproll.pause
Stops animation while keeping subscriptions.
```typescript
pause(): void;
```
#### Snaproll.reset
Resets the animation loop with optional configuration updates.
```typescript
reset(options?: SnaprollResetOptions): void;
```
##### Parameters
| Parameter | Type | Description |
| --------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `options` |
[SnaprollResetOptions](#interface-snaprollresetoptions- 'interface SnaprollResetOptions')
| Reset options controlling configuration overrides and retention behavior. |
##### Remarks
Retains the current [updateRate](#snaprolloptionsupdaterate) and [drawRate](#snaprolloptionsdrawrate) when those fields are omitted.
- `keepSubscriptions` defaults to `true`, preserving existing subscriptions unless explicitly disabled.
- `keepContext` defaults to `true`, reusing the current context. Providing [context](#interface-snaprollusercontext-) copies existing entries when `keepContext` stays `true`, or replaces the context when `keepContext` is `false`.
#### Snaproll.resume
Resumes animation.
```typescript
resume(): void;
```
#### Snaproll.subscribe
Registers subscription callback for animation frame processing.
```typescript
subscribe(value: SnaprollSubscription, options?: {
immediate?: boolean;
}): SnaprollSubscriptionControls;
```
##### Parameters
| Parameter | Type | Description |
| --------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `value` |
[SnaprollSubscription](#type-snaprollsubscription- 'type SnaprollSubscription')
| Callback function to execute during frame processing. |
| `options` | {
immediate?: boolean;
} | Subscription configuration. The `immediate` flag defaults to `true` and activates the subscription on registration. |
##### Returns
[Control object](#interface-snaprollsubscriptioncontrols-) for pausing, resuming, and removing the subscription.
##### Remarks
Subscriptions run in insertion order. New subscriptions start immediately unless `options.immediate` is set to `false`.
#### Snaproll.drawRate
Getter and setter for the current draw rate in Hz.
```typescript
get drawRate(): number;
set drawRate(value: number);
```
#### Snaproll.state
Current animation loop state.
Returns 'active' when running animation frames, 'idle' when no active subscriptions, or 'paused' when stopped by pause().
```typescript
get state(): "active" | "idle" | "paused";
```
#### Snaproll.updateRate
Getter and setter for the current update rate in Hz.
```typescript
get updateRate(): number;
set updateRate(value: number);
```
### class SnaprollDrawRateAdvisor [↗](src/draw-rate-advisor.ts#L241-L311 'SnaprollDrawRateAdvisor')
Provides draw rates inferred from observed frame periods.
The recommendation logic is stateless and deterministic: identical period inputs produce identical outputs. Concurrent class instances do not interfere with each other.
```typescript
export declare class SnaprollDrawRateAdvisor
```
#### new SnaprollDrawRateAdvisor
Creates a new draw rate advisor instance.
```typescript
constructor(options?: SnaprollDrawRateAdvisorOptions);
```
##### Parameters
| Parameter | Type | Description |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| `options` |
[SnaprollDrawRateAdvisorOptions](#interface-snaprolldrawrateadvisoroptions- 'interface SnaprollDrawRateAdvisorOptions')
| Configuration options for the advisor |
##### Throws
Assertion error if any option values are invalid
#### SnaprollDrawRateAdvisor.dispose
Cancels ongoing operations and releases resources.
```typescript
dispose(): void;
```
#### SnaprollDrawRateAdvisor.subscribe
Registers callback for recommendation results.
Callbacks receive SnaprollDrawRateAdvisorResponse when recommendation completes successfully. Callbacks are not invoked if the operation is canceled or produces no recommendations.
```typescript
subscribe(subscription: SnaprollDrawRateAdvisorSubscription): () => void;
```
##### Parameters
| Parameter | Type | Description |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| `subscription` |
[SnaprollDrawRateAdvisorSubscription](#type-snaprolldrawrateadvisorsubscription- 'type SnaprollDrawRateAdvisorSubscription')
| Callback function to invoke with results |
##### Returns
Function that unregisters the subscription when called
#### SnaprollDrawRateAdvisor.trigger
Cancels any previous ongoing operation before starting a new one. Results are delivered to registered subscriptions when the operation completes. If the operation is canceled or produces no recommendations, subscriptions are not invoked.
```typescript
trigger(): void;
```
### enum SnaprollActionType [↗](src/snaproll.ts#L8-L12 'SnaprollActionType')
Animation frame phases.
```typescript
export declare enum SnaprollActionType
```
#### Members
| Member | Value |
| -------- | ------------ |
| `Begin` |
0
|
| `Update` | 1
|
| `Draw` | 2
|
### interface SnaprollActionBegin [↗](src/snaproll.ts#L17-L21 'SnaprollActionBegin')
Frame initialization action.
```typescript
export interface SnaprollActionBegin
```
#### SnaprollActionBegin.timestamp
Current frame time
```typescript
timestamp: number
```
### interface SnaprollActionDraw [↗](src/snaproll.ts#L38-L42 'SnaprollActionDraw')
Interpolated drawing action.
```typescript
export interface SnaprollActionDraw extends Omit
```
#### SnaprollActionDraw\.alpha
Interpolation factor \[0, 1)
```typescript
alpha: number
```
### interface SnaprollActionUpdate [↗](src/snaproll.ts#L27-L33 'SnaprollActionUpdate')
Fixed timestep animation logic action. The updateStep counts down remaining updates in the current frame.
```typescript
export interface SnaprollActionUpdate extends Omit
```
#### SnaprollActionUpdate.timestep
Time to advance per update
```typescript
timestep: number
```
#### SnaprollActionUpdate.updateStep
Remaining updates this frame, counts down to 1
```typescript
updateStep: number
```
### interface SnaprollDrawRateAdvisorOptions [↗](src/draw-rate-advisor.ts#L165-L196 'SnaprollDrawRateAdvisorOptions')
#### SnaprollDrawRateAdvisorOptions.canonicalBases
Array of canonical refresh rates to consider for snapping. Must be non-empty array of positive integers.
```typescript
canonicalBases?: readonly number[];
```
#### SnaprollDrawRateAdvisorOptions.maxDivisor
Maximum count of divisors to consider for candidate generation and output. Must be positive integer ≥ 1.
```typescript
maxDivisor?: number;
```
#### SnaprollDrawRateAdvisorOptions.minDraw
Minimum draw rate to include in results. Must be positive integer ≥ 5.
```typescript
minDraw?: number;
```
#### SnaprollDrawRateAdvisorOptions.samples
Number of frame intervals to collect after warmup. Must be positive integer ≥ 30.
```typescript
samples?: number;
```
#### SnaprollDrawRateAdvisorOptions.warmup
Number of frames to ignore before sampling. Must be non-negative integer ≥ 0.
```typescript
warmup?: number;
```
### interface SnaprollDrawRateAdvisorResponse [↗](src/draw-rate-advisor.ts#L130-L154 'SnaprollDrawRateAdvisorResponse')
#### SnaprollDrawRateAdvisorResponse.score
Quality score in range \[0,1] using RF (Robustness × Fit) formula. Higher scores indicate better data consistency and more reliable recommendations.
Score interpretation:
- `0.95–1.00`: Rock-solid. Extremely stable capture, fits a canonical/divisor cleanly
- `0.85–0.95`: Healthy. Mild jitter only; values are trustworthy
- `0.75–0.85`: Borderline steady. Noticeable instability or light regime mixing; fine for most uses, re-run if chasing perfection
- `0.60–0.75`: Shaky. Significant jitter or likely mid-phase change; consider re-running
- `< 0.60` : Unstable. Strong evidence of blocking/jitter or regime split; re-run recommended
```typescript
score: number
```
#### SnaprollDrawRateAdvisorResponse.values
Array of recommended draw rates (Hz), sorted descending and de-duplicated. All values are integers and ≥ minDraw.
```typescript
values: number[];
```
### interface SnaprollOptions [↗](src/snaproll.ts#L95-L111 'SnaprollOptions')
Configuration interface for animation loop.
```typescript
export interface SnaprollOptions
```
#### Remarks
drawRate and updateRate operate independently, allowing different frequencies for draw and update rates.
#### SnaprollOptions.context
Optional shared state object passed to all subscription callbacks.
```typescript
context?: SnaprollUserContext;
```
#### SnaprollOptions.drawRate
Draw rate in Hz, controls visual frame timing.
```typescript
drawRate: number
```
#### SnaprollOptions.updateRate
Update rate in Hz, determines fixed timestep size.
```typescript
updateRate: number
```
### interface SnaprollResetOptions [↗](src/snaproll.ts#L119-L146 'SnaprollResetOptions')
Options accepted by [Snaproll.reset](#snaprollreset).
```typescript
export interface SnaprollResetOptions extends Partial
```
#### Remarks
Extends [SnaprollOptions](#interface-snaprolloptions-) and controls how subscriptions and context objects are preserved.
#### SnaprollResetOptions.context
Determines the [context](#interface-snaprollusercontext-) to use after reset completes.
```typescript
context?: SnaprollUserContext;
```
##### Remarks
The context resolution follows these rules:
- If `keepContext=true` and `context` is provided: applies the provided object and copies existing context into it.
- If `keepContext=false` and `context` is provided: uses the provided object as-is.
- If `keepContext=true` and `context` is omitted: reuses the existing context object.
- If `keepContext=false` and `context` is omitted: creates a new empty context object.
#### SnaprollResetOptions.keepContext
Preserve the existing context object during reset.
```typescript
keepContext?: boolean;
```
#### SnaprollResetOptions.keepSubscriptions
Preserve existing subscription callbacks during reset.
```typescript
keepSubscriptions?: boolean;
```
### interface SnaprollSubscriptionControls [↗](src/snaproll.ts#L63-L70 'SnaprollSubscriptionControls')
Control interface for managing individual animation subscriptions.
```typescript
export interface SnaprollSubscriptionControls
```
#### Remarks
Each subscription operates independently. Pausing one subscription does not affect others. The animation loop continues running as long as any subscription remains active.
#### SnaprollSubscriptionControls.pause
Pauses this subscription
```typescript
pause: () => void;
```
#### SnaprollSubscriptionControls.resume
Resumes this subscription
```typescript
resume: () => void;
```
#### SnaprollSubscriptionControls.unsubscribe
Removes this subscription
```typescript
unsubscribe: () => void;
```
### interface SnaprollUserContext [↗](src/index.ts#L19 'SnaprollUserContext')
Extension point for application-specific state that the animation loop shares across frames.
```typescript
export interface SnaprollUserContext
```
#### Remarks
Augment this interface via declaration merging so custom properties flow into [SnaprollContext](#type-snaprollcontext-). Snaproll maintains a single context instance per controller; store long-lived data on user fields and rely on [action-specific](#enum-snaprollactiontype-) payloads for phase details.
#### Examples
```ts
declare module 'snaproll' {
interface SnaprollUserContext {
score: number
}
}
```
### type SnaprollContext [↗](src/snaproll.ts#L53-L54 'SnaprollContext')
Context object passed to subscription callbacks during animation frames.
```typescript
export type SnaprollContext = (SnaprollActionBegin | SnaprollActionDraw | SnaprollActionUpdate) &
SnaprollUserContext
```
#### Remarks
Intersects the action-specific payload with [SnaprollUserContext](#interface-snaprollusercontext-), so that custom state persists across [loop phases](#enum-snaprollactiontype-). Inspect the `action` discriminant to determine which `SnaprollAction*` view is valid while treating application-specific fields as shared state.
### type SnaprollDrawRateAdvisorSubscription [↗](src/draw-rate-advisor.ts#L161-L163 'SnaprollDrawRateAdvisorSubscription')
Callback function invoked when draw rate estimation completes.
```typescript
export type SnaprollDrawRateAdvisorSubscription = (
response: SnaprollDrawRateAdvisorResponse,
) => void
```
### type SnaprollSubscription [↗](src/snaproll.ts#L84 'SnaprollSubscription')
Subscription callback function.
```typescript
export type SnaprollSubscription = (context: SnaprollContext) => boolean | undefined
```
#### Remarks
Return value controls frame execution flow:
- `true` from Begin phase skips the entire frame
- `true` from Update phase skips remaining updates and draw for current frame
- `undefined` or `false` continues normal execution
## Acknowledgments
- [Godot](https://github.com/godotengine/godot/blob/master/scene/main/scene_tree.cpp)
- [PixiJS Ticker](https://github.com/pixijs/pixijs/blob/dev/src/ticker/Ticker.ts)
- [Babylon.js](https://github.com/BabylonJS/Babylon.js/blob/master/packages/dev/core/src/Engines/abstractEngine.ts)
- [three.js Timer](https://github.com/mrdoob/three.js/blob/dev/src/core/Timer.js)
- [MainLoop.js](https://github.com/IceCreamYou/MainLoop.js)