【Three.js】day100-rendering-loop

作者:mario 发布时间: 2026-09-07 阅读量:4 评论数:0

Day 100 · 渲染循环与动画纪律 — 让世界动起来

Day 99 我们"画了一次"——静态立方体。今天学习 3D 的灵魂:渲染循环。3D 不像 2D 那样可以做脏检查/局部重绘,它每一帧都要把整个场景重新画一遍,所以"持续渲染"不是可选项而是默认形态。今天要建立三样东西:rAF 循环骨架Clock 时间纪律(动画速度与帧率解耦)、FPS 性能基线。做完,你会有第一个"会自转的 3D 场景",同时拿到"手写 WebGL vs Three.js"对照表的第一行。


目录


一、为什么 3D 是"整帧渲染"

1.1 2D 与 3D 的更新模型差异

维度

2D 大屏(ECharts/Canvas)

3D 场景(Three.js)

更新方式

局部重绘/脏检查/echarts.setOption 增量

每帧全场景重绘

原因

2D 图形相互独立,可只改脏区

光影/透视/遮挡互相影响,GPU 必须整帧重建

典型做法

数据变化才重绘

每帧无条件 render()

为什么不能像 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

特性

requestAnimationFrame

setInterval(16)

与屏幕刷新同步

✅ 由浏览器对齐 vsync

❌ 与刷新率无关

标签页隐藏时

自动暂停(省电)

照常执行(浪费)

丢帧表现

跳过回调,不堆积

可能回调堆积/卡顿

最佳实践

渲染循环只用 rAF

数据轮询/低频任务可用

记忆钩子: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 目标与基线

场景

目标 FPS

备注

桌面浏览器

60

与刷新率一致

大屏(投屏/降帧机器)

≥ 50

本周验收线(Week 10 同款纪律)

明显卡顿

< 30

视为失败,需排查

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>

两个清理动作缺一不可

  1. stopLoop() —— 停止 cancelAnimationFrame,否则组件卸载后循环仍在跑(还引用已销毁的 canvas)

  2. renderer.dispose() —— 释放 GPU 显存(Day 99 已预习)

少了任何一个:前者导致"切走页面后 CPU/GPU 还在空转",后者导致"多次进出后显存持续上涨"——Day 105 会用 Performance 面板验证这两条。


七、对照表第 1 行:手写 WebGL vs Three.js

本周红线第 5 条:对照表持续更新。今天是第一行——渲染循环(也顺带看到了矩阵变换的入口):

环节

手写 WebGL

Three.js

渲染循环

requestAnimationFrame 每帧:更新矩阵 → 上传 uniform → drawElements

renderer.render(scene, camera) 一行搞定

模型矩阵

自己维护 mat4gl-matrix),算旋转/平移

cube.rotation.y += ...(内部自动算矩阵)

视图/投影矩阵

自己算相机矩阵 + perspective()

PerspectiveCamera 属性自动生成

结论:手写 WebGL 不是不能做,而是"每一帧的每一个矩阵都要自己管"。Three.js 把这些封装成"改属性 → 自动重算"。理解封装的价值,但不陷入封装——你不需要手写矩阵,但要知道"改 rotation 背后发生了什么"(面试追问点)。

这张表每学一个概念补一行:Day 101 补光照,Day 102 补几何体。


八、常见坑点

坑 1:循环只跑一帧就停

症状:画面动一下就不动了。原因:tick 里忘了最后 requestAnimationFrame(tick)(自循环断裂)。检查三段式是否齐全

坑 2:动画速度随帧率变

症状:在不同机器上转速明显不同。原因:rotation += 0.01 这种"每帧固定增量"。修法:乘上 clock.getDelta()

坑 3:卸载后还在跑(空转)

症状:切换路由后 CPU 占用不降。原因:只 start 没 stop。修法:onUnmountedcancelAnimationFrame

坑 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 占用回落、显存不涨。


十、总结

环节

要点

整帧渲染

3D 每帧全场景重绘,循环是默认形态而非优化

三段式

更新 → 渲染 → 排队(每帧固定骨架)

时间纪律

clock.getDelta() 驱动动画,速度与帧率解耦

FPS 基线

桌面 60 / 大屏 ≥ 50,每帧预算 ≈16.7ms

Vue 生命周期

onMounted 启动 ↔ onUnmounted 停止+dispose(成对)

对照表

第 1 行:手写矩阵 vs 自动重算——理解封装不陷入封装

立方体开始自转了。明天给它"质感":光照与材质,让工业场景真正立起来。


明日预告:Day 101 光照与材质——AmbientLight/DirectionalLight/PointLight 怎么选,MeshBasic vs MeshStandard 什么时候用哪个,以及工业风的配色纪律(Day 66 token 的 3D 版)。

评论