Day 100 · 渲染循环与动画纪律 — 让世界动起来
Day 99 我们"画了一次"——静态立方体。今天学习 3D 的灵魂:渲染循环。3D 不像 2D 那样可以做脏检查/局部重绘,它每一帧都要把整个场景重新画一遍,所以"持续渲染"不是可选项而是默认形态。今天要建立三样东西:rAF 循环骨架、Clock 时间纪律(动画速度与帧率解耦)、FPS 性能基线。做完,你会有第一个"会自转的 3D 场景",同时拿到"手写 WebGL vs Three.js"对照表的第一行。
目录
一、为什么 3D 是"整帧渲染"
1.1 2D 与 3D 的更新模型差异
为什么不能像 2D 那样"数据变了才画":3D 里相机一动、灯一移、物体一转,画面整体就变了;即使"什么都没动",GPU 也要每帧重新计算一遍投影与光栅化。所以 3D 的默认形态就是持续渲染——这决定了它的性能模型(每帧预算)与 2D 完全不同。
1.2 从"renderOnce"到"每帧 render"
Day 99 的 renderOnce() 只画了一次。它的升级版:
/**
* 标准渲染循环:每帧调用一次 renderer.render(scene, camera)
* —— 这是 3D 世界的心跳,任何 3D 应用的骨架
*/
function startLoop(scene: THREE.Scene, camera: THREE.PerspectiveCamera, renderer: THREE.WebGLRenderer): () => void {
let rafId = 0;
const tick = () => {
renderer.render(scene, camera); // 核心一行:每帧画一次
rafId = requestAnimationFrame(tick); // 注册下一帧(自循环)
};
rafId = requestAnimationFrame(tick);
// 返回停止函数(成对纪律:启动必给停止,供组件卸载时调用)
return () => cancelAnimationFrame(rafId);
}
要点:
requestAnimationFrame(tick)让浏览器在下一帧绘制前调用 tick——自动与屏幕刷新率(通常 60Hz)对齐回调里
render()之后再注册下一帧,形成自循环返回值是"停止函数"——Day 69 成对纪律的延续:startLoop 与 stopLoop 必须成对出现
二、rAF 渲染循环:标准骨架
2.1 为什么用 requestAnimationFrame 而不是 setInterval
记忆钩子:rAF 是浏览器官方钦定的"绘制帧"回调——它知道什么时候该画,setInterval 不知道。动画帧率抖动的第一嫌疑就是用了 setInterval 驱动渲染。
2.2 循环的三段式结构
无论动画多复杂,循环体都是三段:
const tick = () => {
// 1. 更新:改状态(物体位置/旋转、相机、控制器)
update(delta); // delta = 距上一帧的真实秒数(见第三节)
// 2. 渲染:把新状态画出来
renderer.render(scene, camera);
// 3. 排队:注册下一帧
rafId = requestAnimationFrame(tick);
};
三段职责分离(后续每天都会复用这个骨架):
更新段:动画/数据/交互逻辑——只改场景图对象的属性,不做绘制
渲染段:唯一调用
render()的地方排队段:维持循环
三、Clock 与时间纪律
3.1 问题:动画速度被帧率绑架
新手最常见的错误——“每帧转固定角度”:
// ❌ 反例:每帧转 0.01 弧度
// 60fps 时每秒转 0.6 弧度;120fps 时每秒转 1.2 弧度——速度随帧率变
cube.rotation.y += 0.01;
帧率不是恒定的(机器负载、窗口遮挡、大屏降帧)。动画必须按真实流逝时间驱动,而不是按"帧数"。
3.2 THREE.Clock:拿真实时间
import * as THREE from "three";
// 时钟:一次性创建,循环内每帧取 delta
const clock = new THREE.Clock();
const tick = () => {
// delta = 距上一帧的真实秒数(通常 1/60 ≈ 0.0167)
const delta = clock.getDelta();
// ✅ 正确:旋转速度 × 时间 = 与帧率无关的恒定角速度
// 0.8 = 每秒转 0.8 弧度(约 46°/s)
cube.rotation.y += 0.8 * delta;
renderer.render(scene, camera);
rafId = requestAnimationFrame(tick);
};
3.3 为什么 delta 是"秒"而不是"毫秒"
Three.js 的 getDelta() 返回秒(小数)。矩阵与物理引擎的惯用单位是"每秒多少弧度/每秒多少米"——速度 × delta(秒) = 位移,单位自洽,这也是"加速度-速度-位移"物理演算的起点(Day 102 的 Group 平移、Week 16 的物体运动都会用它)。
3.4 另一个常用:getElapsedTime
需要"从开始到现在过了多久"(如循环呼吸动效、脉冲光)时用 getElapsedTime():
// 呼吸灯:elapsed 秒随时间增长,sin 产生往复 → 亮度 0.6~1.0 波动
const t = clock.getElapsedTime();
material.emissiveIntensity = 0.8 + 0.2 * Math.sin(t * 2);
四、FPS 纪律与性能基线
4.1 目标与基线
4.2 一帧的预算
60fps → 每帧预算 ≈16.7ms。这 16.7ms 要装下:更新段 + 渲染段(含 GPU 绘制)。所以:
不要在循环里做重活:不要每帧
new对象/几何体/材质、不要每帧发网络请求、不要每帧改 DOM能缓存就缓存:几何体、材质、纹理在初始化时创建一次,循环里只改引用
4.3 如何测 FPS(三条路)
// ① 浏览器工具:Performance 面板记录帧率(最权威,交付验收用)
// ② 简易计数器:每 1 秒数一次 rAF 回调次数
let frames = 0;
setInterval(() => {
console.log(`FPS: ${frames}`);
frames = 0;
}, 1000);
// tick 里 frames++
// ③ three/stats:官方 FPS 面板(开发时可视化)
本周红线提醒:先做出来、看得出,再谈优化。FPS 现在只要求"能测、够线",真正的 draw call 优化是 Day 106+。
五、动手:让立方体自转
把 Day 99 的 createMiniScene 升级为带循环的版本:
// src/core/three/animated-scene.ts(Day 99 mini-scene 的循环升级版)
import * as THREE from "three";
/**
* 会自转的 3D 场景工厂
* 返回值:start 启动循环(返回停止函数);也可直接改 cube 属性做更多动画
*/
export function createAnimatedScene(canvas: HTMLCanvasElement): {
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
renderer: THREE.WebGLRenderer;
/** 启动渲染循环,返回停止函数(成对纪律) */
start(): () => void;
} {
// —— 场景搭建(Day 99 原样复用)——
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d1421);
const camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 1000);
camera.position.set(3, 2, 5);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
// 立方体:几何 + 材质(Standard 需要光照)
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x00c6ff });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
// 光照:环境光兜底 + 方向光给立体感(Day 101 深入)
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
scene.add(new THREE.DirectionalLight(0xffffff, 0.8));
// —— 渲染循环 ——
const clock = new THREE.Clock(); // 时间源:一帧一个 delta
let rafId = 0;
const tick = () => {
// 更新段:按真实时间旋转(与帧率解耦)
const delta = clock.getDelta();
cube.rotation.y += 0.8 * delta; // 绕 Y 轴自转
// 渲染段
renderer.render(scene, camera);
// 排队段
rafId = requestAnimationFrame(tick);
};
return {
scene,
camera,
renderer,
start() {
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId); // 停止函数
},
};
}
试着把
0.8改大改小——你会发现无论机器快慢,转动快慢只由这个"速度常数"决定,这就是时间纪律的威力。
六、放进 Vue:生命周期中的循环管理
循环是"挂载时启动、卸载时停止"的典型资源——放进 Vue 生命周期,沿用 Day 69/99 的成对纪律:
<!-- src/components/ThreeViewport.vue(Day 99 升级版:循环版) -->
<script setup lang="ts">
/**
* 3D 视口组件:管理渲染循环的生命周期
* onMounted —— 创建场景 + 启动循环
* onUnmounted —— 停止循环 + dispose 渲染器(成对纪律)
*/
import { onMounted, onUnmounted, ref } from "vue";
import { createAnimatedScene } from "../core/three/animated-scene";
const canvas = ref<HTMLCanvasElement>();
let dispose: (() => void) | null = null; // 清理函数:停止循环 + 释放 GPU
onMounted(() => {
const anim = createAnimatedScene(canvas.value!);
// 启动循环,拿到停止函数
const stopLoop = anim.start();
// 清理协议(成对纪律:start ↔ stop,mounted ↔ unmounted)
dispose = () => {
stopLoop(); // 1. 停止 rAF 循环(否则卸载后还在画)
anim.renderer.dispose(); // 2. 释放 GPU 上下文
};
});
onUnmounted(() => dispose?.());
</script>
<template>
<canvas ref="canvas" class="three-viewport" />
</template>
<style scoped>
.three-viewport { width: 100%; height: 100%; display: block; }
</style>
两个清理动作缺一不可:
stopLoop()—— 停止cancelAnimationFrame,否则组件卸载后循环仍在跑(还引用已销毁的 canvas)renderer.dispose()—— 释放 GPU 显存(Day 99 已预习)
少了任何一个:前者导致"切走页面后 CPU/GPU 还在空转",后者导致"多次进出后显存持续上涨"——Day 105 会用 Performance 面板验证这两条。
七、对照表第 1 行:手写 WebGL vs Three.js
本周红线第 5 条:对照表持续更新。今天是第一行——渲染循环(也顺带看到了矩阵变换的入口):
结论:手写 WebGL 不是不能做,而是"每一帧的每一个矩阵都要自己管"。Three.js 把这些封装成"改属性 → 自动重算"。理解封装的价值,但不陷入封装——你不需要手写矩阵,但要知道"改 rotation 背后发生了什么"(面试追问点)。
这张表每学一个概念补一行:Day 101 补光照,Day 102 补几何体。
八、常见坑点
坑 1:循环只跑一帧就停
症状:画面动一下就不动了。原因:tick 里忘了最后 requestAnimationFrame(tick)(自循环断裂)。检查三段式是否齐全。
坑 2:动画速度随帧率变
症状:在不同机器上转速明显不同。原因:rotation += 0.01 这种"每帧固定增量"。修法:乘上 clock.getDelta()。
坑 3:卸载后还在跑(空转)
症状:切换路由后 CPU 占用不降。原因:只 start 没 stop。修法:onUnmounted 里 cancelAnimationFrame。
坑 4:每帧 new 对象
症状:FPS 骤降、内存上涨。原因:循环里 new THREE.Vector3()、new BoxGeometry() 等。修法:初始化时建一次,循环里只改属性。
坑 5:setInterval 驱动渲染
症状:动画抖动、与刷新率错位。原因:用 setInterval 代替 rAF。修法:渲染循环只用 rAF(setInterval 只适合低频数据轮询)。
九、自测挑战
T1 · 会自转的立方体(60 分钟)
完成第一至六节:青色立方体匀速自转,转速与机器无关。改速度常数观察快慢;在 Performance 面板确认 FPS ≥ 50。
T2 · 帧率绑定实验(30 分钟)
故意写"每帧固定增量"版本,然后在浏览器里人为降低刷新率/加负载,观察转速变化——亲手体验"帧率绑定"的危害,再把 Clock 改回来对比。
T3 · 呼吸灯动效(30 分钟)
用 getElapsedTime() + Math.sin 给立方体加"呼吸"效果(材质亮度往复波动),让它同时自转 + 呼吸。两个动画都基于时间,互不干扰。
T4 · 卸载验证(20 分钟)
在组件里加一个"进入/离开"开关,反复切换后打开浏览器 Task Manager / Performance 面板,确认停止循环后 GPU 占用回落、显存不涨。
十、总结
立方体开始自转了。明天给它"质感":光照与材质,让工业场景真正立起来。
明日预告:Day 101 光照与材质——AmbientLight/DirectionalLight/PointLight 怎么选,MeshBasic vs MeshStandard 什么时候用哪个,以及工业风的配色纪律(Day 66 token 的 3D 版)。