mcpField
T
threejs-compositions

threejs-compositions

github.com/editframe创意与媒体1981 次安装2 周前更新
代码仓库

关于 threejs-compositions

面向 Editframe 时间线的 Three.js 场景制作技能,涵盖确定性动画与视频渲染配置。重点包括时间定位、画布捕获和渲染克隆初始化。

主要功能

  1. 01由时间线驱动场景更新
  2. 02确定性且可定位的动画
  3. 03渲染克隆初始化
  4. 04画布捕获配置要求
  5. 05灯光与相机指导

适用场景

  1. 01为 Editframe 时间线制作 Three.js 动画
  2. 02将可定位场景渲染为视频
  3. 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 };
}
关键要求
  1. preserveDrawingBuffer: true —— 如果没有此设置,画布缓冲区会在呈现后清空。帧捕获会读到空像素。

  2. 渲染后调用 gl.finish() —— 强制所有 GL 操作在捕获帧之前完成。如果没有此调用,捕获的内容可能过期。

  3. 时间纯函数 —— 对于相同的输入,update(timeMs) 必须产生相同的结果。使用确定性数学计算(不要用 Math.random() 生成位置)。粒子系统应根据 timeMs 计算位置,而不是累积状态。

  4. 不要使用内部动画循环 —— 不要使用 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);
  });
};

要点:

  • 初始化器会在主实例和克隆上运行。使用 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 在大多数场景中都太暗。Physical 材质加清漆层可以捕捉镜面高光,让物体更清晰可辨。

色调映射
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.6–1.8;  // push bright

尺寸规则

为了在渲染后的视频中清晰可见,物体需要比预期大得多:

  • 最小物体尺寸:画面宽度的 5%
  • 进度条:高度 >= 画面高度的 3%
  • 粒子:size >= 0.08(而非 0.035)
  • 地面网格:提供空间参照,使用不透明度为 20-25% 的 GridHelper

阴影与不透明度同步

透明物体仍会投射完整阴影。请根据不透明度切换 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;

常见陷阱

  1. 场景太暗 — 将环境光强度调至 0.9,主光强度调至 1.8,曝光调至 1.6 以上。请通过渲染检查,而非预览。

  2. 渲染克隆只显示第一帧 — 要么是 ownCurrentTimeMs 未递增(检查 seekForRender 中的 _setLocalTimeMs),要么是初始化器未在克隆上注册帧任务。

  3. 渲染输出为空白 — 缺少 preserveDrawingBuffer: true 或 gl.finish()。

  4. 对象淡入前就能看到阴影 — 随不透明度切换 castShadow(阈值为 0.1)。

  5. 渲染中看不到粒子 — 尺寸太小。请使用 size >= 0.08。在明亮背景下,添加混合可能会使粒子消失。

  6. renderToVideo 返回 undefined — streaming 默认值为 true,会使用 File System Access API 并返回 undefined。需要将缓冲区返回到代码中时,请设置 streaming: false。

  7. 初始化器耗时超过 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 封装为 React 组件,并提供 hooks、声明式场景构建和 drei 辅助工具。渲染克隆请使用原生方式(本文档);实时交互版本请使用 R3F。

评论

支持 Markdown