Filmtone / Motion Blur
From shutter angle to exposure frame count — Filmtone's motion blur math, aligned with industry conventions
Treating shutter angle as exposure duration. `exposureFrames = clamp(angle, 0, 720) / 360`, with 180° as the baseline (`0.5 frame`). Only `additionalExposureFrames = max(0, target - 0.5)` reaches the ring buffer. The mapping is `360° → 2 frames (triangle weights [2/3, 1/3])` and `720° → 3 frames (flat weights [1/3, 1/3, 1/3])`. The same math is shared across a WebGL fragment shader, a WebGPU compute shader, and an iOS Swift export session — and validated against RED, Foundry Nuke, Blender, Houdini, and After Effects.
- Published
- April 29, 2026
- Topics
- Filmtone · Motion Blur · Shutter Angle · Color Science
Treating shutter angle as exposure duration
Normal iPhone and cinema footage is captured with roughly 180° of shutter motion blur already baked in. Filmtone Motion is an additive post process; piling another 180° pass on top of that produces visible double-blur ghosting. So Filmtone treats 180° as the no-op baseline, and only opens the target shutter window past that.
Exposure-frame normalization
Shutter angle is normalized to exposure duration per frame. `MOTION_BLUR_BASELINE_SHUTTER_ANGLE = 180` is fixed at 0.5 frame, and `MOTION_BLUR_MAX_SHUTTER_ANGLE = 720` caps the input at 2.0 frames. Only the additional exposure relative to the source is fed into the ring buffer: `additionalExposureFrames = max(0, target - 0.5)`. For `shutterAngle <= 180` this is always `0`, so preview, desktop export, and iOS export all return the spatial grade output as-is.
// packages/film-lab-renderer/src/motionBlurMath.ts
export const MOTION_BLUR_BASELINE_SHUTTER_ANGLE = 180;
export const MOTION_BLUR_MAX_SHUTTER_ANGLE = 720;
export const MOTION_BLUR_DEFAULT_RING_SLOTS = 8;
export const MOTION_BLUR_BASELINE_EXPOSURE_FRAMES =
MOTION_BLUR_BASELINE_SHUTTER_ANGLE / 360;
export function targetMotionExposureFrames(shutterAngle: number): number {
return clampMotionShutterAngle(shutterAngle) / 360;
}
export function additionalMotionExposureFrames(shutterAngle: number): number {
return Math.max(
0,
targetMotionExposureFrames(shutterAngle) -
MOTION_BLUR_BASELINE_EXPOSURE_FRAMES,
);
}
export function isShutterMotionActive(shutterAngle: number): boolean {
return additionalMotionExposureFrames(shutterAngle) > 0;
}Active frame count — held to a short history window
The active frame count for the ring-buffer approximation is `1 + ceil(additionalExposureFrames)`, clamped to `[2, slots]`. With the default 8 slots: `360°→2 frames`, `540°→3 frames`, `720°→3 frames`. The substantive change relative to the previous `(additionalAngle / 360) * (slots / 2)` formula was replacing the `720°→6 frames` mapping; the old version became a long-tail echo rather than a shutter-window extension. Long stylistic tails are the job of `trailIntensity`, not `shutterAngle`.
export function activeMotionBlurFramesForShutter(
shutterAngle: number,
ringSlots: number = MOTION_BLUR_DEFAULT_RING_SLOTS,
): number {
const additionalExposureFrames = additionalMotionExposureFrames(shutterAngle);
if (additionalExposureFrames <= 0) return 1;
const slots = Math.max(1, Math.round(ringSlots));
const raw = 1 + Math.ceil(additionalExposureFrames);
return Math.max(2, Math.min(slots, raw));
}Weight curves — triangle / box blended by flatness
Weights interpolate between triangle and box via `flatness = clamp((shutterAngle - 360) / 360, 0, 1)`. At `shutterAngle = 360°`, flatness is 0 and the weights are pure triangle; at `720°`, flatness is 1 and the weights are flat. Three curves are available — triangle, box, exponential — but triangle is the default, drifting naturally toward flat as `shutterAngle` grows. The final weight array is normalized by the sum, so total energy stays conserved regardless of window size.
export function computeMotionBlurWeights(
shutterAngle: number,
activeFrames: number,
validSlots: number,
ringSlots: number = MOTION_BLUR_DEFAULT_RING_SLOTS,
weightCurve: MotionBlurWeightCurve = "triangle",
): Float32Array {
const slots = Math.max(1, Math.round(ringSlots));
const weights = new Float32Array(slots);
const effective = Math.min(
Math.max(0, Math.round(activeFrames)),
Math.max(0, Math.round(validSlots)),
slots,
);
if (effective <= 0) return weights;
if (effective === 1) { weights[0] = 1; return weights; }
const clampedShutterAngle = clampMotionShutterAngle(shutterAngle);
const flatness = Math.min(1, Math.max(0, (clampedShutterAngle - 360) / 360));
let sum = 0;
for (let i = 0; i < effective; i++) {
const triangleW = effective - i;
const boxW = 1;
switch (weightCurve) {
case "box": weights[i] = boxW; break;
case "exponential": weights[i] = Math.exp(-1.5 * i) * (1 - flatness) + boxW * flatness; break;
case "triangle":
default: weights[i] = triangleW * (1 - flatness) + boxW * flatness; break;
}
sum += weights[i]!;
}
if (sum > 0) for (let i = 0; i < effective; i++) weights[i]! /= sum;
return weights;
}Swift mirror — `FilmtoneMotionBlurMath`
If iOS export ran a different formula, preview and export would diverge in color. The Swift side is hand-mirrored, and `scripts/swift/test-motion-blur-math.swift` runs as part of `verify:swift-contract` to confirm boundary values (0° / 180° / 360° / 540° / 720°) match the TS output. The check is wired into `bun run --cwd apps/capacitor-film-lab-ios verify:swift-contract`.
// apps/capacitor-film-lab-ios/ios/App/App/FilmtoneMotionBlurMath.swift
enum FilmtoneMotionBlurMath {
static let baselineShutterAngle = 180.0
static let maxShutterAngle = 720.0
static let defaultSlotCount = 8
static let baselineExposureFrames = baselineShutterAngle / 360.0
static func additionalExposureFrames(shutterAngle: Double) -> Double {
max(0, targetExposureFrames(shutterAngle: shutterAngle) - baselineExposureFrames)
}
static func activeFrameCount(
shutterAngle: Double,
slotCount: Int = defaultSlotCount
) -> Int {
let additional = additionalExposureFrames(shutterAngle: shutterAngle)
guard additional > 0 else { return 1 }
let slots = max(1, slotCount)
let raw = 1 + Int(ceil(additional))
return max(2, min(slots, raw))
}
}Boundary behavior at default 8 slots
- `0°` / `180°` — inactive, pass-through (1 frame)
- `360°` — active, 2 frames, weights `[2/3, 1/3]` (triangle, flatness=0)
- `540°` — active, 3 frames, weights between triangle and box at flatness ≈ 0.5
- `720°` — active, 3 frames, weights `[1/3, 1/3, 1/3]` (flatness=1, fully flat)
Aligned with industry conventions
Treating shutter angle as exposure duration matches how the rest of the industry models it. RED's Shutter Angle docs use `180° = 0.5 frame`. Foundry Nuke (Kronos and the render motion blur node) separates Shutter Time from Shutter Samples. Blender Cycles and EEVEE, plus Houdini Mantra, all expose shutter time, shutter offset, and motion samples as independent axes. Adobe AE Pixel Motion Blur generates intermediate samples from optical flow and integrates them along a shutter curve. Filmtone is a ring-buffer approximation rather than a physically accurate shutter simulator, but the meaning of `shutterAngle` is intentionally aligned with all of these.