threejs-compositions について
EditframeのタイムラインからThree.jsシーンを動かすためのスキルです。時間指定で再現できるアニメーションや、動画レンダリング時のキャプチャとクローン初期化を扱います。
主な機能
- 01タイムライン連動のシーン更新
- 02決定的でシーク可能なアニメーション
- 03レンダークローンの初期化
- 04キャンバスキャプチャの要件
- 05ライティングとカメラの指針
活用シーン
- 01Editframeのタイムライン向けThree.jsアニメーション制作
- 02シーク可能なシーンの動画出力
- 03空白または古いキャプチャの問題調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ editframe/ skills --skill threejs-compositionsインストール後、Claude Code で/threejs-compositionsを入力して読み込まれたか確認します。
同梱ファイルr3f.md
詳細
Editframe コンポジションでの Three.js
addFrameTask を介して、Editframe のタイムラインから Three.js シーンを操作します。シーンはコンポジションの時間だけを入力とする純粋関数で、内部クロックを持ちません。そのため、自由にスクラブやシークができ、動画としてレンダリングできます。
アーキテクチャ
EFTimegroup.addFrameTask(({ ownCurrentTimeMs, durationMs }) => {
scene.update(ownCurrentTimeMs, durationMs);
})
Three.js レンダラーは、タイムグループ内の <canvas> を描画先にします。タイミングはコンポジションが制御し、ビジュアルはキャンバスが提供します。
シーンモジュールのパターン
ファクトリー関数をエクスポートする独立したモジュールを作成します。
// my-scene.ts
import * as THREE from "three";
export function createMyScene(canvas: HTMLCanvasElement) {
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
preserveDrawingBuffer: true, // REQUIRED for renderToVideo
});
// ... scene setup ...
function update(timeMs: number, durationMs: number) {
// Position everything deterministically based on timeMs
// NO Math.random() for positions (breaks scrubbing)
// NO internal clocks or requestAnimationFrame
renderer.render(scene, camera);
renderer.getContext().finish(); // REQUIRED for renderToVideo
}
function resize(w: number, h: number) {
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
function dispose() { /* clean up GPU resources */ }
return { update, resize, dispose };
}
重要な要件
-
preserveDrawingBuffer: true— これがないと、表示後にキャンバスバッファがクリアされます。フレームキャプチャでは空のピクセルが読み取られます。 -
レンダリング後に
gl.finish()を呼び出す — すべての GL 操作が完了してからフレームがキャプチャされるようにします。これがないと、古い内容がキャプチャされる場合があります。 -
時間だけを入力とする純粋関数にする —
update(timeMs)は、同じ入力に対して常に同じ結果を生成する必要があります。決定論的な計算を使用し、位置の決定にMath.random()を使わないでください。パーティクルシステムでは、蓄積した状態ではなくtimeMsから位置を導出してください。 -
内部アニメーションループを作らない —
requestAnimationFrameは使用しないでください。コンポジションのaddFrameTaskがすべての更新を制御します。
React コンポーネントとの統合
プライムインスタンス(ライブ再生)
動的インポートの解決後、useEffect 内でシーンを直接セットアップします。
useEffect(() => {
if (!isClient) return;
const container = containerRef.current;
if (!container) return;
let scene = null;
const setup = async () => {
const { createMyScene } = await import("./my-scene");
const canvas = container.querySelector("canvas");
const tg = container.querySelector("ef-timegroup");
if (!canvas || !tg) return;
scene = createMyScene(canvas);
const { width, height } = container.getBoundingClientRect();
scene.resize(width, height);
tg.addFrameTask(({ ownCurrentTimeMs, durationMs }) => {
scene.update(ownCurrentTimeMs, durationMs);
});
};
setup();
return () => scene?.dispose();
}, [isClient]);
レンダークローン(renderToVideo 用)
renderToVideo は DOM クローンを作成します。クローンでは React の useEffect は実行されません。タイムグループの initializer プロパティを使用します。
// Inside the setup function, AFTER creating the prime scene:
tg.initializer = (instance) => {
if (instance === tg) return; // skip prime, already set up
let cloneScene = null;
instance.addFrameTask(({ ownCurrentTimeMs, durationMs }) => {
if (!cloneScene) {
// Lazy creation (initializer must be <100ms)
const cvs = instance.querySelector("canvas");
if (!cvs) return;
cloneScene = createMyScene(cvs);
const rect = cvs.getBoundingClientRect();
cloneScene.resize(rect.width || cvs.clientWidth || 800,
rect.height || cvs.clientHeight || 500);
}
cloneScene.update(ownCurrentTimeMs, durationMs);
});
};
要点:
initializerはプライムとクローンの両方で実行されます。instance === tgの場合はプライムなのでスキップします。- 100ms の初期化時間制限を超えないよう、シーンは初期化子の本体ではなく、フレームタスク内で遅延作成します。
- クローンのキャンバスは画面外にあるため、
getBoundingClientRect()が 0 を返す場合があります。代わりにclientWidthまたはハードコードしたデフォルト値を使用します。
JSX の構造
<Preview id={rootId} loop>
<Timegroup mode="fixed" duration="14s"
className="relative w-full overflow-hidden"
style={{ aspectRatio: "16/10", background: "#1e2233" }}>
<canvas style={{
position: "absolute", inset: 0,
width: "100%", height: "100%", display: "block",
}} />
{/* HTML overlays on top of the canvas */}
<div style={{ position: "absolute", ... }}>Text labels</div>
</Timegroup>
</Preview>
視認性を高めるライティングとマテリアル
暗い 3D シーンは最大の問題です。プレビューでは問題なく見えるオブジェクトが、レンダリング結果では見えなくなることがあります。
最低限必要なライティング
scene.add(new THREE.AmbientLight(0xd0d8f0, 0.9)); // strong ambient
const key = new THREE.DirectionalLight(0xffffff, 1.8); // key light with shadows
const spot = new THREE.SpotLight(0xffffff, 2.0, 25); // specular catch
scene.add(new THREE.PointLight(0x82b1ff, 0.9, 25)); // rim/accent
マテリアルの推奨事項
視認性の高いスペキュラハイライトを得るために、クリアコートを設定した MeshPhysicalMaterial を使用します。
new THREE.MeshPhysicalMaterial({
color: 0x448aff,
roughness: 0.12, // low = shiny
metalness: 0.15,
clearcoat: 1.0, // glossy lacquer layer
clearcoatRoughness: 0.15,
emissive: new THREE.Color(0x448aff),
emissiveIntensity: 0.1, // self-illumination for dark scenes
transparent: true,
opacity: 0, // start hidden, fade in
});
ほとんどのシーンでは、MeshStandardMaterial は暗すぎます。物理ベースのマテリアルとクリアコートを使用すると、オブジェクトを認識しやすくするスペキュラハイライトが得られます。
トーンマッピング
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.6–1.8; // push bright
サイズのルール
レンダリング動画では、オブジェクトを想定よりかなり大きくする必要があります。
- オブジェクトの最小サイズ:視認できるよう、フレーム幅の 5%
- プログレスバー:高さはフレーム高の 3% 以上
- パーティクル:
size >= 0.08(0.035ではありません) - 床のグリッド:空間の基準を加えるため、
GridHelperを不透明度 20〜25% で使用します
影と不透明度の同期
透明なオブジェクトも、完全な濃さの影を落とします。不透明度に応じて castShadow を切り替えます。
function setOpacity(mesh, opacity) {
mesh.material.opacity = opacity;
mesh.castShadow = opacity > 0.1; // no shadow when nearly invisible
}
カメラ演出
カメラ位置は、視線を誘導する主な手段です。カメラのポーズをキーフレーム化して補間します。
const CAM_CLOSE = new THREE.Vector3(0, 0.8, 2.8); // hero shot, fills frame
const CAM_WIDE = new THREE.Vector3(0, 3.8, 10); // reveals full scene
const CAM_WIN = new THREE.Vector3(2, 3, 8); // orbits toward payoff
// In update():
const pullBack = easeInOut(progress(timeMs, startMs, endMs));
lerpV3(camPos, CAM_CLOSE, CAM_WIDE, pullBack);
ルール:
- 被写体がフレームいっぱいに映る近距離から始め、後退して全体を見せます
- 新しい要素を追加する前に後退し、画面外から突然現れないようにします
- 比較シーンでは「勝者」の方向へカメラを回り込ませます
- 重要な指標を強調するため、スナップズーム(camPos.z に対して 200〜300ms の正弦パルス)を使います
環境
暗い背景には、空虚ではなく場所の雰囲気が必要です。
const BG = 0x1e2233;
scene.background = new THREE.Color(BG);
scene.fog = new THREE.Fog(BG, 16, 35); // objects fade into background at distance
// Floor with grid
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(50, 35),
new THREE.MeshStandardMaterial({ color: 0x2a2e42, roughness: 0.75, metalness: 0.1 }),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.7;
floor.receiveShadow = true;
const grid = new THREE.GridHelper(30, 30, 0x3a3f58, 0x3a3f58);
grid.position.y = -0.69;
grid.material.transparent = true;
grid.material.opacity = 0.25;
よくある問題
-
シーンが暗すぎる — アンビエントを 0.9、キーライトを 1.8、露出を 1.6 以上にします。プレビューではなく、レンダリングして確認してください。
-
レンダリング用クローンに最初のフレームしか表示されない —
ownCurrentTimeMsが進んでいない(seekForRender 内の_setLocalTimeMsを確認してください)か、初期化処理でクローンにフレームタスクが登録されていません。 -
レンダリング結果が空白になる —
preserveDrawingBuffer: trueまたはgl.finish()が不足しています。 -
オブジェクトがフェードインする前に影が見える — 不透明度に応じて
castShadowを切り替えます(しきい値は 0.1)。 -
レンダリングでパーティクルが見えない — サイズが小さすぎます。
size >= 0.08を使用してください。加算合成は明るい背景では見えなくなることがあります。 -
renderToVideo が undefined を返す —
streamingの既定値は true で、File System Access API を使用するため、undefined が返されます。バッファをコードに返す必要がある場合は、streaming: falseに設定してください。 -
初期化処理が 100ms を超える — 初期化処理に対してシーンの作成が重すぎます。代わりに、フレームタスクのコールバック内でシーンを遅延作成してください。
依存関係
# Install in telecine (via Docker scripts)
telecine/scripts/npm install three
telecine/scripts/npm install --save-dev @types/three
コンポーネントで動的インポートを行うと、SSR の問題を回避できます:
const { createMyScene } = await import("./my-scene");
React Three Fiber (R3F)
開発者体験に優れたコンポーネントベースの 3D シーンについては、r3f.md を参照してください。R3F は Three.js をフック、宣言的なシーン構築、drei ヘルパーを備えた React コンポーネントでラップします。レンダリング用クローンにはバニラ方式(このファイル)を使用し、ライブのインタラクティブ版には R3F を使用してください。