目录
序言
资料来源:
- b站老陈打码学习资源
- b站郭隆邦学习资源
文章参考:
声明:
- 本文件仅用于学习交流,禁止用于其他用途
- 如果文章内容涉及侵权,请联系本人修改或删除
1. threejs入门
1.1. 快速掌握
1.1.1. 简介与说明
1.1.2. 第一个3D页面
创建vite脚手架,然后进入项目根目录,下载脚手架依赖
npm init vite@latest
设置style.css样式方便模型展示
*{
margin: 0;
padding: 0;
}
canvas{
display: block;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
安装three.js
npm install three
页面骨架
<script setup> import { ref, onMounted } from 'vue' // 必须导入 ref import * as THREE from "three" const demo = ref(null) onMounted(() => { // 后面几步的代码都写在这里 }) </script> <template> <div ref="demo"/> </template> <style scoped></style>
搭建场景
// 创建场景 const scene = new THREE.Scene() // 场景相机 const camera = new THREE.PerspectiveCamera( 45, // 视角 window.innerWidth / window.innerHeight, // 宽高比 0.1, // 近平面 1000 // 远平面 ) // 创建渲染器 const renderer = new THREE.WebGLRenderer() renderer.setSize(window.innerWidth, window.innerHeight) demo.value.appendChild(renderer.domElement) // 使用 demo.value 访问 DOM 元素 // 创建几何体 const geometry = new THREE.BoxGeometry(1, 1, 1) // 创建材质 const material = new THREE.MeshBasicMaterial({ color: 0xcccccc }) // 设置颜色值 // 创建网格 const cube = new THREE.Mesh(geometry, material) // 往场景添加网格 scene.add(cube)
渲染和观看
// 设置相机位置 camera.position.z = 5 camera.lookAt(0, 0, 0) // 渲染函数(递归看效果) const animate = () => { requestAnimationFrame(animate) // 旋转 cube.rotation.x += 0.01 cube.rotation.y += 0.01 // 渲染 renderer.render(scene, camera) } animate()
效果图:
1.2. 常用工具
1.2.1. 本地部署
我们可以在本地部署一个调试工具,快速进行调试
下载方式: git下载或直接下载压缩包
安装依赖,启动
打开 http://localhost:8080,如果有警告,无视风险,继续安装,用得比较多的是editor编辑器,manual则是本地下载的教程
1.2.2. 坐标辅助器
坐标辅助器可以方便我们查看原点的位置和空间的方位
// 设置相机位置
camera.position.z = 5
camera.position.x = 5
camera.position.y = 5
camera.lookAt(0, 0, 0)
// 添加世界坐标辅助器(写在渲染函数调用前,场景创建后即可)
const axesHelper = new THREE.AxesHelper(5)// 5是坐标辅助器的长度
scene.add(axesHelper)
- 效果图:
1.2.3. 轨道控制器
轨道控制器可以方便我们移动到不同视角
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js" // 导入
// 导入轨道控制器
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true // 设置阻尼
controls.dampingFactor = 0.05 // 设置阻尼系数
controls.autoRotate = true // 设置自动旋转
在代码const controls = new OrbitControls(camera, renderer.domElement)
中,传入的是renderer.domElement,所以监听的是canvas对象,我们也可以监听body,但是最好还是监听renderer.domElement,否则可能会导致 GUI 控件无法正常响应交互
const controls = new OrbitControls(camera, document.body)
1.3. 空间和位置
1.3.1. 局部和世界坐标
局部坐标和世界坐标类似于blender的物体原点和世界坐标原点或css3的绝对定位和相对定位
举个例子,我入住了一个宾馆,我们该如何描述我们的位置呢,如果宾馆大门是坐标原点,我们的位置离大门的3维距离就是世界坐标,房间门离大门的位置也是世界坐标,我们的位置离房间门的位置就是局部坐标,这样可以减小点的坐标的描述,比如大门坐标是(0,0),房间门坐标是(9,9),我们的坐标是(10,10),那么我们的相对坐标就可以是(1,1)。
父子元素的概念
子元素相当于父元素的一部分,会继承父元素的某些属性(比如坐标、缩放、旋转),关于如何关联父子元素,可以看下面的示例。
世界坐标
如果一个物体(元素)没有父元素,默认为世界坐标
坐标可以设置单个坐标,不设置默认值为0;也可以多个坐标一起设置
cube.position.x = 2 // 设置单个坐标 cube.position.set(2, 2, 2) // 多个坐标一起设置
局部坐标
如果一个物体(元素)有父元素,那么物体坐标就自动变成了局部坐标,接下来我们给上面的cube网格添加父元素来试试局部坐标
// 创建父元素 let parentCube = new THREE.Mesh(geometry, material); // 创建网格 const cube = new THREE.Mesh(geometry, material) // 往父元素中添加子元素 parentCube.add(cube) parentCube.position.x = -2 cube.position.x = 2 // 往场景添加网格 scene.add(parentCube)
效果图:
而且,和blender一样,子元素会随着父元素的坐标移动
1.3.2. 旋转和缩放
// 控制缩放
cube.scale.set(2, 2, 2)
- 效果图
同理,子元素会睡着父元素的放大而放大,加入下面这段代码,看起来和上面的效果差不多,但是父元素的三维向量(我们统称长宽高为三维向量)放大了2倍,子元素放大了4倍
parentCube.scale.set(2, 2, 2)
旋转的参数为欧拉角对象(通过指定轴顺序和其各轴向上的指定旋转角度来旋转一个物体),通过Euler实例进行遍历将相应的顺序生成它的分量
欧拉角对象有三个参数,如(x, y, z, order)。x,y,z及以x,y,z轴为轴心旋转的
弧度
(注意这里是弧度制);order则是旋转的顺序(在三维世界中,不同的旋转顺序得到的结果是不一样的),比如order值为’ZYX’,则分别以x,y,x轴的顺序旋转。
// 控制旋转
cube.rotation.x = Math.PI / 4
- 效果图:
旋转也是一样的道理,如果分别给父元素、子元素旋转45°,则子元素会旋转90°
cube.rotation.x = Math.PI / 4
parentCube.rotation.x = Math.PI / 4
- 效果图:
1.4. GUI调试
1.4.1. 画布显示
响应式画布
在上面的示例中当画布渲染好了后,不会自适应窗口尺寸的变化(如你F12打开浏览器监测后刷新页面,再关闭检查,发现画布不会占满全屏),我们可以通过监听窗口刷新来实现自适应。
// 监听窗口变化 window.addEventListener("resize", () => { // 重置渲染器宽高比 renderer.setSize(window.innerWidth, window.innerHeight) // 重置相机宽高比 camera.aspect = window.innerWidth / window.innerHeight // 更新相机投影矩阵 camera.updateProjectionMatrix() })
用按钮控制全屏显示
注意:这里我和老师的代码实现有一些差异,因为老师是用的元素js创建元素并插入,我用的直接创建元素并通过vue的ref获取,所有会有一些代码生命周期(勾子)的差距,而且用我这种方法由于生命周期(勾子)的不同,不会产生老师的阻塞问题。
css和html的代码
<template> <button class="btn" @click="onClick">全屏显示</button> <button class="exit_btn" @click="onExit">退出全屏</button> <div ref="demo"/> </template> <style scoped> .btn, .exit_btn { position: absolute; top: 10px; z-index: 10; } .btn { left: 10px; } .exit_btn { left: 80px; } </style>
js核心代码(因为vue生命周期和变量作用域的缘故,这段代码要写在onMounted外面,且要写在上面)
let renderer = null // 全屏显示 const onClick = () => { document.body.requestFullscreen() } // 退出全屏 const onExit = () => { document.exitFullscreen() }
GUI实现控制全屏
显然创建组件实现全屏预览的相关流程有些复杂,那么就有人就要问了,煮啵煮啵~有没有更加强势的打法!有的有的,我这有一套更加丝滑的小连招你要不要学?
首先,我们需要导入GUI并创建对象
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js' //导入GUI const gui = new GUI() // 创建对象
由于gui.add方法第一个参数是对象,我们先编写函数放进对象里面(如果three里面有封装好的对象例如cube.position,也可以直接使用)
const eventObj = { Fullscreen: () => { document.body.requestFullscreen() }, ExitFullscreen: () => { document.exitFullscreen() } }
再往里面添加函数(这里解释一下,如果gui.add方法第二个参数是字符串,在底层会转化为第一个参数的键名,以Fullscreen为例,如果不添加.name(‘…’))那么第二个参数会添加.name(‘Fullscreen’)
gui.add(eventObj, 'Fullscreen').name('全屏显示') gui.add(eventObj, 'ExitFullscreen').name('退出全屏')
1.4.2. 控制物体
物体的控制有两种写法,如下
gui.add(cube.position, 'x', -5, 5, 1).name('立方体x轴位置')
gui.add(cube.position, 'y').min(-5).max(5).step(1).name('立方体y轴位置')
// gui.add(cube.position, 'z', -5, 5, 1).name('立方体z轴位置')
效果图:
可以发现,移动xyx是一类操作,我们可以将这些操作归一下类,在gui控制器较多的情况下可以实现折叠,使控制器的结构更简洁清晰
const floder = gui.addFolder("位置控制")
floder.add(cube.position, 'x', -5, 5, 1).name('立方体x轴位置')
floder.add(cube.position, 'y').min(-5).max(5).step(1).name('立方体y轴位置')
floder.add(cube.position, 'z', -5, 5, 1).name('立方体z轴位置')
效果图:
gui也封装了事件方法来供我们调用
// 比如我们向打印当前数值
floder.add(cube.position, 'x', -5, 5, 1).name('立方体x轴位置').onChange(value => console.log(value))
还有个onFinishChange事件方法,我们停止操作后才会触发,相当于底层做了防抖优化
floder.add(cube.position, 'x', -5, 5, 1).name('立方体x轴位置').onFinishChange(value => console.log(value))
1.4.3. 第二个参数
如果第二个参数为布尔值,控制器是勾选框的形式
// 线框显示 material.wireframe = false const floder1 = gui.addFolder("显示") floder1.add(material, 'wireframe').name('线框')
gui还有一个
addColor
,如果第二个参数是hexg形式的颜色值字符串,控制器是颜色选择器的形式const colorParam = { cubeColor: "#ff0000" } floder1.addColor(colorParam, 'cubeColor').name("颜色").onChange(value => cube.material.color.set(value))
效果图:
2. Threejs基础
2.1. 几何体
2.1.1. BufferGeometry
在上面的线框模式可以看出来,所有面是由一个个三角形组成,在three.js中,所有的几何体都继承于BufferGeometry,接下来我们简单来了解一下BufferGeometry
利用BufferGeometry绘制三角形
// 创建几何体 const geometry = new THREE.BufferGeometry() // 创建顶点数据 const vertices = new Float32Array([ -1.0, -1.0, 0.0, 1.0, -1.0, 0.0, 1.0, 1.0, 0.0, ]) // 创建顶点属性(这些属性和方法的属性了解就好,没必要去深究) geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3)) // 创建材质 const material = new THREE.MeshBasicMaterial({ color: 0xcccccc }) // 创建平面 const plane = new THREE.Mesh(geometry, material) // 加入场景 scene.add(plane)
效果图:
面的方向性
three.js中,利用BufferGeometry创建的面是有方向的,所以从我们上面创建的三角形的背面是看不到三角形的,我们可以通过点的顺序来调节正反面(逆时针为正,顺时针为反)
const vertices = new Float32Array([ -1.0, -1.0, 0.0, 1.0, 1.0, 0.0, 1.0, -1.0, 0.0A, ])
我们也可以修改材质属性side的值(有THREE.side BackSide、THREE.FrontSide、THREE.DoubleSide三个值,默认为THREE.FrontSide显示正面),来控制正反面的显示
// 正反面都显示 const material = new THREE.MeshBasicMaterial({ color: 0xcccccc, side: THREE.DoubleSide })
利用BufferGeometry绘制矩形
多增加一组三角点,即可完成矩形的绘制
const vertices = new Float32Array([ -1.0, -1.0, 0.0, 1.0, 1.0, 0.0, 1.0, -1.0, 0.0, 1.0, 1.0, 0.0, -1.0, 1.0, 0.0, -1.0, -1.0, 0.0, ])
通过索引复用顶点
但是一共矩形的绘制其实只需要4个点,我们可以通过索引复用顶点来减少点的数量
// 创建几何体 const geometry = new THREE.BufferGeometry() // 创建顶点数据 const vertices = new Float32Array([ -1.0, -1.0, 0.0, // 0 1.0, -1.0, 0.0, // 1 1.0, 1.0, 0.0, // 2 -1.0, 1.0, 0.0, // 3 ]) // 创建顶点属性 geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3)) // 创建索引(0, 1, 2生成一个三角形, 2, 3, 0生成一个三角形) const indexs = new Uint16Array([0, 1, 2, 2, 3, 0]) // 创建索引属性 geometry.setIndex(new THREE.BufferAttribute(indexs, 1))
2.1.2. 顶点组
将几何体的面所需要的顶点分成组
addGroup有三个属性
- 第一个参数:从那个顶点开始
- 第二个参数:从那个顶点结束
- 第三个参数:材质索引
拿下面geometry.addGroup(0, 3, 0)
这段代码举例
第一个参数和第二个参数是选择顶点的,0和3是表示在索引组中的索引**[ 0, 3 )**,也就是索引 0,1,2;对应顶点数据的( -1.0, -1.0, 0.0,),(1.0, -1.0, 0.0,),(-1.0, 1.0, 0.0)这三个点来组成顶点组
第三个参数是用来选择材质的,创建平面的语句将材质以数组添加
const plane = new THREE.Mesh(geometry, [material1, material2])
,0对应数组中的材质material0,1对应数组中的材质material1我推测这里three.js底层应该对geometry.addGroup方法进行了优化,因为第三参数所用到的数组的定义在使用之后,即代码
const plane = new THREE.Mesh(geometry, [material0, material1])
在geometry.addGroup(0, 3, 0)
之后,这种情况一般会 报未定义错误// 创建几何体 const geometry = new THREE.BufferGeometry() // 创建顶点数据 const vertices = new Float32Array([ -1.0, -1.0, 0.0, 1.0, -1.0, 0.0, 1.0, 1.0, 0.0, -1.0, 1.0, 0.0, ]) // 创建顶点属性 geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3)) // 创建索引 const indexs = new Uint16Array([0, 1, 2, 2, 3, 0]) // 创建索引属性 geometry.setIndex(new THREE.BufferAttribute(indexs, 1)) // 设置材质 const material0 = new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true }) const material1 = new THREE.MeshBasicMaterial({ color: 0xff0000, }) // 设置两个顶点组,形成两个材质 geometry.addGroup(0, 3, 0) geometry.addGroup(3, 3, 1) // 创建平面并绑定材质 const plane = new THREE.Mesh(geometry, [material0, material1]) // 往场景添加网格 scene.add(plane)
效果图:
给立方体设置材质
// 创建几何体 const cubegeometry = new THREE.BoxGeometry(1, 1, 1) // 设置材质 const material0 = new THREE.MeshBasicMaterial({ color: 0x00ff00, }) const material1 = new THREE.MeshBasicMaterial({ color: 0xff0000, }) const material2 = new THREE.MeshBasicMaterial({ color: 0x0000ff, }) const material3 = new THREE.MeshBasicMaterial({ color: 0xffff00, }) const material4 = new THREE.MeshBasicMaterial({ color: 0x00ffff, }) const material5 = new THREE.MeshBasicMaterial({ color: 0xff00ff, }) const cube = new THREE.Mesh(cubegeometry, [material0, material1, material2, material3, material4, material5]) scene.add(cube)
效果图:
2.1.3. 常见几何体
对象 | 对应物体 | 主要参数描述 |
---|---|---|
THREE.BoxGeometry(1, 1, 1) | 立方体 | 宽,长,高,宽细分,长细分,高细分, |
THREE.CircleGeometry(5,32) | 平面 | 半径,细分数量,起始角度(0),中心角(2PI) |
THREE.ConeGeometry(5, 20, 32) | 圆锥 | 底面半径,高,底面细分,高度细分,是否封底,起始角度(0),中心角(2PI) |
THREE.CylinderGeometry(5, 5, 20,32) | 圆柱 | 上圆半径,下圆半径,高,半径细分,高度细分,是否封底,起始角度(0),中心角(2PI) |
2.2. 材质与贴图
基础网格材质(MeshBasicMaterial)是一种以简单着色方式来绘制集合体或线框的材质(最基础,性能最好的材质),不受光照影响
2.2.1. 贴图
纹理贴图
// 创建纹理加载器 const textureLoader = new THREE.TextureLoader() // 加载纹理 const texture = textureLoader.load("./texture/watercover/CityNewYork002_COLVAR1_1K.png") // 创建平面几何体 const planeGeonetry = new THREE.PlaneGeometry(1, 1) const planeMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, map:texture // 设置纹理贴图 }) // 创建平面 const plane = new THREE.Mesh(planeGeonetry, planeMaterial) scene.add(plane)
或者也可以在外面设置map
planeMaterial.map = texture
允许图片透明
planeMaterial.transparent = ture
ao贴图
// 加载ao贴图(环境遮挡贴图) const aoMap = textureLoader.load("./texture/watercover/CityNewYork002_AO_1K.jpg") ... // 设置ao贴图 planeMaterial.aoMap = aoMap
利用GUI动态调试ao贴图的强度
gui.add(planeMaterial, "aoMapIntensity").min(0).max(1).name('ao强度')
透明度贴图
// 加载透明度贴图 const alphaMap = textureLoader.load("./texture/door/height.jpg") ... // 设置ao贴图 planeMaterial.alphaMap = alphaMap
环境光照贴图
// 加载光照贴图 const lightMap = textureLoader.load("./texture/colors.png") ... // 设置光照贴图 planeMaterial.lightMap = lightMap // 也可以调节反射强度 planeMaterial.reflectivity = 0.3
环境贴图
envMap.mapping = THREE.EquirectangularReflectionMapping
指定了如何将2D全景贴图(通常是360° HDR环境贴图)"包裹"在3D场景周围three.js规则怪谈:hdr纹理资源最好放在public目录下,否则可能会报错:RGBELoader Bad File Format: bad initial token
// 导入hdr加载器 import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js' ... new RGBELoader().load( '../public/texture/hdr/sky.hdr', (envMap) => { envMap.mapping = THREE.EquirectangularReflectionMapping scene.background = envMap // 背景 scene.environment = envMap // 环境,如光照 planeMaterial.envMap = envMap // 设置plane环境贴图(反射) } )
高光贴图
const specularMap = textureLoader.load("./texture/door/CityNewYork002_GLOSS_1k.jpg") ... planeMaterial.specularMap = specularMap
2.2.2. 纹理
color颜色空间有LinearSRGBColorSpace和SRGBColorSpace空间两种,SRGBColorSpace按人的感知光的强度均匀分布,在很多情况下展示效果更好
texture.colorSpace = THREE.SRGBColorSpace
...
// gui调试
gui.add(texture,'colorSpace',{
sRGB: THREE.SRGBColorSpace,
linear: THREE.LinearSRGBColorSpace
}).onChange(() => {
texture.needsUpdate = true
})
2.2.3. UV
uv是模型的一种贴图方案,与x,y,z相区别,uv是贴图上面的某个点,图片还记录着空间上的点(x, y, z)和贴图上的点(u, v)的对应关系,因为这种关系,模型和贴图才能紧密贴合
创建uv文件加载器
// 创建纹理加载器 const uvTexture = new THREE.TextureLoader().load("../public/texture/image/uv_grid.jpg")
内置函数创建平面
// 利用内置函数创建几何体 const planeGeometry = new THREE.PlaneGeometry(2, 2) console.log(planeGeometry); // 设置材质 const planeMaterial = new THREE.MeshBasicMaterial({ // 添加uv贴图 map: uvTexture, }) // 创建并加入平面 const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial) planeMesh.position.x = -2 scene.add(planeMesh)
利用顶点创建平面
const geometry = new THREE.BufferGeometry() // 创建顶点数据 const vertices = new Float32Array([ -1.0, -1.0, 0.0, 1.0, -1.0, 0.0, 1.0, 1.0, 0.0, -1.0, 1.0, 0.0, ]) // 创建顶点属性 geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3)) // 创建索引 const indexs = new Uint16Array([0, 1, 2, 2, 3, 0]) // 创建索引属性 geometry.setIndex(new THREE.BufferAttribute(indexs, 1)) // 设置材质 const material = new THREE.MeshBasicMaterial({ map: uvTexture }) // 设置两个顶点组,形成两个材质 geometry.addGroup(0, 3, 0) geometry.addGroup(3, 3, 1) console.log(geometry); // 创建并加入平面 const plane = new THREE.Mesh(geometry, material) plane.position.x = 2 scene.add(plane)
效果图(从左往右分别是利用内置函数创建的平面,利用顶点创建的平面):
我们可以发现的是利用顶点创建的平面没有uv属性,所以该平面没有绑定成功贴图
我们可以手动给该平面添加贴图
// 创建uv坐标 const uv = new Float32Array([ 0, 0, 1, 0, 1, 1, 0, 1, // 正面 ]) // 绑定uv坐标 geometry.setAttribute("uv", new THREE.BufferAttribute(uv, 2)) // 设置材质 const material = new THREE.MeshBasicMaterial({ map: uvTexture })
效果图:
将两个点重合,则会从其余的边缘点的贴图拉伸贴住
const uv = new Float32Array([ 0, 0, 1, 0, 1, 1, 0, 0, // 正面 ])
效果图:
2.2.4. 法向量
学过初高中物理的都知道,法向影响着光的反射,同样,在three中法向也影响光的反射
同样,又是这张图,我们发现利用顶点创建的物体也没有法向属性
加载环境贴图后,没有法向的平面无法反射光线,只呈现物体本来的颜色
// 加载环境贴图 const rbgeLoader = new RGBELoader() rbgeLoader.load("../public/texture/hdr/indoor.hdr", (envMap) => { envMap.mapping = THREE.EquirectangularReflectionMapping // 设置球形贴图 scene.background = envMap // 设置环境贴图 scene.environment = envMap planeMaterial.envMap = envMap })
效果图:
为了控制变量,我们可以将两个平面的材质设置成一样,planeMaterial和material是不一样的,别忘了
planeMaterial.envMap = envMap
让该材质能够反射周围的环境,这里反射的是环境光(可以理解为环境的基础光,不分方向),而不是场景光,所以我们无论从哪个方向看右边这个平面都是一样的光const plane = new THREE.Mesh(geometry, planeMaterial)
效果图:
我们只需要设置计算法向量就可以添加法向量属性
geometry.computeVertexNormals()// 计算法向
或者直接设置反射属性
// 创建法向量 const normals = new Float32Array([ 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1// 正面 ]) // 绑定法向量 geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3))
效果图:
使用法向量辅助器可以使法向量可视化
// 法向量辅助器 import { VertexNormalsHelper } from 'three/examples/jsm/helpers/VertexNormalsHelper.js' ... // 创建法向量辅助器 const normalsHelper = new VertexNormalsHelper(plane, 0.5, 0x00ff00) scene.add(normalsHelper)
效果图:
2.3. 场景与模型
2.3.1. 外部模型
常见的3d格式文件
格式 扩展名 说明 OBJ .obj 最通用的多边形网格格式,支持顶点/UV/法线,纯文本可编辑,无动画/材质信息较简单 FBX .fbx Autodesk主推格式,支持动画/骨骼/材质/灯光- 二进制或ASCII存储,行业标准交换格式 glTF .gltf/.glb Web3D标准格式(Three.js推荐),包含场景/材质/动画-,.glb为二进制版本,加载速度快 STL .stl 3D打印专用,仅存储三角面片几何体,分ASCII和二进制版本,无色彩/材质信息 这里主要讲一些glb文件格式,它是glTF的二进制文件格式,即将gltf的所有信息二进制化,也也是我们用于网络传输中性能最好的格式,其余向obj,gltf等格式会挂载一些附属文件,如材质图片、mtr文件,这些在加载器配置中会有一点小麻烦,所有更推荐大家使用glb格式文件。
我们可以用一些常用的3d模型软件来完成不同格式的转换(blender)
加载外部模型
// 导入hdr加载器 import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js' // 导入gltf加载器 import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' ... // 加载模型 new GLTFLoader().load( // 模型路径 '../public/texture/model/Duck.glb', // 加载完成回调 (gltf) => scene.add(gltf.scene) ) // 加载环境贴图,如果没有光,模型是黑的,这里加入环境贴图主要是为了让环境光照亮模型 new RGBELoader().load( '../public/texture/hdr/sky.hdr', (envMap) => { envMap.mapping = THREE.EquirectangularReflectionMapping scene.background = envMap // 背景 scene.environment = envMap // 环境,如光照 planeMaterial.envMap = envMap // 设置plane环境贴图(反射) } )
解压被压缩过的模型
将node_modules\three\examples\jsm\libs\draco文件夹复制到public文件夹
// 导入draco解码器 import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js' ... // 实例化解码器 const dracoLoader = new DRACOLoader() // 设置解码器路径 dracoLoader.setDecoderPath("./draco/") // 给加载器绑定解码器 gltfLoader.setDRACOLoader(dracoLoader) // 加载模型 gltfLoader.load( "./model/city.glb", gitf => scene.add(gitf.scene) )
2.3.2. 压缩glb文件
全局安装gltf-pipeline工具
npm install -g gltf-pipeline
利用gltf-pipeline对glb文件进行压缩
gltf-pipeline -i input_model.glb -o output_model.glb -d -k 10
参数:
input_model.glb
:指定要压缩的原始模型文件。output_model.glb
:指定输出压缩后的模型文件。-d
:压缩方式,启用 Draco 压缩。10
:压缩的最大级别(0-10,10 为最大压缩)。
2.3.3. 观察线框
在threejs中,要以线框的形式观察物体,可以用线框几何体(Wireframe Geometry)和边缘几何体(Edges Geometry)两种方式
- 线框几何体:显示所有三角面片的边,包括所有三角面片的边缘,无论角度大小
- 线框几何体:不显示所有三角面片的边,基于角度提取硬边,只显示相邻面法线夹角大于设定阈值的边缘(默认
15°
),适合展示模型的“硬边”
先加载外部模型
// 导入Draco加载器 import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js' ... // 导入外部gbl文件 const gltfLoader = new GLTFLoader() // 实例化draco加载器,并设置路径、绑定 const dracoLoader = new DRACOLoader() dracoLoader.setDecoderPath("./draco/") gltfLoader.setDRACOLoader(dracoLoader) gltfLoader.load("../public/texture/model/building.glb", (gltf) => { scene.add(gltf.scene) console.log(gltf.scene); })
效果图:
线框几何体
// 线框几何体 --- const wireframe = new THREE.WireframeGeometry(geometry) const wireMaterial = new THREE.LineBasicMaterial({ color: 0xffffff }) const wire = new THREE.LineSegments(wireframe, wireMaterial) scene.add(wire)
效果图:
边缘几何体
// 这个是新加载的模型 gltfLoader.load("../public/texture/model/building.glb", (gltf) => { console.log(gltf.scene); const building = gltf.scene.children[0].geometry // 获取边缘几何体--- const edgesGeometry = new THREE.EdgesGeometry(building) // 生成边缘材质 const edgesMaterial = new THREE.LineBasicMaterial({ color: 0xffffff }) const edges = new THREE.LineSegments(edgesGeometry, edgesMaterial) scene.add(edges) })
效果图:
可以看到,线框建筑物的位置发生了偏移,我们可以通过世界矩阵转换来使线框和模型重合
// 更新建筑物世界转换矩阵 building.updateMatrixWorld(true, true) edges.matrix.copy(building.matrixWorld) edges.matrix.decompose(edges.position, edges.quaternion, edges.scale)
效果图:
利用遍历将城市模型边缘线框化
// 记得注释掉环境贴图 gltfLoader.load("/texture/model/city.glb", (gltf) => { // 循环 gltf.scene.traverse((child) => { if (child.isMesh) { const building = child const geometry = building.geometry // 获取边缘几何体--- const edgesGeometry = new THREE.EdgesGeometry(geometry) // 生成边缘材质 const edgesMaterial = new THREE.LineBasicMaterial({ color: 0xffffff }) const edges = new THREE.LineSegments(edgesGeometry, edgesMaterial) scene.add(edges) // 更新建筑物世界转换矩阵 building.updateMatrixWorld(true, true) edges.matrix.copy(building.matrixWorld) edges.matrix.decompose(edges.position, edges.quaternion, edges.scale) scene.add(gltf.scene) } }) })
效果图:
感觉有的不太对劲,在分析后,发现将物体添加进场景使发生了错误,应该添加的是edges才对
scene.add(edges)
2.3.4. 雾
往场景添加雾
THREE.Fog(0x666666, 0.1, 30)
中,0.1代表雾的浓度,30代表不被雾影响的可视距离// 创建长方体 const boxGeometry = new THREE.BoxGeometry(1, 1, 20) const boxMaterial = new THREE.MeshBasicMaterial({ color: 0x00ff00 }) const box = new THREE.Mesh(boxGeometry, boxMaterial) scene.add(box) // 创建fog scene.fog = new THREE.Fog(0x666666, 0.1, 30) scene.background = new THREE.Color(0x666666)
效果图:
2.4. 包围盒与世界矩阵
2.4.1. 顶点的转换
在大部分情况下,我们都是用position等方法来操作物体,这不会改变物体的一些内在属性(如物体原点),但在一些情况,如物体原点不在坐标原点使,我们可以通过移动顶点等方法来实现操作。
- 操作位置:改变物体的一些内在属性
- 操作顶点:不会改变物体的一些内在属性
位置移动
如果只是将上面案例中的物体通过position来移动物体,物体的顶点数据不会变(后面的相应的位置旋转、缩放也和这个一样,坐标不发生变化,后面就不再演示了)
顶点移动
将上面案例中的物体通过移动顶点的方式来向右平移4,即x + 4
const vertices = new Float32Array([ 3.0, -1.0, 0.0, 5.0, -1.0, 0.0, 5.0, 1.0, 0.0, 3.0, 1.0, 0.0, ]) // 也可以使用translate方法完成该操作 geometry.translate(4, 0, 0)
效果图:
但是控制台的物体位置也会发生变化
其实二维网页和三维1网页的一些地方是相通的,CSS里面也有个translate方法和position定位,其属性的特点也大致相同
顶点旋转
geometry.rotateY(Math.PI / 2)// 旋转物体90度
效果图:
顶点旋转后,位置发生改变(PI是无限不循环的小数,所以一些数据会非常趋近于0)
顶点缩放
geometry.scale(3, 3, 3) // 放大成三倍
效果图:
相应的,顶点发生变化
2.4.2. 包围盒
包围盒和包围球是每个物体都有的属性,但是边形矩阵 不会默认计算,需要调用该接口指定计算边界矩形,否则保持默认值null
加载模型
// 加载模型 new GLTFLoader().load( // 模型路径 '../public/texture/model/Duck.glb', // 加载完成回调 (gltf) => scene.add(gltf.scene) )
效果图:
获取物体
打印场景对象,我们可以看到,场景children对象的children对象有相应的name和id属性
我们可以通过物体名称或者物体id来获取物体(类似于css选择器或js通过id或类名获取Dom元素)
注意:可能是因为threejs版本不一样,我是用的最新版,和老师找的路径有所不同,所以大家要注意看属性关系,别找错了,要Mesh才行
// 1. 利用id获取模型(不过这个id好像是个临时的属性,会变,所以最好还是通过名称来获取模型) const duck1 = gltf.scene.getObjectById(32) // 2. 利用名称获取模型 const duck2 = gltf.scene.getObjectByName("LOD3spShape") // 3.利用属性查找获取模型 const duck3 = gltf.scene.children[0].children[0] console.log(duck1 === duck2); // true console.log(duck2 === duck3); // true console.log(duck1);
获取到的物体对象(和上面相对应):
获取包围盒
// 获取模型几何体 const duckGeometry = duck2.geometry // 计算包围盒 duckGeometry.computeBoundingBox() // 获取包围盒 const box = duckGeometry.boundingBox // 创建包围盒辅助器 const boxHelper = new THREE.Box3Helper(box, 0xffff00) scene.add(boxHelper) console.log(box);
包围盒有max和min两个属性,因为包围盒是规则的立方体嘛,通过包围盒距离最长的两个顶点就可以确定包围盒
效果图:
2.4.3. 世界矩阵转换
可以发现包围盒远远大于模型,这是因为他的父元素进行了缩放(大概缩小了100倍)
我们需要更新世界转换矩阵和包围盒
// 更新世界矩阵(注意,是更新的模型对象) duck2.updateMatrixWorld(true, true) // 更新包围盒 box.applyMatrix4(duck2.matrixWorld)
效果图:
补充:老师这里讲得有的模糊,我在查阅资料和思考后,这里我按自己的理解来解释一下,如果有不对的地方,欢迎批评指正。
什么是世界矩阵?:
世界矩阵和局部矩阵
世界矩阵记录了物体的变换信息,包括旋转、缩放、
- 局部变换矩阵:仅包含当前对象的
position
、rotation
和scale
,不考虑父级变换。 - 世界变换矩阵:是
object.matrix
与所有父级matrixWorld
的乘积,表示物体在全局场景中的最终位置。
- 局部变换矩阵:仅包含当前对象的
世界矩阵的计算
// 伪代码
object.matrixWorld(世界矩阵) = parent.matrixWorld(局部矩阵) × object.matrix(所有父级矩阵);
为什么世界矩阵不更新
Three.js 默认在渲染时自动更新
matrixWorld
,但如果你在手动修改position
/rotation
/scale
后需要立即获取matrixWorld
,必须先调用mesh.updateMatrixWorld(true);
为什么模型要缩小?:
- 首先,包围盒的父元素(一个3D模型对象)发生了缩放,但是缩放是发生在生成世界矩阵之后,所以缩放没有使世界矩阵更新
- 但是父元素缩放了,模型也会缩放,所以模型也大概被缩小了100倍
- 所以,我们操作的是模型对象的世界矩阵更新,之后再进行包围盒的更新,所以,物体也大概被缩小的100倍
- 那么为什么包围盒的父元素要进行更新呢,我推测是因为我们的相机视角设置得太近了,threejs默认将包围盒的父元素缩小到可以看清模型的全貌的大小
- 只不过底层在缩小时没有更新包围盒(因为包围盒默认为null,而且一般都不咋用,不进行更新处理可以减小开销)
2.3.4. 包围球
创建包围球
// 创建包围球 const duckSphere = duck2.geometry.boundingSphere duckSphere.applyMatrix4(duck1.matrixWorld) // 更新世界矩阵 // 创建包围圈辅助器 const sphereGeometry = new THREE.SphereGeometry( duckSphere.radius, // 设置包围圈半径 16, // 设置细分 16, // 设置细分 ) // 设置包围球基本材质 const sphereMaterial = new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true, }) const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial) sphere.position.copy(duckSphere.center) scene.add(sphere)
效果图:
2.3.5. 获取中心与居中
居中
// 计算包围盒 duckGeometry.computeBoundingBox() // 设置几何体居中 duckGeometry.center() ... // 获取包围盒中心点 const center = box.getCenter(new THREE.Vector3()) console.log(center); // {x: 0, y: 0, z: 0}
效果图:
计算中心
... // 更新包围盒 box.applyMatrix4(duck2.matrixWorld) // 获取包围盒中心点 const center = box.getCenter(new THREE.Vector3()) console.log(center); // {x: 0, y: 0, z: 0} ...
包围盒中心在原点是因为我在计算中心之前已经将物体矩阵了,如果注释掉居中,那么这个鸭子包围盒的中心应该是
{x: 0.13440700230582792, y: 0.8694968363010096, z: -0.03701499856229873}
2.3.6. 多物体包围盒
先创建三个小球
// 添加不同颜色的三个小球 const ball1 = new THREE.Mesh( new THREE.SphereGeometry(0.5, 32, 32), new THREE.MeshStandardMaterial({ color: 0xff0000, metalness: 0.7, roughness: 0.3, }) ) ball1.position.x = -2 scene.add(ball1) const ball2 = new THREE.Mesh( new THREE.SphereGeometry(0.5, 32, 32), new THREE.MeshStandardMaterial({ color: 0x00ff00, metalness: 0.7, roughness: 0.3, }) ) ball2.position.x = 0 scene.add(ball2) const ball3 = new THREE.Mesh( new THREE.SphereGeometry(0.5, 32, 32), new THREE.MeshStandardMaterial({ color: 0x0000ff, metalness: 0.7, roughness: 0.3, }) ) ball3.position.x = 2 scene.add(ball3)
效果图:
创建多个物体包围盒
const box = new THREE.Box3() const arrSphere = [ball1, ball2, ball3] for (let i = 0; i < arrSphere.length; i++) { // 获取当前物体的包围盒 scene.children[i].geometry.computeBoundingBox() const boxItem = scene.children[i].geometry.boundingBox // 更新世界矩阵 scene.children[i].updateWorldMatrix() // 将包围盒转换到世界坐标系 boxItem.applyMatrix4(scene.children[i].matrixWorld) // 合并包围盒 box.union(itemBox) } // 创建包围盒辅助器 const boxHelper = new THREE.Box3Helper(box) scene.add(boxHelper)
我们也可以用第二种方法,利用setFromObject方法计算包围盒的3D对象(更简洁)
const box = new THREE.Box3() const arrSphere = [ball1, ball2, ball3] for (let i = 0; i < arrSphere.length; i++) { // 第二种方式 const boxItem = new THREE.Box3().setFromObject(arrSphere[i]) box.union(boxItem) } // 创建包围盒辅助器 const boxHelper = new THREE.Box3Helper(box) scene.add(boxHelper)
效果图:
2.5. 交互和动画
2.5.1. 射线拾取
创建三个小球
// 创建3个球 const spherre1 = new THREE.Mesh( new THREE.SphereGeometry(1, 32, 32), new THREE.MeshBasicMaterial({ color: 0x00ff00, }) ) const spherre2 = new THREE.Mesh( new THREE.SphereGeometry(1, 32, 32), new THREE.MeshBasicMaterial({ color: 0xff0000, }) ) const spherre3 = new THREE.Mesh( new THREE.SphereGeometry(1, 32, 32), new THREE.MeshBasicMaterial({ color: 0xff00ff, }) ) spherre1.position.x = -5 spherre2.position.x = 0 spherre3.position.x = 5 scene.add(spherre1) scene.add(spherre2) scene.add(spherre3)
创建射线,鼠标向量并监听鼠标点击事件
// 创建射线 const raycaster = new THREE.Raycaster() // 创建鼠标向量 const mouse = new THREE.Vector2() // 创建点击事件 window.addEventListener('click', (e) => console.log(e.clientX, e.clientY))
点击页面位置,控制台会打印点击点的平面坐标
通过摄像机和鼠标的位置来更新射线
// 创建点击事件 window.addEventListener('click', (e) => { mouse.x = (e.clientX / window.innerWidth) * 2 - 1 mouse.y = -(e.clientY / window.innerHeight) * 2 + 1 // 通过摄像机和鼠标的位置来更新射线 raycaster.setFromCamera(mouse, camera) })
- 先将屏幕坐标归一化到
[0, 1]
范围:e.clientX / window.innerWidth
→ x 方向从0
(左)到1
(右)。e.clientY / window.innerHeight
→ y 方向从0
(上)到1
(下)。 - 再将[0, 1]范围映射到[-1, 1]
- x 轴:
*2
后范围变为[0, 2]
,再-1
得到[-1, 1]
。 - y 轴:由于屏幕坐标 y 轴是 从上到下递增(与 NDC 的 y 轴方向相反),所以先加负号反转方向,再
*2
后-1
得到[-1, 1]
。
- x 轴:
例如:
- 当鼠标点击屏幕正中心时,
e.clientX / window.innerWidth = 0.5
,mouse.x = 0.5*2 -1 = 0
;同理mouse.y = 0
,对应 原点。
- 先将屏幕坐标归一化到
关于计算公式详解
mouse.x = (e.clientX / window.innerWidth) * 2 - 1
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1-(e.clientY / window.innerHeight) * 2 + 1
=- [(e.clientY / window.innerHeight) * 2 - 1]
将mouse.y公式的负号提出来后,里面的格式就变得和mouse.x 的计算公式一样了其实mouse.y的计算公式就是对mouse.x的计算公式进行了取负操作,这是因为e.clientY和3维坐标y轴相反的原因
点击换色
// 创建点击事件 window.addEventListener('click', (e) => { mouse.x = (e.clientX / window.innerWidth) * 2 - 1 mouse.y = -(e.clientY / window.innerHeight) * 2 + 1 // 通过摄像机和鼠标的位置来更新射线 raycaster.setFromCamera(mouse, camera) // 计算物体和射线的焦点 // const intersects = raycaster.intersectObject(scene.children) const intersects = raycaster.intersectObjects([spherre1, spherre2, spherre3]) if (intersects.length > 0) { // 如果已经选择 if (intersects[0].object._isSelect) { intersects[0].object.material.color.set(intersects[0].object._originColor) intersects[0].object._isSelect = false return } // 如果没有选择 intersects[0].object._isSelect = true intersects[0].object._originColor = intersects[0].object.material.color.getHex() intersects[0].object.material.color.set(0x666666) } })
这里利用的给对象自定义属性
_isSelect、_originColor
的方法分别存储小球的选中状态和原始色,在正式的开发中可以利用pinia来进行浏览器本地化存储
2.5.2. 补间动画
类似于PPT的平滑切换和剪影的关键帧,补间动画会自动补全两个状态的过渡
基本位移动画
// 导入tween import * as TWEEN from "three/examples/jsm/libs/tween.module.js" ... // 创建1个球 const spherre1 = new THREE.Mesh( new THREE.SphereGeometry(1, 32, 32), new THREE.MeshBasicMaterial({ color: 0x00ff00, }) ) spherre1.position.x = -2 scene.add(spherre1) // tween动画 new TWEEN.Tween(spherre1.position) .to({ x: 2 }, 1000) // to, x的值变为4, 1秒的动画; .repeat(Infinity) // repeat, 循环播放,Infinity可以替换为数字,表示播放次数 .yoyo(true) // delay, 每次运行(或返回)前等待3000毫秒 .easing(TWEEN.Easing.Quadratic.InOut) // yoyo, 动画返回 .delay(3000) // start, 启动动画 .start() // easing,设置缓动函数(速度曲线) ... // 渲染函数(递归看效果) const animate = () => { ... TWEEN.update() }
运动速度曲线,默认为linear
多段运动动画
const tween2 = new TWEEN.Tween(spherre1.position) .to({ y: -2 }, 1000) const tween1 = new TWEEN.Tween(spherre1.position) .to({ x: 2 }, 1000) .chain(tween2).start()
tween的回调函数
回调函数 含义 onUpdate 更新 onStart 开始 onComplete 完成 onStop 停止 暂停动画
// 创建Tween动画 tween1 = new TWEEN.Tween(sphere.position).to({ x: 2 }, 1000) tween2 = new TWEEN.Tween(sphere.position).to({ x: -2 }, 1000) // 设置动画链 tween1.chain(tween2) tween2.chain(tween1) tween1.start() // GUI控制 gui = new GUI() const params = { toggleAnimation: function () { if (isPlaying.value) { TWEEN.removeAll() // 暂停所有动画 } else { tween1.start() // 重新开始动画链 } isPlaying.value = !isPlaying.value // 直接更新按钮文本 toggleController.name(isPlaying.value ? '暂停' : '继续') } } toggleController = gui.add(params, 'toggleAnimation').name(isPlaying.value ? '暂停' : '继续')
2.6. 灯光
2.6.1. 灯光与阴影
2.6.2. 平行光
场景关键代码
// 灯光 const light = new THREE.AmbientLight(0xffffff, 0.5) scene.add(light) // 直线光源 const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5) directionalLight.position.set(10, 10, 10) scene.add(directionalLight) // 添加圆形球 const sphereGeometry = new THREE.SphereGeometry(1, 20, 20) const material = new THREE.MeshStandardMaterial() const sphere = new THREE.Mesh(sphereGeometry, material) scene.add(sphere) // 创建平面 const planeGeometry = new THREE.PlaneGeometry(20, 20) const plane = new THREE.Mesh(planeGeometry, material) plane.position.set(0, -1, 0) plane.rotation.x = -Math.PI / 2 // 绕x轴旋转90度 scene.add(plane)
效果图:
开启阴影
开启阴影主要需要对渲染器、光、物体、平面四个方面进行设置
// 1.对于渲染器: 设置渲染器开启阴影计算(默认关闭,节省性能) renderer.shadowMap.enabled = true // 2.对于光: // 设置平行光产生动态阴影 directionalLight.castShadow = true // 3.对于物体(球) // 投射阴影(能挡住光) sphere.castShadow = true // 4.对于平面 // 接受阴影 plane.receiveShadow = true
效果图:
阴影模糊度:
// 设置阴影模糊度 directionalLight.shadow.radius = 20
阴影分辨率大小调节(默认为512 * 512)
// 阴影贴图分辨率 directionalLight.shadow.mapSize.set(4096, 4096)
效果图:
阴影相机
在 Three.js 中,阴影相机(Shadow Camera) 是用于确定哪些物体需要投射阴影以及如何生成阴影贴图的关键组件。
本质上是一个用于定义阴影生成范围的视锥体(Frustum),由平行光(DirectionalLight)或聚光灯(SpotLight)控制。
// 设置平行光阴影相机的属性 directionalLight.shadow.camera.near = 0.5 directionalLight.shadow.camera.far = 500 directionalLight.shadow.camera.top = 25 directionalLight.shadow.camera.bottom = -25 directionalLight.shadow.camera.left = -25 directionalLight.shadow.camera.right = 25 // gui调试 const gui = new GUI() gui.add(directionalLight.shadow.camera, "near") .min(5) .max(20) .step(0.1) .onChange(() => { // 更新同时更新阴影相机 directionalLight.shadow.camera.updateProjectionMatrix() // 手动触发渲染 renderer.render(scene, camera); })
效果图(可以把max设置大一点):
2.6.3. 聚光灯
聚光灯(如控制台没有报错而且看不到,请增大聚光灯强度或减小聚光灯衰减)
// 聚光灯 const spotLight = new THREE.SpotLight(0xffffff, 5) spotLight.position.set(5, 5, 5) spotLight.castShadow = true // -> 灯光开启阴影 spotLight.shadow.radius = 20 // 设置阴影模糊度 spotLight.shadow.mapSize.set(4096, 4096) // 阴影贴图分辨率 spotLight.decay = 1 // 聚光灯衰减 scene.add(spotLight)
给聚光灯指定对象,有的想夜晚去偷袭被哨兵发现,然后哨兵就拿着探照灯锁定着你
// 设置聚光灯的照射对象 spotLight.target = sphere // gui调试 const gui = new GUI() gui.add(sphere.position, "x").min(-5).max(5).step(0.1)// 小球x位置调试
效果图:
移动位置,聚光灯位置不变,角度变化
聚光灯设置角度(默认PI/3 = 60°,不能超过PI/2 = 90°)
// 设置相机角度 spotLight.angle = Math.PI / 6 // gui调试 const gui = new GUI() gui.add(spotLight, "angle") .min(0) .max(Math.PI / 2) .step(0.1)
效果图:
其他属性
intensity:光照亮度
decay:光照举例衰减,在physicall correct模式下,decay设置为2将实现现实世界的光衰减
renderer.physicallyCorrectLights = true
distance:从光源发出光的最大距离(生命周期)
penumbra:聚光锥的半影衰减百分比,在0和1之间,默认为0
- decay是距离衰减,在光源发出光的最大距离(生命周期)内离光源越远,衰减程度越大
- penumbra是光照中心的聚光锥衰减,离中心约远,衰减程度越大
2.6.4. 点光源
点光源
// 点光源 const spotLight = new THREE.SpotLight(0xff0000, 15) spotLight.position.set(5, 5, 5) spotLight.castShadow = true // 允许阴影 spotLight.decay = 1 spotLight.penumbra = 0.5 spotLight.shadow.radius = 20 // 设置阴影贴图模糊度 spotLight.shadow.mapSize.set(4096, 4096) // 设置阴影贴图分辨率 scene.add(spotLight)
效果图:
给点光源绑定物体
// 这里注意将点光源注释掉,控制变量 // scene.add(spotLight) // 创建一个小球 const ball = new THREE.Mesh( new THREE.SphereGeometry(0.1, 20, 20), new THREE.MeshBasicMaterial({ color: 0xff0000 }) ) ball.position.set(5, 5, 5) // 给小球添加点光源 ball.add(spotLight) scene.add(ball)
如果注释掉最后一句将绑定好点光源的小球添加到场景中,那么页面则不会有灯光
效果图:
点光源运动
// 设置时钟 const clock = new THREE.Clock() // 动画循环 const animate = () => { const time = clock.getElapsedTime() ball.position.x = Math.sin(time) * 3 ball.position.z = Math.cos(time) * 3 requestAnimationFrame(animate) controls.update() TWEEN.update() renderer.render(scene, camera) }
效果图:
相当于做了个底层经过优化的定时器函数,来改变x,y的数值
很多人纠结 Math.sin(time)和Math.cos(time)是个啥子东西,其实你只需要明白,他的值域在[-1, +1],会画一个以原点为圆心,1为半径的圆(在只有两个坐标变化的情况下),现在我们来盘一盘这段代码的逻辑:
const time = clock.getElapsedTime()
- 获取一个时钟(
clock
)从开始计时到现在经过的时间(以秒为单位) - 这个
time
值会随着程序运行不断增大
- 获取一个时钟(
时间嘛,重既然从程序运行开始计时,那么不为负数,可以参考正余弦函数的图像
点光源在最开始的时候位置就应该在时间为0(即图中x为0的位置),(0, 5, 1)的位置(不要忘了上面设置了小球位置为**(5,5,5)**)
这里下x, z值被重置了,但是y没有
然后是轨迹为什么是圆其实学过高中数学很好理解,请看picture
- 至于为什么是匀速,因为
θ
是角度(弧度制),随时间均匀增加θ = time * speed
(这里speed = 1
),所以θ均匀增加,做匀速圆周运动
2.7. 相机
3. Threejs进阶
3.1. 后处理通道
3.1.1. 初始化代码
初始化一个蓝色立方体
<script setup> import { ref, onMounted } from 'vue' import * as THREE from "three" import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js" // 轨道控制器 const demoRef = ref(null) // --- 创建场景 --- const scene = new THREE.Scene() // --- 创建相机 --- const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ) camera.position.set(3, 3, 3) // 相机位置 camera.updateProjectionMatrix() // 更新投影矩阵 // --- 创建渲染器 --- const renderer = new THREE.WebGLRenderer({ antialias: true, // 开启抗锯齿 }) renderer.setSize(window.innerWidth, window.innerHeight) // 创建一个三维向量 const boxV3 = new THREE.Vector3(1, 1, 1) // 创建基础材质的蓝色盒子 const box = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 0x00ffff, }) ) box.position.set(boxV3.x, boxV3.y, boxV3.z) scene.add(box) // --- 添加控制器 --- const controls = new OrbitControls(camera, renderer.domElement) controls.update() // 添加轨道辅助器 const axesHelper = new THREE.AxesHelper(5) scene.add(axesHelper) // 渲染函数 const render = () => { renderer.render(scene, camera) controls && controls.update() requestAnimationFrame(render) // 渲染下一帧 } // --- 挂载到dom中 --- onMounted(() => { demoRef.value.appendChild(renderer.domElement) // 将渲染器添加到场景中 render() // 调用渲染函数 }) // 窗口自适应 window.addEventListener("resize", () => { renderer.setSize(window.innerWidth, window.innerHeight) camera.aspect = window.innerWidth / window.innerHeight camera.updateProjectionMatrix() }) </script> <template> <div ref="demoRef"></div> </template> <style scoped lang="scss"> * { margin: 0; padding: 0; } canvas { position: absolute; width: 50%; height: 50%; } </style>
效果图:
3.1.2. 发光描边通道
在文件夹
node_modules\three\examples\jsm\postprocessing
下,可以看见各种后处理的类常见的后处理类 作用 OutlinePass.js 高亮发光描边 UnrealBloomPass. js Bloom发光 GlitchPass.js 画面抖动效果 物体描边
// 引入EffectComposer import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js' // 引入渲染器通道 import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js' // 引入描边 import { OutlinePass } from 'three/examples/jsm/postprocessing/OutlinePass.js' ... // renderer得到的是一个图像,为了进行后处理,需要将renderer作为EffectComposer的参数 const composer = new EffectComposer(renderer) // 创建渲染器通道 const renderPass = new RenderPass(scene, camera) // 合成器绑定渲染器通道 composer.addPass(renderPass) // 创建描边通道 const sizeV2 = new THREE.Vector2(window.innerWidth, window.innerHeight) const outlinePass = new OutlinePass(sizeV2, scene, camera) outlinePass.selectedObjects = [box] // 选择需要描边的对象(可以选择多个) composer.addPass(outlinePass) // 渲染函数 const render = () => { composer.render() ... }
效果合成器EffectComposer指明渲染器,渲染器知名场景和相机(模型 =>场景 => 渲染器 => => 相机 => 可视)
有了composer.render(),renderer.render(scene, camera)可以删除,只渲染一次即可
效果图:
描边样式
样式 效果 visibleEdgeColor 描边颜色 edgeStrength 描边强度(亮度) edgeThickness 描边宽度 pulsePeriod 描边闪烁 将描边改为红色
outlinePass.visibleEdgeColor.set(0xff0000) // 描边颜色
效果图:
增加描边宽度
outlinePass.edgeThickness = 4 // 描边宽度
效果图:
仅仅设置描边宽度,描边颜色会变暗,可以通过描边强度来改善
outlinePass.edgeStrength = 10 // 描边强度
效果图:
描边闪烁
数值越小,闪烁频率越高
outlinePass.pulsePeriod = 2 // 描边闪烁
效果图:
描边闪烁
3.1.3. Bloom辉光通道
区别于OutlinePass.js描边高亮发光,UnrealBloomPass.js(Bloom发光)是物体辉光
Bloom发光(辉光效果依赖 emissive自发光属性)
// 引入UnrealBloom发光通道 import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js' ... // 创建基础材质的蓝色盒子 const box = new THREE.Mesh( ... new THREE.MeshStandardMaterial({ ... emissive: 0x00ffff // !!!社长材质的自发光颜色 }) ) ... // 创建辉光通道 const sizeV2 = new THREE.Vector2(window.innerWidth, window.innerHeight) const bloomPass = new UnrealBloomPass(sizeV2, 1.5, 0.5, 0.2) bloomPass. composer.addPass(bloomPass) bloomPass.strength = 0.5
效果图:
多通道组合
通道如流水线工序,多个通道可以任意组合,这里将发光描边通道和Bloom辉光通道组合
const sizeV2 = new THREE.Vector2(window.innerWidth, window.innerHeight) // 创建辉光通道 const bloomPass = new UnrealBloomPass(sizeV2, 1.5, 0.5, 0.2) composer.addPass(bloomPass) bloomPass.strength = 0.5 // 创建描边通道 const outlinePass = new OutlinePass(sizeV2, scene, camera) outlinePass.selectedObjects = [box] // 选择需要描边的对象(可以选择多个) composer.addPass(outlinePass)
效果图:
3.1.4. 闪屏通道
注意:闪屏通道被改位置了
// 引入闪屏通道 import { GlitchPass } from "three/examples/jsm/Addons.js"; ... // 创建闪屏通道 const glitchPass = new GlitchPass() composer.addPass(glitchPass)
效果图:
闪屏通道效果演示
3.1.5. 伽马颜色校正
如果gltf模型颜色出现偏差,需要设置renderer.outputEncoding
解决,但如果使用后处理通道后,renderer.outputEncoding
会无效
gltf模型颜色偏差解决
renderer.outputEncoding = THREE.sRGBEncoding
后处理通道颜色偏差解决
// 引入伽马校正 import { GammaCorrectionShader } from "three/addons/shaders/GammaCorrectionShader.js"; // 引入ShaderPass(将shader目录下就类接入通道) import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js"; ... // 创建伽马校正通道 const gammaPass = new ShaderPass(GammaCorrectionShader); composer.addPass(gammaPass);
3.1.6. 后处理抗锯齿问题
渲染器抗锯齿处理
renderer.antialias = true
后处理通道抗锯齿处理
和颜色一样,在某些时候后处理可以会出现锯齿,我们一般采用FXAA抗锯齿Shader或SMAA抗锯齿通道来进行抗锯齿处理(二选一)
FXAA抗锯齿
// 引入FXAA抗锯齿 import { FXAAShader } from "three/addons/shaders/FXAAShader.js" ... // FXAA抗锯齿处理 const FXAAPass = new ShaderPass(FXAAShader); const pixelRatio1 = renderer.getPixelRatio(); FXAAPass.uniforms.resolution.value.x = 1 / (window.innerWidth * pixelRatio1); FXAAPass.uniforms.resolution.value.y = 1 / (window.innerHeight * pixelRatio1); composer.addPass(FXAAPass);
SMAA抗锯齿
// 引入SMAA抗锯齿 import { SMAAPass } from "three/addons/postprocessing/SMAAPass.js" ... // SMAA抗锯齿处理 const pixelRatio2 = renderer.getPixelRatio(); const smaaPass = new SMAAPass( window.innerWidth * pixelRatio2, window.innerHeight * pixelRatio2 ); composer.addPass(smaaPass);
3.2. 标签
3.2.1. CSS2DRenderer
CSS2DRenderer.js
是一个 threejs 的扩展库,通过CSS2DRenderer.js可 以把HTML元素作为标签标注三维场景。通过CSS2DObject类,可以把一个HTML元素转化为一个类似threejs网格模型的对象,可以把CSS2DObject当成threejs模型取操作。
库路径:
node_modules\three\examples\jsm\renderers\CSS2DRenderer.js
,该文件末尾导出的CSS2DObject, CSS2DRenderer两个类是我们主要学习的export { CSS2DObject, CSS2DRenderer };
初始化代码(我创建了一个初始化场景并添加了一个基础材质蓝色盒子和标签)
<script setup> import { ref, onMounted } from 'vue' import * as THREE from "three" import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js" // 轨道控制器 const demoRef = ref(null) const tagRef = ref(null) // --- 创建场景 --- const scene = new THREE.Scene() // --- 创建相机 --- const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ) camera.position.set(3, 3, 3) // 相机位置 camera.updateProjectionMatrix() // 更新投影矩阵 // --- 创建渲染器 --- const renderer = new THREE.WebGLRenderer({ antialias: true, // 开启抗锯齿 }) renderer.setSize(window.innerWidth, window.innerHeight) // 渲染函数 const render = () => { renderer.render(scene, camera) controls && controls.update() requestAnimationFrame(render) // 渲染下一帧 } // 创建基础材质的蓝色盒子 const box = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 0x00ffff, }) ) box.position.set(1, 0, 1) scene.add(box) // --- 添加控制器 --- const controls = new OrbitControls(camera, renderer.domElement) controls.update() // 添加轨道辅助器 const axesHelper = new THREE.AxesHelper(5) scene.add(axesHelper) // --- 挂载到dom中 --- onMounted(() => { demoRef.value.appendChild(renderer.domElement) // 将渲染器添加到场景中 render() // 调用渲染函数 // 获取标签, 并将标签转化为CSS2DObject const tag = new CSS2DObject(tagRef.value) tag.position.set(1, 0, 1) }) // 窗口自适应 window.addEventListener("resize", () => { renderer.setSize(window.innerWidth, window.innerHeight) camera.aspect = window.innerWidth / window.innerHeight camera.updateProjectionMatrix() }) </script> <template> <div ref="demoRef"> <!-- 标签 --> <div class="tag" ref="tagRef">标签</div> </div> </template> <style scoped lang="scss"> * { margin: 0; padding: 0; } canvas { position: absolute; width: 50%; height: 50%; } .tag { position: absolute; z-index: 2; padding: 10px; color: #fff; background-color: rgba(0, 0, 0, 0.3); border-radius: 5px; width: 65px; text-align: center; border: 1px solid #fff; } </style>
效果图:
将标签转化为css2DObject
// (1)导入CSS2DObject import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js' ... onMounted(() => { ... // (2)获取标签, 并将标签转化为CSS2DObject const tag = new CSS2DObject(tagRef.value) tag.position.set(1, 0, 1) // 标签的位置(和盒子一样) scene.add(tag) // 添加标签到场景 })
注意,要在
onMounted
中创建 CSS2DObject,因为 在setup
阶段DOM 元素还未挂载,所以tagRef.value是 null,而CSS2DObject 需要 DOM 元素,但在setup
阶段就尝试创建它,此时tagRef
还未绑定到 DOM。创建CSS2DRenderer渲染css2DObject
// (1)引入CSS2DRenderer import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js' ... // (2)创建CSS2D渲染器 const css2DRenderer = new CSS2DRenderer() css2DRenderer.setSize(window.innerWidth , window.innerHeight ) // 渲染函数 const render = () => { // (3)渲染CSS2D对象 css2DRenderer.render(scene, camera) ... } // --- 挂载到dom中 --- onMounted(() => { ... // (4)将CSS2D渲染器添加到场景中 demoRef.value.appendChild(css2DRenderer.domElement) ... })
效果图:
标签成功添加到场景中盒子位置
注意:在这里郭老师他的html、css代码和我有些有所区别,我这种写法这里不用设置定位让标签和物体的画布重合
CSS2DRenderer原理
这里我们可以打开浏览器的元素检查栏,再通过拖动边界改变可视区域的尺寸,发现标签的属性在不断变化。
其实,在CSS2DRenderer底层,也是通过给标签动态计算添加定位,来模拟标签在3维空间中的位置(根据CSS2D模型对象的三维坐标,动态计算canvas画布的屏幕定位坐标)
3.2.2. 标签位置
三维坐标关联
通过三维向量关联盒子和标签的坐标 ,可以将盒子和标签坐标关联,如果需要同时操作这两个物体的位置,可以直接通过操作这个三维向量实现
... const boxV3 = new THREE.Vector3(1, 1, 1) // 创建一个三维向量 ... box.position.set(boxV3.x, boxV3.y, boxV3.z) // 盒子坐标设置 ... tag.position.set(boxV3.x, boxV3.y, boxV3.z) // 标签坐标位置
同样,通过将盒子绑定为标签父元素、或绑定组等方法也可以实现
标记顶点
// 获取标签, 并将标签转化为CSS2DObject const tag = new CSS2DObject(tagRef.value) // tag.position.set(boxV3.x, boxV3.y, boxV3.z) // 标签的位置 box.add(tag) // 添加标签为盒子子元素 const pos = box.geometry.attributes.position tag.position.set(pos.getX(0), pos.getY(0), pos.getZ(0))
效果图:
通过改变盒子的局部坐标系原点位置来移动标签(标签作为盒子的子元素,继承了盒子的局部坐标系,所以盒子的局部坐标的坐标原点位置发生变化,标签位置发生相应变化)
// 打印盒子局部坐标系 onMounted(() => { ... // 获取标签, 并将标签转化为CSS2DObject const tag = new CSS2DObject(tagRef.value) box.add(tag) // 添加标签为盒子子元素 const axesHelper = new THREE.AxesHelper(10) box.add(axesHelper) })
效果图:
将盒子的局部坐标系移动到底部
onMounted(() => { ... box.geometry.translate(0, 0.5, 0) })
效果图:
改变标签自身位置
onMounted(() => { ... tag.position.set(0, 0.5, 0) })
效果图:
3.2.3. 事件阻挡
由于标签在盒子上面,在某些情况下标签可能会阻挡盒子的事件(如点击盒子,盒子被标签挡住了,不能触发点击事件; 或在标签上滚动滚轮,轨道控制器失效)
可以设置.style.pointerEvents = none,就可以解决事件的遮挡。
css2DRenderer.domElement.style.pointerEvents = 'none'
`3.2.4. 点击显示标签
初始化代码
下面的代码设计到本节的标签和前面的后处理通道的知识,如果不理解代码,可以回顾一下前面几节
<script setup> import { ref, onMounted } from 'vue' import * as THREE from "three" import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js" // 轨道控制器 import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js' // 引入EffectComposer import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js' // 引入渲染器通道 import { OutlinePass } from 'three/examples/jsm/postprocessing/OutlinePass.js' // 引入描边 // 导入CSS2DObject import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js' // 引入CSS2DRenderer import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js' const demoRef = ref(null) const tag1Ref = ref(null) const tag2Ref = ref(null) let tag1, tag2 const boxV3 = new THREE.Vector3(1, 1, 1) // 创建一个三维向量 const sizeV2 = new THREE.Vector2(window.innerWidth, window.innerHeight) // 创建屏幕尺寸二维向量 // --- 创建场景 --- const scene = new THREE.Scene() // --- 创建相机 --- const camera = new THREE.PerspectiveCamera(75, sizeV2.x / sizeV2.y, 0.1, 1000) camera.position.set(0, 2, 6) // 相机位置 // --- 创建渲染器 --- const renderer = new THREE.WebGLRenderer({ antialias: true, // 开启抗锯齿 }) renderer.setSize(sizeV2.x, sizeV2.y) // 创建基础材质的蓝色盒子 const box1 = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0x00ffff, emissive: 0x00ffff // 材质的自发光颜色 }) ) const box2 = box1.clone() box1.position.set(boxV3.x, boxV3.y, boxV3.z) box2.position.set(-boxV3.x, boxV3.y, boxV3.z) scene.add(box1, box2) // --- 添加控制器 --- const controls = new OrbitControls(camera, renderer.domElement) controls.update() // 添加环境光 const ambientLight = new THREE.AmbientLight(0xffffff, 0.5) scene.add(ambientLight) // 添加轨道辅助器 const axesHelper = new THREE.AxesHelper(5) scene.add(axesHelper) // --- 创建CSS2D渲染器 --- const css2DRenderer = new CSS2DRenderer() css2DRenderer.setSize(sizeV2.x, sizeV2.y) css2DRenderer.domElement.style.pointerEvents = 'none' // 预防事件阻挡 // --- 后处理通道 --- // 创建后处理通道 const composer = new EffectComposer(renderer) // 创建渲染器通道 const renderPass = new RenderPass(scene, camera) // 合成器绑定渲染器通道 composer.addPass(renderPass) // 创建描边通道 const outlinePass = new OutlinePass(sizeV2, scene, camera) outlinePass.selectedObjects = [box1, box2] outlinePass.visibleEdgeColor.set('#ff0000') composer.addPass(outlinePass) // 渲染函数 const render = () => { css2DRenderer.render(scene, camera) // 渲染CSS2D对象 composer.render() controls && controls.update() requestAnimationFrame(render) // 渲染下一帧 } // --- 挂载到dom中 --- onMounted(() => { demoRef.value.appendChild(renderer.domElement) // 将渲染器添加到场景中 demoRef.value.appendChild(css2DRenderer.domElement) // 将CSS2D渲染器添加到场景中 // 获取标签, 并将标签转化为CSS2DObject tag1 = new CSS2DObject(tag1Ref.value) tag2 = new CSS2DObject(tag2Ref.value) box1.add(tag1) // 添加标签为盒子子元素 box2.add(tag2) tag1.position.set(0, 1, 0) tag2.position.set(0, 1, 0) render() // 调用渲染函数 }) // 窗口自适应 window.addEventListener("resize", () => { renderer.setSize(window.innerWidth, window.innerHeight) css2DRenderer.setSize(window.innerWidth, window.innerHeight) camera.aspect = window.innerWidth / window.innerHeight camera.updateProjectionMatrix() }) </script> <template> <div ref="demoRef"> <!-- 标签 --> <div class="tag1" ref="tag1Ref">标签1</div> <div class="tag2" ref="tag2Ref">标签2</div> </div> </template> <style scoped lang="scss"> * { margin: 0; padding: 0; } canvas { position: absolute; width: 50%; height: 50%; } .tag1, .tag2 { position: absolute; z-index: 2; padding: 10px; color: #fff; background-color: rgba(0, 0, 0, 0.3); border-radius: 5px; width: 65px; text-align: center; border: 1px solid #fff; } </style>
效果图:
显示(切换代码)
现在我们需求是
每个盒子的标签默认隐藏
点击盒子出现该盒子对应的标签
同时只能出现一个标签
// 1. 存储当前显示的标签 const activeTag = ref(null) ... // 2. 点击事件处理 const handleClick = (event) => { const mouse = new THREE.Vector2( (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ) const raycaster = new THREE.Raycaster() raycaster.setFromCamera(mouse, camera) const intersects = raycaster.intersectObjects([box1, box2]) if (intersects.length > 0) { const clickedBox = intersects[0].object // 隐藏当前活动的标签 if (activeTag.value) { activeTag.value.visible = false } // 显示点击的盒子对应的标签 if (clickedBox === box1) { tag1.visible = true activeTag.value = tag1 outlinePass.selectedObjects = [box1] } else if (clickedBox === box2) { tag2.visible = true activeTag.value = tag2 outlinePass.selectedObjects = [box2] } } else { // 点击空白处隐藏所有标签 if (activeTag.value) { activeTag.value.visible = false activeTag.value = null outlinePass.selectedObjects = [] } } } ... // --- 挂载到dom中 --- onMounted(() => { ... // 3. 初始隐藏标签 tag1.visible = false tag2.visible = false // 监听渲染器点击事件 renderer.domElement.addEventListener('click', handleClick) render() // 调用渲染函数 })
效果图:
点击切换标签演示
3.3. 精灵模型
3.4. 骨骼
3.5. 计算图像学补充
1
3.6. 物体引擎
1
3.7. WebGPU
1
3.8. 3D几何变换
1