threejs-compositions

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Three.js in Editframe Compositions

在Editframe合成项目中使用Three.js

Drive Three.js scenes from Editframe's timeline via
addFrameTask
. The scene is a pure function of composition time — no internal clock — making it fully scrubable, seekable, and renderable to video.
通过
addFrameTask
由Editframe的时间轴驱动Three.js场景。场景是合成时间的纯函数——没有内部时钟——因此完全支持拖动时间轴预览、可跳转,并且可渲染为视频。

Architecture

架构

EFTimegroup.addFrameTask(({ ownCurrentTimeMs, durationMs }) => {
  scene.update(ownCurrentTimeMs, durationMs);
})
The Three.js renderer targets a
<canvas>
inside the timegroup. The composition provides timing; the canvas provides visuals.
EFTimegroup.addFrameTask(({ ownCurrentTimeMs, durationMs }) => {
  scene.update(ownCurrentTimeMs, durationMs);
})
Three.js渲染器以时间组内的
<canvas>
为目标。合成项目提供时序控制;画布提供视觉内容。

Scene Module Pattern

场景模块模式

Create a standalone module that exports a factory function:
typescript
// 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 };
}
创建一个独立模块,导出工厂函数:
typescript
// 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
  });

  // ... 场景设置 ...

  function update(timeMs: number, durationMs: number) {
    // 根据timeMs确定性地设置所有元素位置
    // 不要使用Math.random()设置位置(会破坏拖动预览功能)
    // 不要使用内部时钟或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() { /* 清理GPU资源 */ }

  return { update, resize, dispose };
}

Critical Requirements

关键要求

  1. preserveDrawingBuffer: true
    — Without this, the canvas buffer is cleared after presenting. Frame capture reads empty pixels.
  2. gl.finish()
    after render
    — Forces all GL operations to complete before the frame is captured. Without this, the capture may read stale content.
  3. Pure function of time
    update(timeMs)
    must produce the same result for the same input. Use deterministic math (not
    Math.random()
    for positions). Particle systems should derive positions from
    timeMs
    , not accumulated state.
  4. No internal animation loop — No
    requestAnimationFrame
    . The composition's
    addFrameTask
    drives all updates.
  1. preserveDrawingBuffer: true
    — 没有此设置,画布缓冲区会在画面展示后被清除,帧捕获会读取到空像素。
  2. 渲染后调用
    gl.finish()
    — 强制所有GL操作在帧捕获前完成。没有此设置,捕获可能会读取到过期内容。
  3. 基于时间的纯函数
    update(timeMs)
    对于相同输入必须产生相同结果。使用确定性数学运算(不要用
    Math.random()
    设置位置)。粒子系统应从
    timeMs
    推导位置,而非累积状态。
  4. 无内部动画循环 — 不要使用
    requestAnimationFrame
    。合成项目的
    addFrameTask
    驱动所有更新。

Integration with React Components

与React组件集成

Prime Instance (live playback)

主实例(实时播放)

Set up the scene directly in a
useEffect
, after the dynamic import resolves:
tsx
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]);
useEffect
中直接设置场景,等待动态导入完成后执行:
tsx
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]);

Render Clones (for renderToVideo)

渲染克隆(用于renderToVideo)

renderToVideo
creates a DOM clone. React
useEffect
doesn't run on clones. Use the timegroup's
initializer
property:
tsx
// 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);
  });
};
Key points:
  • The initializer runs on both prime and clones. Skip the prime with
    instance === tg
    .
  • Create the scene lazily inside the frame task (not in the initializer body) to stay under the 100ms initializer time limit.
  • The clone's canvas is offscreen;
    getBoundingClientRect()
    may return 0. Fall back to
    clientWidth
    or hardcoded defaults.
renderToVideo
会创建DOM克隆。React的
useEffect
不会在克隆上运行。使用时间组的
initializer
属性:
tsx
// 在setup函数内部,创建主场景之后:
tg.initializer = (instance) => {
  if (instance === tg) return; // 跳过主实例,已完成设置

  let cloneScene = null;
  instance.addFrameTask(({ ownCurrentTimeMs, durationMs }) => {
    if (!cloneScene) {
      // 延迟创建(initializer执行时间必须<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
    跳过主实例。
  • 在帧任务内部延迟创建场景(不要在initializer主体中创建),以保持initializer执行时间在100ms限制内。
  • 克隆的画布是离屏的;
    getBoundingClientRect()
    可能返回0。回退到
    clientWidth
    或硬编码默认值。

JSX Structure

JSX结构

tsx
<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>
tsx
<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叠加层 */}
    <div style={{ position: "absolute", ... }}>文本标签</div>
  </Timegroup>
</Preview>

Lighting & Materials for Visibility

可见性相关的光照与材质

Dark 3D scenes are the #1 problem. Objects that look fine in preview disappear in rendered output.
3D场景过暗是头号问题。在预览中看起来正常的对象在渲染输出中可能消失。

Minimum viable lighting

最低可行光照设置

typescript
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
typescript
scene.add(new THREE.AmbientLight(0xd0d8f0, 0.9));       // 强环境光
const key = new THREE.DirectionalLight(0xffffff, 1.8);   // 带阴影的主光源
const spot = new THREE.SpotLight(0xffffff, 2.0, 25);     // 镜面补光
scene.add(new THREE.PointLight(0x82b1ff, 0.9, 25));      // 轮廓/强调光

Material recommendations

材质推荐

Use
MeshPhysicalMaterial
with clearcoat for visible specular highlights:
typescript
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
is too dark in most scenes. Physical + clearcoat catches specular highlights that make objects readable.
使用带清漆层的
MeshPhysicalMaterial
以获得清晰的镜面高光:
typescript
new THREE.MeshPhysicalMaterial({
  color: 0x448aff,
  roughness: 0.12,      // 值越低=越亮面
  metalness: 0.15,
  clearcoat: 1.0,        // 光泽清漆层
  clearcoatRoughness: 0.15,
  emissive: new THREE.Color(0x448aff),
  emissiveIntensity: 0.1, // 暗场景中的自发光
  transparent: true,
  opacity: 0,             // 初始隐藏,渐显
});
MeshStandardMaterial
在大多数场景中过暗。Physical材质+清漆层能捕捉到让对象清晰可见的镜面高光。

Tone mapping

色调映射

typescript
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.61.8;  // push bright
typescript
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.61.8;  // 提高亮度

Sizing Rules

尺寸规则

Objects need to be much larger than expected for rendered video:
  • Minimum object size: 5% of frame width to be visible
  • Progress bars: height >= 3% of frame height
  • Particles:
    size >= 0.08
    (not 0.035)
  • Floor grid: adds spatial grounding, use
    GridHelper
    at 20-25% opacity
对象需要比预期大得多才能在渲染视频中可见:
  • 最小对象尺寸:帧宽度的5%才能可见
  • 进度条:高度>=帧高度的3%
  • 粒子:
    size >= 0.08
    (不要用0.035)
  • 地面网格:增加空间定位感,使用
    GridHelper
    并设置20-25%的透明度

Shadow-Opacity Sync

阴影与透明度同步

Transparent objects still cast full shadows. Toggle
castShadow
based on opacity:
typescript
function setOpacity(mesh, opacity) {
  mesh.material.opacity = opacity;
  mesh.castShadow = opacity > 0.1;  // no shadow when nearly invisible
}
透明对象仍会投射完整阴影。根据透明度切换
castShadow
typescript
function setOpacity(mesh, opacity) {
  mesh.material.opacity = opacity;
  mesh.castShadow = opacity > 0.1;  // 几乎不可见时不投射阴影
}

Camera Choreography

相机运镜

Camera position is the primary attention tool. Keyframe camera poses and interpolate:
typescript
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);
Rules:
  • Start close (subject fills frame), pull back to reveal
  • Pull back BEFORE adding new elements (so they don't appear off-screen)
  • Orbit toward the "winner" in comparison scenes
  • Snap zoom (200-300ms sine pulse on camPos.z) for emphasis on key metrics
相机位置是主要的注意力引导工具。为相机姿态设置关键帧并进行插值:
typescript
const CAM_CLOSE = new THREE.Vector3(0, 0.8, 2.8);  // 特写镜头,填满画面
const CAM_WIDE  = new THREE.Vector3(0, 3.8, 10);    // 展示完整场景
const CAM_WIN   = new THREE.Vector3(2, 3, 8);        // 环绕移动至重点区域

// 在update()中:
const pullBack = easeInOut(progress(timeMs, startMs, endMs));
lerpV3(camPos, CAM_CLOSE, CAM_WIDE, pullBack);
规则:
  • 从特写开始(主体填满画面),向后拉远以展示更多内容
  • 在添加新元素之前向后拉远(避免元素出现在画面外)
  • 在对比场景中环绕移动至“重点对象”
  • 快速缩放(在camPos.z上应用200-300ms的正弦脉冲)以强调关键指标

Environment

环境设置

Dark backgrounds need a sense of place, not a void:
typescript
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;
深色背景需要空间感,而非空洞:
typescript
const BG = 0x1e2233;
scene.background = new THREE.Color(BG);
scene.fog = new THREE.Fog(BG, 16, 35);  // 对象在远处渐隐入背景

// 带网格的地面
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;

Common Pitfalls

常见陷阱

  1. Scene too dark — Push ambient to 0.9, key to 1.8, exposure to 1.6+. Check by rendering, not by preview.
  2. Render clone shows only first frame — Either
    ownCurrentTimeMs
    isn't advancing (check
    _setLocalTimeMs
    in seekForRender) or the initializer isn't registering the frame task on the clone.
  3. Render output is blank — Missing
    preserveDrawingBuffer: true
    or
    gl.finish()
    .
  4. Shadows visible before objects fade in — Toggle
    castShadow
    with opacity (threshold 0.1).
  5. Particles invisible in render — Size too small. Use
    size >= 0.08
    . Additive blending can disappear against bright backgrounds.
  6. renderToVideo returns undefined
    streaming
    defaults to true, which uses File System Access API and returns undefined. Set
    streaming: false
    when you need the buffer returned to your code.
  7. Initializer exceeds 100ms — Scene creation is too heavy for the initializer. Create the scene lazily inside the frame task callback instead.
  1. 场景过暗 — 将环境光强度设为0.9,主光源设为1.8,曝光度设为1.6以上。通过渲染结果检查,而非预览。
  2. 渲染克隆仅显示第一帧 — 要么
    ownCurrentTimeMs
    未推进(检查seekForRender中的
    _setLocalTimeMs
    ),要么initializer未在克隆上注册帧任务。
  3. 渲染输出为空 — 缺少
    preserveDrawingBuffer: true
    gl.finish()
  4. 对象渐显前阴影已可见 — 根据透明度切换
    castShadow
    (阈值0.1)。
  5. 粒子在渲染中不可见 — 尺寸过小。使用
    size >= 0.08
    。 additive混合模式在亮背景下可能消失。
  6. renderToVideo返回undefined
    streaming
    默认值为true,会使用文件系统访问API并返回undefined。当需要将缓冲区返回至代码时,设置
    streaming: false
  7. Initializer执行时间超过100ms — 场景创建在initializer中过于繁重。改为在帧任务回调内部延迟创建场景。

Dependencies

依赖

bash
undefined
bash
undefined

Install in telecine (via Docker scripts)

在telecine中安装(通过Docker脚本)

telecine/scripts/npm install three telecine/scripts/npm install --save-dev @types/three

Dynamic import in the component avoids SSR issues:
```typescript
const { createMyScene } = await import("./my-scene");
telecine/scripts/npm install three telecine/scripts/npm install --save-dev @types/three

组件中的动态导入可避免SSR问题:
```typescript
const { createMyScene } = await import("./my-scene");

React Three Fiber (R3F)

React Three Fiber (R3F)

For component-based 3D scenes with better developer experience, see r3f.md. R3F wraps Three.js in React components with hooks, declarative scene construction, and drei helpers. Use the vanilla approach (this file) for render clones; use R3F for the live interactive version.
如需拥有更好开发体验的组件化3D场景,请查看r3f.md。R3F通过钩子、声明式场景构建和drei助手将Three.js封装为React组件。渲染克隆使用原生方法(本文档内容);实时交互版本使用R3F。