基础信息
官方文档
基本介绍
Three.js 是一个基于 JavaScript 的开源 3D 图形库,用于在浏览器中创建和显示动画的 3D 图形。它简化了 WebGL 的使用,通过封装底层的复杂操作,为开发者提供了更直观的 API 来创建丰富的 3D 体验。
官网:→ threejs.org/
主要特点
Three.js 的主要特点
- 跨平台:运行于现代浏览器中,基于 WebGL 技术,无需插件。
- 简单易用:封装了 WebGL 的复杂操作,提供更简洁的接口。
- 功能强大:支持 3D 模型加载、光照、材质、动画、物理效果等功能。
- 高性能:使用底层的 WebGL API,性能优异。
常见用途
Three.js 的常见用途
- 3D 游戏开发:通过物理引擎和动画创建交互式 3D 游戏。
- 数据可视化:将复杂的数据以 3D 图表或几何方式展现。
- 虚拟现实 (VR) 和增强现实 (AR) :与 WebXR 结合,提供沉浸式体验。
- 建筑和工业设计:在线展示产品模型或建筑设计。
开发工具
调试工具
- lil-gui:一个轻量级库,用于创建调试用的图形界面
-
动态调整 Three.js 场景中的参数(如 灯光、材质颜色 等)
-
完全支持开发者,自定义响应逻辑
-
# 引入方式一(不推荐理由:在 threejs 中,内置的也是 lil-gui 包构建产物 lib)
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'
# 引入方式二(推荐)
# 安装:npm install lil-gui --save-dev
import GUI from 'lil-gui'
3D 模型设计与优化
- Blender: 一款免费且开源的 3D 建模、渲染、动画工具。
- 创建或修改 3D 模型。
- 导出为支持 Three.js 的格式(如 glTF/glb)。
- 官网:www.blendercn.org/
- glTF Viewer:用于预览和检查 glTF/glb 模型的工具
辅助库
- Stats.js: 性能监控工具(three.js npm 包 资源自带辅助库)
- 实时显示 FPS 和内存使用情况
import Stats from 'three/examples/jsm/libs/stats.module.js';
stats = new Stats();
- GSAP:一个高性能和强大的 Javascript 动画库
- 实现 Three.js 场景中的平滑过渡和复杂动画
- 实现任何其他场景中平滑过渡和复杂动画
- gsap.com/
npm install gsap # 安装
import { gsap } from "gsap"
gsap.to(object, { property: value, duration: value })
浏览器调试
Rendering
我们知道 浏览器渲染页面 的过程是:
developer.mozilla.org/zh-CN/docs/…
通过帧渲染统计数据实时查看每秒帧数,以 绘制帧吞吐量、丢帧分布和 GPU 内存等
三维世界
三维世界,是指构建 3D 场景的虚拟空间,它包括 场景(宇宙空间)、对象、光源、相机 和 渲染器 等关键概念。允许用户和开发者创建复杂的 3D 图形、交互效果 与 动画。
基本构成
在对三维世界,有了个简单的全局认识后,我们来尝试快速创造一个可见的三维世界吧!
快速上手
目标: 以最快速度,用 three.js 创造一个可见的三维世界!
安装准备
目标: 快速完成 three.js 的准备工作
- 通过 npm 或 yarn 安装 three.js 包
npm install three
- 和 echarts 图表需要准备根节点一样,three.js 也需要准备一个要挂载的目标节点
<template>
<!-- 3D 画布根节点 -->
<div ref="paintEl" class="paint-three-root">
<!-- 如果不指定宽高,canvas 的默认宽高为 300px * 150px -->
<canvas ref="canvasEl" class="paint-three-canvas"></canvas>
</div>
</template>
<style scoped>
.paint-three-root {
width: 100%;
height: 100%;
position: relative;
.paint-three-canvas {
width: 100%;
height: 100%;
display: block;
/**
* canvas 的 display 默认为 inline
* 而行内元素的末尾会有空格!
* 通过设置 canvas 为块级元素,
* 就能消除这个空格
*/
}
}
</style>
创建场景
目标: 创建一个什么都没有的场景画作
<script setup>
import { onMounted, ref } from 'vue'
import { Scene, PerspectiveCamera, WebGLRenderer } from 'three'
const paintEl = ref()
// 创建 Three.js 画作
function createThreePainting(width, height) {
const scene = new Scene()
const camera = new PerspectiveCamera(75, width / height, 0.1, 1000)
const renderer = new WebGLRenderer()
renderer.setSize(width, height)
renderer.render(scene, camera)
paintEl.value.append(renderer.domElement)
}
onMounted(() => {
const width = paintEl.value.clientWidth
const height = paintEl.value.clientHeight
createThreePainting(width, height)
})
</script>
👇👇👇效果 👇👇👇
添加立方体
目标: 添加一个旋转的立方体(用 网格模型 表示)
<script setup>
import {
Mesh,
Scene,
Color,
BoxGeometry,
WebGLRendererm,
MeshBasicMaterial,
PerspectiveCamera
} from 'three'
import { onMounted, ref } from 'vue'
const paintEl = ref()
// 创建 Three.js 画作
function createThreePainting(width, height) {
const scene = new Scene()
const camera = new PerspectiveCamera(75, width / height, 0.1, 1000)
const renderer = new WebGLRenderer({ antialias: true }) // 启用抗锯齿
const geometry = new BoxGeometry(1, 1, 1) // 几何体
const material = new MeshBasicMaterial({ color: new Color('skyblue') })
const cube = new Mesh(geometry, material)
scene.add(cube)
camera.position.set(0, 0, 5)
renderer.setSize(width, height)
// 动画帧改变
function animate() {
cube.rotation.x += 0.01
cube.rotation.y += 0.01
renderer.render(scene, camera)
}
renderer.setAnimationLoop(animate)
paintEl.value.append(renderer.domElement)
}
onMounted(() => {
const width = paintEl.value.clientWidth
const height = paintEl.value.clientHeight
createThreePainting(width, height)
})
</script>
👇👇👇效果 👇👇👇
检查兼容性
目标: 抽离暴露通用的 WebGL 兼容性检查能力
尽管这个问题越来越不严重,但某些设备或浏览器可能仍然不支持 WebGL 2。以下方法允许您检查它是否受支持,如果不支持,则向用户显示一条消息。
我们定义并导入 WebGL 支持检测模块,并在 尝试渲染任何内容之前 运行以下命令:
import WebGL from 'three/examples/jsm/capabilities/WebGL.js'
if (WebGL.isWebGL2Available()) {
// Initiate function or other initializations here
} else {
const warning = WebGL.getWebGL2ErrorMessage()
document.getElementById( 'container' ).appendChild( warning )
}
但这样的检查方式,似乎并不具备可移植性,我们改造下
👇👇👇改造后 👇👇👇
import WebGL from 'three/examples/jsm/capabilities/WebGL.js'
// 判断根节点是否可用
export function isElAvailable(el) {
return el instanceof HTMLElement
}
/**
* 兼容性检查:浏览器是否支持 WebGL(以Promise的形式暴露)
* el: 画布根节点(HTMLElement 元素)
*/
// 兼容性检查:浏览器是否支持 WebGL
export function isWebGL2Available(el) {
return new Promise((resolve, reject) => {
if (WebGL.isWebGL2Available()) {
resolve()
} else {
const warning = WebGL.getWebGL2ErrorMessage()
if (isElAvailable(el) && el.appendChild) el.appendChild(warning)
reject(new Error('Your graphics card does not seem to support WebGL2'))
}
})
}
👇👇👇不支持 WebGL2 的效果 👇👇👇
当然您也可以根据需要自定义【不兼容】时的提示内容及样式。
因与 three.js 无关,故这里不再赘述。
添加一条线
目标: 添加一条折线(带拐点)
<script setup>
import {
Line,
Mesh,
Scene,
Color,
Vector3,
BoxGeometry,
WebGLRenderer,
BufferGeometry,
LineBasicMaterial,
MeshBasicMaterial,
PerspectiveCamera
} from 'three'
import { onMounted, ref } from 'vue'
import { isWebGL2Available } from '@/components/three/shared/index.js'
const paintEl = ref()
// 创建 Three.js 画作
function createThreePainting(width, height) {
const scene = new Scene()
const camera = new PerspectiveCamera(75, width / height, 0.1, 1000)
const renderer = new WebGLRenderer({ antialias: true }) // 启用抗锯齿
// 添加立方体
const geometry = new BoxGeometry(1, 1, 1) // 几何体
const material = new MeshBasicMaterial({ color: new Color('skyblue') })
const cube = new Mesh(geometry, material)
// 添加一条线
const lineMaterial = new LineBasicMaterial({ color: new Color('#ffffff') })
const points = [new Vector3(-1, 0, 0), new Vector3(0, 1, 0), new Vector3(1, 0, 0)]
const lineGeometry = new BufferGeometry().setFromPoints(points)
const line = new Line(lineGeometry, lineMaterial)
scene.add(cube, line)
camera.position.set(0, 0, 5)
renderer.setSize(width, height)
// 动画帧改变
function animate() {
cube.rotation.x += 0.01
cube.rotation.y += 0.01
renderer.render(scene, camera)
}
renderer.setAnimationLoop(animate)
paintEl.value.append(renderer.domElement)
}
onMounted(async () => {
const width = paintEl.value.clientWidth
const height = paintEl.value.clientHeight
try {
await isWebGL2Available(paintEl.value)
createThreePainting(width, height)
} catch (e) {
console.warn(e)
}
})
</script>
👇👇👇效果 👇👇👇
添加一行字
目标: 添加一行文字(添加文字的方式有很多种,如下图)
本文仅介绍【使用 TextGeometry 创建 3D 立体文字】方式
// 添加一行字(使用 TextGeometry 创建 3D 文字)
const fontLoader = new FontLoader() // 字体加载器
const fontURL = 'https://threejs.org/examples/fonts/helvetiker_regular.typeface.json'
let textMesh = null
fontLoader.load(fontURL, (font) => {
const textGeometry = new TextGeometry('Hello, Three.js!', {
font: font,
size: 0.5, // 字体大小
depth: 0.01, // 字体厚度
curveSegments: 12, // 曲线段数
bevelEnabled: true, // 是否启用斜角
bevelThickness: 0.5, // 斜角厚度
bevelSize: 0.03, // 斜角大小
bevelSegments: 5, // 斜角段数
})
const textMaterial = new MeshBasicMaterial({ color: 0x00ff00 })
textMesh = new Mesh(textGeometry, textMaterial)
textMesh.position.set(0, -2, 0) // 调整文字位置
scene.add(textMesh)
})
// 动画帧改变
function animate() {
cube.rotation.x += 0.01
cube.rotation.y += 0.01
if (textMesh) {
textMesh.rotation.y += 0.01 // 绕 y 轴逆时针旋转 0.01 弧度
}
renderer.render(scene, camera)
}
👇👇👇效果 👇👇👇
加载 3D 模型
目标:认识并向画布中加载一个简单 3D 模型(一匹马)
我们可以通过不同类型的设计与优化软件,创建图形学中各种形形色色的 3D 物体,这些 3D 物体,以同样的身份、同样的数据格式存在于 3D 世界 中,我们统一称呼他们为 3D 模型。
3D 模型是 three.js 甚至 3D 图形学中一类重要资源,我们无法回避它!
3D 模型有数百种文件格式,每种格式都有不同的用途、不同的功能和不同的复杂性。
Three.js 推荐我们使用 .glb/.gltf 格式的模型文件。
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
const loader = new GLTFLoader()
loader.load('path/to/model.glb', function (gltf) {
scene.add(gltf.scene)
}, undefined, function (error) {
console.error( error )
})
// 加载一个3D模型
let horseMesh = null
const gltfLoader = new GLTFLoader()
const mainLight = new DirectionalLight('white', 8) // 创建一个直接照明(颜色,强度)
gltfLoader.load(`${import.meta.env.BASE_URL}models/study/Horse.glb`, (gltf) => {
horseMesh = gltf.scene.children[0] // 提取网格
horseMesh.position.set(-3, 0, 0)
horseMesh.scale.set(0.01, 0.01, 0.01)
scene.add(horseMesh, mainLight) // 添加网格和灯光
})
// 动画帧变化
function animate() {
cube.rotation.x += 0.01
cube.rotation.y += 0.01
if (textMesh) textMesh.rotation.y += 0.01
if (horseMesh) horseMesh.rotation.y += 0.01 // 旋转起来
renderer.render(scene, camera)
}
👇👇👇效果 👇👇👇
可以让马跑起来吗 ? ? ?
// 加载一个3D模型
let horseMesh = null
let horseMixer = null // 混合器:将静态对象转换为动画对象
const gltfLoader = new GLTFLoader()
const mainLight = new DirectionalLight('white', 8) // 创建一个直接照明(颜色,强度)
gltfLoader.load(`${import.meta.env.BASE_URL}models/study/Horse.glb`, (gltf) => {
const clip = gltf.animations[0] // 提取动画
horseMesh = gltf.scene.children[0] // 提取网格
horseMixer = new AnimationMixer(horseMesh) // 混合器:将静态对象转换为动画对象
const action = horseMixer.clipAction(clip)
action.play() // 播放动画
horseMesh.position.set(-3, 0, 0)
horseMesh.scale.set(0.01, 0.01, 0.01)
scene.add(horseMesh, mainLight) // 添加网格和灯光
})
/**** 分割线 **/
// 动画帧变化
function animate() {
cube.rotation.x += 0.01
cube.rotation.y += 0.01
if (textMesh) textMesh.rotation.y += 0.01
if (horseMesh) horseMesh.rotation.y += 0.01
if (horseMixer) horseMixer.update(0.015) // 马儿跑起来
renderer.render(scene, camera)
}
总结基本流程
按照此章节,一步步操作,最终亲眼看到自己的杰作,显示在屏幕中。
现在,我们尝试从这些操作中,挖掘些 换汤不换药、万变不离其宗 的规律,梳理出一套 3D 可视化规范流程!
注:尝试培养从现象中寻找规律的思维方式,总能让我们从纷纷扰扰、形形色色的晃眼现象中,发现事物的本质,最终全面认识它
那么,所以 ……
抛出问题: 用 Three.js 完成 布景、打光、拍摄、出片 的三维作品的制作,基本流程究竟是什么呢?
给上答案:如下图所示 (不卖关子,先上答案,再解释)
流程解释(仅作简单介绍,不会详细赘述)
- 初始设置:准备画布挂载根节点,并定义画布尺寸等
- 创建场景:场景即3D宇宙!所有3D物体存在于场景中,与现实生活中的场景,雷同!
- 创建相机:相机是场景观测视角,与现实生活中的相机作用,雷同!
- 创建物体、照明,并添加至场景中
- 创建渲染器
- 渲染场景
本节就要结束了,您肯定有很多很多疑问!!!
不得不承认,上面这些问题,是有深度、有质量的!
正如本节的 目标 所描述的:以最快速度,用 three.js 创造一个可见的三维世界!
我们并不打算在此节中,阐述 3D 世界 的一切、所有。
不过,我们已经迈出了认识 3D 世界 的 第一步(快速上手) !
接下来,就让我们带着问题,一步步地认识 Three.js,见证它的神奇吧!
三维世界
初步认识
以下 高亮块 内容,摘自 Three.js 官方文档:
Three.js 经常会和 WebGL 混淆, 但也并不总是,three.js 其实是使用 WebGL 来绘制三维效果的。 WebGL是一个只能画点、线和三角形的非常底层的系统
想要用 WebGL 来做一些实用的东西通常需要大量的代码, 这就是 Three.js 的用武之地。它封装了诸如场景、灯光、阴影、材质、贴图、空间运算等一系列功能,让你不必要再从底层 WebGL 开始写起。
- 1 个渲染器 Renderer
- 1 个场景图 ( 1 个场景 Scene 根对象、N 个网格 Mesh 对象、M 个光源 Light 对象、Q 个群组 Group 逻辑对象、X 个摄像机 Camera 对象、Y 个三维物体 Object3D 对象 等 )
- 网格对象 Mesh 是用一种特定 材质 Material, 绘制的一个特定的 几何体 Geometry
- 几何体 Geometry 对象 和 材质对象 Material 可以被多个不同网格对象 Mesh 使用
- 1 个材质对象 Material 可以引用一个或多个纹理 Texture
- 纹理(Texture) 对象通常表示一幅要么从文件中加载,要么在画布上生成,要么由另一个场景渲染出 的图像
摄像机(观察视角)
在深入学习下文之前,我们尝试达成几个 共识:
- 现实空间世界什么样,Three.js 创作的 3D 世界就可以是什么样,尽可能逼近现实
- 现实空间世界什么样,取决于我们 [人类] 的观察视角。我们闭上眼,世界就不存在
- 与现实空间世界相比,Three.js 创作的 3D 世界,自由度要高的多,您可尽情发挥
本文要介绍的 摄像机 对象,就是我们 观察 Three.js 创作的 3D 世界 的视角!
透视投影
我们本节重点介绍 透视摄像机(PerspectiveCamera) : 它与人类肉眼观察现实空间世界的视角完全一致!
- 视野 fov(field of view) 定义了平截头体扩展的角度。小视场会产生窄截锥体,而宽视场会产生宽截锥体。
- 纵横比 aspect 将平截头体与场景容器元素相匹配。当我们将其设置为容器的宽度除以其高度时,我们确保可以将类似矩形的平截头体完美的扩展到容器中。如果我们弄错了这个值,场景看起来会伸展和模糊。
- 近剪切平面 near 定义了平截头体的小端(最接近相机的点)
- 远剪裁平面 far 定义了平截头体的大端(距相机最远)
正交投影
正交投影摄像机(OrthographicCamera)常用于 2D 场景(如工程蓝图等)。我们一笔带过!
场景(拍摄空间)
正如官文文档所描述的,渲染器、场景 和 摄像机 是 Three.js 创建 3D 世界的基本三要素。
场景 是我们能看到的一切的载体
- 场景 即 世界
- Three.js 所有所需 3D 空间元素,都存在于场景中
场景图
我们会向场景中:
- 添加照明:scene.add(light)
- 添加模型:scene.add(mesh)
- 添加助手:scene.add(axesHelper)
- 添加逻辑组:scene.add(group)
他们存在于同一个根场景下,且存在以下关系,并形成一颗类似于 DOM 树的场景图:
场景图中的每个对象(顶级场景除外)只有一个父对象,并且可以有任意数量的子对象。
scene.add(mesh);
// the children array contains the mesh we added
scene.children; // -> [mesh]
// now, add a light:
scene.add(light);
// the children array now contains both the mesh and the light
scene.children; // -> [mesh, light];
// now you can access the mesh and light using array indices
scene.children[0]; // -> mesh
scene.children[1]; // -> light
坐标系
3D 空间中,物体的 变换 ,使用 笛卡尔坐标系 来描述。
在 Three.js 中,XYZ 轴的位置遵循 右手定则:
- 拇指:指向 X 轴的正向
- 食指:指向 Y 轴的正向
- 中指:指向 Z 轴的正向
世界空间坐标系
场景这个全局空间,就是我们说的 世界空间。空间的中心,即 X、Y、Z 轴的交点(原点)
当我们直接将一个对象添加到场景中,然后平移、旋转或缩放它时,该对象将相对于世界空间移动——即相对于场景的中心。
局部空间坐标系
我们可以添加到场景中的每个对象也都有一个局部坐标系
在太阳系这个宇宙空间中,地球围绕太阳转,我们可以很轻松的描述地球的移动轨迹,因为太阳和地球处于同一个世界空间坐标系中,且都相对于世界原点(太阳)。
如果我们仍然以太阳为原点的世界坐标系,描述我们在地球上的行动,就会十分吃力了!
针对类似场景,Three.js 就引入了 局部空间坐标系,来描述物体的平移等变换。
- 场景图是一系列嵌入式 坐标系,顶部有世界空间
- 每个对象都有一个坐标系
- 每当我们变换一个对象时,我们都是相对于它的父坐标系进行的
- 我们最终在屏幕上看到的是世界空间
- 每个对象都从其父对象坐标系内的原点开始
变换
在场景(世界)中,对象的 移动、变化,我们称之为 变换。
物体的 变换 是基于 各种坐标系 进行的。
空间三个基本变换 TRS:平移(translation)、旋转(rotation)、缩放(scale)
平移
我们用 向量(x, y, z) 来描述物体平移变换前后的 位置 信息 position。
// translate one axis at a time
mesh.position.x = 1;
mesh.position.y = 2;
mesh.position.z = 3;
// translate all three axes at once
mesh.position.set(1,2,3);
const origin = new Vector3();
origin.x; // 0
origin.y; // 0
origin.z; // 0
mesh.position = new Vector3();
mesh.position.x; // 0
mesh.position.y; // 0
mesh.position.z; // 0
经过观察,我们把物体从 A 点平移到 B 点后,我们终会发现:
- 无论我们以何种顺序变换 (① X)(② Y)(③ Z) ,结果都一样!
- 平移好像没有单位
-= 我们约定:平移 的单位为 米 =-
缩放
就像气球能被人放大缩小似的,Three.js 3D 世界中的物体也可以被放大缩小;
就像两小儿辩日远大近小似的,Three.js 3D 世界中的物体也可以因目标远近发生视觉变化;
但我们所要谈的 缩放 变换,仅指 前者!
// when we create a mesh...
const mesh = new Mesh();
// ... internally, three.js creates a Vector3 for us:
mesh.scale = new Vector3(1, 1, 1);
mesh.scale.set(1, 1, 2)
经过观察,我们对物体 A 进行缩放操作后,发现:
- 缩放的值是相对于对象的初始大小的比值
- 三个轴缩放值相同时,就是等比均匀缩放
- 三个轴缩放值不同时,就是不均匀缩放
- 负值时,就是镜像缩放
- 相机 和 照明 无法缩放(这完全符合我们对空间的认知)
-= 缩放 的单位为 倍数 =-
旋转
在自然界中,月球围绕地球 公转,地球围绕太阳 公转,三个各自又在 自转
这也就是 Three.js 中所描述的 旋转 变换!
与 平移、缩放 使用一个 Vector3 类描述变换不同,描述旋转的方式有 两 种:
一:Euler(欧拉角)
// when we create a mesh...
const mesh = new Mesh();
// ... internally, three.js creates an Euler for us:
mesh.rotation = new Euler();
mesh.rotation.x = 2;
mesh.rotation.y = 2;
mesh.rotation.z = 2;
mesh.rotation.set(2, 2, 2);
二:Quaternions(四元数)
// when we create a mesh
const mesh = new Mesh();
// ... internally, three.js creates an Euler for us:
// 使用欧拉角,使用Euler类表示并存储在.rotation属性中
mesh.rotation = new Euler();
// .. AND a Quaternion:
// 使用四元数,使用Quaternion类表示并存储在.quaternion属性中。
mesh.quaternion = new Quaternion();
-= 旋转的单位是 弧度 =-
import { MathUtils } from 'three';
const rads = MathUtils.degToRad(90); // 1.57079... = π/2
- 并非所有对象都可以旋转。比如 我们上一章介绍的 DirectionalLight 就不能旋转。灯光从某个位置照射到目标,灯光的角度是根据目标的位置而不是
.rotation属性计算得出的。 - three.js 中的角度是使用弧度而不是度数指定的。唯一的例外是 PerspectiveCamera.fov 属性使用度数来匹配真实世界摄影惯例的。
网格模型
网格 Mesh 是 3D 计算机图形学中最常见的可见对象
import { Mesh } from 'three';
const mesh = new Mesh(geometry, material);
如您所见,Mesh 构造函数有两个关键参数:几何体 Geometry、材质 Material
几何体与材质
几何体 定义了网格的形状
材质 定义了网格的外观
// create a geometry:cube
const geometry = new BoxBufferGeometry(2, 2, 2);
// create a default (white) Basic material
const material = new MeshBasicMaterial()
模型与设计
复杂的网格模型,往往需要专业的 3D 建模应用程序(如 blender)来创建,然后以标准的 3D资源交换格式 输出。目前在 AICFD 中,常用的 3D 模型文件类型如下:
- 几何:stp / step / igs / iges
- 面网格:stl
- 体网格:cgns / msh
- 后处理:vtk
-= 模型文件可以包含模型、动画、几何图形、材质、灯光、相机,甚至整个场景 =-
在 Web 世界中,发送 3D资源 的最佳方式,是 glTF
glTF 文件以标准和二进制形式出现。这些有不同的扩展名:
- 标准 .gltf 文件未压缩,可能附带一个额外的 .bin 数据文件。
- 二进制 .glb 文件将所有数据包含在一个文件中。
标准和二进制glTF文件都可能包含嵌入在文件中的纹理或可能引用外部纹理。由于二进制 .glb 文件要小得多,因此最好使用这种类型。另一方面,未压缩的 .gltf 在文本编辑器中很容易阅读,因此它们可能对调试有用。
针对不同类型的模型文件,Three.js 提供不同的模型加载器
渲染器
渲染器:负责将场景绘制(渲染)到<canvas>元素中。
使用渲染器
// create the renderer
const renderer = new WebGLRenderer();
// next, set the renderer to the same size as our container element
renderer.setSize(container.clientWidth, container.clientHeight);
-= 渲染场景 =-
// render, or 'create a still image', of the scene
renderer.render(scene, camera);
保存为图片
在浏览器中存在两种有效的方式进行截图。 旧的 canvas.toDataURL 与新的更好的 canvas.toBlob
canvas.toBlob((blob) => {})
// 以 Blob 形式,保存 Canvas 到本地
export function saveBlobToLocal(canvas = HTMLElement) {
try {
canvas.toBlob((blob) => {
const a = document.createElement('a')
const fileName = `Three.js-${canvas.width}x${canvas.height}.png`
a.style.display = 'none'
a.href = window.URL.createObjectURL(blob)
a.download = fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
})
} catch (e) {
console.error('Exception: Save Three.js 3D Canvas Blob To Local', e)
}
}
const canvas = this.#renderer.domElement
saveBlobToLocal(canvas)
因为基于性能和兼容性的考量,默认情况下浏览器会在绘制 3D 完成后,
清除 WebGL canvas 的缓存,以至于下载时黑白屏!
解决方案 : 是在你捕获截图前调用一次渲染代码
render()
const canvas = this.#renderer.domElement
canvas.toBlob((blob) => saveBlobToLocal(blob, canvas))
资源释放
Three.js 应用经常使用大量的内存。一个 3D 模型的所有节点,可能占用 1-20M 内存。 一个模型可能会使用很多纹理,即使它们被压缩成了图片文件,也必须被展开成为未压缩的形态来使用。每个 1024 x 1024 大小的纹理会占用 4-5M 内存。
大多数的 three.js 应用在初始化的时候加载资源,并且一直使用这些资源直到页面关闭。但是,如果你想随时间的变动加载和改变资源怎么办呢?
不像大多数的 JavaScript 库,three.js 不能自动的清除这些资源。 如果你切换页面,浏览器会清除这些资源,其它时候如何管理它们取决于你。这是 WebGL 设计的问题,three.js 没有追索权,只能将释放资源的责任托付给你。
geometry.dispose(); // 几何体销毁
texture.dispose(); // 纹理销毁
material.dispose(); // 材质销毁
light.dispose(); // 照明销毁
scene.clear(); // 场景销毁
camera.clear(); // 相机销毁
renderer.dispose(); // 渲染器销毁
性能提升
Wasm
请您移步至我的另外一篇文章:Hello Wasm
Worker
Web Worker (简称:worker) 是 HTML5 提供的一种在后台运行 JavaScript 的机制,允许开发者在不阻塞主线程(也就是浏览器的 UI 渲染 和 用户交互线程)的情况下执行耗时任务。它提供了一种将复杂计算从主线程分离出来的方法,从而提高 Web 应用程序的性能和响应性。
主要特点
- 多线程能力
Web Worker 运行在独立的线程中,与主线程互不干扰。 - 异步执行
由于 Web Worker 不会阻塞主线程,可以用来执行复杂的计算任务,同时主线程可以继续处理用户交互和界面更新。 - 受限环境
Web Worker 没有访问 DOM 的能力,也不能直接操作 UI。它的作用仅限于计算逻辑,可以通过消息机制与主线程通信。 - 跨浏览器支持
Web Worker 在现代主流浏览器中均被支持。
应用场景
- 复杂计算任务
如数据加密/解密、图像处理、大量数据计算等。 - 文件处理
在前端处理用户上传的文件,避免主线程阻塞。 - 实时数据处理
如 WebSocket 消息解析、数据流处理等。 - 游戏开发
用于运行独立的游戏逻辑或 AI 计算。
注意事项
- 无 DOM 访问
Worker 无法直接操作 DOM。如果需要与页面交互,必须通过主线程完成。 - 与主线程通信
使用postMessage和onmessage进行双向通信。 - 安全性
Worker 的脚本必须来自与主页面相同的源,或者通过 CORS 允许跨域。 - 资源消耗
每个 Worker 都有一定的开销(线程、内存),避免创建过多的 Worker。
不同类型
- Dedicated Worker
专用 Worker,仅供创建它的主线程使用。 - Shared Worker
共享 Worker,可以被同一页面的多个脚本共享。 - Service Worker
特殊类型的 Worker,主要用于实现离线缓存和网络代理功能,例如 PWA(渐进式 Web 应用)。
使用步骤
Step1:创建独立的 worker.js 文件
import wasmUrl from '/wasm/release.wasm?url'
import { fetchWasmUrl } from '@/utils/request.js'
let wasmExports = null
// 实例化 wasm 资源
const reifyWasm = () => {
fetchWasmUrl(wasmUrl)
.then(({ instance }) => {
wasmExports = instance.exports
})
}
reifyWasm()
const Handlers = {
calculate: (data) => {
if (!wasmExports) return
const { input } = data || {}
const start = performance.now()
const output = wasmExports.fibonacci(input)
const end = performance.now()
self.postMessage({
output: output,
markup: end - start
}) // 发送消息给主线程
},
close: () => {
self.close() // 终止 worker 线程
}
}
self.onmessage = async ({ data }) => {
const fn = Handlers[data.type] // 接收主线程发来的消息
if (typeof fn !== 'function') throw new Error('no handler for type: ' + data.type)
fn(data)
}
Step2:在主进程中引入并创建 worker(以 Vite 5+ 构建方式为例)
import CsWorker from '@/components/wasm/worker.js?worker'
let worker = null
// 创建
function createWorker() {
worker = new CsWorker()
worker.onmessage = (event) => {
const { markup, output } = event.data
time.value = markup
result.value = output
}
}
Step3:释放主进程与 worker 进程中相关资源
// 释放
function disposeWorker() {
if (!worker) return
worker.postMessage({ type: 'close' }) // 终止 worker 线程
worker.terminate() // 终止主线程的 worker
}
在线演示
太阳星系
→ 太阳星系演示 ←
👇👇👇**创作流程**👇👇👇
👇👇👇**场景图**👇👇👇
黑暗之光
→ 黑暗之光演示 ←
👇👇👇**创作流程**👇👇👇
👇👇👇**场景图**👇👇👇
幸运女神
→ 幸运女神演示 ←
👇👇👇**创作流程**👇👇👇
👇👇👇**场景图**👇👇👇
空中飞鸟
→ 空中飞鸟演示 ←
👇👇👇**创作流程**👇👇👇
👇👇👇**场景图**👇👇👇
聪明的你,估计已经发现:
- 演示的几个 3D 世界的创作主流程是完全一样的!
- 几个 3D 世界不同的是: 网格模型、照明、动画、摄像机、资源(如纹理) 等元素 存在差异
- 就像文章前面提到的“万变不离其宗”,画人先画骨!
掌握核心流程,就能让我们在 3D 世界创造之路,越走越远。
离屏渲染
→ 离屏渲染演示 ←
-= 什么是离屏渲染?=-
OffscreenCanvas 是一个相对较新的浏览器功能,目前仅在 Chrome 可用,但显然未来会适用到别的浏览器上。 OffscreenCanvas****允许使用 Web Worker 去渲染画布,这是一种减轻繁重复杂工作的方法,比如把渲染一个复杂的3D场景交给一个 Web Worker,避免减慢浏览器的响应速度。它也意味着数据在 Worker 中加载和解析,因此可能会减少页面加载时的卡顿!独立于浏览器 js 进程与渲染进程,单开 worker 进程,完成渲染工作,这就是我们说的 离屏渲染
对象拾取
→ 对象拾取演示 ←
画布纹理
→ 画布纹理演示 ←
-= 什么是画布纹理?=-
画布纹理(Canvas Texture) 是指直接由 canvas 创建,而非静态图片等方式创建的纹理
灵魂星球
→ 灵魂星球演示 ←
能力沉淀
本小节:记录 Three.js **知识学习 和 成果输出 的整个实践过程中,总结和积累的一些经验,沉淀出一些便捷、通用的 工具和方法,以便能在未来开发其他 3D 世界任务中,提升工作效率。
渲染跟随容器
在实际的需求场景中,我们往往需要权衡以下几种情况:
- 浏览器窗口本身完全支持用户自定义调整大小,而画布常常默认撑满浏览器窗口
- 画布容器并不总是撑满浏览器窗口,有时只是页面的某一局域部分,固定了尺寸
因此,画布渲染如何能跟随容器尺寸更新场景,是我们难以绕开的话题。
我的解决思路是这样的,具体步骤如下所述:
- 先直接为目标容器元素,绑定尺寸监听句柄
- 检查渲染尺寸与显示尺寸,是否有差异
- 有差异时将渲染尺寸设为显示尺寸!
- 设置摄像机观察范围宽高比
- 更新摄像机投影矩阵
- 此时便达成渲染跟随容器的目标
import debounce from 'lodash/debounce'
import ResizeObserver from 'resize-observer-polyfill'
import { cbCommonRun, existFalsyKey } from './index.js'
// 容器尺寸监听器
export default class Resizer {
#el; #camera; #renderer;
constructor(el, camera, renderer) {
this.#el = el
this.#camera = camera
this.#renderer = renderer
this.#bindResizeObserver()
}
// 取消元素监听
dispose() {
this.#cleanResizeObserver()
}
// 自定义尺寸响应
setResizeHandler() {}
// [ES2022 引入私有字段#] 监听元素尺寸
#bindResizeObserver() {
const onSizeChange = debounce(({ width, height } = {}) => {
if (!width || !height) return
cbCommonRun(this.setResizeHandler, { width, height })
cbCommonRun(this.#resize, {
width,
height,
camera: this.#camera,
renderer: this.#renderer,
instance: this
})
}, 0) // 响应尺寸变化
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
onSizeChange({ width, height })
}
})
this.resizeObserver.observe(this.#el)
}
// [ES2022 引入私有字段#] 取消监听元素
#cleanResizeObserver() {
this.resizeObserver.unobserve(this.#el)
this.resizeObserver = null
}
// [ES2022 引入私有字段#] 响应尺寸变化
#resize(options = {}) {
if (existFalsyKey(options)) return
// 因为只有 canvas 的显示尺寸变化时,宽高比才变化!
// 所以我们此时才设置摄像机的宽高比
// 以保证渲染的分辨率应该是和 canvas 的显示尺寸一样
if (options.instance.#resizeRendererToDisplaySize(options.renderer)) {
const canvas = options.renderer.domElement
// 设置观察范围宽高比
options.camera.aspect = canvas.clientWidth / canvas.clientHeight
options.camera.updateProjectionMatrix() // 更新摄像机投影矩阵
}
}
// [ES2022 引入私有字段#] 将渲染尺寸设为显示尺寸
#resizeRendererToDisplaySize(renderer) {
const canvas = renderer.domElement
const DPR = window.devicePixelRatio
// 计算 canvas 渲染尺寸-宽
const width = Math.floor(canvas.clientWidth * DPR)
// 计算 canvas 渲染尺寸-高
const height = Math.floor(canvas.clientHeight * DPR)
// 是否更新渲染尺寸
const needResize = canvas.width !== width || canvas.height !== height
/**
* 检查渲染尺寸与显示尺寸,是否有差异
* 有差异时将渲染尺寸设为显示尺寸!
* 此方案胜于 renderer.setPixelRatio 方案
*/
if (needResize) {
renderer.setSize(width, height, false)
}
return needResize
}
}
| 开源依赖 | npm | 协议 |
|---|---|---|
| resize-observer-polyfill | www.npmjs.com/package/res… | MIT |
动画循环渲染
首先解释一下这里的动画循环渲染:
如果我们要为一个立方体网格,添加一个简单的旋转动画。我们将这样做:
- 调用
renderer.render(...) - 等待。。。直到是时候画下一帧
- 稍微旋转立方体一点
- 调用
renderer.render(...) - 等待。。。直到是时候画下一帧
- 稍微旋转立方体一点
- 调用
renderer.render(...) - 等待。。。直到是时候画下一帧
- 稍微旋转立方体一点
…… 这样的循环往复 , 就是此节说的 动画循环渲染!
我们可能以下遇到的问题:
- 用户系统刷新频率往往是不同的,那我们如何保证动画速度不受影响?
- 中心化还是去中心化?
- 如何开始、停止动画?
- 是否可以自定义帧摆句柄?
上述问题,在下面的代码段中,都能找到答案!*代码量并不多,这里不再逐行解释 …… 😛😛😛
import { Clock } from 'three'
// 处理所有的循环逻辑和动画系统
export default class Loop {
#clock = new Clock() // 时钟器
constructor(camera, scene, renderer) {
this.scene = scene
this.camera = camera
this.renderer = renderer
this.updatables = []
}
// 开始动画
start() {
this.renderer.setAnimationLoop(() => {
this.tick()
this.#tickFrame()
this.renderFrame()
})
}
// 停止动画
stop() {
this.renderer.setAnimationLoop(null)
}
// 时钟秒摆
tick() {
// only call the getDelta function once per frame!
const delta = this.#clock.getDelta()
for (const object of this.updatables) {
if (typeof object.tick === 'function') object.tick(delta)
}
}
// 自定义句柄
tickHandler() {}
// 渲染单帧
renderFrame() {
this.renderer.render(this.scene, this.camera)
}
// 帧摆句柄
#tickFrame() {
if (typeof this.tickHandler !== 'function') return
const delta = this.#clock.getDelta()
this.tickHandler(delta) // 用于支持自定义句柄
}
}
通用工具方法
// 生成随机颜色
export function randomColor() {
return `hsl(${rand(360) | 0}, ${rand(50, 100) | 0}%, 50%)`;
}
// 以 Blob 形式,保存 Canvas 到本地
export function saveBlobToLocal(canvas = HTMLElement) {
try {
canvas.toBlob((blob) => {
const a = document.createElement('a')
const fileName = `Three.js-${canvas.width}x${canvas.height}.png`
a.style.display = 'none'
a.href = window.URL.createObjectURL(blob)
a.download = fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
})
} catch (e) {
console.error('Exception: Save Three.js 3D Canvas Blob To Local', e)
}
}
// 兼容性检查:浏览器是否支持 WebGL
export function isWebGL2Available(el) {
return new Promise((resolve, reject) => {
if (WebGL.isWebGL2Available()) {
resolve()
} else {
const warning = WebGL.getWebGL2ErrorMessage()
if (isElAvailable(el) && el.appendChild) el.appendChild(warning)
reject(new Error('Your graphics card does not seem to support WebGL2'))
}
})
}
// 执行回调函数的通用方法
export const cbCommonRun = (cb = () => {}, ...args) => {
if (typeof cb === 'function') cb(...args)
}
// 判断根节点是否可用
export function isElAvailable(el) {
return el instanceof HTMLElement
}
// 不四舍五入,仅截取小数位;整数不补零
export function snipViaDecimal(number, decimal = 2) {
const string = `${number}`
const dotIndex = string.indexOf('.')
if (dotIndex === -1) return string
if (decimal === 0) return string.slice(0, dotIndex)
return string.slice(0, dotIndex + 1 + decimal)
}
画布渲染骨架
<script setup>
defineProps({
loading: Boolean
})
</script>
<template>
<el-skeleton
animated
:loading="loading"
:throttle="{ leading: 500, trailing: 500, initVal: true }"
>
<template #template>
<div class="skeleton-tpl-container">
<div class="skeleton-wrap">
<el-skeleton-item variant="rect" class="variant-icon" />
<el-skeleton-item variant="circle" class="variant-icon" />
<el-skeleton-item variant="rect" class="triangle-item variant-icon" />
</div>
<div class="compute-text">3D 引擎计算中...</div>
</div>
</template>
</el-skeleton>
</template>
<style scoped lang="scss">
.skeleton-tpl-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
.skeleton-wrap {
margin-bottom: 10px;
.variant-icon {
width: 50px;
height: 50px;
margin: 0 10px;
}
.triangle-item {
width: 57.734px;
height: 50px;
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
}
}
.compute-text {
font-size: 13px;
color: var(--el-color-primary);
animation: gradual-hide 1.4s ease infinite;
}
@keyframes gradual-hide {
0% { background-position: 100% 50%; }
50% { opacity: 0.8; }
100% { opacity: 0.4; }
}
}
</style>