关于 threejs-compositions
面向 Editframe 时间线的 Three.js 场景制作技能,涵盖确定性动画与视频渲染配置。重点包括时间定位、画布捕获和渲染克隆初始化。
主要功能
- 01由时间线驱动场景更新
- 02确定性且可定位的动画
- 03渲染克隆初始化
- 04画布捕获配置要求
- 05灯光与相机指导
适用场景
- 01为 Editframe 时间线制作 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);
});
};
要点:
- 初始化器会在主实例和克隆上运行。使用
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;
常见陷阱
-
场景太暗 — 将环境光强度调至 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 封装为 React 组件,并提供 hooks、声明式场景构建和 drei 辅助工具。渲染克隆请使用原生方式(本文档);实时交互版本请使用 R3F。