Filmtone / Motion Blur
シャッター角度から露光フレーム数への変換 — Filmtone のモーションブラー数式と業界標準への準拠
シャッター角度を露光時間として扱う実装。`exposureFrames = clamp(angle, 0, 720) / 360` で露光時間に正規化し、180度を「ベースライン (0.5 frame)」として扱う。リングバッファに渡されるのは `additionalExposureFrames = max(0, target - 0.5)` のみ。マッピングは `360度 → 2フレーム (triangle weights `[2/3, 1/3]`)`、`720度 → 3フレーム (flat weights `[1/3, 1/3, 1/3]`)`。同じ数式が WebGL fragment shader / WebGPU compute / iOS Swift export session に共有され、RED / Foundry Nuke / Blender / Houdini / After Effects の実装と整合する。
- Published
- 2026年4月29日
- Topics
- Filmtone · Motion Blur · Shutter Angle · Color Science
シャッター角度を露光時間として正規化する
通常の iPhone / シネマ素材は撮影時点で約 180° 相当のモーションブラーが既に焼き込まれている。Filmtone Motion はあくまで追加的な post process なので、180° の入力に追加ブラーを乗せると double blur になり目に見えるゴーストが出る。だから Filmtone は 180° を「ベースライン (no-op)」として扱い、それ以上を「target shutter window の延長」として開く。
露光フレーム換算
シャッター角度を「露光時間 / 1 frame」に正規化する。`MOTION_BLUR_BASELINE_SHUTTER_ANGLE = 180` を 0.5 frame 相当の baseline として固定し、`MOTION_BLUR_MAX_SHUTTER_ANGLE = 720` を 2.0 frame 相当の上限とする。リングバッファに渡されるのは元の露光に対する **追加** 露光分のみ、すなわち `additionalExposureFrames = max(0, target - 0.5)`。`shutterAngle <= 180` は常に `0` なので、preview / desktop export / iOS export いずれも spatial grade の結果をそのまま返す。
// 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 — 短い history window に抑える
リングバッファ近似での active frame 数は `1 + ceil(additionalExposureFrames)` を `[2, slots]` に clamp する。デフォルト 8 slots では `360°→2 frames`、`540°→3 frames`、`720°→3 frames`。古い `(additionalAngle / 360) * (slots / 2)` 式が `720°→6 frames` に飛んでいたのを置き換えたのが本質的な修正で、長すぎる echo にはならない。長尾は `trailIntensity` 側に分離してある。
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 の flatness ブレンド
weights は `flatness = clamp((shutterAngle - 360) / 360, 0, 1)` を経由して triangle と box を blend する。`shutterAngle = 360°` なら flatness = 0 で純粋な triangle、`720°` なら flatness = 1 で完全な flat。triangle / box / exponential の 3 curve から選べるが、デフォルトは triangle で、`shutterAngle` が大きくなるほど自然に flat へ寄っていく。weight 配列は最後に総和で正規化されるので、shutter window の長短に関わらずエネルギー保存。
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`
iOS の export session も同じ数式を読まないと、preview と書き出しの色が割れる。Swift 側は手書きで写し、`scripts/swift/test-motion-blur-math.swift` の contract test が境界値 (0° / 180° / 360° / 540° / 720°) で TS 出力と一致することを毎回検証する。`verify:swift-contract` の motion blur ステップに組まれていて、`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))
}
}境界値の振る舞い (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 flatness ≈ 0.5 で triangle と box の中間
- `720°` — active、3 frames、weights `[1/3, 1/3, 1/3]` (flatness=1, full flat)
業界実装と整合する根拠
シャッター角度を露光時間相当として扱う設計は業界標準と並列に立つ。RED の Shutter Angle ドキュメントは `180° = 0.5 frame`、Foundry Nuke の Kronos / Render motion blur は Shutter Time と Shutter Samples を別パラメータに分離、Blender Cycles / EEVEE と Houdini Mantra は shutter time + shutter offset + motion samples の 3 軸構成、Adobe AE Pixel Motion Blur は optical flow から中間 sample を生成して shutter curve で積分する。Filmtone は ring-buffer 近似なので物理的な shutter 模写ではないが、語彙としての `shutterAngle` は同じ意味で揃えてある。