前言
为了满足工作需求,我已着手学习 Three.js,并决定详细记录这一学习过程。在此旅程中,如果出现理解偏差或有其他更佳的学习方法,请大家不吝赐教,在评论区给予指正或分享您的宝贵建议,我将不胜感激。
项目基础
请参考hello,正方体 2!。
1. 抗锯齿
除非直线完全水平或垂直,否则使用方形像素绘制直线是很困难的。我们将使用一种称为抗锯齿(AA) 的技术来解决这个问题。
1.1 多重采样抗锯齿 (MSAA)
抗锯齿是使用内置的 WebGL 方法执行的,即 多重采样抗锯齿 (MSAA)。
1.2 启用抗锯齿
renderer.js: 添加如下
import { WebGLRenderer } from "three";/*** @description - 创建渲染器* @returns {WebGLRenderer} - 渲染器实例*/
export const createRenderder = () => {// 创建WebGLRenderer类的一个实例// 打开抗锯齿antialiasconst renderer = new WebGLRenderer({ antialias: true });// 启用物理上正确的光照renderer.physicallyCorrectLights = true;return renderer;
};
注意
一旦创建了渲染器,就无法更改此设置。
2. 浏览器窗口大小变化
目的是为了用户调整预览窗口的大小,场景适应新的大小。
2.1 扩展 Resizer 类
- 因为初始化时要设置初始大小,页面变化时也要重新设置大小,所有要把这部分设置代码移入到一个函数里。
- 将使用 ResizeObserver 来监听容器的大小变化。
- 因为我们只调用了.render 一次,它在画布中绘制了一个帧。当画布被调整大小时,这个框架被拉伸以适应新的大小。所以要创建一个 onResize 钩子,每次调整大小事件触发时生成一个新帧。
- 在 World 中使用 onResize 函数。
Resizer.js:添加如下
import { PerspectiveCamera, WebGLRenderer } from "three";
class Resizer {#container; // 容器#camera; // 相机#renderer; // 渲染器onResize = () => {}; // 页面大小变化钩子函数/*** @param {Element} container - 容器* @param {PerspectiveCamera} camera - 相机* @param {WebGLRenderer} renderer - 渲染器*/constructor(container, camera, renderer) {this.#container = container;this.#camera = camera;this.#renderer = renderer;// 初始化sizethis.#setSize();// 监听容器大小变化const resizeObserver = new ResizeObserver(() => {this.#setSize();this.onResize();});resizeObserver.observe(container);}#setSize() {this.#camera.aspect =this.#container.clientWidth / this.#container.clientHeight;this.#camera.updateProjectionMatrix();this.#renderer.setSize(this.#container.clientWidth,this.#container.clientHeight);this.#renderer.setPixelRatio(window.devicePixelRatio);}
}
export { Resizer };
World.js:添加如下
import { createScene } from "./components/scene";
import { createCamera } from "./components/camera";
import { createCude } from "./components/cube";
import { createLights } from "./components/lights";
import { createRenderder } from "./systems/renderer";
import { Resizer } from "./systems/Resizer";
class World {#scene; // 场景#camera; // 相机#renderer; // 渲染/*** @param {Element} container - 容器*/constructor(container) {this.#scene = createScene();this.#camera = createCamera();this.#renderer = createRenderder();container.append(this.#renderer.domElement);const cube = createCude();const light = createLights();this.#scene.add(cube, light);const resizer = new Resizer(container, this.#camera, this.#renderer);resizer.onResize = () => {this.render();};}/*** @description - 渲染函数*/render() {this.#renderer.render(this.#scene, this.#camera);}
}export { World };
3. 动画循环(运动)
为立方体添加一个简单的旋转动画。
- 创建一个动画循环
创建 systems/Loop.js 模块并在其中创建一个新 Loop 类
import { PerspectiveCamera, Scene, WebGLRenderer } from "three";
class Loop {#camera; // 相机#scene; // 场景#renderer; // 渲染/*** @param {PerspectiveCamera} camera - 相机* @param {Scene} scene - 场景* @param {WebGLRenderer} renderer - 渲染*/constructor(camera, scene, renderer) {this.#camera = camera;this.#scene = scene;this.#renderer = renderer;}/*** @description - 动画循环开始*/start() {}/*** @description - 动画循环结束*/stop() {}
}export { Loop };
- 在 World 中,将这个新类添加到导入列表中:
World.js: 添加如下
import { createScene } from "./components/scene";
import { createCamera } from "./components/camera";
import { createCude } from "./components/cube";
import { createLights } from "./components/lights";
import { createRenderder } from "./systems/renderer";
import { Resizer } from "./systems/Resizer";
import { Loop } from "./systems/Loop";
class World {#scene; // 场景#camera; // 相机#renderer; // 渲染#loop; // 动画循环/*** @param {Element} container - 容器*/constructor(container) {this.#scene = createScene();this.#camera = createCamera();this.#renderer = createRenderder();this.#loop = new Loop(this.#camera, this.#scene, this.#renderer);container.append(this.#renderer.domElement);const cube = createCude();const light = createLights();this.#scene.add(cube, light);const resizer = new Resizer(container, this.#camera, this.#renderer);resizer.onResize = () => {this.render();};}/*** @description - 渲染函数*/render() {this.#renderer.render(this.#scene, this.#camera);}/*** @description - 动画循环开始*/start() {this.#loop.start();}/*** @description - 动画循环结束*/stop() {this.#loop.stop();}
}export { World };
- 启动动画循环
main.js:添加如下
import { World } from "../World/World";function main() {// 获取容器const container = document.querySelector("#scene-container");// 创建一个world类实例const world = new World(container);// 开始动画循环world.start();
}main();
- 使用.setAnimationLoop 创建循环
在内部,循环是使用 .requestAnimationFrame。这种内置的浏览器方法可以智能地安排帧与显示器的刷新率同步,如果您的硬件跟不上,它会平滑地降低帧率。然而,.setAnimationLoop 还有一点额外的魔力可以确保循环在虚拟现实和增强现实环境中工作。
Loop.js:添加如下
import { PerspectiveCamera, Scene, WebGLRenderer } from "three";
class Loop {#camera; // 相机#scene; // 场景#renderer; // 渲染/*** @param {PerspectiveCamera} camera - 相机* @param {Scene} scene - 场景* @param {WebGLRenderer} renderer - 渲染*/constructor(camera, scene, renderer) {this.#camera = camera;this.#scene = scene;this.#renderer = renderer;}/*** @description - 动画循环开始*/start() {this.#renderer.setAnimationLoop(() => {this.#renderer.render(this.#scene, this.#camera);});}/*** @description - 动画循环结束*/stop() {this.#renderer.setAnimationLoop(null);}
}export { Loop };
- 移除 onResize 钩子
现在循环正在运行,每当我们调整窗口大小时,都会在循环的下一次迭代中生成一个新帧。这足够快,不会注意到任何延迟,因此我们不再需要在调整大小时手动重绘场景。从 World 中移除 resizer.onResize 钩子
World.js:添加如下
import { createScene } from "./components/scene";
import { createCamera } from "./components/camera";
import { createCude } from "./components/cube";
import { createLights } from "./components/lights";
import { createRenderder } from "./systems/renderer";
import { Resizer } from "./systems/Resizer";
import { Loop } from "./systems/Loop";
class World {#scene; // 场景#camera; // 相机#renderer; // 渲染#loop; // 动画循环/*** @param {Element} container - 容器*/constructor(container) {this.#scene = createScene();this.#camera = createCamera();this.#renderer = createRenderder();this.#loop = new Loop(this.#camera, this.#scene, this.#renderer);container.append(this.#renderer.domElement);const cube = createCude();const light = createLights();this.#scene.add(cube, light);new Resizer(container, this.#camera, this.#renderer);}/*** @description - 渲染函数*/render() {this.#renderer.render(this.#scene, this.#camera);}/*** @description - 动画循环开始*/start() {this.#loop.start();}/*** @description - 动画循环结束*/stop() {this.#loop.stop();}
}export { World };
4. 动画系统
每次循环运行时,希望通过将它们向前移动一帧来更新所有这些动画。就在我们渲染每一帧之前,我们会让立方体旋转一点点, 几乎是肉眼无法看到的微小量,但随着时间的推移会产生流畅的动画效果。
- Loop.tick 方法
为了处理所有这些,我们需要一个更新所有动画的函数,并且这个函数应该在每一帧开始时运行一次。
Loop.js:添加如下
import { PerspectiveCamera, Scene, WebGLRenderer } from "three";
class Loop {#camera; // 相机#scene; // 场景#renderer; // 渲染/*** @param {PerspectiveCamera} camera - 相机* @param {Scene} scene - 场景* @param {WebGLRenderer} renderer - 渲染*/constructor(camera, scene, renderer) {this.#camera = camera;this.#scene = scene;this.#renderer = renderer;}/*** @description - 动画循环开始*/start() {this.#renderer.setAnimationLoop(() => {this.tick();this.#renderer.render(this.#scene, this.#camera);});}/*** @description - 动画循环结束*/stop() {this.#renderer.setAnimationLoop(null);}/*** @description - 更新动画*/tick() {}
}export { Loop };
- cube.tick 方法
为动画对象创建一个.tick 方法,更新自身。
cube.js: 添加如下
import { BoxGeometry, Mesh, MeshStandardMaterial, MathUtils } from "three";
/*** @description - 创建立方体* @returns {Mesh} - 网格实例*/export const createCude = () => {// 创建边长为2的几何体(就是边长2米)const geometry = new BoxGeometry(2, 2, 2);// 创建一个高质量、通用、物理精确的材料 设置颜色为紫色const material = new MeshStandardMaterial({ color: "purple" });// 创建一个网格添加几何体和材质const cube = new Mesh(geometry, material);// 旋转立方体cube.rotation.set(-0.5, -0.1, 0.8);// 添加更新动画方法tickcube.tick = () => {cube.rotation.z += 0.01;cube.rotation.x += 0.01;cube.rotation.y += 0.01;};return cube;
};
注意
像这样在运行时向现有类添加属性称为 猴子补丁。
- Loop.#updatables
循环类中的动画对象列表。
Loop.js:添加如下
import { PerspectiveCamera, Scene, WebGLRenderer, Object3D } from "three";
class Loop {#camera; // 相机#scene; // 场景#renderer; // 渲染#updatables = []; // 循环里的动画对象列表/*** @param {PerspectiveCamera} camera - 相机* @param {Scene} scene - 场景* @param {WebGLRenderer} renderer - 渲染* @param {Object3D[]} updatables - 循环里的动画对象列表*/constructor(camera, scene, renderer, updatables = []) {this.#camera = camera;this.#scene = scene;this.#renderer = renderer;this.#updatables = updatables;}/*** @description - 动画循环开始*/start() {this.#renderer.setAnimationLoop(() => {this.tick();this.#renderer.render(this.#scene, this.#camera);});}/*** @description - 动画循环结束*/stop() {this.#renderer.setAnimationLoop(null);}/*** @description - 更新动画*/tick() {for (const object of this.#updatables) {object.tick();}}/*** @description - 添加动画对象* @param {...Object3D} object*/addObjectToUpdatables(...object) {this.#updatables.push(...object);}/*** @description - 删除动画对象* @param {...Object3D} object*/removeObjectFromUpdatables(...object) {this.#updatables = this.#updatables.filter((obj) => !object.includes(obj));}
}export { Loop };
- 添加 cube 到 Loop.#updatables
World.js:添加如下
import { createScene } from "./components/scene";
import { createCamera } from "./components/camera";
import { createCude } from "./components/cube";
import { createLights } from "./components/lights";
import { createRenderder } from "./systems/renderer";
import { Resizer } from "./systems/Resizer";
import { Loop } from "./systems/Loop";
class World {#scene; // 场景#camera; // 相机#renderer; // 渲染#loop; // 动画循环/*** @param {Element} container - 容器*/constructor(container) {this.#scene = createScene();this.#camera = createCamera();this.#renderer = createRenderder();this.#loop = new Loop(this.#camera, this.#scene, this.#renderer);container.append(this.#renderer.domElement);const cube = createCude();this.#loop.addObjectToUpdatables(cube);const light = createLights();this.#scene.add(cube, light);new Resizer(container, this.#camera, this.#renderer);}/*** @description - 渲染函数*/render() {this.#renderer.render(this.#scene, this.#camera);}/*** @description - 动画循环开始*/start() {this.#loop.start();}/*** @description - 动画循环结束*/stop() {this.#loop.stop();}
}export { World };
小结
立方体应该立即开始旋转。
5. 将动画速度与帧速率解耦
我们动画的速度取决于观看它的设备。我们的动画循环不会以固定速率生成帧。这意味着,在 60Hz 屏幕上,目标帧率为 60FPS,在 90Hz 屏幕上,目标帧率为 90FPS,以此类推。
在每一种情况下,动画循环都会以较低的速率生成帧,并且这个速率可能会因为许多因素从一个时刻到下一个时刻波动。这称为可变帧速率。
为了防止这种情况,我们需要将动画速度与帧速率解耦。我们将这样做:当我们告诉一个对象.tick 前进一帧时,我们将根据前一帧花费的时间来缩放移动的大小。
- 使用 Clock 类
用 Clock.getDelta 来衡量前一帧花了多长时间。.getDelta 告诉我们自上次调用.getDelta 以来已经过去了多少时间。
将结果保存在一个名为 delta 的变量中,然后我们将其传递给每个动画对象的.tick 方法。
Loop.js:添加如下
import {PerspectiveCamera,Scene,WebGLRenderer,Object3D,Clock,
} from "three";
class Loop {#camera; // 相机#scene; // 场景#renderer; // 渲染#updatables = []; // 循环里的动画对象列表#clock; // 秒表/*** @param {PerspectiveCamera} camera - 相机* @param {Scene} scene - 场景* @param {WebGLRenderer} renderer - 渲染* @param {Object3D[]} updatables - 循环里的动画对象列表*/constructor(camera, scene, renderer, updatables = []) {this.#camera = camera;this.#scene = scene;this.#renderer = renderer;this.#updatables = updatables;this.#clock = new Clock();}/*** @description - 动画循环开始*/start() {this.#renderer.setAnimationLoop(() => {this.tick();this.#renderer.render(this.#scene, this.#camera);});}/*** @description - 动画循环结束*/stop() {this.#renderer.setAnimationLoop(null);}/*** @description - 更新动画*/tick() {// .getDelta告诉我们自上次调用.getDelta以来已经过去了多少时间。const delta = this.#clock.getDelta();for (const object of this.#updatables) {object.tick(delta);}}/*** @description - 添加动画对象* @param {...Object3D} object*/addObjectToUpdatables(...object) {this.#updatables.push(...object);}/*** @description - 删除动画对象* @param {...Object3D} object*/removeObjectFromUpdatables(...object) {this.#updatables = this.#updatables.filter((obj) => !object.includes(obj));}
}export { Loop };
cube.js:添加如下
import { BoxGeometry, Mesh, MeshStandardMaterial, MathUtils } from "three";
/*** @description - 创建立方体* @returns {Mesh} - 网格实例*/export const createCude = () => {// 创建边长为2的几何体(就是边长2米)const geometry = new BoxGeometry(2, 2, 2);// 创建一个高质量、通用、物理精确的材料 设置颜色为紫色const material = new MeshStandardMaterial({ color: "purple" });// 创建一个网格添加几何体和材质const cube = new Mesh(geometry, material);// 旋转立方体cube.rotation.set(-0.5, -0.1, 0.8);// 度数转弧度const radiansPerSecond = MathUtils.degToRad(30);// 添加更新动画方法tickcube.tick = (delta) => {const radian = radiansPerSecond * delta;cube.rotation.z += radian;cube.rotation.x += radian;cube.rotation.y += radian;};return cube;
};
6. 总结
至此已经全部完成。你好,正方体 3!如果出现理解偏差或有其他更佳的学习方法,请大家不吝赐教,在评论区给予指正或分享您的宝贵建议,我将不胜感激。
主要文献
three.js 官网
《discoverthreejs》