├── .gitignore ├── 21 Three.js优化之合并对象的动画.md ├── 28 Three.js解决方案之选中、拾取某物体.md ├── 01 Three.js简介.md ├── 14 Three.js基础之雾.md ├── Three.js实用知识点笔记.md ├── 18 Three.js技巧之调试.md ├── 02 初始化Three.js项目.md ├── 03 编写HelloThreejs.md ├── 27 Three.js解决方案之多画布、多场景.md ├── 06 图元练习示例.md ├── README.md ├── 15 Three.js基础之离屏渲染.md ├── 05 Three.js基础之图元.md ├── 26 Three.js解决方案之透明度bug.md ├── 17 Three.js技巧之按需渲染.md ├── 08 Three.js基础之场景.md ├── 09 Three.js基础之材质.md ├── 16 Three.js基础之自定义几何体.md ├── 24 Three.js解决方案之加载.gLTF模型.md ├── 07 图元之3D文字.md ├── 04 添加一些自适应.md ├── 25 Three.js解决方案之添加背景和天空盒.md └── 12 Three.js基础之镜头.md /.gitignore: -------------------------------------------------------------------------------- 1 | temp/ -------------------------------------------------------------------------------- /21 Three.js优化之合并对象的动画.md: -------------------------------------------------------------------------------- 1 | # 21 Three.js优化之合并对象的动画 2 | 3 | 在上一篇中,我们将 19000 个柱状对象合并为 1 个整体,这样优化过后渲染速度性能大幅提高。 4 | 5 | 但是,我们所加载的是 2010 年 全球男性人口数量统计。 6 | 7 | **假设现在我们添加新的需求:** 8 | 9 | 1. 加载并显示全球女性人口数量 10 | 2. 此时,我们程序中 男性与女性 各有 1 份数据 11 | 3. 我们需要做的动画就是:当切换 不同性别数据时,柱状物也会随着人口数量不同而发生 高低 变化的动画 12 | 13 | 14 | 15 | **不同性别的人口数据 .asc 文件下载:** 16 | 17 | 1. 男性:https://threejsfundamentals.org/threejs/resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc 18 | 2. 女性:https://threejsfundamentals.org/threejs/resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc 19 | 20 | 21 | 22 | 很显然,之前把所有柱状物都合并成 1 个整体之后,是没有办法单独操作某一个柱状物的。 23 | 24 | 想实现每一个柱状物高低变化的动画,又该如何实现呢? 25 | 26 | 27 | 28 | #### 实现方式: 29 | 30 | 1. 通过 设置物体材质的 morphtargets(变形目标) 属性为 true 来改变柱状物形状 31 | 32 | ``` 33 | const material = new THREE.MeshBasicMaterial({ 34 | vertexColors: true, 35 | morphTargets: true, 36 | }) 37 | ``` 38 | 39 | 40 | 41 | 2. 通过 Tween.js 来创建改变过程中的动画 42 | 43 | ``` 44 | import { TWEEN } from 'three/examples/jsm/libs/tween.module.min.js' 45 | 46 | 或者自己去安装 tween.js 的模块 47 | 48 | yarn add @tweenjs/tween.js 49 | //npm i @tweenjs/tween.js --save 50 | import TWEEN from '@tweenjs/tween.js' 51 | ``` 52 | 53 | > Tween.js 的用法,请参考官网:https://github.com/tweenjs/tween.js 54 | 55 | 56 | 57 | 3. 通过材质的 Material.onBeforeCompile() 函数来产生改变过程中柱状物颜色的变化 58 | 59 | > Material.onBeforeCompile() 的用法,请参考文档:https://threejs.org/docs/index.html#api/zh/materials/Material.onBeforeCompile 60 | 61 | 62 | 63 | #### 具体代码 64 | 65 | ... 66 | 67 | 上面仅仅是提供了解决思路,而实际具体的代码复杂程度,超出了我目前的认知。 68 | 69 | 所以我们暂且搁置,所有能力的同学,可自行查看本文对应的原版教程: 70 | 71 | https://threejsfundamentals.org/threejs/lessons/threejs-optimize-lots-of-objects-animated.html, 72 | 73 | 74 | 75 | 将来有一天 Three.js 能力提高了,再来弥补上。 76 | 77 | 下一节,我们学习另外一个可以提升计算性能的方式:使用 WebWorker。 -------------------------------------------------------------------------------- /28 Three.js解决方案之选中、拾取某物体.md: -------------------------------------------------------------------------------- 1 | # 28 Three.js解决方案之选中、拾取某物体 2 | 3 | 在日常的 Three.js 应用中,我们除了要渲染出 3D 场景外,还需要大量用户鼠标或笔触(手指触摸)操作。 4 | 5 | 在讲解本文正式内容之前,我们先讲解一下 Three.js 官方帮我们写好的控制器。 6 | 7 | > 在之前示例中我们经常使用的是 OrbitControls,除了这个以外还有其他几种控制器。 8 | 9 | 10 | 11 | ### 镜头轨道控制器 12 | 13 | > 再次重申,在本系列文章中 “镜头” 和 “摄像机” 是同一个意思,我个人更习惯使用 “镜头” 这个词。 14 | 15 | 在 Three.js 中,官方提供了以下几种控制器: 16 | 17 | | 控制器 | 作用 | 补充说明 | 18 | | ------------------------- | -------------- | ------------------------------------------------------------ | 19 | | DeviceOrientationControls | 设备朝向控制器 | 这个控制器只能应用在手机端,
监听手机设备朝向变化,进而改变镜头朝向 | 20 | | DragControls | 拖放控制器 | 该控制器实例化时,需要传入可拖放的元素数组 | 21 | | FirstPersonControls | 第一人称控制器 | 像游戏中人物行走一样,来切换场景视角
该控制器是 FlyControls 的另一种实现 | 22 | | FlyControls | 飞行控制器 | 像鸟飞行一样的视角,来切换场景 | 23 | | OrbitControls | 轨道控制器 | 我们日常使用最为频繁的一个控制器 | 24 | | PointerLockControls | 指针锁定控制器 | Pointer,可以让我们脱离鼠标,无限移动使用 | 25 | | TrackballControls | 轨迹球控制器 | 与 OrbitControls 类似,但又不相同,
它不能恒定保持镜头的 up 向量 | 26 | 27 | 28 | 29 |
30 | 31 | **补充说明:** 32 | 33 | 1. 在使用 DeviceOrientationControls 时,需要在每次渲染函数中执行 .update() 34 | 2. 在使用 DragControls 时,需要给控制器实例添加 .addEventListener('drag', render) 35 | 3. 在使用 FirstPersonControls 时,当浏览器窗口尺寸发生变化时,需要执行 .handleResize(),已更新交互范围 36 | 4. 在使用 FlyControls 时,当浏览器窗口尺寸发生变化时,需要适当修改 .movementSpeed 和 执行 .update( delta:number) 37 | 5. 在使用 OrbitControls 时,若要获得阻尼惯性效果,要设置 .enableDamping = true 38 | 6. 在使用 PointerLockControls 时,锁定(隐藏)鼠标光标的是执行 .lock(),退出(显示)鼠标光标的是执行 .unlock 39 | 7. 在使用 TrackballControls 时,可以通过键盘 A/S/D 键来控制场景视角 40 | 41 | 42 | 43 |
44 | 45 | 以上这些控制器,都是用户与场景之间的交互操作。 46 | 47 | 那么接下来,我们讲解一下在场景中,一些我们自定义的一些鼠标交互操作:拾取元素 48 | 49 | 50 | 51 |
52 | 53 | #### 抱歉,本系列教程暂停更新。 54 | 55 | 接下来一段时间,我打算去开发一个 3D 云点标注工具。 56 | 57 | 还是从实战中去学习 Three.js 吧,或许有一天会重新继续更新本系列教程。 -------------------------------------------------------------------------------- /01 Three.js简介.md: -------------------------------------------------------------------------------- 1 | # 01 Three.js简介 2 | 3 | ## Three.js简介概述 4 | 5 | **Three.js概述** 6 | 7 | Three.js 是基于 WebGL 技术,用于浏览器中开发 3D 交互场景的 JS 引擎。 8 | 9 | > 默认 WebGL 只支持简单的 点、线、三角,Three.js 就是在此 WebGL 基础之上,封装出强大且使用起来简单的 JS 3D 类库。 10 | 11 | > 目前主流现代浏览器都已支持 WebGL,也意味着支持 Three.js。 12 | 13 | 14 | 15 | **Three.js优缺点** 16 | 17 | * Three.js 擅长 WebGL 场景渲染,作为 JS 类库特别原生、灵活、自由度高 18 | * Three.js 不擅长物理碰撞,因此不适合开发 3D 游戏 19 | 20 | 21 | 22 | **先感受几个Three.js示例** 23 | 24 | * 3D沙发产品在线预览:http://app.xuanke3d.com/apps/trayton/#/show 25 | * 游乐园可交互场景:http://letsplay.ouigo.com/ 26 | 27 | * 跟随音乐楼房跳动:http://analysis.4sceners.de/ 28 | 29 | 30 | 31 | **Three.js应用场景** 32 | 33 | 1. 3D数据可视化场景 34 | 2. 产品720度在线预览 35 | 3. H5/微信小游戏 36 | 4. 科技教学3D模型展示 37 | 5. 网页VR、网页VR看房 38 | 39 | 40 | 41 | **Three.js相关资料官网** 42 | 43 | * Three.js官网:https://threejs.org/ 44 | * threejs.org 中文文档:https://threejs.org/docs/index.html#manual/zh/introduction/Creating-a-scene 45 | * threejs.org 官方教程:https://threejsfundamentals.org/threejs/lessons/zh_cn/ 46 | * Three.js Github:https://github.com/mrdoob/three.js 47 | * hewebgl.com Three.js基础教程:http://www.hewebgl.com/article/articledir/1 48 | * webgl3d.cn Three.js教程:http://www.webgl3d.cn/Three.js/ 49 | 50 | 51 | 52 | ## Three.js中的技术名词 53 | 54 | ### 3大核心关键模块 55 | 56 | **场景(scene)** 57 | 58 | 场景是所有物体的容器。 59 | 60 | 61 | 62 | **相机(camera)** 63 | 64 | 决定场景中哪些角度的内容会显示出来。 65 | 66 | 当然你也可以把`相机` 称呼为 `摄像头` 、`镜头`、`摄像机` 等。 67 | 68 | > 本系列文章中,绝大多数时候都会使用 “镜头” 这个称呼 69 | 70 | 71 | 72 | **渲染器(renderer)** 73 | 74 | 将 `相机` 中的内容渲染到浏览器页面中。 75 | 76 | 77 | 78 | ### 其他技术关键词 79 | 80 | **几何体(Geometry)** 81 | 82 | 顾名思义,就是几何体,例如 球体、立方体、平面、以及自定义的几何体(汽车、动物、房子、数目等)。 83 | 84 | 在 Three.js 中,一个几何体的来源有 3 个: 85 | 86 | 1. Three.js 中内置的一些基本几何体 87 | 2. 自己创建自定义的几何体 88 | 3. 通过文件加载进来的几何体 89 | 90 | 91 | 92 | **材质(Material)** 93 | 94 | 几何体的表面属性,包括颜色、光亮程度。 95 | 96 | > 光亮程度是指物体表面反射光的能力值。Three.js 内置了不同的材质,不同材质对应不同的光亮程度。 97 | > 98 | > 内置材质 MeshBasicMaterial 是一种不可以反射光的材质,请注意这里说的不可以反射光并不是指该物体向黑洞那样连光都能吸收,而是指无论什么光源以何种角度照射到该物体上,该物体都不显示 “光亮”,而仅仅以材质本身的颜色或纹理来显示。 99 | 100 | > 幸亏以前我学些过 C4D,所以对于这些名词和概念不是那么陌生 101 | 102 | 一个材质可以引用一个或多个纹理。 103 | 104 | 105 | 106 | **纹理(Texture)** 107 | 108 | 纹理可以简单理解为一种图像或一张图片,用来包裹到几何体表面上。 109 | 110 | 纹理来源可以是: 111 | 112 | 1. 通过文件加载进来 113 | 2. 在画布上生成 114 | 3. 由另外一个场景渲染出 115 | 116 | 117 | 118 | **网格(Mesh)** 119 | 120 | 一种特定的 几何体和材质 绘制出的一个特定的几何体系。 121 | 122 | > 网格包含的内容为:几何体、几何体的材质、几何体的自身网格坐标体系 123 | 124 | > 同一个材质和几何体可以被多个网格对象使用。 125 | > 126 | > 一个场景可以同时添加多个网格。 127 | 128 | 129 | 130 | **光源(Light)** 131 | 132 | 指不同种类的光。 133 | 134 | 135 | 136 | **视椎(frustum)** 137 | 138 | 透视镜头(PerspectiveCamera)所创造出的一种视觉可见空间。 139 | 140 | 141 | 142 | 以上提到的所有关键词和概念,会在后续学习过程中,逐个细致学习掌握。 143 | 144 | 加油! -------------------------------------------------------------------------------- /14 Three.js基础之雾.md: -------------------------------------------------------------------------------- 1 | # 14 Three.js基础之雾 2 | 3 | ## 雾(Fog)概述 4 | 5 | 这里的 雾(Fog) 就是指我们日常生活中的雾气。 6 | 7 | 我们之前所有的示例的 scene 中,都是完全清晰、透明的空间,如果想创建出有雾气的场景,就需要 雾 了。 8 | 9 | 10 | 11 | #### 雾的特点: 12 | 13 | 1. 越靠近镜头 雾气越小 14 | 2. 越远离镜头 雾气越大 15 | 3. 雾气本身只会影响物体的渲染效果,但雾气本身并不会流动 16 | 4. 默认所有材质都可以被雾影响,若某物体不想被雾影响,可以将该物体材质的 fog 属性设置为 false 17 | 18 | 19 | 20 | #### 雾的 2 种类型 21 | 22 | 在 Three.js 中,一共有 2 种 雾的类型; 23 | 24 | | 雾的类型 | 名称 | 解释 | 25 | | -------- | ------ | ------------------------- | 26 | | Fog | 雾 | 雾的密度随着距离 线性增大 | 27 | | FogExp2 | 指数雾 | 雾的密度随着距离 指数增大 | 28 | 29 | 30 | 31 | #### Fog构造参数 32 | 33 | ``` 34 | Fog( color : Integer, near : Float, far : Float ) 35 | ``` 36 | 37 | 1. color:雾的颜色 38 | 39 | 2. near:开始应用雾的最小距离,默认值为 1 40 | 41 | > 假设 雾的 near 数值 小于 镜头 near 的值,则该区域的物体不会被雾所影响。 42 | > 43 | > 因为小于镜头 near 区域的物体根本就不可见,Three.js 也不会渲染该区域。 44 | 45 | 3. far:应用雾的最大距离,默认值为 1000 46 | 47 | > 假设 雾的 far 数值 大于 镜头 far 的值,则该区域的物体不会被雾所影响。 48 | 49 | 50 | 51 | #### FogExp2构造参数 52 | 53 | ``` 54 | FogExp2( color : Integer, density : Float ) 55 | ``` 56 | 57 | 1. color:雾的颜色 58 | 2. density:定义雾的密度将会增加的有多快,默认值为 0.00025 59 | 60 | 61 | 62 | #### 如何把雾添加到场景中? 63 | 64 | 添加的方式非常简单: 65 | 66 | ``` 67 | scene.fog = new Three.Fog(0xFFFFFF,10,100) 68 | 或 69 | scene.fog = new Three.FogExp2(0xFFFFFF,0.001) 70 | ``` 71 | 72 | 73 | 74 | #### 究竟该选择哪种雾? 75 | 76 | 从实际渲染效果 真实度 而言,FogExp2 更加逼真。 77 | 78 | 但实际项目中,往往更多选择 Fog,因为 Fog 更加简单。 79 | 80 | > Fog 还允许你调整 near 和 far 的值,而 FogExp2 只允许调整指数值,若想对雾气距离更加精准控制,Fog 是第一选择。 81 | 82 | 83 | 84 | #### 关于雾的颜色的补充说明 85 | 86 | 为了让 雾和物体、场景融合比较好,通常情况下我们会将 雾的颜色和场景的背景色 设置成相同值。 87 | 88 | 当然如果你希望 场景背景色 和 雾气颜色不相同,完全没有问题,根据实际需求来设定就好了。 89 | 90 | 91 | 92 | ## 雾的示例:HelloFog 93 | 94 | #### 示例目标 95 | 96 | 1. 场景上有 3 个不断旋转、不同颜色的立方体 97 | 2. 场景中添加 雾,让 3 个立方体被雾气包围 98 | 99 | 100 | 101 | #### 实现思路 102 | 103 | 额~,这个场景除了 雾 之外其他的实现,和我们最初刚开始学 “03 编写HelloThreejs.md” 那篇文章一样,具体就不多说了,直接上代码。 104 | 105 | 106 | 107 | #### 示例代码 108 | 109 | 代码位于 scr/components/hello-fog/index.tsx 110 | 111 | ``` 112 | import { useEffect, useRef } from 'react' 113 | import * as Three from 'three' 114 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' 115 | 116 | import './index.scss' 117 | 118 | const HelloFog = () => { 119 | 120 | const canvasRef = useRef(null) 121 | 122 | useEffect(() => { 123 | 124 | if (canvasRef.current === null) { 125 | return 126 | } 127 | 128 | const renderer = new Three.WebGLRenderer({ canvas: canvasRef.current }) 129 | 130 | const scene = new Three.Scene() 131 | scene.background = new Three.Color(0xadd8e6) 132 | scene.fog = new Three.Fog(0xadd8e6, 1, 2) //向场景中添加 雾 133 | //scene.fog = new Three.FogExp2(0xadd8e6,0.8) //向场景中添加 指数雾 134 | 135 | const camera = new Three.PerspectiveCamera(75, 2, 0.1, 5) 136 | camera.position.z = 2 137 | 138 | const controls = new OrbitControls(camera, canvasRef.current) 139 | controls.update() 140 | 141 | const light = new Three.DirectionalLight(0XFFFFFF, 1) 142 | light.position.set(-1, 2, 4) 143 | scene.add(light) 144 | 145 | const colors = ['blue', 'red', 'green'] 146 | const boxs: Three.Mesh[] = [] 147 | 148 | colors.forEach((color, index) => { 149 | const mat = new Three.MeshPhongMaterial({ color }) 150 | const geo = new Three.BoxBufferGeometry(1, 1, 1) 151 | const mesh = new Three.Mesh(geo, mat) 152 | mesh.position.set((index - 1) * 2, 0, 0) 153 | scene.add(mesh) 154 | boxs.push(mesh) 155 | }) 156 | 157 | const render = (time: number) => { 158 | time *= 0.001 159 | 160 | boxs.forEach((box) => { 161 | box.rotation.x = time 162 | box.rotation.y = time 163 | }) 164 | 165 | renderer.render(scene, camera) 166 | window.requestAnimationFrame(render) 167 | } 168 | window.requestAnimationFrame(render) 169 | 170 | const handleResize = () => { 171 | if (canvasRef.current === null) { 172 | return 173 | } 174 | const width = canvasRef.current.clientWidth 175 | const height = canvasRef.current.clientHeight 176 | camera.aspect = width / height 177 | camera.updateProjectionMatrix() 178 | renderer.setSize(width, height, false) 179 | } 180 | handleResize() 181 | window.addEventListener('resize', handleResize) 182 | 183 | return () => { 184 | window.removeEventListener('resize', handleResize) 185 | } 186 | }, [canvasRef]) 187 | 188 | return ( 189 | 190 | ) 191 | } 192 | 193 | export default HelloFog 194 | ``` 195 | 196 | 197 | 198 | ## 如何让材质不受雾的影响? 199 | 200 | **所有材质默认都会受到雾的影响和作用。** 201 | 202 | 若希望物体不受雾的影响(即使物体处于雾气当中),那么可以将物体材质的 fog 属性设置为 false 即可。 203 | 204 | 205 | 206 | #### 试想一下以下场景: 207 | 208 | 1. 我们有一个房子,房子四周被雾气包围 209 | 2. 此时我们打开窗户,我们希望的效果是:窗外的物体继续被雾气环绕,但屋内的物体并不受雾气影响。 210 | 3. 为了实现这个效果,我们只需将屋内的物体材质 fog 设置为 false 即可 211 | 212 | 213 | 214 | #### 举例演示 215 | 216 | 我们修改 HelloFog 中的代码,我们让中间的红色立方体不受雾气影响,代码如下: 217 | 218 | ```diff 219 | colors.forEach((color, index) => { 220 | const mat = new Three.MeshPhongMaterial({ color }) 221 | const geo = new Three.BoxBufferGeometry(1, 1, 1) 222 | const mesh = new Three.Mesh(geo, mat) 223 | mesh.position.set((index - 1) * 2, 0, 0) 224 | scene.add(mesh) 225 | boxs.push(mesh) 226 | }) 227 | 228 | + const redBox = boxs[1].material as Three.Material //找到中间 红色立方体 229 | + redBox.fog = false //让红色立方体的材质不受雾的影响 230 | ``` 231 | 232 | 运行后,左右两侧的立方体继续受到雾气影响,若隐若现,但中间红色立方体则不受雾气任何影响。 233 | 234 | > 滚动鼠标中轴,调整场景上的观察视角,拉远观察距离,左右两侧立方体可能会完全消失在雾气中,但中间红色立方体不会消失,会一直处于可见状态。 235 | 236 | 237 | 238 | 至此,关于 雾 讲解完毕。 239 | 240 | 这一节可能是最近一系列文章中,最简单的一篇了。 241 | 242 | 下一节,讲解 离屏渲染(render target) -------------------------------------------------------------------------------- /Three.js实用知识点笔记.md: -------------------------------------------------------------------------------- 1 | # Three.js实用知识点笔记 2 | 3 | 从今天开始,在本文中记录实际 Three.js 开发过程中所遇到的知识点。 4 | 5 | 6 | 7 |
8 | 9 | **关于示例代码的一般约定说明:** 10 | 11 | 1. 举例的时候,很多都是伪代码 12 | 2. 为了保证代码简洁,所以在实用某些类时没有添加 `Three.` 前缀 13 | 3. 绝大多数时候都使用箭头函数 14 | 4. 使用 Xxx 泛指同一类型,例如 XxxCamer 泛指各类相机 15 | 5. 由于我们讲解的是 Three.js,所以全部使用的是 右手坐标系 16 | 17 | 18 | 19 |
20 | 21 | #### 01、每一个Object3D对象都只能有一个父级 22 | 23 | 这里说的 Object3D 实际上包括所有继承于 Object3D 的子类,例如 Mesh、Camera、Group 等 24 | 25 | 举例说明: 26 | 27 | ``` 28 | const mesh = new Mesh(geometry,material) 29 | 30 | const sceneA = new Scene() 31 | const sceneB = new Scene() 32 | 33 | sceneA.add(mesh) 34 | sceneB.add(mesh) 35 | ``` 36 | 37 | 由于 mesh 只能有一个父类,所以当 sceneB 也执行 .add(mesh) 后,sceneA.children 中会自动删除掉 mesh。 38 | 39 | 40 | 41 |
42 | 43 | #### 02、克隆或复制 Mesh 不会在内存中真正复制出一份 顶点(geometry)和材质(material),它们使用的是引用,而不是复制 44 | 45 | Object3D 拥有 .clone() 和 .copy() 两个方法,Mesh 继承于 Object3D,所以也拥有这两个方法。 46 | 47 | 在 Mesh 类中扩展了 .copy() 方法,但是针对 Mesh 内部的属性 顶点和材质,实用的是引用而不是真正内存中的复制。 48 | 49 | ``` 50 | const meshB = meshA.clone() 51 | ``` 52 | 53 | 上述代码中新复制得到的 meshB 仅仅复制了 meshA 的一些变换相关的属性,例如 matrix 等,但是对于占内存大头的 顶点和材质 这两项实用的是引用。 54 | 55 | 也就是说此时 meshA 和 meshB 它们共用了一份 geometry 和 material。 56 | 57 | > 不用担心因为多复制了几份 mesh 而增加很多内存。 58 | 59 | 60 | 61 |
62 | 63 | #### 03、添加场景(scene)或其他Object3D渲染之前和渲染之后的回调函数 64 | 65 | 场景 scene 继承于 Object3D,而 Object3D 可以配置 2 个渲染之前或之后的回调函数: 66 | 67 | ``` 68 | scene.onBeforeRender = () => { ...} 69 | scnet.onAfterRende = () => { ... } 70 | ``` 71 | 72 | 使用场景举例:假设我们希望不渲染场景上的某一类元素,那么我们可以在 renderer.render() 之前通过上面 2 个回调函数进行设置 73 | 74 | ``` 75 | scene.onBeforeRender = () => { 76 | scene.children.forEach(item => { 77 | if(item.type === 'Points'){ 78 | item.visible = false 79 | } 80 | }) 81 | } 82 | 83 | scene.onAfterRender = () => { 84 | scene.children.forEach(item => item.visible = true) 85 | } 86 | 87 | renderer.render(scene,camera) 88 | ``` 89 | 90 | 91 | 92 |
93 | 94 | #### 04、通过 .layers 控制物体是否被渲染 95 | 96 | 在 Three.js 中 .layers 对应的是 Layers 这个类,Three.js 规定 Layers 级别的值取值范围为 0 - 32。 97 | 98 | > 你可以把 layers 翻译成 “级别”,也可以称呼为 “层级” 99 | 100 | 任何继承于 Object3D 的类,例如 相机、物体 等都具有 .layers 属性。 101 | 102 | 它们的 .layers 默认级别都为 0。 103 | 104 | 不能通过直接给 .layers 赋值的方式修改级别,而是应该通过 .set(value) 这种形式。 105 | 106 | ``` 107 | mymesh.layers.set(1) 108 | camera.layers.set(1) 109 | ``` 110 | 111 | 112 | 113 |
114 | 115 | 对于相机而言,它只能渲染出同一级别的物体元素。 116 | 117 | ``` 118 | const meshA = new Mesh(...) 119 | //meshA.layers.set(0) //默认就是 0 120 | 121 | const meshB = new Mesh(...) 122 | meshB.layers.set(1) 123 | 124 | const scene = new Scene() 125 | scene.add(meshA) 126 | scene.add(meshB) 127 | 128 | const cameraA = new XxxCamera() 129 | //cameraA.layers.set(0) //默认就是 0 130 | 131 | const cameraB = new XxxCamera() 132 | cameraB.layers.set(1) 133 | ``` 134 | 135 | 在上面代码中: 136 | 137 | 1. 我们按照默认的形式添加了 meshA、cameraA,它们默认层级为 0 138 | 2. 手动修改了 meshB、cameraB 的 .layers 层级为 1 139 | 140 |
141 | 142 | 那么当执行下面的代码: 143 | 144 | ``` 145 | renderer.render(scene, cameraA) 146 | renderer.render(scene. cameraB) 147 | ``` 148 | 149 | 1. cameraA 只会渲染出场景中同一级别的 meshA 150 | 2. cameraB 只会渲染出场景中同一级别的 meshB 151 | 152 | 153 | 154 |
155 | 156 | 也可以选择随时修改 meshA 的 .layers 值,这样 cameraB 就可以渲染到它了。 157 | 158 | ``` 159 | meshA.layers.set(1) 160 | renderer.render(secen, cameraB) 161 | ``` 162 | 163 | 164 | 165 |
166 | 167 | 换句话说,假设我们希望控制是否渲染场景中某些元素,那么有 2 种途径: 168 | 169 | 1. 设置其 .visible 的值来决定是否渲染 170 | 2. 设置其 .layers 的值来决定只被同一层级的相机渲染 171 | 172 | 173 | 174 |
175 | 176 | #### 05、手工修改Object3D实例的.matrix时切记要设置 .matrixAutoUpdate=false 177 | 178 | 默认情况下 Object3D 实例的 .matrixAutoUpdate 的值为 true,也就是说当通过 .applyMatrix4()、.applyQuaternion() 等修改实例的变换时,默认会自动更新其他所有相关属性值,例如 position、quaternion、scale、rotation。 179 | 180 | 但是,如果直接通过修改 Object3D 实例的 .matrix 值时,生效的前提是: 181 | 182 | 1. 先把 .matrixAutoUpdate 设置为 false 183 | 2. 不去调用 .updateMatrix() 184 | 185 | ``` 186 | const mesh = new Mesh(...) 187 | 188 | mesh.matrixAutoUpdate = false 189 | mesh.matrix.copy(otherMatrix) 190 | ``` 191 | 192 | 193 | 194 |
195 | 196 | 但是上面的代码存在另外一个问题:尽管 .matrix 值更新了,可是 mesh 的其他属性值 例如 .position,.quaternion,scale,rotation 却没有自动更新。 197 | 198 | 解决方式很简单,可以通过 Matrix 的 .decompose() 方法优雅更新它们。 199 | 200 | 举例说明:假设现在有 meshA、meshB 两个对象,需要将 meshB 的各种变换属性值设置成和 meshA 完全相同 201 | 202 | ``` 203 | meshB.matrixAutoUpdate = false 204 | meshB.matrix.copy(meshA.matrix) 205 | meshB.matrix.decompose(meshB.position, meshB.quaternion, meshB.scale) 206 | ``` 207 | 208 | > 当修改 meshB.quaternion 值后会自动修改 meshB.rotation 的值 209 | 210 | 211 | 212 |
213 | 214 | #### 06、绘制三角形的顶点顺序决定了该三角形是正面(顺时针)还是反面(逆时针) 215 | 216 | 一个三角形有 3 个顶点,假定为 a、b、c,那么: 217 | 218 | 1. 假定 a b c 连接顺序为 逆时针,那么最终形成的三角形为 正面 219 | 2. 假定 a b c 连接顺序为 顺时针,那么最终形成的三角形为 反面(背面) 220 | 221 | 另外一种判定形式是:右手握住沿着两个顶点添加顺序的连接线,此时大拇指指示方向即为正面 222 | 223 | 224 | 225 |
226 | 227 | #### 07、保持外观和位置的前提下,将立方体的顶点坐标 "归一化" 228 | 229 | 这里说的立方体是指基于 BoxGeometry 而创建的立方体。 230 | 231 | 这里说的 顶点坐标 “归一化” 是指将立方体顶点坐标修改成 1x1x1 规格的立方体顶点坐标。 232 | 233 | 这里说的 保持外观和位置 是指通过修改其 变换矩阵 .matrix 来实现。 234 | 235 | 实现思路: 236 | 237 | 1. 凡是基于 BoxGeometry 的立方体,其顶点坐标信息都是统一规范的,尽管其值可能不同 238 | 2. 所以我们就根据其值来确定这个立方体与 1x1x1 立方体的 宽、高、深 比例 239 | 3. 将这个缩放比例应用到立方体本身的矩阵中即可 240 | 4. 同时将这个立方体的顶点信息修改成 1x1x1 立方体的顶点信息 241 | 242 | ``` 243 | const boxGeometryNormalize = (box) => { 244 | const originX = box.geometry.attributes.position.array[0] 245 | const originY = box.geometry.attributes.position.array[1] 246 | const originZ = box.geometry.attributes.position.array[2] 247 | 248 | const scaleX = originX / 0.5 249 | const scaleY = originY / 0.5 250 | const scaleZ = originZ / 0.5 251 | 252 | box.geometry = new BoxGeometry() 253 | box.matrixAutoUpdate = false 254 | box.matrix.makeScale(scaleX, scaleY, scaleZ) 255 | box.matrix.decompose(box.position, box.quaternion, box.scale) 256 | } 257 | ``` 258 | 259 | 260 | 261 |
262 | 263 | 264 | 265 | -------------------------------------------------------------------------------- /18 Three.js技巧之调试.md: -------------------------------------------------------------------------------- 1 | # 18 Three.js技巧之调试 2 | 3 | 本文讲解一些 Three.js 的调试技巧,是其他程序员在开发 Three.js 过程中积累的一些找错、调试经验。 4 | 5 | > 其中一些调试经验适用于所有的前端项目 6 | 7 | 8 | 9 | ## 调试的几点经验 10 | 11 | #### 1、使用浏览器调试 12 | 13 | 个人推荐使用 谷歌浏览器 或 最新版的微软 Edge 浏览器 调试工具。 14 | 15 | 以谷歌浏览器 Chrome 为例,打开调试的快捷键为:Ctrl + Shift + I 16 | 17 | > 个别测试时候,鼠标放在画布上点击右键不显示菜单,或者不显示 “检查”,此时使用快捷键调出调试工具最为合适。 18 | 19 | 20 | 21 | #### 2、关闭缓存 22 | 23 | 对于开发阶段,为了确保所加载的各种资源是最新的,而不是缓存的,所以推荐关闭缓存。 24 | 25 | 关闭缓存的方法:打开调试面板(Toggle Tools) > 网络面板(Network) > 勾选 禁用缓存(Disable cache) 26 | 27 | 28 | 29 | #### 3、善用信息打印 Console 30 | 31 | 可以通过在代码中添加 console.log(xxx),或者直接在 Console 面板中 添加打印代码,查看当前 JS 环境中的信息变量。 32 | 33 | 34 | 35 | #### 4、添加debugger 36 | 37 | 我们可以在代码中,添加 debugger ,给代码执行过程中添加断点,好一步步确认整个执行过程。 38 | 39 | 1. 直接通过 VSCoder 在某行代码左侧,添加断点(小红点) 40 | 2. 在代码中添加 `debugger`,当代码执行到此处时即进入调试状态 41 | 42 | > 个人推荐使用第 1 种形式添加断点 43 | 44 | 45 | 46 | #### 5、通过URL来获取参数 47 | 48 | 假如说我们现在需要在场景上创建一个立方体,URL 参数中包含立方体对应的尺寸。 49 | 50 | 假设 URL 参数为: 51 | 52 | ``` 53 | https://xxx.com/threejs/xxx.index?width=3&height=2&depth=1 54 | ``` 55 | 56 | `width=3&height=2&depth=1` 即我们需要获取并配置给立方体的参数。 57 | 58 | > 为了避免参数缺失或错误而导致立方体创建失败,我们给立方体的 宽、高、厚 设置一个默认值 1 59 | 60 | **我们可以使用浏览器新增的 URLSearchParams 来解析 URL 参数:** 61 | 62 | ``` 63 | interface URLParams { 64 | width: number, 65 | height: number, 66 | depth: number 67 | } 68 | 69 | const getURLParams = (): URLParams => { 70 | const params = new URLSearchParams(window.location.search.substring(1)) 71 | const widthStr = params.get('width') 72 | const heightStr = params.get('height') 73 | const depthStr = params.get('depth') 74 | 75 | let [width, height, depth] = [0, 0, 0] 76 | 77 | if (widthStr) { width = parseInt(widthStr, 10) || 1 } 78 | if (heightStr) { height = parseInt(heightStr, 10) || 1 } 79 | if (depthStr) { depth = parseInt(depthStr, 10) || 1 } 80 | 81 | return { width, height, depth } 82 | } 83 | 84 | const TestDebugging = () => { 85 | const urlParams = getURLParams() //获取 URL 参数 86 | ... 87 | 88 | const geometry = new Three.BoxBufferGeometry(urlParams.width, urlParams.height, urlParams.depth) 89 | 90 | ... 91 | } 92 | ``` 93 | 94 | 95 | 96 | #### 6、把一些参数显示在屏幕上 97 | 98 | 我们还以 刚才的代码为例,在之前的示例中,我们是将组件直接 return 一个 对象, 99 | 100 | ``` 101 | return ( 102 | 103 | ) 104 | ``` 105 | 106 | 我们可以改造一下: 107 | 108 | ``` 109 | return ( 110 |
111 | 112 |
113 | width:{urlParams.width} 114 | height:{urlParams.height} 115 | depth:{urlParams.depth} 116 |
117 |
118 | ) 119 | ``` 120 | 121 | 对应的样式: 122 | 123 | ``` 124 | .full-screen, canvas { 125 | display: block; 126 | height: inherit; 127 | width: inherit; 128 | } 129 | 130 | .debug { 131 | position: fixed; 132 | top: 20px; 133 | left: 20px; 134 | width: 80px; 135 | padding: 20px; 136 | background-color: rgba($color: #FFFFFF, $alpha: 0.7); 137 | } 138 | 139 | .debug span { 140 | display: block; 141 | } 142 | ``` 143 | 144 | 这样当我们调试网页的时候,就可以直接在左上角,看到 立方体的尺寸具体的值。 145 | 146 | > 本示例演示的立方体尺寸是固定的,若通过 useState 来定义尺寸,且尺寸会发生修改,那么左上角的展示的参数也可以对应修改成动态可变动的。 147 | 148 | > 依次类推,可以延展成其他参数展示 149 | 150 | 151 | 152 | #### 7、把 window.requestAnimationFrame 添加在靠后位置 153 | 154 | 在之前的一些示例中,渲染场景的函数,可能如下: 155 | 156 | ``` 157 | const render = () => { 158 | renderer.render(scene, camera) 159 | window.requestAnimationFrame(render) 160 | } 161 | window.requestAnimationFrame(render) 162 | ``` 163 | 164 | 你是否考虑过,假设我们修改成这样: 165 | 166 | ``` 167 | const render = () => { 168 | window.requestAnimationFrame(render) //代码顺序改变 169 | renderer.render(scene, camera) 170 | } 171 | window.requestAnimationFrame(render) 172 | ``` 173 | 174 | 代码顺序调整后,会有什么问题吗? 175 | 176 | 答:这里可能会产生一个隐患——由于 window.requestAnimationFrame 代码在前,renderer.render(scene, camera) 代码在后,那么意味着 即使 renderer.render() 执行发生错误,那么代码依然在下一帧中继续执行。 177 | 178 | 而我们之前的顺序是 renderer.render() 在前,window.requestAnimationFrame 在后,那么万一 renderer.render() 执行时发生错误,此时 浏览器即报错,JS 停止运行,那么后面的 window.requestAnimationFrame 就不会再执行了。 179 | 180 | 结论:应该尽量把 window.requestAnimationFrame 添加到靠后位置。 181 | 182 | 183 | 184 | #### 8、检查 Three.js 中的单位 185 | 186 | 在 Three.js 中,单位并没有统一,具体表现在: 187 | 188 | 1. 镜头的角度使用的是度数、而其他地方涉及角度时单位使用的是 弧度。 189 | 2. 默认情况下,对于距离、尺寸的数值 1 表示 1 米,但是也可以通过配置让数值 1 表示为 1 厘米。 190 | 191 | 因此,在使用 Three.js 时,关于数值单位请格外留意。 192 | 193 | 194 | 195 | #### 9、添加辅助对象、添加镜头轨道控制器 196 | 197 | 在开发阶段,可以多添加一些辅助对象,例如:坐标辅助对象、灯光辅助对象、镜头辅助对象。 198 | 199 | 辅助对象可以比较直观得帮助我们去观察,去调试。 200 | 201 | 同时也要添加镜头轨道控制器,例如 OrbitControls,可以让我们比较方便操控、查看场景。 202 | 203 | 204 | 205 | #### 10、必要时可以将物体材质设置为 MeshBasicMaterial 206 | 207 | 假设你设置的物体材质是可反光材料,但是渲染时发现并没有渲染出该物体。 208 | 209 | 这个时候,你可以先将物体材质修改为不反光的 MeshBasicMaterial,这样可以快速排除一些问题。 210 | 211 | 假设物体材质不反光,此时若依然渲染不出物体,那么就可以肯定问题没有出在灯光上。 212 | 213 | 反之,则应该去检查灯光的问题。 214 | 215 | 216 | 217 | #### 11、检查镜头的 near 和 far 配置 218 | 219 | 有些时候场景没有渲染出物体,那么你也要去检查一下镜头的 near 和 far 的值。 220 | 221 | 比如我们可以暂时性的将镜头的 far 设置为 10000,或者将 near 设置为 0.001,在这种极端配置下,再去看场景是否渲染出物体。 222 | 223 | 调试结束后,记得将 near 和 far 调整会合理的精度范围。 224 | 225 | 226 | 227 | #### 12、遇到疑惑的地方,查文档,查源码 228 | 229 | 遇到某些 Three.js 疑惑的地方,例如某属性,某方法,请记得一定先去查阅官方文档,如果没有解决就直接去看 Three.js 的源码。 230 | 231 | Three.js 的源码并不是特别复杂,要敢于查看源码来解决疑惑。 232 | 233 | 234 | 235 | ## 使用GUI调试场景中的参数 236 | 237 | **图形用户界面( Graphical User Interface ) 简称 GUI。** 238 | 239 | > 主要目的是用来帮我们快速搭建可视化调试参数面板。 240 | 241 | 242 | 243 | **针对 JS 的 GUI ——dat.gui** 244 | 245 | 官网地址:https://github.com/dataarts/dat.gui 246 | 247 | 248 | 249 | **针对 React 的 GUI——react-dat-gui** 250 | 251 | 官网地址:https://github.com/claus/react-dat-gui 252 | 253 | 254 | 255 | **react-dat-gui 的具体用法,请查看我的另外一篇文章:[React中使用GUI.md](https://github.com/puxiao/notes/blob/master/React%E4%B8%AD%E4%BD%BF%E7%94%A8GUI.md)** 256 | 257 | 258 | 259 | 本教程所有示例都是基于 react + typescript 的,所以我们选择使用 react-dat-gui 260 | 261 | 在我们后续的示例中,就会使用到 react-dat-gui。 262 | 263 | 264 | 265 | ## 调试GLSL 266 | 267 | **图形库着色语言( Graphic Library Shader Language ) 简称 GLSL。** 268 | 269 | > 学不动了,学不动了! 270 | 271 | GLSL 的相关介绍,可查阅: 272 | 273 | WebGL与GLSL:https://webglfundamentals.org/webgl/lessons/zh_cn/webgl-shaders-and-glsl.html 274 | 275 | WebGL2与GLSL:https://webgl2fundamentals.org/webgl/lessons/zh_cn/webgl-shaders-and-glsl.html 276 | 277 | 278 | 279 | 由于我个人没有学习过 WebGL和 GLSL,所以暂时先不讨论如何调试 GLSL。 280 | 281 | > 谷歌浏览器还有一个专门用来调试着色器的插件:Shader Editor 282 | > 283 | > https://chrome.google.com/webstore/detail/shader-editor/ggeaidddejpbakgafapihjbgdlbbbpob?hl=en 284 | 285 | 286 | 287 | 关于 Three.js 的调试技巧,就讲到这里。 288 | 289 | 接下来讲解 Canvas 的一些常用小技巧。 -------------------------------------------------------------------------------- /02 初始化Three.js项目.md: -------------------------------------------------------------------------------- 1 | # 02 初始化Three.js项目 2 | 3 | 本文以 Yarn 而不是 NPM 安装 Three.js。 4 | 5 | **第1步:全局安装 create-react-app** 6 | 7 | ``` 8 | yarn global add create-react-app 9 | ``` 10 | 11 | 12 | 13 | **第2步:初始化React+TypeScript项目** 14 | 15 | > 我用的是 create-react-app 4.0.2 版本,对应的是 React 17.0.1 16 | 17 | ``` 18 | yarn create react-app test-threejs --template typescript 19 | ``` 20 | 21 | 22 | 23 | **第3步:初次修改 tsconfig.json 配置** 24 | 25 | 1. 修改 TS 编译目标 ES版本为 es2017 26 | 27 | ``` 28 | "target": "es2017" 29 | ``` 30 | 31 | 2. 添加一些本人 TS 配置偏好 32 | 33 | ``` 34 | "noUnusedLocals": true, 35 | "noUnusedParameters": true, 36 | "sourceMap": true, 37 | "removeComments": false 38 | ``` 39 | 40 | 41 | 42 | **第4步:配置 alias,安装对应模块** 43 | 44 | 由 create-react-app 创建的 React 项目中,配置 alias(路径映射),我采用的方案是:react-app-rewired + react-app-rewire-alias 45 | 46 | ``` 47 | yarn add --dev react-app-rewired react-app-rewire-alias 48 | ``` 49 | 50 | 51 | 52 | **第5步:完善 alias 配置** 53 | 54 | 1. 在项目根目录,新建文件 tsconfig.paths.json,内容暂时设置为: 55 | 56 | ``` 57 | { 58 | "compilerOptions": { 59 | "baseUrl": ".", 60 | "paths": { 61 | "@/src/*": ["./src/*"], 62 | "@/components/*": ["./src/components/*"] 63 | } 64 | } 65 | } 66 | ``` 67 | 68 | > 我们暂时先添加 2 个路径映射 src 和 components,具体路径还会根据将来实际开发过程中所需要创建不同的目录结构进行修改。 69 | > 70 | > 补充:根据 typescript 官方更新说明文档,baseUrl 这一项是可以省略的,但是上面代码中还是遵循了之前的配置方式,继续添加上了 baseUrl。 71 | 72 | 2. 在项目根目录,新建文件 config-overrides.js,内容为: 73 | 74 | ``` 75 | const { alias, configPaths } = require('react-app-rewire-alias') 76 | 77 | module.exports = function override(config) { 78 | alias(configPaths('./tsconfig.paths.json'))(config) 79 | 80 | return config 81 | } 82 | ``` 83 | 84 | > 注意是 .js 文件 而不是 .ts 文件,鼠标放到 require 上后也许会显示提示文字:文件是 CommonJS 模块;它可能会转换为 ES6 模块。 85 | > 86 | > 请忽略这个提示,这并不是什么错误信息。 87 | > 88 | > require 是 Nodejs 导入模块的方式,TypeScript 导入模块使用的是 import。 89 | 90 | 3. 在项目根目录,新建文件 global.d.ts,内容为: 91 | 92 | > 注意:本步骤的目的是为了让 TS 忽略对 react-app-rewire-alias 和其他一些非常规格式文件的导入检查。 93 | > 94 | > 本步骤是可选的,不是必须的,你可以跳过本步骤。 95 | 96 | ``` 97 | declare module '*.png'; 98 | declare module '*.gif'; 99 | declare module '*.jpg'; 100 | declare module '*.jpeg'; 101 | declare module '*.svg'; 102 | declare module '*.css'; 103 | declare module '*.less'; 104 | declare module '*.scss'; 105 | declare module '*.sass'; 106 | declare module '*.styl'; 107 | declare module 'react-app-rewire-alias'; 108 | ``` 109 | 110 | 4. 修改 tsconfig.json 文件,添加以下一行内容: 111 | 112 | ``` 113 | { 114 | "extends": "./tsconfig.paths.json", 115 | "compilerOptions": { 116 | ... 117 | } 118 | } 119 | ``` 120 | 121 | > 请注意 extends 是和 compilerOptions 平级的。 122 | 123 | 至此,我们的 tsconfig.json 最终内容如下: 124 | 125 | ``` 126 | { 127 | "extends": "./tsconfig.paths.json", 128 | "compilerOptions": { 129 | "target": "es2017", 130 | "lib": [ 131 | "dom", 132 | "dom.iterable", 133 | "esnext" 134 | ], 135 | "allowJs": true, 136 | "skipLibCheck": true, 137 | "esModuleInterop": true, 138 | "allowSyntheticDefaultImports": true, 139 | "strict": true, 140 | "forceConsistentCasingInFileNames": true, 141 | "noFallthroughCasesInSwitch": true, 142 | "module": "esnext", 143 | "moduleResolution": "node", 144 | "resolveJsonModule": true, 145 | "isolatedModules": true, 146 | "noEmit": true, 147 | "jsx": "react-jsx", 148 | "noUnusedLocals": true, 149 | "noUnusedParameters": true, 150 | "sourceMap": true, 151 | "removeComments": false 152 | }, 153 | "include": [ 154 | "src" 155 | ] 156 | } 157 | ``` 158 | 159 | 160 | 161 | **第6步:修改 package.json 中的 scripts 命令** 162 | 163 | 将命令中 start、build、test 3 条命令中的 react-scripts 修改为 react-app-rewired 164 | 165 | ``` 166 | "scripts": { 167 | "start": "react-app-rewired start", 168 | "build": "react-app-rewired build", 169 | "test": "react-app-rewired test", 170 | "eject": "react-scripts eject" 171 | }, 172 | ``` 173 | 174 | 175 | 176 | **第7步:安装 scss** 177 | 178 | ``` 179 | yarn add node-sass --dev 180 | ``` 181 | 182 | > 假设你使用的是较早版本的 create-react-app,那么当时还不支持最新版 node-sass 5.0,所以你只能安装: `yarn add node-sass@4.14.1 --dev` 183 | 184 | 185 | 186 | **第8步:安装three.js** 187 | 188 | ``` 189 | //npm install --save three 190 | yarn add three 191 | ``` 192 | 193 | 194 | 195 | > 以下更新于 2021.04.11 196 | 197 | **关于 .d.ts 文件的特别说明:** 198 | 199 | 写这篇文章的时候是 2020年11月底,当时应该是 r124 版本,当时的 Three.js 版本里内置了 .d.ts 文件,但是随着 Three.js 版本升级,大约在 r126 版本以后官方已经将内置的 .d.ts 文件移除,目前最新的版本是 r128。 200 | 201 | **所以,我们现在还需要额外安装对应的 .d.ts 文件包:** 202 | 203 | ``` 204 | //npm install @types/three 205 | yarn add @types/three 206 | ``` 207 | 208 | 209 | 210 |
211 | 212 | 本系列教程前面相当一部分示例是基于 Three.js 0.124.0 的,而这些示例有可能会在最新版本 0.127.0 中不能正常运行,特此说明。 213 | 214 | > 所谓不能正常运行,多数都是因为某些类在新版本中引入路径发生了变化,可根据 VSCode 中的提示进行修改。 215 | 216 | 217 | 218 | 我们会在第 23 小节开始,使用 Three.js 最新版本 r127。 219 | 220 |
221 | 222 | 223 | 224 | > 以上更新于 2021.04.11 225 | 226 | 227 | 228 | **第9步:给 package.json 添加 homepage 字段** 229 | 230 | ``` 231 | { 232 | "name": "test-threejs", 233 | "homepage": ".", 234 | ... 235 | } 236 | ``` 237 | 238 | homepage 字段是用来设定 html 中文件资源(编译后的 js 或 css) 的根 URL 地址。 239 | 240 | 假设不添加 homepage 字段,则默认使用网站根目录作为 资源根目录。 241 | 242 | > 我猜测 homepage 的默认值是 "/" 243 | 244 | 如果你的项目将来并不是发布在网站根目录,那么设置 homepage 字段会非常有用。 245 | 246 | 举例: 247 | 248 | 假设将来项目网址为:https://threejs.puxiao.com,那么你完全可以跳过本步骤,不作任何修改。 249 | 250 | 但若将来项目网址为:https://threejs.puxiao.com/demo/,那么此时所有文件资源存放在 demo 这个目录下(并不是网站根目录),那么必须给 package.json 添加 "homepage": ".",不然文件资源都会出现 404 状态。 251 | 252 | 253 | 254 | **第10步:清理默认 create-react-app 创建的一些无用内容** 255 | 256 | 以下为我个人的习惯,你可以根据自己喜好对应选择是否清理。 257 | 258 | 1. 清除掉 index.tsx 中 、reportWebVitals() 259 | 260 | > 目前 React 最新版中对应的 tsconfig.json 默认就使用的是严格模式,因此 是可以删除掉的 261 | 262 | 2. 删除掉 src 目录下 setupTests.ts、reportWebVitals.ts、App.test.tsx 文件 263 | 264 | > 这些都是用来做 React 自动单元调试的,暂且用不到 265 | 266 | 3. 删除掉 public 目录下 logo192.png、logo512.png、manifest.json、robots.txt 267 | 268 | 4. 清除掉 public 目录下 index.html 中无用的代码,只保留最基础的标签。 269 | 270 | ``` 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | Hello Threejs 281 | 282 | 283 | 284 | 285 |
286 | 287 | 288 | 289 | ``` 290 | 291 | 292 | 293 | 294 | 经过以上 10 个步骤之后,已经创建好了一个基础的开发环境:React + TypeScript + Scss + Alias + Threejs 295 | 296 | 下一章,终于要开始编写 hello world 了。 297 | 298 | -------------------------------------------------------------------------------- /03 编写HelloThreejs.md: -------------------------------------------------------------------------------- 1 | # 03 编写HelloThreejs 2 | 3 | 终于要真正第一次亲密接触 Three.js 了。 4 | 5 | 我们先梳理一下创建一个 Three.js 示例所需要的过程,认真阅读并理解整个过程,会更加容易让你读懂我后面的示例代码。 6 | 7 | 8 | 9 | ## 从引入Three到创建示例的过程 10 | 11 | ### 第1环节:引入Three.js 12 | 13 | **引入方式1:将 THREE 一次全部引入** 14 | 15 | ``` 16 | import THREE from 'three' 17 | 18 | //当需要使用某个具体的模块时,例如创建场景,则代码如下 19 | const scene = new THREE.Scene() 20 | ``` 21 | 22 | > 这种方式会将所有 Three.js 相关模块都引入进来,虽然引入代码简介,但是会造成项目打包输出时文件过大,因此并不建议这样引入。 23 | 24 | 25 | 26 | **请注意:默认 three 模块导出的名字就是全部大写的 “THREE”。我个人非常不习惯 模块名称 全部大写,我的代码习惯是使用 “Three”。** 27 | 28 | ``` 29 | import * as Three from 'three' 30 | 31 | const scene = new Three.Scene() 32 | ``` 33 | 34 | > **本系列文章中使用的示例,代码绝大多数都采用这种引入形式,使用 Three 而 不使用 THREE。 35 | > 所以网上一些教程中可能描述某个类时使用的是 THREE.Xxxx,而我在本系列文章中都会使用 Three.Xxxx 这种方式。** 36 | 37 | 38 | 39 | **引入方式2:按需引入模块** 40 | 41 | 例如我们需要使用 Scene 模块,则仅引入该模块即可 42 | 43 | ``` 44 | import { Scene } from 'three' 45 | 46 | //当需要使用某个具体的模块时,例如创建场景,则代码如下 47 | const scene = new Scene() 48 | ``` 49 | 50 | > 本文示例代码,都将采用按需引入的方式。 51 | 52 | 53 | 54 | ### 第2环节:将DOM中的canvas与Threejs中的渲染器进行挂钩 55 | 56 | **采用 React 的 useEffect + useRef 来实现所谓 “挂钩” 。** 57 | 58 | > 具体参见示例代码 59 | 60 | 61 | 62 | ### 第3环节:创建Three.js基础3大元素、场景可见元素 63 | 64 | **基础3大元素:** 65 | 66 | 1. 渲染器 > 本文示例采用的渲染器是 WebGLRenderer 67 | 2. 透视镜头 > 本文示例采用的是 PerspectiveCamera 68 | 3. 场景 > Scene 69 | 70 | **场景可见元素:** 71 | 72 | 1. 几何体 > 本文示例采用的是 BoxGeometry(立方体) 73 | 2. 几何体的材质(颜色、光亮程度) > 本文示例采用的是 MeshBasicMaterial 或 MeshPhongMaterial 74 | 3. 网格 > Mesh 75 | 4. 光源 > 本文示例采用的是 DirectionalLight(平行光源) 76 | 77 | **补充说明:** 78 | 79 | 你应该发现,除了 场景(Scene)、网格(Mesh) 之外,其他的元素我都注明 “本文示例采用的是...”。 80 | 81 | 因为无论渲染器,还是几何体,以及其他元素,Three.js 都内置了非常多不同种类的元素构造函数,这个会在以后学习中逐渐详细说明举例。 82 | 83 | 84 | 85 | ### 第4环节:使用渲染器渲染出画面 86 | 87 | **渲染画面** 88 | 89 | 就是根据第3环节中所创建出的 3D 场景,渲染出画面,并将画面内容填充到 canvas 中。 90 | 91 | 本文示例中,为了呈现 3D 动画,使用到了浏览器中 window.requestAnimationFrame() 这个函数。 92 | 93 | > 关于 window.requestAnimationFrame() 的用法请参考:https://developer.mozilla.org/zh-CN/docs/Web/API/window/requestAnimationFrame 94 | 95 | 96 | 97 | ### 补充说明:3D动画是怎么动起来的? 98 | 99 | 默认情况下,渲染出的 3D 场景都是静止的,所谓 3D 动画,本质上是因为 “场景” 上发生了 “变化” 被渲染器不断重新渲染。 100 | 101 | 引起这些所谓 “变化”,简单可归纳为以下几种原因: 102 | 103 | 1. 镜头不变,但可见场景元素发生了变化,例如几何体发生了变化、网格角度发生了变化等 104 | 2. 可见场景元素不变,但是镜头发生了变化,例如镜头的推近、拉远等 105 | 3. 镜头变化了,同时场景元素也变化了...... 106 | 107 | 108 | 109 | ## 示例代码 110 | 111 | #### 第1步:创建并编写index.tsx代码内容 112 | 113 | 在 src/components/hello-threejs 目录下,创建 index.tsx 作为我们自定义的组件。 114 | 115 | 编写该组件对应的代码内容: 116 | 117 | ``` 118 | import React, { useRef, useEffect } from 'react' 119 | import { WebGLRenderer, PerspectiveCamera, Scene, BoxGeometry, Mesh, DirectionalLight, MeshPhongMaterial } from 'three' 120 | 121 | const HelloThreejs: React.FC = () => { 122 | const canvasRef = useRef(null) 123 | 124 | useEffect(() => { 125 | if (canvasRef.current) { 126 | //创建渲染器 127 | const renderer = new WebGLRenderer({ canvas: canvasRef.current }) 128 | 129 | //创建镜头 130 | //PerspectiveCamera() 中的 4 个参数分别为: 131 | //1、fov(field of view 的缩写),可选参数,默认值为 50,指垂直方向上的角度,注意该值是度数而不是弧度 132 | //2、aspect,可选参数,默认值为 1,画布的宽高比(宽/高),例如画布宽300像素,高150像素,那么意味着宽高比为 2 133 | //3、near,可选参数,默认值为 0.1,近平面,限制摄像机可绘制最近的距离,若小于该距离则不会绘制(相当于被裁切掉) 134 | //4、far,可选参数,默认值为 2000,远平面,限制摄像机可绘制最远的距离,若超出该距离则不会绘制(相当于被裁切掉) 135 | //以上 4 个参数在一起,构成了一个 “视椎”,关于视椎的概念理解,暂时先不作详细描述。 136 | const camera = new PerspectiveCamera(75, 2, 0.1, 5) 137 | 138 | //创建场景 139 | const scene = new Scene() 140 | 141 | //创建几何体 142 | const geometry = new BoxGeometry(1, 1, 1) 143 | 144 | //创建材质 145 | //我们需要让立方体能够反射光,所以不使用MeshBasicMaterial,而是改用MeshPhongMaterial 146 | //const material = new MeshBasicMaterial({ color: 0x44aa88 }) 147 | const material = new MeshPhongMaterial({ color: 0x44aa88 }) 148 | 149 | //创建网格 150 | const cube = new Mesh(geometry, material) 151 | scene.add(cube)//将网格添加到场景中 152 | 153 | //创建光源 154 | const light = new DirectionalLight(0xFFFFFF, 1) 155 | light.position.set(-1, 2, 4) 156 | scene.add(light)//将光源添加到场景中,若场景中没有任何光源,则可反光材质的物体渲染出的结果是一片漆黑,什么也看不见 157 | 158 | //设置透视镜头的Z轴距离,以便我们以某个距离来观察几何体 159 | //之前初始化透视镜头时,设置的近平面为 0.1,远平面为 5 160 | //因此 camera.position.z 的值一定要在 0.1 - 5 的范围内,超出这个范围则画面不会被渲染 161 | camera.position.z = 2 162 | 163 | //渲染器根据场景、透视镜头来渲染画面,并将该画面内容填充到 DOM 的 canvas 元素中 164 | //renderer.render(scene, camera)//由于后面我们添加了自动渲染渲染动画,所以此处的渲染可以注释掉 165 | 166 | //添加自动旋转渲染动画 167 | const render = (time: number) => { 168 | time = time * 0.001 //原本 time 为毫秒,我们这里对 time 进行转化,修改成 秒,以便于我们动画旋转角度的递增 169 | cube.rotation.x = time 170 | cube.rotation.y = time 171 | renderer.render(scene, camera) 172 | window.requestAnimationFrame(render) 173 | } 174 | window.requestAnimationFrame(render) 175 | 176 | } 177 | }, [canvasRef]) 178 | 179 | 180 | return ( 181 | 182 | ) 183 | } 184 | 185 | export default HelloThreejs 186 | ``` 187 | 188 | #### 第2步:添加对HelloThreejs组件的使用 189 | 190 | 修改 src/app.tsx 对应的代码: 191 | 192 | ``` 193 | import './App.scss' 194 | import HelloThreejs from '@/components/hello-threejs'; 195 | 196 | const App = () => { 197 | return ( 198 | 199 | ) 200 | } 201 | 202 | export default App; 203 | ``` 204 | 205 | #### 第3步:查看运行效果 206 | 207 | ``` 208 | yarn start 209 | ``` 210 | 211 | 若无意外,你会在浏览器中看到一个 高150像素,宽300像素的 黑色场景,该场景上一直有一个 3D 立方体在旋转。 212 | 213 | 至此,我们的第一个 Three.js 示例完成。 214 | 215 | 216 | 217 | ## 如何让场景有多个立方体? 218 | 219 | 首先回忆一下 "01 Three.js简介.md" 中 “Three.js中的技术名词” 中关于 网格的介绍。 220 | 221 | **网格:一种特定的 几何体和材质 绘制出的一个特定的几何体系。** 222 | 223 | **网格包含的内容为:几何体、几何体的材质、几何体的自身网格坐标体系** 224 | 225 | **在 Three.js 中,要牢记以下几个概念:** 226 | 227 | * 一个几何体或材质,可以同时被多个网格使用(引用) 228 | * 一个场景内,可以添加多个网格 229 | 230 | 231 | 232 | **那和让场景中有多个立方体?** 233 | 234 | 答:使用相同或不同的几何体(立方体),以及相同或不同的材质,去创建多个网格(特定的几何体),然后将多个网格添加到同一个场景中。 235 | 236 | > 注意:为了不同的立方体在场景中不叠加在一起,所以我们还要将网格(特定的几何体)的位置设置成不同的值。 237 | 238 | 239 | 240 | #### 具体代码的修改: 241 | 242 | 1、我们假定继续使用原有示例中的立方体,因此创建几何体的代码不变。 243 | 244 | 2、为了凸显立方体的区别,我们将创建 3 个不同颜色的材质。 245 | 246 | ```diff 247 | - //创建纹理 248 | - const material = new MeshBasicMaterial({ color: 0x44aa88 }) 249 | 250 | + //创建 3 个纹理 251 | + const material1 = new MeshPhongMaterial({ color: 0x44aa88 }) 252 | + const material2 = new MeshPhongMaterial({ color: 0xc50d0d }) 253 | + const material3 = new MeshPhongMaterial({ color: 0x39b20a }) 254 | ``` 255 | 256 | 3、创建 3 个网格,每个网格的水平位置不同 257 | 258 | ```diff 259 | - //创建网格 260 | - const cube = new Mesh(geometry, material)~~ 261 | - scene.add(cube) 262 | 263 | + //创建 3 个网格 264 | + const cube1 = new Mesh(geometry, material1) 265 | + cube1.position.x = -2 266 | + scene.add(cube1)//将网格添加到场景中 267 | 268 | + const cube2 = new Mesh(geometry, material2) 269 | + cube2.position.x = 0 270 | + scene.add(cube2)//将网格添加到场景中 271 | 272 | + const cube3 = new Mesh(geometry, material3) 273 | + cube3.position.x = 2 274 | + scene.add(cube3)//将网格添加到场景中 275 | ``` 276 | 277 | 278 | 279 | 4、为了便于后面对于不同网格的循环修改,我们将创建包含 3 个网格的一个数组 280 | 281 | ``` 282 | const cubes = [cube1, cube2, cube3] 283 | ``` 284 | 285 | 5、修改自动旋转渲染动画的相关代码 286 | 287 | ```diff 288 | - cube.rotation.x = time 289 | - cube.rotation.y = time 290 | 291 | + //通过 cube.map 循环遍历修改网格相关属性 292 | + cubes.map(cube => { 293 | + cube.rotation.x = time 294 | + cube.rotation.y = time 295 | + }) 296 | ``` 297 | 298 | 299 | 300 | 6、保存并重新执行 yarn start,若一切正常此时就会看到 画面中有 3 个不同颜色的立方体同时在做旋转动画。 301 | 302 | > 目前 3 个立方体仅仅是颜色和位置不同,你可以尝试将立方体设置为不同的尺寸,不同的旋转频率等等,自己发挥吧。 303 | 304 | 305 | 306 | 是不是感觉自己对 Three.js 场景有进一步有所掌握 ^_^。 307 | 308 | **下一节,我们将进一步改进这个示例代码。** 309 | 310 | -------------------------------------------------------------------------------- /27 Three.js解决方案之多画布、多场景.md: -------------------------------------------------------------------------------- 1 | # 27 Three.js解决方案之多画布、多场景 2 | 3 | 在我们之前的示例中,通常都是 1 个网页中只有 1 个画布,1 个渲染器,1 个场景。 4 | 5 | 1 个画布(Canvas) + 1 个渲染器 相当于在当前浏览器的 JS 中创建了 1 个 webgl。 6 | 7 | > 1 个 webgl 就会占用一定量的内存和性能,浏览器也是为了用户体验着想,所以才会限制 webgl 数量的。 8 | 9 | 10 | 11 |
12 | 13 | 请注意: 14 | 15 | **浏览器并不限制 DOM 中 画布 标签的数量,浏览器只是限制 webgl 的数量。** 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | ### 网页中 webgl 数量限制 24 | 25 | 不同浏览器都会对 webgl 创建的数量进行限制,通常情况下可以创建 8 个左右。 26 | 27 | 如果超出浏览器对 webgl 数量,则新创建的会顶替较早之前创建的。 28 | 29 | > 此时较早之前创建的 webgl 会消失,变为不可用 30 | 31 | 32 | 33 |
34 | 35 | 如果我们一个网页中需要多个 webgl,那是不是我们多创建几个画布就可以了? 36 | 37 | #### 试想一下这个场景 38 | 39 | 假设我们现在要制作一个产品列表页,该页面上需要展示 15 个产品,且每一个产品我们都希望搭配一个 3D 模型展示。 40 | 41 | 那么我们现在就会遇到一些问题: 42 | 43 | 1. 问题一:如果每一个产品对应 1 个 webgl,因此我们就需要创建 15 个 webgl,这超出了浏览器对于一个页面上可创建 webgl 数量限制。 44 | 45 | > 我们假设浏览器最多只允许我们创建 8 个 webgl 46 | 47 | > 特别强调:假设我们在一段 JS 代码中创建了 N 个渲染器 或 N 个场景,这并不会创建 N 个webgl,他们仍然被视为仅仅是 1 个 webgl 48 | 49 | > 你可以简单粗暴得去理解:webgl 的数量仅和画布(canvas)数量有关,和创建几个渲染器或场景无关。 50 | 51 | 2. 问题二:假设每个产品只是模型不同,但是所使用的材质相同,或者多个产品使用同一个纹理贴图,如果我们对每一个产品都创建一套 webgl,那同一个材质或贴图就可能需要被我们反复多次加载。换句话说每一个 Three.js 创建的产品 3D 展示都相互独立(孤立),资源无法共享。 52 | 53 | > 上面我们说 “创建一套 webgl” 的意思是:创建一个 canvas,创建 一个渲染器,创建一个场景 等等 54 | 55 | 56 | 57 |
58 | 59 | 那...解决方案是什么呢? 60 | 61 | 62 | 63 | ### 第1种解决方案:用其他标签充当占位,然后使用渲染器的剪裁渲染功能 64 | 65 | **用 1 个 画布来渲染全部,用一些其他元素标签来 “代替” “充当” N 个画布。** 66 | 67 | 68 | 69 |
70 | 71 | **具体的事实细节:** 72 | 73 | 1. 创建一个 标签,并设置 z-index:-1,这样该画布就会显示在其他元素的下面 74 | 75 | > 事实上相当于将 画布 当成了 “大背景” 76 | 77 | 2. 在需要展示 “画布” 的位置,我们添加一些网页标签,用来启到 “占位” 的作用。 78 | 79 | > 该标签里并没有实际内容,但是我们通过 CSS 给该标签添加宽和高 80 | 81 | 3. 在 JS 中使用 Three.js,添加不同的灯光和镜头。 82 | 83 | > 一组灯光和镜头 对应一个 需要渲染的对象内容 84 | 85 | 4. 我们 “判断元素当前是否可见”,然后通过渲染器的以下 3 个方法,对渲染器进行 “裁剪”。 86 | 87 | 1. Renderer.setScissorTest() 88 | 89 | > 该方法接收 1 个参数:boolean,来决定是否启用或禁用裁剪检测。 90 | 91 | 2. Renderer.setViewport() 92 | 93 | > 该方法接收 4 个参数:x、y、width、height,这 4 个参数构成 1 个矩形的裁剪框。 94 | > 95 | > 若此时已启用 剪裁检测,那么只有在该矩形框内的才会被渲染,不在该矩形框内的则不会被渲染。 96 | 97 | 3. Renderer.setScissor() 98 | 99 | > 该方法接收 4 个参数:x、y、width、height,这 4 个参数构成 1 个视窗(视框)。 100 | 101 | 5. 不断判断,不断清空画布内容,已实现实时更新裁剪可见区域。 102 | 103 | > 但是请注意:由于 Three.js 渲染需要一定时间,当网页快速滚动时可能会出现 “渲染不及时”,看上去似乎是一个 “bug”,具体我们会稍后讲解。 104 | 105 | 6. 最终,我们将那些 “占位”标签的位置和尺寸 传递给 Three.js,通过 裁剪,只在渲染出相应内容。 106 | 107 | 108 | 109 |
110 | 111 | 下面我们将针对以上步骤中,一些关键的点进行详细讲解。 112 | 113 | 114 | 115 |
116 | 117 | ### 第1:启到占位作用的网页标签 118 | 119 | 我们知道这些标签本身不需要显示任何内容,我们会通过 CSS 来给他们设定宽高。 120 | 121 | 那究竟使用什么标签呢? 122 | 123 | 我们会很容易想到
这些标签都可以。无论使用哪个标签,我们只要确保这些标签统一即可。 124 | 125 | **我们推荐一种更加优雅、通用、明确的做法:给标签添加 html5 新增的 data-* 属性** 126 | 127 | 128 | 129 |
130 | 131 | **data-* 属性介绍:** 132 | 133 | 在传统的网页标签中,例如 标签,默认它只能有以下几种信息: 134 | 135 | 1. 该标签拥有的 属性和处理事件函数,例如 id、onclick 等 136 | 2. 该标签的样式,例如 class、style 137 | 3. 该标签和闭合标签之间的内容 138 | 139 | 除此之外,该标签无法承载其他信息。 140 | 141 | > 实际上若想还包含其他信息,通常变相的实现手段是将其他信息 包装成 样式名称(class name) 142 | 143 | 144 | 145 |
146 | 147 | 在 HTML5 出现之后,任何标签都可以新增以 `data-*` 的自定义属性。 148 | 149 | > 请注意上面中的 * 是需要我们自己根据实际情况来自定义的 150 | 151 | 例如我们给 添加一个额外的属性,也就是自定义信息 data-author: 152 | 153 | ``` 154 | 155 | ``` 156 | 157 | > 上面代码中,我们给 span 增加了一个自定义属性 data-author,我们假设用这个属性来记录作者名字 158 | 159 | 160 | 161 |
162 | 163 | **我们可以通过以下 JS 获取该标签:** 164 | 165 | ``` 166 | document.querySelector('#span') 167 | ``` 168 | 169 | 现在,我们还可以通过查找自定义属性的方式,来获取: 170 | 171 | ``` 172 | document.getAttribute('data-author') 173 | ``` 174 | 175 | > 如果要获取多个拥有该属性的 DOM 元素,我们可以使用:getAttributes() 这个方法 176 | 177 | 178 | 179 |
180 | 181 | **使用 CSS 统一获取并设置样式:** 182 | 183 | ``` 184 | span{ 185 | content:attr(data-author) 186 | } 187 | ``` 188 | 189 | 或 190 | 191 | ``` 192 | span[data-author]{ 193 | ... 194 | } 195 | ``` 196 | 197 | 甚至直接给所有拥有 data-author 属性的标签统一设置样式 198 | 199 | ``` 200 | [data-author]{ 201 | ... 202 | } 203 | ``` 204 | 205 | 206 | 207 |
208 | 209 | **补充说明:** 210 | 211 | 上面讲解的都是我们在 JS 中获取标签的自定义属性,假设要通过 JS 给标签添加自定义属性,还是以 span 为例,具体操作方式为: 212 | 213 | 第1种方式:使用 setAttribute() 214 | 215 | ```` 216 | span.setAttribute('data-author','xxxx') 217 | ```` 218 | 219 |
220 | 221 | 第2种方式:使用 dataset 222 | 223 | ``` 224 | span.dataset.author = 'xxxx' 225 | ``` 226 | 227 | 请注意: 228 | 229 | 1. dataset 作为该标签的自定义属性统一对象,该标签的所有自定义属性都将挂载在该属性值下面 230 | 231 | 2. 我们在去设置自定义属性名时,是无需添加 "data-" 的,例如原本的 data-author 我们只需 dataset.author 232 | 233 | 3. 自定义属性名需遵循驼峰命名方式,在上面示例中我们自定义属性为 `data-author`,去掉不用写的 data-,那剩下的就只有 author,我们可以直接这样写。但是假设我们自定义属性名为 `data-author-name`,此时去掉不用写的 data- 后,还剩下 author-name,我们就需要遵循驼峰命名方式,实际代码应为: 234 | 235 | ``` 236 | span.dataset.authorName = 'xxx' 237 | ``` 238 | 239 | > 浏览器会自动将驼峰命名转化为 data-xxx-xxx 赋予给标签 240 | 241 | 242 | 243 |
244 | 245 | 回到我们本文要讲解的内容上面,我们可以将负责 "占位" 的标签都添加上统一的自定义属性,这样在 JS 中可根据该自定义字段来获取所有占位的标签。 246 | 247 | > 这样的做法对于我们来说有一个好处,就是不用再考虑标签究竟使用的是
还是 248 | 249 | 250 | 251 |
252 | 253 | ### 第2:判断网页中某标签当前是否在可见窗口内,并告知渲染器进行如何裁切渲染 254 | 255 | 大体思路为: 256 | 257 | 1. 在 JS 中获取该标签,假设该标签(DOM元素)在 js 中的变量引用名为 elem 258 | 259 | 2. 通过 elem.getBoundingClientRect() 获取该标签相对于视窗的位置信息 260 | 261 | > 这些位置信息有:left、right、top、bottom、width、height 262 | 263 | 3. 然后进行判断,如果出现以下情况,只要符合一条,那么我们就可以直接认为该标签当前不在可见窗口内。 264 | 265 | ``` 266 | bottom < 0 267 | top > canvas.clientHeight 268 | right < 0 269 | left > canvas.clientWidth 270 | ``` 271 | 272 | 4. 假设我们经过判断元素在可见窗口内,那么我们就要告知渲染器可以根据该元素的位置和尺寸,来进行裁剪渲染。 273 | 274 | ``` 275 | //让画布的高 - 元素的底部,从而计算出超出的部分,这些部分不必再做渲染了 276 | const positiveYUpBottom = canvas.clientHeight - bottom 277 | 278 | renderer.setScissor(left,positiveYUpBottom,width,height) 279 | renderer.setViewport(left,positiveYUpBottom,width,height) 280 | ``` 281 | 282 | 283 | 284 |
285 | 286 | ### 第3:添加轨道控制器、将光添加到镜头中,而非场景中 287 | 288 | 这里讲解一个新的知识点。 289 | 290 | 在以前所有的示例中,假设我们希望物体有反射光,那么我们都会创建光,并将光添加到场景(Three.Scene)中。 291 | 292 | 此时我们添加镜头轨道控制器,当移动鼠标修改镜头位置时,光的位置是不变的。 293 | 294 | > 因为我们是将 光 添加到了场景中,所以光的位置是和场景保持固定不变的。 295 | 296 | 297 | 298 |
299 | 300 | 假设我们的场景中有多个物体,每个物体都有自己对应的镜头,我们希望对每个物体的镜头添加轨道控制器,且保证物体对应的光永远跟随着镜头移动,那么我就要将光添加到镜头里。 301 | 302 | 303 | 304 |
305 | 306 | 你没有听错,我再说一遍:**将光由原来添加到场景中,修改为添加到镜头中。** 307 | 308 | ```diff 309 | - scene.add(light) 310 | + camera.add(light) 311 | ``` 312 | 313 | 如此操作之后,光就不再跟随场景,而是跟随着镜头移动而移动。 314 | 315 | 这样可以保证我们每个物体的镜头中,始终有该物体的光 316 | 317 | 318 | 319 |
320 | 321 | **对于本文示例讲解的场景,不推荐使用 OrbitControls,而是推荐使用 TrackballControls。** 322 | 323 | > TrackballControls 不提供滚动鼠标中轴缩放镜头这个功能,因为在这个示例场景中,滚动鼠标应该出现的是网页的滚动,而不是 Three.js 场景的视角缩放。 324 | 325 | 请一定记得在每次渲染函数中,要对轨道控制器进行更新: 326 | 327 | 1. controls.handleResize() 328 | 2. controls.update() 329 | 330 | 331 | 332 |
333 | 334 | ### 第2种解决方案:通过 web worker 来创建和渲染场景 335 | 336 | **该方案的优点很明确:** 337 | 338 | 1. 本身就是对网页性能的一种提升 339 | 2. 由于是 web worker,不再受限于浏览器对 webgl 数量的限制 340 | 341 | 342 | 343 |
344 | 345 | **不过缺点也很明确:** 346 | 347 | 1. 需要浏览器支持 OffscreenCanvas 才可以 348 | 349 | > 目前火狐、苹果浏览器均不支持 OffscreenCanvas 350 | 351 | 2. 默认 web worker 内部不支持对 DOM 元素交互事件的侦听,也就是说无法添加 轨道控制器 352 | 353 | > 不过可以通过变相的方式,请参考本系列教程 [22 Three.js优化之OffscreenCanvas与WebWorker.md](https://github.com/puxiao/threejs-tutorial/blob/main/22%20Three.js%E4%BC%98%E5%8C%96%E4%B9%8BOffscreenCanvas%E4%B8%8EWebWorker.md) 354 | 355 | 356 | 357 |
358 | 359 | ### 第3种解决方案:Three.js渲染的画布不直接显示,让不同位置的标签(画布)去复制该画布的局部结果 360 | 361 | 由于浏览器并不显示 画布 的数量,我们可以将不同位置的 占位标签 直接使用画布标签,然后让不同的画布去复制渲染出的画布结果内容。 362 | 363 | 这样做的缺点是:性能不好,速度慢,每个区域都需要进行相应的复制操作。 364 | 365 | 366 | 367 | 368 | 369 |
370 | 371 | 本文只是阐述了某些特殊场景,例如需要多画布、多场景的情况下的解决方案。 372 | 373 | 并没有深入、完整编写示例代码。 374 | 375 | > 我个人认出现这种场景的几率并不大,所以就偷懒一下,不去写完整的示例了。 376 | 377 | 378 | 379 |
380 | 381 | 本文此致结束。 382 | 383 | 下一节,我们将讲解一个非常重要的内容,关乎绝大多数我们编写的 Three.js 程序。 384 | 385 | 那就是:鼠标选中场景中的物体,并发生交互。 386 | 387 | -------------------------------------------------------------------------------- /06 图元练习示例.md: -------------------------------------------------------------------------------- 1 | # 06 图元练习示例 2 | 3 | 上一篇文章中,列举了 Three.js 中内置的 22 种图元(Primitives),那么本文将重点练习,尝试使用这些图元。 4 | 5 | > 复习一下图元的概念:图元 就是 Three.js 中内置的 几何体。 6 | 7 | 8 | 9 | ## 示例代码目标 10 | 11 | 1. 将内置的所有种类图元,除 TextBufferGeometry 以外,其他 21 种图元 逐一练习 12 | 13 | > TextBufferGeometry 比较特殊,会在稍后一节专门讲解 14 | 15 | 2. 在练习中,尽量都使用图元的 BufferGeometry 类型,不做过多自定义设置 16 | 17 | 3. 最终将所有创建出的形状放置在同一场景中,并进行渲染 18 | 19 | 20 | 21 | ## 示例代码组织逻辑 22 | 23 | 1. 创建 src/components/hello-primitives/ 目录,用来存放本示例所有代码 24 | 2. 在该目录下,创建 index.tsx 文件,用来构建 Three.js 3 大基础元素:场景、镜头、渲染器 25 | 3. 在该目录下,创建 index.scss 文件,用来添加需要用到的 CSS 样式 26 | 4. 在该目录下,分别创建 MyBox.ts、MyCircle.ts ... 等文件,用来依次创建不同的图元实例 27 | 5. 在 index.stx 文件中,引入各个图元实例,并加入场景中进行渲染 28 | 6. 修改 App.tsx,将之前编写的 替换为我们本示例的组件 29 | 30 | 31 | 32 | 本示例主要用来练习 Three.js,对于示例中使用到的一些 ES6、React Hooks、TypeScript 等相关知识,若非必要,一般情况下就不再过多讲解说明。 33 | 34 | 35 | 36 | ## 示例代码 37 | 38 | ### index.tsx 39 | 40 | ``` 41 | import { useRef, useEffect, useCallback } from 'react' 42 | import * as Three from 'three' 43 | 44 | import './index.scss' 45 | 46 | import myBox from './my-box' 47 | import myCircle from './my-circle' 48 | import myCone from './my-cone' 49 | import myCylinder from './my-cylinder' 50 | import myDodecahedron from './my-dodecahedron' 51 | import myEdges from './my-edges' 52 | import myExtrude from './my-extrude' 53 | import myIcosahedron from './my-icosahedron' 54 | import myLathe from './my-lathe' 55 | import myOctahedron from './my-octahedron' 56 | import myParametric from './my-parametric' 57 | import myPlane from './my-plane' 58 | import myPolyhedron from './my-polyhedron' 59 | import myRing from './my-ring' 60 | import myShape from './my-shape' 61 | import mySphere from './my-sphere' 62 | import myTetrahedron from './my-tetrahedron' 63 | import myTorus from './my-torus' 64 | import myTorusKnot from './my-torus-knot' 65 | import myTube from './my-tube' 66 | import myWireframe from './my-wireframe' 67 | 68 | const meshArr: (Three.Mesh | Three.LineSegments)[] = [] 69 | 70 | const HelloPrimitives = () => { 71 | const canvasRef = useRef(null) 72 | const rendererRef = useRef(null) 73 | const cameraRef = useRef(null) 74 | 75 | const createMaterial = () => { 76 | const material = new Three.MeshPhongMaterial({ side: Three.DoubleSide }) 77 | 78 | const hue = Math.floor(Math.random() * 100) / 100 //随机获得一个色相 79 | const saturation = 1 //饱和度 80 | const luminance = 0.5 //亮度 81 | 82 | material.color.setHSL(hue, saturation, luminance) 83 | 84 | return material 85 | } 86 | 87 | const createInit = useCallback( 88 | () => { 89 | 90 | if (canvasRef.current === null) { 91 | return 92 | } 93 | 94 | meshArr.length = 0 //以防万一,先清空原有数组 95 | 96 | //初始化场景 97 | const scene = new Three.Scene() 98 | scene.background = new Three.Color(0xAAAAAA) 99 | 100 | //初始化镜头 101 | const camera = new Three.PerspectiveCamera(40, 2, 0.1, 1000) 102 | camera.position.z = 120 103 | cameraRef.current = camera 104 | 105 | //初始化渲染器 106 | const renderer = new Three.WebGLRenderer({ canvas: canvasRef.current as HTMLCanvasElement }) 107 | rendererRef.current = renderer 108 | 109 | //添加 2 盏灯光 110 | const light0 = new Three.DirectionalLight(0xFFFFFF, 1) 111 | light0.position.set(-1, 2, 4) 112 | scene.add(light0) 113 | 114 | const light1 = new Three.DirectionalLight(0xFFFFFF, 1) 115 | light1.position.set(1, -2, -4) 116 | scene.add(light1) 117 | 118 | //获得各个 solid 类型的图元实例,并添加到 solidPrimitivesArr 中 119 | const solidPrimitivesArr: Three.BufferGeometry[] = [] 120 | solidPrimitivesArr.push(myBox, myCircle, myCone, myCylinder, myDodecahedron) 121 | solidPrimitivesArr.push(myExtrude, myIcosahedron, myLathe, myOctahedron, myParametric) 122 | solidPrimitivesArr.push(myPlane, myPolyhedron, myRing, myShape, mySphere) 123 | solidPrimitivesArr.push(myTetrahedron, myTorus, myTorusKnot, myTube) 124 | 125 | //将各个 solid 类型的图元实例转化为网格,并添加到 primitivesArr 中 126 | solidPrimitivesArr.forEach((item) => { 127 | const material = createMaterial() //随机获得一种颜色材质 128 | const mesh = new Three.Mesh(item, material) 129 | meshArr.push(mesh) //将网格添加到网格数组中 130 | }) 131 | 132 | //获得各个 line 类型的图元实例,并添加到 meshArr 中 133 | const linePrimitivesArr: Three.BufferGeometry[] = [] 134 | linePrimitivesArr.push(myEdges, myWireframe) 135 | 136 | //将各个 line 类型的图元实例转化为网格,并添加到 meshArr 中 137 | linePrimitivesArr.forEach((item) => { 138 | const material = new Three.LineBasicMaterial({ color: 0x000000 }) 139 | const mesh = new Three.LineSegments(item, material) 140 | meshArr.push(mesh) 141 | }) 142 | 143 | //定义物体在画面中显示的网格布局 144 | const eachRow = 5 //每一行显示 5 个 145 | const spread = 15 //行高 和 列宽 146 | 147 | //配置每一个图元实例,转化为网格,并位置和材质后,将其添加到场景中 148 | meshArr.forEach((mesh, index) => { 149 | //我们设定的排列是每行显示 eachRow,即 5 个物体、行高 和 列宽 均为 spread 即 15 150 | //因此每个物体根据顺序,计算出自己所在的位置 151 | const row = Math.floor(index / eachRow) //计算出所在行 152 | const column = index % eachRow //计算出所在列 153 | 154 | mesh.position.x = (column - 2) * spread //为什么要 -2 ? 155 | //因为我们希望将每一行物体摆放的单元格,依次是:-2、-1、0、1、2,这样可以使每一整行物体处于居中显示 156 | mesh.position.y = (2 - row) * spread 157 | 158 | scene.add(mesh) //将网格添加到场景中 159 | }) 160 | 161 | //添加自动旋转渲染动画 162 | const render = (time: number) => { 163 | time = time * 0.001 164 | meshArr.forEach(item => { 165 | item.rotation.x = time 166 | item.rotation.y = time 167 | }) 168 | 169 | renderer.render(scene, camera) 170 | window.requestAnimationFrame(render) 171 | } 172 | window.requestAnimationFrame(render) 173 | }, 174 | [canvasRef], 175 | ) 176 | 177 | const resizeHandle = () => { 178 | //根据窗口大小变化,重新修改渲染器的视椎 179 | if (rendererRef.current === null || cameraRef.current === null) { 180 | return 181 | } 182 | 183 | const canvas = rendererRef.current.domElement 184 | cameraRef.current.aspect = canvas.clientWidth / canvas.clientHeight 185 | cameraRef.current.updateProjectionMatrix() 186 | rendererRef.current.setSize(canvas.clientWidth, canvas.clientHeight, false) 187 | } 188 | 189 | //组件首次装载到网页后触发,开始创建并初始化 3D 场景 190 | useEffect(() => { 191 | createInit() 192 | resizeHandle() 193 | window.addEventListener('resize', resizeHandle) 194 | return () => { 195 | window.removeEventListener('resize', resizeHandle) 196 | } 197 | }, [canvasRef, createInit]) 198 | 199 | return ( 200 | 201 | ) 202 | } 203 | 204 | export default HelloPrimitives 205 | ``` 206 | 207 | 208 | 209 | ### my-box.ts 210 | 211 | ``` 212 | import { BoxBufferGeometry } from "three" 213 | 214 | const width = 8 215 | const height = 8 216 | const depth = 8 217 | 218 | const myBox = new BoxBufferGeometry(width, height, depth) 219 | 220 | export default myBox 221 | ``` 222 | 223 | 224 | 225 | ### my-circle.ts 226 | 227 | ``` 228 | import { CircleBufferGeometry } from "three" 229 | 230 | const radius = 7 231 | const segments = 24 232 | 233 | const myCircle = new CircleBufferGeometry(radius,segments) 234 | 235 | export default myCircle 236 | ``` 237 | 238 | 239 | 240 | 由于图元实在是太多,这里省略掉一些图元相关代码...... 241 | 242 | 243 | 244 | ### my-wireframe.ts 245 | 246 | ``` 247 | import { BoxBufferGeometry, WireframeGeometry } from "three"; 248 | 249 | const width = 8; 250 | const height = 8; 251 | const depth = 8 252 | 253 | const myWireframe = new WireframeGeometry(new BoxBufferGeometry(width, height, depth)) 254 | 255 | export default myWireframe 256 | ``` 257 | 258 | 259 | 260 | 想要查看完整的图元示例代码,可以访问:https://threejsfundamentals.org/threejs/threejs-primitives.html 261 | 262 | 本文示例中的代码源头,都来源于上面那个网页,尽管该网页 JS 中创建图元使用的是 JS 而非 TS。 263 | 264 | > 你可以通过查看该页面中内嵌的 JS 代码,来补齐其他图元对应的创建写法。 265 | 266 | 267 | 268 | **图元种类这么多,本文只是带着你一块过一遍,具体每一个图元具体的参数含义,以后总会慢慢了解的。** 269 | 270 | 下一节,我们将讲述一下 TextBufferGeometry 的用法。 271 | 272 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # threejs-tutorial 2 | 3 | 从今天 2020年11月27日 开始学习和探索 Three.js 。 4 | 5 | 6 |
7 |
8 | wechat.jpg 9 | 10 |
11 | 12 | 13 |
14 | 15 | > 以下内容更新于 2021.04.16 16 | 17 | **特别提醒:** 18 | 19 | 本教程最开始使用的是 r123 版本,但是后来 Three.js 更新到 r125 版后,r125 中做了一些修改,将 Geometry 从核心类( core ) 中移除,转移到了 examples/jsm/deprecated/。 20 | 21 | > Geometry 类已被废弃,不建议继续使用 22 | 23 | 然后使用 BufferGeometry 代替之前的 Geometry。此外还有其他很多地方修改,这就造成本教程一些文章中的示例代码在最新的版本中已经不可用了。 24 | 25 | 但是文章中讲解的代码思路、原理、用法是不会有太大的差异。 26 | 27 | 目前最新版本为 r127版本,所以...随着 Three.js 版本不断更新,本教程中的示例代码终会有过时的时候。 28 | 29 | 对于某个具体的类,Three.js 官方文档都有详细的使用示例,可去官网文档查看最新的用法。 30 | 31 |
32 | 33 | 说一声抱歉:我在写第 25 节文章的时候才彻底理解 左手坐标系统和右手坐标系统,而我在前面章节中有可能讲解坐标体系对应的 上下左右前后 时把方向搞错了,但是我记不清是哪个章节了。 34 | 35 |
36 | 37 | > 以上内容更新于 2021.04.16 38 | 39 | 40 | 41 |
42 | 43 | > 以下内容更新于 2021.05.22 44 | 45 | 46 | 47 |
48 | 49 | > 因为本系列暂停了本系列教程的更新,所以就暂时在这里补充上关于 3D 坐标系的相关知识吧。 50 | 51 | 直角坐标系与球极坐标系: 52 | 53 | 1. 左右手坐标系统他们都是 直角坐标系,使用 (x,y,z) 来表示空间某个点的坐标,webgl/three.js 采用右手坐标系。 54 | 55 | 2. 除 右手坐标系 用来确定 xyz 轴朝向外,还有一个 “右手螺旋法则” 用来判定旋转方向。 56 | 57 | 3. 球极坐标系,又称 空间极坐标,使用 (r,φ,θ) 来表示空间某个点的坐标。 58 | 59 | > Three.js 的球极坐标 对应的类是:Spherical 60 | > 61 | > https://threejs.org/docs/index.html#api/zh/math/Spherical 62 | 63 | > 只有真正了解 Three.js 的这 2 套坐标系,同时理解 Vector2(二维向量)、Vector3(三维向量)、Raycaster(光线投射),才有可能晋级为 Three.js 空间高手。 64 | 65 | 66 | 67 |
68 | 69 | 我在学习的过程中也向 Three.js 官方提交了自己的 PR,贡献出自己一点点力量。 70 | 71 | 1. PR [21409](https://github.com/mrdoob/three.js/pull/21409) 已获准在 r127 中合并 72 | 2. PR [21642](https://github.com/mrdoob/three.js/pull/21642) 已获准在 r128 中合并 73 | 3. PR [21687](https://github.com/mrdoob/three.js/pull/21687) 已获准在 r128中合并 74 | 4. PR [21729](https://github.com/mrdoob/three.js/pull/21729) 已获准在 r129中合并 75 | 76 | Three.js 官方维护人员非常热心和严谨。 77 | 78 | 几乎每天都有新的 PR 被提交,感觉 Three.js 社区活力满满。 79 | 80 | 81 | 82 | > 以上内容更新于 2021.05.22 83 | 84 | 85 | 86 |
87 | 88 | 89 | ## 我的学习资料 90 | 91 | 我刚开始学习 three.js,目前主要看 Three.js 官方出的 教程 和文档: 92 | 93 | * [threejsfundamentals.org:官方教程](https://threejsfundamentals.org/threejs/lessons/zh_cn/) (该教程只有前几篇是有中文翻译的) 94 | * [threejs.org:官方中文文档](https://threejs.org/docs/index.html#manual/zh/introduction/Creating-a-scene) 95 | 96 | 除此之外,还有其他几个值得推荐的、国内博主写的 Three.js 系列教程: 97 | 98 | * [wjceo.com:暮志未晚写的three.js教程](https://www.wjceo.com/blog/threejs/) 99 | 100 | * [hewebgl.com:Three.js基础教程](http://www.hewebgl.com/article/articledir/1) 101 | 102 | * [webgl3d.cn:Three.js教程](http://www.webgl3d.cn/Three.js/) 103 | 104 | * 强烈推荐看一下 [图解WebGL&Three.js工作原理](https://www.cnblogs.com/wanbo/p/6754066.html) 105 | 106 | > 可惜该作者近几年都没再更新 Three.js 相关文章。 107 | 108 | 特别说明 hewebgl.com 和 webgl3d.cn 的教程存在问题就是: 109 | 110 | 1. 教程内容版本有些老化,使用的并不是最新版 three.js 111 | 2. 教程基于网页,而不是基于 React,更不是基于 React + TypeScript 112 | 113 | 但是这两个网站教程作者编写的时候,非常用心,里面讲述的大量关于 Three.js 理论知识是值得反复学习阅读的。 114 | 115 | **综上所述** 116 | 117 | 1. 我会以官方教程(https://threejsfundamentals.org/threejs/lessons/zh_cn/) 为主线。 118 | 2. 我会在以上教程、文档,以及我搜集到的其他相关教程基础上,来编写本系列 Three.js 教程。 119 | 3. 我会以一个新手的视角,心路历程,来编写本系列 Three.js 教程。 120 | 121 | 122 | 123 |
124 | 125 | ## Three.js官方文档的补充说明 126 | 127 | 当 Three.js 每次版本迭代更新时,官方只负责维护 英文版 文档,中文版文档完全是靠网友业余时间友情翻译与维护的。 128 | 129 | **这就会造成 中文版文档 落后于 英文版文档。** 130 | 131 | 比如 r130 版本中 `AxesHelper` 新增加了 `.setColors()` 方法,而此时的中文文档中,还未有人相应增加这个方法。 132 | 133 | 因此当你想要查找某个 类 的用法时,你应该最优先选择去看 **英文** 的官方文档。 134 | 135 | > https://threejs.org/docs/index.html#manual/en/introduction/Creating-a-scene 136 | 137 | 138 | 139 |
140 | 141 | 我曾经也翻译过好几处地方,提交 PR 也被并入,但是随着时间的推移,逐渐没有翻译的热情了。 142 | 143 | 因为实在是太多,更新太频繁,没有那么多精力去搞文档。 144 | 145 | 146 | 147 |
148 | 149 | ## 关于国内有些Three.js示例中代码过时的补充说明 150 | 151 | 我加了一些 Three.js 交流QQ群,经常有人在里面发一些问题,处在学习阶段的我,经常会去帮忙看一下。 152 | 153 | > 看别人遇到的问题,也特别能够提高自己的一些所见所闻,知识面。 154 | 155 | 经常发生一些这样的情景:**对方说是照着某个示例敲的代码,可就是运行不起来。** 156 | 157 | 首先我会去官网文档中,查一下他们代码中用到的 类、属性、方法,但是很多时候根本查不到。 158 | 159 | 这说明他们用的类,属性,方法已发生变更、修改、废弃等。 160 | 161 | 162 | 163 |
164 | 165 | 此时,我都会到 Three.js github 官方仓库中,在 `Pull requests` 中搜索该属性或方法。 166 | 167 | > https://github.com/mrdoob/three.js/pulls 168 | 169 | > 搜索时请注意要把 is:open 删除掉,因为既然都被废弃了,那肯定 PR 已经是被并入过的了,状态肯定是 close,不可能是 open。 170 | 171 | 通常情况下,都可以检索出和废弃的 类、属性、方法相关 PR 信息,点击查看 PR 详情,就能够找到为什么要废弃,建议以后改用 xxx 之类的信息。 172 | 173 | 至此,原因和结果都知道了,就很容易修复代码了。 174 | 175 | 176 | 177 |
178 | 179 | 总结一下,想把 Three.js 搞明白,一定要经常做以下 4 件事: 180 | 181 | 1. 看 英文/中文 文档 182 | 2. 去 Github 仓库看源码 183 | 3. 去 `Pull requests` 中看最新或之前的 PR 改动 184 | 4. 使用、查看源码过程中,发现可以改进的地方,勇敢、大胆得去提交 PR 185 | 186 | 187 | 188 |
189 | 190 | ## 相关书籍推荐 191 | 192 | 事实上,目前我个人并没有购买过任何 Three.js 相关的书籍。 193 | 194 | 因为我认为最好的 Three.js 书籍就是 最新源码、官方文档、官方示例。 195 | 196 |
197 | 198 | 我购买了很多和 WebGL、3D 图形学相关的书籍。 199 | 200 | 如果没有 3D 图形学相关知识,那么后期提升 Three.js 会比较困难。 201 | 202 | 203 | 204 |
205 | 206 | **推荐的第1本书籍:《3D数学基础:图形和游戏开发(第2版)》** 207 | 208 | 购买地址:http://product.dangdang.com/28552828.html 209 | 210 | 这本书系统全面得介绍了 3D 图形学中各个数学概念。 211 | 212 | > 笛卡尔坐标系、极坐标系、向量、点乘、叉乘、欧拉角、四元数、矩阵转换... 等等这些概念,你都需要了解,否则你后期根本无法理解和写出 复杂点的 Three.js 交互代码。 213 | 214 | > 简直就该人手 1 本 215 | 216 | 不要被书名中的 数学 二字吓到,书中的数据公式,根本不需要你去记忆。 217 | 218 | 219 | 220 | > 尽量在 当当或京东 做活动时候购买,比如 `5折` 或 `满100减50` 时购买,比较划算。 221 | 222 | 223 | 224 |
225 | 226 | **推荐的第2本书籍:《基于WebGL的自顶向下方法(第七版)》** 227 | 228 | 购买地址:http://product.dangdang.com/23933108.html 229 | 230 | 这本书系统全面介绍了 基于 webgl 的 3D图形学知识体系。 231 | 232 | 可以让你在大脑中快速构建出 3D 图形学的渲染概念。 233 | 234 | > 注意,是基于 webgl,而不是基于 three.js 235 | 236 | 237 | 238 |
239 | 240 | **推荐的第3本书籍:虎书(第4版)** 241 | 242 | 第四版英文下载地址: 243 | 244 | http://index-of.es/z0ro-Repository-2/Cyber/01%20-%20Computer%20Science/Fundamentals%20Of%20Computer%20Graphics%20-%20Peter%20Shirley,%20Steve%20Marschner.pdf 245 | 246 | 这本书系统全面得介绍了 3D 图形学,这本书是计算机图形学最权威的书籍,没有之一。 247 | 248 | > 需要有梯子才可以访问。 249 | 250 | > 由于这本书的封面是一只老虎,所以这本书才被称为 “虎书” 251 | > 252 | > 这本书只有第2版有简体中文,不过第2版已经过时,第三版和第四版差别不是太大。 253 | 254 | 255 | 256 |
257 | 258 | **其他书籍** 259 | 260 | 除此之外,我还购买了其他书籍,但是,这些书籍并不属于 强烈推荐 的那种。 261 | 262 | > 这些书我个人认为看一下可以,不看也无所谓。 263 | > 264 | > 假设满星为 5 颗星 265 | 266 | 1. 本人推荐指数 2 颗星:《深入理解OpenGL、WebGL 和 OpenGL ES》 267 | 2. 本人推荐指数 1 颗星:《计算机图形学——几何体数据结构》 268 | 3. 本人推荐指数 0 颗星:《3D图形系统设计与实现》 269 | 270 | 271 | 272 |
273 | 274 | ## WebGL相关教程 275 | 276 | 首先说明一下,如果学想对 Three.js 有更深层次的修炼,那么你一定要去学习一下 WebGL。 277 | 278 | > WebGL又分为:WebGL1、WebGL2 279 | 280 | Three.js 本身就是针对 WebGL 的封装。 281 | 282 | WebGL 教程:https://webglfundamentals.org/webgl/lessons/zh_cn/ 283 | 284 | WebGL2 教程:https://webgl2fundamentals.org/webgl/lessons/zh_cn/ 285 | 286 | **假设你不想学习 WebGL 也没有关系,直接学习 Three.js 也是完全没有问题的。** 287 | 288 | 289 | 290 |
291 | 292 | ## 你还需要掌握的技术栈 293 | 294 | * **JS、ES6** 295 | * **CSS、SCSS** 296 | * **React、hooks** 297 | * **TypeScript** 298 | * **包管理工具Yarn 或 NPM** 299 | 300 | 以上是本系列文章使用的技术栈。 301 | 302 | 若将来要将开发的项目发布到线上,你可能还需要掌握: 303 | 304 | * **Git 代码管理** 305 | * **Koa 创建简单web服务器** 306 | * **Nginx 配置静态服务器** 307 | * **Docker 创建容器服务** 308 | 309 | 310 | 311 |
312 | 313 | ## 关于3D建模 314 | 315 | Three.js 内置了很多基础模型,也支持内部自定义图形。 316 | 317 | 但是,**建模并不是 Three.js 最核心和擅长的,Three.js 最核心功能是进行 浏览器 3D 场景渲染和交互**。 318 | 319 | **因此学习 Three.js 的核心应该放在 渲染和交互 上,而不是建模。** 320 | 321 | > 以上纯粹目前个人观点,仅供参考 322 | 323 |
324 | 325 | #### 传统 3D 软件 326 | 327 | 多数场景下 3D 建模这个工作还应该在传统的 3D 软件中完成,例如 3D Max、C4D、Blender 等。 328 | 329 | 虽然 3D 软件各有不同,但是他们导出文件格式标准相同,所以 Three.js 是支持他们所导出的模型的。 330 | 331 | 因此,若想学好、用好 Three.js,你还需要掌握一门 3D 软件,我个人强烈推荐以下 2 个软件: 332 | 333 | **第1推荐(强烈推荐):C4D** 334 | 335 | 优点:轻量级 3D 建模软件、支持简体中文、国内中文教程、资源非常多 336 | 337 | 缺点:软件收费,当然你可以自己网上搜到 ** 版 338 | 339 | **第2推荐:Blender** 340 | 341 | 优点:开源免费、也属于轻量级 342 | 343 | 缺点:国内使用人群数量较少,教程和资源较少 344 | 345 | **对于完全不懂3D软件的人来说,Windows 10 自带的 “画图 3D” 这个软件也是可以的。** 346 | 347 |
348 | 349 | #### 补充说明 350 | 351 | 即使在你的项目团队中,有专门的人负责 3D 建模并导出 Three.js 支持的 文件给你使用,我也非常建议你要学习一下 3D 软件。 352 | 353 | 如果你不曾使用过 3D 软件,那么你会对 Three.js 中的很多概念感到陌生,甚至是无法想象为什么会是这样。 354 | 355 | > 例如:场景、网格、材质、灯光 等等。 356 | 357 | 358 | 359 |
360 | 361 | ## 本教程的缺点 362 | 363 | #### 1、是Three.js教程,但不是Three.js文档 364 | 365 | 我们只是从一个初学者的角度来讲解 Three.js,但是不会讲解每一个讲解对象、每一个类的全部属性或方法,如果想了解某个类的全部属性和方法,建议你直接去看 Three.js 的官方文档。 366 | 367 | 本教程可以带你入门,但你依然需要不断地查阅官方文档,来弥补本教程中没有提及的属性或方法。 368 | 369 |
370 | 371 | #### 2、没有配图 372 | 373 | 无论是相关知识点,纹理、示例运行效果,都没有配图。 374 | 375 | 没有别的原因,就是因为我懒,打字已经够占用时间了,真的没更多精力去配图。 376 | 377 | 不过我的每个示例都有详细完整的代码,你只需要复制到本地,实际调试一下就能看到效果。 378 | 379 |
380 | 381 | #### 3、所有的示例基本上都是独立的,没有抽离出公共的类或组件 382 | 383 | 在实际的项目中一定会把某些创建过程、处理函数、逻辑进行抽离,单独成为一个类、函数或组件。 384 | 385 | 但是本系列教程中,为了避免比较绕,每个示例基本上都是完全独立的,包括样式 scss 文件。 386 | 387 | 这既是优点,也是缺点。 388 | 389 | 优点是你在查看某个示例时,代码独立而完整。 390 | 391 | 缺点是由于没有代码抽离,所以代码量会比较多,阅读起来略显麻烦。 392 | 393 | > 我只在刚开始的几个示例中添加了代码注释,后面的示例中就因为懒,所以没有添加代码注释。 394 | 395 |
396 | 397 | #### 4、本文没有讲解图形学相关知识 398 | 399 | 因为我在写本系列教程时,我自己都未曾学习过图形学,所以肯定无法站在 `图形学或 webgl` 的维度来讲解 three.js。 400 | 401 | **即使学完本系列教程,你也只是 Three.js 简单入门。** 402 | 403 | 因为如果没有图形学知识作为基础,你很难完成复杂点的 Three.js 开发。 404 | 405 | 406 | 407 |
408 | 409 | 我现在正在学习图形学,只有掌握图形学后,才会更加容易理解 three.js 。 410 | 411 | > 尽管我目前也只是学习了图形学一点点基础的东西,但是此刻再回头去看 three.js 中的很多属性和方法,相对容易很多。 412 | 413 | 414 | 415 |
416 | 417 | 除了本文上面推荐的基本书籍之外,强烈推荐大家观看这个视频。 418 | 419 | **闫令琪:现代计算机图形学入门** 420 | 421 | https://www.bilibili.com/video/BV1X7411F744 422 | 423 | 424 | 425 |
426 | 427 | **大家都是 Three.js 小白新手,一起加油!** 428 | -------------------------------------------------------------------------------- /15 Three.js基础之离屏渲染.md: -------------------------------------------------------------------------------- 1 | # 15 Three.js基础之离屏渲染 2 | 3 | ## Render Targets(离屏渲染)简介 4 | 5 | #### 名词解释:Render Targets 6 | 7 | **从字面上直接翻译,“Render Targets” 应该翻译为 “渲染目标”。** 8 | 9 | **从实际作用上翻译,”Render Targets“ 应该翻译为 ”离屏渲染目标 或 离屏渲染对象“。** 10 | 11 | > 国内绝大多数 Three.js 教程都把 Render Targets 翻译为 离屏渲染。 12 | > 13 | > 我个人认为翻译成:离屏渲染目标 更为合适,但有时我会不自觉使用 离线渲染对象 这个词,所以你只需要明白 虽然称呼不同,但指向的都是同一个东西。 14 | 15 | 16 | 17 | #### 离屏渲染概念解释 18 | 19 | 首先,我们先说一下 普通的“渲染”。 20 | 21 | 之前示例中我们使用的渲染器都是 WebGLRenderer。我们创建的渲染器实例 renderer 会根据 场景(含场景中的物体)、灯光 来将视觉结果渲染到网页中。 22 | 23 | 此时的 渲染 就是普通的渲染,渲染结果直接出现在网页中。 24 | 25 | 26 | 27 | **那什么又是离屏渲染?** 28 | 29 | 答:渲染器会渲染场景,但是不会吧渲染结果直接呈现在网页中,而是把渲染结果保存到 GPU 内部中。 30 | 31 | 此时 暂存到 GPU 中的渲染结果(图片),可以被当做一种纹理(texture),使用到其他物体中。 32 | 33 | 离屏渲染 和 正常渲染 整个计算过程完全相同,不同的地方在于 离屏渲染 的结果是保存在 GPU 内存中,而非直接显示在网页中。 34 | 35 | 36 | 37 | #### 离屏渲染的种类 38 | 39 | 在 Three.js 中,一共有 3 种离屏渲染类型。 40 | 41 | | 离屏渲染类型 | 名称及解释 | 42 | | ---------------------------- | -------------------------- | 43 | | WebGLMultisampleRenderTarget | WebGL 2 对应的离屏渲染 | 44 | | WebGLRenderTarget | WebGLRender 对应的离屏渲染 | 45 | | WebGLCubeRenderTarget | CubeCamera 对应的离屏渲染 | 46 | 47 | 48 | 49 | #### 离屏渲染的用途 50 | 51 | 假设有这样一个场景:场景中有 3 个不同颜色、不停转动的立方体。 52 | 53 | 我们之前示例中会使用 WebGLRenderer ,把这个场景画面内容渲染到网页中,这属于正常的渲染。 54 | 55 | 若我们现在改变需求,我们希望修改成: 56 | 57 | 1. 场景中有一面镜子 58 | 2. 在镜子中显示出 3 个不同颜色、不同旋转的立方体 59 | 3. 场景本身当中,是看不见这 3 个立方体的 60 | 61 | 为了实现这个需求,我们此时就需要用到 离屏渲染。 62 | 63 | 具体做法是: 64 | 65 | 1. 创建一个子场景,该子场景中 有 3 个不同颜色、不停旋转的立方体 66 | 67 | 2. 创建一个总场景、一个渲染器,一面镜子 68 | 69 | 3. 使用总场景的渲染器,对子场景进行渲染,得到一个离屏渲染结果(图像纹理) 70 | 71 | > 注意:由于是离屏渲染,只是将 3 个立方体渲染出的视觉效果保存到 GPU 内存中,网页中并不会显示出离屏渲染结果 72 | 73 | 4. 将离屏渲染结果作为一个纹理,作用在镜子面上 74 | 75 | 5. 使用总场景的渲染器,将镜子渲染到网页中 76 | 77 | > 至此,完成我们的目标。 78 | 79 | 80 | 81 | **再试想另外一个应用场景:** 82 | 83 | 一辆汽车,汽车的倒车镜中可以显示出汽车后面的场景,这也需要用到 离线渲染。 84 | 85 | 86 | 87 | ## 离屏渲染示例:HelloRenderTarget 88 | 89 | #### 示例目标: 90 | 91 | 1. 创建一个 “子场景”,子场景中有 光、镜头、3 个不同颜色的立方体 92 | 2. 创建一个 “总场景”,总场景中有 光、镜头、1 个平面圆(镜子)、1个立方体 93 | 3. 在 总场景中,控制子场景中的 3 个立方体,让他们不停旋转 94 | 4. 通过离屏渲染,将子场景中的 “景象” 作为图片纹理,作用在镜子和立方体的6个面上 95 | 96 | 97 | 98 | #### 代码思路: 99 | 100 | **场景搭建:** 101 | 102 | 子场景和总场景的创建过程,比较简单,不再过多讲述。 103 | 104 | 由于子场景和总场景中都有 镜头、光、立方体这些,为了方便我们区分,也为了让我们代码更加简单清晰,所以我们会单独创建一个 scr/components/hello-render-target/render-target-scene.ts 的文件,用来创建子场景。 105 | 106 | > 注意:子场景中不需要创建渲染器 107 | 108 | > 我们说的 总场景,其实就是 HelloRenderTarget 组件本身 109 | 110 | 111 | 112 | 子场景需要对外暴露出 场景、立方体、镜头: 113 | 114 | ``` 115 | export default { 116 | scene, 117 | boxs, 118 | camera 119 | } 120 | ``` 121 | 122 | 123 | 124 | 总场景获取子场景中的关键元素: 125 | 126 | ``` 127 | import * as RTScene from './render-target-scene' 128 | ... 129 | const rtScene = RTScene.default.scene 130 | const rtBoxs = RTScene.default.boxs 131 | const rtCamera = RTScene.default.camera 132 | ``` 133 | 134 | 135 | 136 | **创建离屏渲染对象** 137 | 138 | ``` 139 | const rendererTarget = new Three.WebGLRenderTarget(512, 512) 140 | ``` 141 | 142 | > 请注意,上面代码中设置 离屏渲染对象的尺寸为 宽 512 像素、高 512 像素 143 | 144 | 145 | 146 | **创建材质,并将材质纹理与离屏渲染对象的渲染结果纹理进行绑定** 147 | 148 | ``` 149 | const material = new Three.MeshPhongMaterial({ 150 | map: rendererTarget.texture 151 | }) 152 | ``` 153 | 154 | > 由于离屏渲染对象的渲染出的纹理尺寸为 512 X 512,这样意味着我们应该将子场景中的镜头宽高比(aspect) 设置为 1 155 | 156 | > 同样,也意味着我们将来 总场景中的 物体(镜子和立方体) 渲染的面 宽高比 也应该是 1:1。 157 | > 158 | > 若物体渲染的面 宽高比不是 1:1,那么最终渲染出的面上的图片会变形。 159 | 160 | 161 | 162 | **修改渲染器的渲染目标,让渲染器去渲染离屏渲染对象,当渲染完成后再清除(恢复)渲染器的渲染目标** 163 | 164 | ``` 165 | renderer.setRenderTarget(rendererTarget) 166 | renderer.render(rtScene, rtCamera) 167 | renderer.setRenderTarget(null) 168 | ``` 169 | 170 | > 虽然渲染器的渲染目标最终又被设置为 null,但是 离屏渲染的画面我们已经获得并保存在 rendererTarget 中。 171 | 172 | 173 | 174 | **最终,在使用渲染器把镜子和立方体进行渲染输出** 175 | 176 | ``` 177 | renderer.render(scene, camera) 178 | ``` 179 | 180 | > 至此,整个代码完成 181 | 182 | 183 | 184 | #### 示例代码: 185 | 186 | **子场景:** 187 | 188 | 文件位于 scr/components/hello-render-target/render-target-scene.ts 189 | 190 | ``` 191 | import * as Three from 'three' 192 | 193 | const scene = new Three.Scene() 194 | scene.background = new Three.Color(0x00FFFF) 195 | 196 | const camera = new Three.PerspectiveCamera(45, 1, 0.1, 10) 197 | camera.position.z = 10 198 | 199 | const light = new Three.DirectionalLight(0xFFFFFF, 1) 200 | light.position.set(0, 10, 10) 201 | scene.add(light) 202 | 203 | const colors = ['blue', 'red', 'green'] 204 | const boxs: Three.Mesh[] = [] 205 | 206 | colors.forEach((color, index) => { 207 | const mat = new Three.MeshPhongMaterial({ color }) 208 | const geo = new Three.BoxBufferGeometry(2, 2, 2) 209 | const mesh = new Three.Mesh(geo, mat) 210 | mesh.position.x = (index - 1) * 3 211 | scene.add(mesh) 212 | boxs.push(mesh) 213 | }) 214 | 215 | export default { 216 | scene, 217 | boxs, 218 | camera 219 | } 220 | ``` 221 | 222 | 223 | 224 | **总场景(HelloRenderTarget):** 225 | 226 | 文件位于 scr/components/hello-render-target/index.tsx 227 | 228 | ``` 229 | import { useEffect, useRef } from 'react' 230 | import * as Three from 'three' 231 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' 232 | import * as RTScene from './render-target-scene' 233 | 234 | import './index.scss' 235 | 236 | const HelloRenderTarget = () => { 237 | 238 | const canvasRef = useRef(null) 239 | 240 | useEffect(() => { 241 | if (canvasRef.current === null) { 242 | return 243 | } 244 | 245 | const rtScene = RTScene.default.scene 246 | const rtBoxs = RTScene.default.boxs 247 | const rtCamera = RTScene.default.camera 248 | 249 | const renderer = new Three.WebGLRenderer({ canvas: canvasRef.current }) 250 | const rendererTarget = new Three.WebGLRenderTarget(512, 512) 251 | 252 | const scene = new Three.Scene() 253 | scene.background = new Three.Color(0x333333) 254 | 255 | const light = new Three.DirectionalLight(0xFFFFFF, 1) 256 | light.position.set(0, 10, 10) 257 | light.target.position.set(-2, 2, 2) 258 | scene.add(light) 259 | scene.add(light.target) 260 | 261 | const camera = new Three.PerspectiveCamera(45, 2, 0.1, 100) 262 | camera.position.z = 15 263 | 264 | const controls = new OrbitControls(camera, canvasRef.current) 265 | controls.update() 266 | 267 | const material = new Three.MeshPhongMaterial({ 268 | map: rendererTarget.texture 269 | }) 270 | 271 | const cubeGeo = new Three.BoxBufferGeometry(4, 4, 4) 272 | const cubeMesh = new Three.Mesh(cubeGeo, material) 273 | cubeMesh.position.x = 4 274 | scene.add(cubeMesh) 275 | 276 | const circleGeo = new Three.CircleBufferGeometry(2.8, 36) 277 | const circleMesh = new Three.Mesh(circleGeo, material) 278 | circleMesh.position.x = -4 279 | scene.add(circleMesh) 280 | 281 | const render = (time: number) => { 282 | time *= 0.001 283 | 284 | rtBoxs.forEach((item) => { 285 | item.rotation.set(time, time, 0) 286 | }) 287 | renderer.setRenderTarget(rendererTarget) 288 | renderer.render(rtScene, rtCamera) 289 | renderer.setRenderTarget(null) 290 | 291 | cubeMesh.rotation.set(time, time, 0) 292 | renderer.render(scene, camera) 293 | 294 | window.requestAnimationFrame(render) 295 | } 296 | window.requestAnimationFrame(render) 297 | 298 | const handleResize = () => { 299 | if (canvasRef.current === null) { 300 | return 301 | } 302 | const width = canvasRef.current.clientWidth 303 | const height = canvasRef.current.clientHeight 304 | camera.aspect = width / height 305 | camera.updateProjectionMatrix() 306 | renderer.setSize(width, height, false) 307 | } 308 | handleResize() 309 | window.addEventListener('resize', handleResize) 310 | 311 | return () => { 312 | window.removeEventListener('resize', handleResize) 313 | } 314 | }, [canvasRef]) 315 | 316 | 317 | return ( 318 | 319 | ) 320 | } 321 | 322 | export default HelloRenderTarget 323 | ``` 324 | 325 | > 补充一下:上面代码中关于灯光的位置、灯光目标的位置、镜头的位置、立方体的位置 都是我随手 填上的,并没有特别的含义,你完全可以适当修改一下。 326 | 327 | 发布运行,就会在网页中看到 镜子的 1 个面、立方体的 6 个面 上 显示着 3 个不停旋转的立方体。 328 | 329 | 330 | 331 | #### 补充说明1: 332 | 333 | 在上述示例代码中,离线渲染目标的尺寸我设置的宽高均为 512,你完全可以设置成其他比例的值。 334 | 335 | 但是为了画面不出现变形内容,所以要遵循以下原则: 336 | 337 | **离线渲染目标的宽高比、子场景中镜头的宽高比、总场景中物体被渲染的面的宽高比,这 3 者要保持一致,这样就不会变形。** 338 | 339 | 340 | 341 | 假设你想在运行的过程中,修改 离线渲染目标的宽高、以及子场景中镜头的宽高比,其操作方式和修改普通的渲染器或镜头没有什么区别。例如: 342 | 343 | ``` 344 | renderTarget.setSize(newWidth,newHeight) 345 | 346 | rtCamera.aspect = newWidth/newHeight 347 | rtCamera.updateProjectionMatrix() 348 | ``` 349 | 350 | 351 | 352 | #### 补充说明2:3D绘制中的 4 大数据缓冲 353 | 354 | 1. 颜色缓冲: 355 | 2. 像素缓冲: 356 | 3. 深度缓冲:depth buffer 357 | 4. 模板缓冲:stencil buffer 358 | 359 | > stencilBuffer 又被称为 印模缓冲 360 | 361 | **模板(stencil)与模板(template)的差异之处:** 362 | 363 | 单词 stencil 和 template 都可以被翻译为 模板,但是他们 2 者含义是有区别的。 364 | 365 | 首先这 2 个单词都是来源以 印刷。 366 | 367 | 1. 模板(template):形模,例如通过修剪 木板或钢板 的外形,以此外形来进行印刷 368 | 369 | 2. 模板(stencil):印模,另外一种印刷技术,例如通过蜡纸来印刷 370 | 371 | > 你把 印模 与 形模 理解成 2 种 不同的印刷方式即可 372 | 373 | 374 | 375 | #### 保存 渲染目标对应的 图片纹理之外,还会额外创建 颜色纹理 和 深度模板纹理。 376 | 377 | 离屏渲染目标 除了得到并保存 渲染目标对应的 图片纹理之外,还会额外创建 颜色纹理 和 深度模板纹理。 378 | 379 | > 图片纹理中,就使用到了 像素缓冲 380 | 381 | 像我们上面示例中根本就用不到 深度缓冲,那么我们可以在 离屏渲染目标初始化的时候,直接设置 不需要创建 深度缓冲 和 模板缓冲,以节省性能。 382 | 383 | ``` 384 | const rendererTarget = new Three.WebGLRenderTarget(512, 512,{ 385 | depthBuffer:false, 386 | stencilBuffer:false 387 | }) 388 | ``` 389 | 390 | **补充说明:** 391 | 392 | 1. depthBuffer:深度缓存、默认值为 true 393 | 2. stencilBuffer:模板缓冲,默认值为 false 394 | 395 | 396 | 397 | > 关于上述 补充说明2 、补充说明3 中的几个缓冲,我个人理解也不够深,不够透彻,观点仅供参考。 398 | 399 | 400 | 401 | 至此,离线渲染 讲解完毕。 402 | 403 | 今天是 2020年最后一天,大家元旦快乐。 404 | 405 | 元旦过后,我们将开始学习 Three.js 基础中最后 2 个知识:自定义几何体(current Geometry)、自定义缓冲几何体(current buffer geometry) 406 | 407 | -------------------------------------------------------------------------------- /05 Three.js基础之图元.md: -------------------------------------------------------------------------------- 1 | # 05 Three.js基础之图元 2 | 3 | ## 图元(Primitives)介绍 4 | 5 | Primitive 这个单词在百度翻译里的解释是:原始的、远古的 6 | 7 | Primitive 的复数即为 Primitives。 8 | 9 | **所谓 图元 就是 Three.js 内置的一些基础 3D 形状,例如 立方体、球体、圆锥体等。** 10 | 11 | **有些文章或教程,包括 Three.js 官方文档,都是将 图元 称呼为 几何体。** 12 | 13 | **但是在本文中,我们依然先使用 图元 这个称呼。** 14 | 15 | > 请注意,内置的图元并不一定都是 3 维体,也可以是 2 维的,例如 平面圆。 16 | 17 | 例如之前写的 HelloThreejs 示例中,就使用 BoxGeometry 来创建立方体。 18 | 19 | > 虽然我们一直称呼为 立方体,但实际在 Three.js 中称呼其为 盒子(box) 20 | 21 | > 在本文后面的一些文章中,也会将图元称呼为几何体。 22 | 23 | 24 | 25 |
26 | 27 |
28 | 29 | --- 30 | 31 | > 以下内容更新于 2021.07.20 32 | 33 | 之前在写这篇文章的时候,还没有学习过图形学,所以对于一些名词的概念解释都是想当然,甚至是胡说八道,胡言乱语。 34 | 35 |
36 | 37 | #### 顶点、图元、片元、图像 他们之间的递进关系 38 | 39 | **顶点**:就是在 3D 世界中某一个具体的点,即点的位置(x,y,z)。除了位置信息,还可能包括 点的颜色或其他信息。 40 | 41 | > 请注意,这些顶点位置都是相对的,依次是 局部位置、全局位置、镜头位置等等。 42 | > 43 | > 在管线渲染流程中,顶点处理模块的作用就是负责将顶点进行坐标转换。 44 | 45 | 46 | 47 |
48 | 49 | **图元**:由若干个顶点构成的一组数据,用于构建或描述某种 二维或三维物体。 50 | 51 | > 图元 中的 “元” 字可以理解为 “原始”的原,也就是说使用最少的点来描述一个物体的空间信息。 52 | > 53 | > 只有 1 个顶点依然可以是 图元,它只能表示某一个 点。例如 自动驾驶中扫描周围环境得到的 3D 点云数据就是由 一个一个小点 组成的。 54 | > 55 | > 如果是 2 个顶点,则可以表示出是一个 线段,同时 2 个点也可以表示出一个长方体。 56 | > 57 | > > 2 个顶点信息就可以表述出 1 个长方体? 58 | > > 没错的,你可以想象成 这 2 个点分别是长方体的 斜对角线上的 2 个点,例如在 three.js 中 包装盒 Box3 就只有 2 个点的信息:坐标最大的点、坐标最小的点 59 | > 60 | > 3个顶点,则可以表示出一个 三角形,同时 3 个点也可以表示出一个圆。 61 | > 62 | > > 至于为什么 3 个顶点 可以表示出一个圆,你可以自己搜索或脑补。 63 | 64 | > 请注意:图元依然为一堆顶点数据,而不是图像数据。 65 | 66 | > 再次补充:假设一个物体有一部分不在显示范围之内,那么 webgl 会通过 裁切体(由镜头视椎体决定的) 对物体进行裁切,只将需要渲染的部分进行渲染,而裁切得到的内容则会重新计算,得到一个新的图元。 67 | 68 | 69 | 70 |
71 | 72 | **关于图元的额外补充:** 73 | 74 | 实际上在 opengl 、webgl 的概念中,图元分为 2 种: 75 | 76 | 1. 几何图元:使用顶点、线段、三角形、曲线等等 用于描述物体 “几何轮廓” 。 77 | 78 | > 几何图元可以进行空间转换,例如平移,旋转,缩放等操作 79 | 80 | 2. 图像图元:图像图元又被称为 光栅图元。使用像素阵列 用于直观储存 “图片信息”。 81 | 82 | > 通过描述就应该知道,实际上所谓的 图像图元就是材质中的纹理贴图。 83 | 84 | > 图像图元不可进行空间转换 85 | 86 | 87 | 88 |
89 | 90 | 几何图元 经过变换、投影、光栅化后,到达片元操作环节的。 91 | 92 | 图像图元(也就是纹理)是直接到达片元操作环节的。 93 | 94 | 最终在片元操作环节,几何图元 + 图像图元,最终合成得到物体图像。 95 | 96 | > 当然还需要其他操作,例如光线反射等 97 | 98 | 99 | 100 |
101 | 102 | 而实际中,我们通常不会使用 “图像图元” 这个名词,而是使用 “纹理”。 103 | 104 | 所以在本文或者一些常见的教程中,“图元” 往往都是指 “几何图元”。 105 | 106 | 107 | 108 |
109 | 110 | **片元**:包含图像颜色、位置、深度的信息数据。你可以把片元简单理解为 “未完全加工完成的图像数据”。 111 | 112 | > 在 3D 图形管线渲染的流程中,经过裁切处理模块和图元组装模块之后,下一步经过光栅化处理模块,会将需要渲染的图元由一堆顶点数据转化为一堆图像数据。 113 | 114 | > 请注意:片元已经不再是顶点数据,而是图像数据了,只不过这些图像数据是为完全加工完成,可以最终显示在屏幕上的图像数据。 115 | 116 | 117 | 118 |
119 | 120 | **图像**:由 片元 经过片元处理模块,得到的最终图像数据。就是 3D 渲染输出到屏幕上的显示结果。 121 | 122 | > 片元数据经过处理,用来更新缓存帧 上的像素,最终 缓存帧 上的结果就是最终渲染出的图像。 123 | 124 | > 请注意:图像是由一个个像素构成。 125 | 126 | 127 | 128 |
129 | 130 | 以上内容为 图形学 中的相关知识,但是在本文中讲解的 “Three.js 中内置的图元” 是 Three.js 为了帮助我们快速创建一些常见物体所提供的 JS 类。 131 | 132 | 所以一定要理解清楚,本文讲解的 图元 和实际图形学中的图元 是有差异。 133 | 134 | > 再次重申一遍:本文讲解的 图元 实际上是 JS 的类,帮助我们快速创建某些形状的 顶点数据。 135 | > 136 | > 一组相关的顶点数据才是图形学中的图元。 137 | 138 | 139 | 140 |
141 | 142 | > 以上内容更新于 2021.07.20 143 | 144 | --- 145 | 146 | 147 | 148 | 149 | 150 |
151 | 152 | ## 3D模型的补充说明 153 | 154 | 内置的图元,都是一些基础的形状,相对简单,但也可以组合成相对复杂的 3D 场景。 155 | 156 | **但对于绝大多数 3D 应用来说,通常流程是:** 157 | 158 | 1. 在专业的 3D 软件 例如 May、Blender、C4D 中创建模型 159 | 2. 将创建好的模型导出成模型文件,文件格式为 .obj 或 .gltf 160 | 3. Three.js 加载模型文件,然后开始后续操作 161 | 162 | 163 | 164 | 我们先不讨论如何导出或加载模型,那些会在后续操作中讲解。 165 | 166 | 此刻还是回归到默认的 图元 学习中。 167 | 168 | 169 | 170 | ## 图元的种类 171 | 172 | ### 图元汇总 173 | 174 | | 图元种类(按英文首字母排序) | 图元构造函数 | 175 | | ---------------------------- | ------------------------------------------------ | 176 | | 盒子(Box) | BoxBufferGeometry、BoxGeometry | 177 | | 平面圆(Circle) | CircleBufferGeometry、CircleGeometry | 178 | | 锥形(Cone) | ConeBufferGeometry、ConeGeometry | 179 | | 圆柱(Cylinder) | CylinderBufferGeometry、CylinderGeometry | 180 | | 十二面体(Dodecahedron) | DodecahedronBufferGeometry、DodecahedronGeometry | 181 | | 受挤压的2D形状(Extrude) | ExtrudeBufferGeometry、ExtrudeGeometry | 182 | | 二十面体(Icosahedron) | IcosahedronBufferGeometry、IcosahedronGeometry | 183 | | 由线旋转形成的形状(Lathe) | LatheBufferGeometry、LatheGeometry | 184 | | 八面体(Octahedron) | OctahedronBufferGeometry、OctahedronGeometry | 185 | | 由函数生成的形状(Parametric) | ParametricBufferGeometry、ParametriceGeometry | 186 | | 2D平面矩形(Plane) | PlaneBufferGeometry、PlaneGeometry | 187 | | 多面体(Polyhedron) | PolyhedronBufferGeometry、PolyhedronGeometry | 188 | | 环形/孔形(Ring) | RingBufferGeometry、RingGeometry | 189 | | 2D形状(Shape) | ShapeBufferGeometry、ShapeGeometry | 190 | | 球体(Sphere) | SphereBufferGeometry、SphereGeometry | 191 | | 四面体(Tetrahedron) | TetrahedronBufferGeometry、TetrahedronGeometry | 192 | | 3D文字(Text) | TextBufferGeometry、TextGeometry | 193 | | 环形体(Torus) | TorusBufferGeometry、TorusGeometry | 194 | | 环形结(TorusKnot) | TorusKnotBufferGeometry、TorusKnotGeometry | 195 | | 管道/管状(Tube) | TubeBufferGeometry、TubeGeometry | 196 | | 几何体的所有边缘(Edges) | EdgesGeometry | 197 | | 线框图(Wireframe) | WireframeGeometry | 198 | 199 | 一共有 22 种内置的图元。 200 | 201 | > 上面表格中关于图元的中文名字,有些是我根据含义自己编的,我已经尽量靠近英文原意。 202 | > 不同文章或教程可能对同一图元的称呼略微不同。 203 | 204 | 205 | 206 | **不要被上面那么多图元吓到**,事实上他们并不复杂,并且多数情况下我们也用不到。 207 | 208 | 当需要用到了,只需要去查阅 Three.js 文档即可。 209 | 210 | 211 | 212 |
213 | 214 | > 以下内容更新于 2021.11.27 215 | 216 | **特别补充说明:内置的图元实际上也是变化多端的!** 217 | 218 | 为什么这么说呢? 219 | 220 | 例如:圆柱(Cylinder),字面上它是用于创建圆柱体的,但是实际上认真阅读官方文档你会发现是这样描述它的构造函数的 221 | 222 | > CylinderGeometry 官方文档:https://threejs.org/docs/index.html#api/zh/geometries/CylinderGeometry 223 | 224 | ``` 225 | CylinderGeometry(radiusTop : Float, radiusBottom : Float, height : Float, radialSegments : Integer, heightSegments : Integer, openEnded : Boolean, thetaStart : Float, thetaLength : Float) 226 | radiusTop — 圆柱的顶部半径,默认值是1。 227 | radiusBottom — 圆柱的底部半径,默认值是1。 228 | height — 圆柱的高度,默认值是1。 229 | radialSegments — 圆柱侧面周围的分段数,默认为8。 230 | heightSegments — 圆柱侧面沿着其高度的分段数,默认值为1。 231 | openEnded — 一个Boolean值,指明该圆锥的底面是开放的还是封顶的。默认值为false,即其底面默认是封顶的。 232 | thetaStart — 第一个分段的起始角度,默认为0。(three o'clock position) 233 | thetaLength — 圆柱底面圆扇区的中心角,通常被称为“θ”(西塔)。默认值是2*Pi,这使其成为一个完整的圆柱。 234 | ``` 235 | 236 | 请注意最后的 2 个参数: 237 | 238 | 1. thetaStart(默认值为0) 239 | 2. thetaLength(默认值为2*Pi) 240 | 241 | 也就是说,你不修改这 2 个默认值,**那么默认创建出的是一个完整的圆柱体**,但是假设你修改了这 2 个值,比如 将 thetaLength 修改成 0.3*Pi (54°),那么最终将创建出一个 夹角为 54° 的**扇形**(体)。 242 | 243 | 如果感兴趣,可以看一下我发布的这个项目,由数据生成3D饼图:https://github.com/puxiao/pie-3d 244 | 245 | > 提醒:最好你在看完本系列教程后(不仅是本小节),再去看上面提到的 pie-3d 。 246 | 247 | 通过上面对 CylinderGeometry 的描述,我们可以知道 Three.js 默认自带的图元实际上是可以产生很多变化的,得到的不一定仅仅是图元的 "字面" 物体。 248 | 249 | > 以上内容更新于 2021.11.27 250 | 251 | 252 | 253 |
254 | 255 | ### BufferGeometry 与 Geometry 的区别 256 | 257 | 从上面的图元表格中不难发现,除了 Edges、WireframeGeometry 以外,其他图元的构造函数都是成对出现的。 258 | 259 | **虽然 EdgesGeometry、WireframeGeometry 名字中并未出现 “Buffer”,但和其他所有包含 “Buffer” 字样的图元一样,他们都继承于 BufferGeometry。** 260 | 261 | | 差异之处 | BufferGeometry | Geometry | 262 | | ------------------------ | -------------- | ------------------------------------------------------------ | 263 | | 运算、渲染所消耗的性能 | 快 | 慢 | 264 | | GPU渲染 | 支持 | 不支持,
需要 Three.js 内部转化为 BufferGeometry 后才支持 | 265 | | 修改灵活度、可自定义程度 | 不高 | 高 | 266 | | 添加新顶点 | 不支持 | 支持 | 267 | 268 | **简单来说就是:** 269 | 270 | * BufferGeometry 可自定义地方比较少,但性能高 271 | * Geometry 可自定义地方比较多,但性能低一些 272 | 273 | > 所有的 Geometry 对象最终都会被 Three.js 转化为 BufferGeometry 对象,然后再进行渲染。 274 | 275 | 276 | 277 |
278 | 279 | > 以下内容更新于 2021.11.27 280 | 281 | **上面关于 BufferGeometry 和 Geometry 的区别这段话已经过时了**,因为在较新的 Three.js 版本中已经将 Geometry 从核心类中移除。 282 | 283 | 目前你接触到的都应该只有 BufferGeometry。 284 | 285 | #### 一些心里话: 286 | 287 | 首先非常抱歉得说一句:一年前在写本系列文章时,我是一个对图形学一无所知,对 Three.js 好奇但又非常小白的人,我是一边学习一边写下本系列文章的。 288 | 289 | **所以本系列教程绝对不是好的教程——假设 Three.js 是一座大山的话,而我是站在山脚下向你讲述上山道路的那个人,但我自己也未曾上过这座山。** 290 | 291 | 随着我对图形学、webgl、Three.js、Canvas 的一些认知提升,我深深觉得想要写出好教程,一定要站在更高的维度,拥有更高的视野才可以更好向别人指明方向,写出好教程。 292 | 293 | 但是本教程对于那些完全小白,完全对 Three.js 一无所知的人,多少还是有些帮助的(尽管我指明的道路并不是最佳道路),**很感谢那些 Star 本教程的人**。 294 | 295 | 即使看完全部的本教程,那么最多你也仅仅算是学会了个皮毛,简单入门而已,真正复杂难的是 图形学 中的一些知识点,例如 向量,矩阵,齐次坐标,点乘,叉乘,球极坐标,当然最复杂的莫过于 自定义渲染器(shader)。 296 | 297 | 关于3D 技术栈,虽然不够严谨,但是大体上可以这样表述:**图形学(CG) > OpenGL > OpenGL ES 2.0 > WebGL > Three.js** 298 | 299 | 所以 Three.js 仅仅是 web 3D 最基础,表层的知识技术栈,想要深入学习,你会发现这是一条几乎不到头的道路,学秃。 300 | 301 | > 图形学就是那种 从入门到放弃 的知识体系。 302 | > 303 | > 但是别灰心,我们实际上并不会真正需要那么深入高深的,学会 Three.js 可以做一些基础的 网页 3D 还是会比一般前端要显得厉害很多。 304 | 305 |
306 | 307 | #### BufferGeometry的重要知识点:position、normal、uv 308 | 309 | > 先普及个基础知识:在 3D 中 Vector3 既可以表示一个 三维坐标,也可以表示一个三维方向。 310 | 311 | 一个完整的 BufferGeometry 是由若干个 点(Vector3) 构成的: 312 | 313 | > 上面提到的 点 准确说应该是 3维 点坐标,对应的是 Vector3 :https://threejs.org/docs/index.html#api/zh/math/Vector3 314 | 315 | 下面的知识实际上是针对 图形学 和 OpenGL 的。 316 | 317 | 1. position:坐标(每个坐标就是一个 vector3,由 3 个数字组成),所有的坐标就是组成该 BufferGeometry 的所有 点 的信息(对于底层的 BufferGeometry 而言 3维点的 (x,y,z) 坐标是分开存储值的)。 318 | 319 | > 这就是 Three.js 针对 webgl 进行的封装,实际上我们平时更多时候都使用的是 Vector3,而不是具体的 3 个值。 320 | 321 | 2. normal:法线(每个法线就是一个 vector3,由 3 个数字组成),用于存储每个 3D 坐标点的朝向,用于计算 反光。 322 | 323 | 3. uv:纹理映射坐标(每个uv就是一个 vector2,由 2 个数字组成),用于存储每个 3D 坐标点对应渲染纹理时对应的 位置点信息,用于计算 贴图。 324 | 325 | > 对于纹理而言,它都是 二维的平面,因此 uv 的值对应的是 Vector2,由 x,y 2 个坐标值组成,且每个值的取值范围都是 0 - 1。 326 | > 327 | > 你可以简单把 0 - 1 理解成 0% - 100%,对应的是一个百分比的值。 328 | 329 | 通过上面的讲述,我们大致可以作出以下结论,如果我们自定义一个 BufferGeometry,那么: 330 | 331 | > 对于初学者而言几乎不需要、也做不到 可以 自定义 BufferGeometry 这一步,我这里只是超前提一下。 332 | 333 | 1. 假设这个 BufferGeometry 不需要考虑 反光 和 纹理贴图,那么它只需要拥有(设置) positon 就可以了。 334 | 335 | > this.setAttribute('position', new BufferAttribute(this._vertices, 3)) 336 | 337 | 2. 假设这个 BufferGeometry 需要考虑反光,但不需要考虑纹理贴图,那么它需要设置 postion 和 normal。 338 | 339 | > this.setAttribute('position', new BufferAttribute(this._vertices, 3)) 340 | > 341 | > this.setAttribute('normal', new BufferAttribute(this._normals, 3)) 342 | 343 | 3. 假设这个 BufferGeometry 需要考虑反光和纹理贴图,那么它的 postion 、normal、uv 都需要设置。 344 | 345 | > this.setAttribute('position', new BufferAttribute(this._vertices, 3)) 346 | > 347 | > this.setAttribute('normal', new BufferAttribute(this._normals, 3)) 348 | > 349 | > this.setAttribute('uv', new BufferAttribute(this._uvs, 2)) 350 | 351 | 4. **特别强调,上面提到的 position 是一个 BufferGeomerty 所有点信息的集合,它并不是 Mesh(网格,3D物体) 的 位置信息。** 352 | 353 | 如果你理解不了我说的这段话,完全没有关系,忽略这段我补充的知识点,我也是学习 Three.js 快 1 年后才明白的。对于现在的你而言不理解是正常的。 354 | 355 | 忽略我上面的这段话,继续本教程后面的学习吧。 356 | 357 | > 以上内容更新于 2021.11.27 358 | 359 | 360 | 361 | 图元理论上的知识就先讲到这里,在下一节中,会编写一些图元示例。 362 | -------------------------------------------------------------------------------- /26 Three.js解决方案之透明度bug.md: -------------------------------------------------------------------------------- 1 | # 26 Three.js解决方案之透明度bug 2 | 3 | 透明度(transparency) 在 Three.js 中很容易实现,但是透明度又存在一个 "Bug",解决起来又比较难。 4 | 5 | > 请注意,这里说的 “Bug” 是加了引号的,具体原因我们稍后讲解。 6 | 7 | 8 | 9 |
10 | 11 | 我们先从一个简单的示例开始。 12 | 13 | ### 示例1:渲染一个半透明的立方体 14 | 15 | 所有材质的基类 Three.Material 有 2 个属性和设置透明度有关: 16 | 17 | 1. transparent:设置材质是否透明 18 | 2. opacity:设置材质透明度,取值范围 0 - 1 19 | 20 | > 由于 Material 是所有材质的基类,也就意味着所有材质都拥有上述 2 个属性 21 | 22 | 23 | 24 |
25 | 26 | 假设我们想创建一个半透明的立方体,代码如下: 27 | 28 | ``` 29 | const geometry = new Three.BoxBufferGeometry(2, 2, 2) 30 | const material = new Three.MeshBasicMaterial({ 31 | color: 'red', 32 | transparent: true, 33 | opacity: 0.5 34 | }) 35 | const cube = new Three.Mesh(geometry, material) 36 | scene.add(cube) 37 | ``` 38 | 39 | 40 | 41 |
42 | 43 | 上面我们只是给材质设置了一个 红色,当我们运行程序的时候会发现:单独一个半透明立方体,似乎看不出任何半透明的意思。 44 | 45 | > 即使给材质添加 `side: Three.DoubleSide` 46 | 47 | 我们需要添加多个半透明立方体,才更容易看出来彼此半透明。 48 | 49 | 50 | 51 | 我们继续修改示例。 52 | 53 |
54 | 55 | ### 渲染8个小立方体 56 | 57 | 我们的渲染目标: 58 | 59 | 1. 渲染8个不同颜色,透明度都为 0.5 的立方体 60 | 61 | 2. 这 8 个立方体分布在一个 2 x 2 x 2 的空间中 62 | 63 | > 这种分布方式,在魔方玩具中被称为 “二阶魔方” 64 | 65 | 3. 每个立方体 里外 2 个面都要进行渲染 66 | 67 | 68 | 69 |
70 | 71 | 具体实现代码: 72 | 73 | ``` 74 | const colors = ['red', 'blue', 'darkorange', 'darkviolet', 'green', 'tomato', 'sienna', 'crimson'] 75 | const cube_size = 1 //立方体尺寸 76 | const cube_margin = 0.6 //立方体间距空隙 77 | colors.forEach((color, index) => { 78 | const geometry = new Three.BoxBufferGeometry(cube_size, cube_size, cube_size) 79 | const material = new Three.MeshPhongMaterial({ 80 | color, 81 | transparent: true, 82 | opacity: 0.5, 83 | side: Three.DoubleSide 84 | }) 85 | 86 | const cube = new Three.Mesh(geometry, material) 87 | cube.position.x = (index % 2 ? 1 : -1) * cube_size * cube_margin 88 | cube.position.y = (Math.floor(index / 4) ? -1 : 1) * cube_size * cube_margin 89 | cube.position.z = ((index % 4) >= 2) ? 1 : -1 * cube_size * cube_margin / 2 90 | 91 | scene.add(cube) 92 | }) 93 | ``` 94 | 95 | > 在目前最新的 Three.js r127 版本中,对于预置颜色的单词,只支持全小写,例如: 96 | > 97 | > 1. 红色 只可以写 `red`,不可以写成 `Red` 98 | > 2. 再或者 暗桔色 只可以写 `darkorange`,不可以写成`DarkOrange` 99 | > 100 | > 这是因为在 Color 源码中,记录内置颜色值的对象 key 都是小写,我已针对这个问题提交了自己的 pr: 101 | > 102 | > https://github.com/mrdoob/three.js/pull/21687 103 | > 104 | > 我修改了一点代码,让取值时对颜色值的字符串执行 .toLowerCase(),这样即使颜色值字符串有大写可以最终实际被转化为小写。 105 | 106 | 107 | 108 |
109 | 110 | 实际运行后,就会看到 8 个半透明的小立方体。通过 OrbitControls 旋转视角,看着感觉挺好的呀。 111 | 112 | 113 | 114 |
115 | 116 | 117 | 118 | 当你尝试不断变换视角查看立方体时,在某些特殊的视角下,我们看不到立方体左侧后表面。 119 | 120 | > 也就是说,原本立方体左侧后表面应该也被渲染,但是实际上并未被渲染。 121 | 122 | 123 | 124 |
125 | 126 | 如果你实在是没有看出来什么问题,暂时相信我一下,就好像你已真的发现了那样。 127 | 128 | 下面听一下关于出现这个 "bug" 的解释。 129 | 130 | 131 | 132 |
133 | 134 | ### Three.js绘制 3D 对象的方式 135 | 136 | 上面提到的渲染 “Bug”,是由于 Three.js 绘制 3D 对象的方式造成的。 137 | 138 |
139 | 140 | 对于每一个几何图形,每个三角形一次只绘制一个。 141 | 142 | > 立方体的 1 个面是一个 正方形,而这个正方形是由 2 个三角形构成的。 143 | > 144 | > 在绘制 1 个面(正方形),Three.js 会先后绘制 2 个三角形,最终拼接成 1 个正方形。 145 | 146 | 147 | 148 |
149 | 150 | 每次绘制一个三角形时,会记录 2 个事情: 151 | 152 | 1. 三角形的颜色 153 | 154 | 2. 三角形的像素深度 155 | 156 | > 像素深度是指存储每个像素所用的位数,用来度量图像的分辨率。 157 | 158 | 3. 当绘制下一个三角形时,对于每一个像素,如果深度比之前记录的深度还要深,则不会绘制任何像素 159 | 160 | > 这其实是 Three.js 绘制物体时采取的一种节省性能的策略 161 | 162 | 163 | 164 |
165 | 166 | 这套策略对于不透明的物体来说非常有用,但是对于透明的物体却不起作用。 167 | 168 | 169 | 170 |
171 | 172 | 一个立方体有 6 个面,每个面 2 个三角形,也就意味着一个立方体需要绘制 12 个三角形。 173 | 174 | 而这 12 个三角形究竟先绘制哪个,他们绘制的顺序是什么呢? 175 | 176 | 答:绘制顺序取决于我们的视角,越接近相机的三角形越优先被绘制。 177 | 178 | > 这就是为什么我们上面提到的绘制 bug 只有在某些特定角度下才会出现的原因。 179 | 180 | > 越靠近相机的三角形越先被绘制,这也意味着在某些角度下,远离摄像机的某个面(立方体背面或侧背面)有可能不会被绘制。 181 | 182 | 183 | 184 | 这种情况不仅会出现在立方体身上,球体上也会出现。 185 | 186 | 187 | 188 |
189 | 190 | **针对以上情况,有一种解决方案:** 191 | 192 | 1. 将每个立方体添加 2 次到场景中 193 | 2. 第 1 次添加的立方体设置只让渲染 背面(Three.BackSide) 194 | 3. 第 2 次添加的立方体设置只让渲染 前面(Three.FrontSide) 195 | 196 | 这样一番操作过后,确保 Three.js 可以将每个立方体的前面、后面都会渲染,拼合一下 2 次的渲染结果,将 “正确的” 结果渲染出来。 197 | 198 | 199 | 200 |
201 | 202 | **补充说明** 203 | 204 | 1. 上面的解决方案实际上需要绘制 2 次,这也许会造成性能上的浪费。 205 | 2. 如果你并不是特别在乎那一点点渲染 “Bug”,你完全可以忽视它。 206 | 207 | 208 | 209 |
210 | 211 | 接下来我们再通过另外一个示例,讲解另外一种有针对性的解决方案。 212 | 213 | 214 | 215 |
216 | 217 | ### 绘制2个中心交叉的平面正方形 218 | 219 | 我们的示例目标是: 220 | 221 | 1. 绘制 2 个平面的正方形 222 | 223 | > 创建平面 在 Three.js 中使用的是 Three.PlaneBufferGeometry 224 | 225 | 2. 给每个平面添加一个颜色和纹理贴图,两面都渲染,并设置正方形平面透明度为 0.5 226 | 227 | > 贴图我们直接使用网上的 2 张图片资源 228 | > 229 | > 图片都是由背景色的,并非背景透明的 PNG 230 | 231 | 3. 让这 2 个平面形成 十字交叉 的状态 232 | 233 | > 也就是说让其中一个平面的 y 轴旋转 180度 234 | 235 | 236 | 237 |
238 | 239 | **实现的代码:** 240 | 241 | ``` 242 | const planeDataArr = [ 243 | { 244 | color: 'red', 245 | ratation: 0, 246 | imgsrc: 'https://threejsfundamentals.org/threejs/resources/images/happyface.png' 247 | }, 248 | { 249 | color: 'yellow', 250 | ratation: Math.PI * 0.5, 251 | imgsrc: 'https://threejsfundamentals.org/threejs/resources/images/hmmmface.png' 252 | } 253 | ] 254 | 255 | planeDataArr.forEach((value) => { 256 | const geometry = new Three.PlaneBufferGeometry(2, 2) 257 | const textureLoader = new TextureLoader() 258 | const material = new Three.MeshBasicMaterial({ 259 | color: value.color, 260 | map: textureLoader.load(value.imgsrc), 261 | opacity: 0.5, 262 | transparent: true, 263 | side:Three.DoubleSide 264 | }) 265 | const plane = new Three.Mesh(geometry, material) 266 | plane.rotation.y = value.ratation 267 | scene.add(plane) 268 | }) 269 | ``` 270 | 271 | 272 | 273 |
274 | 275 | 这次,我们将很容易看到,当红色平面一侧完全覆盖住黄色平面一侧时,会完全看不到黄色平面那一侧。 276 | 277 | > 实际运行效果我就不贴图了,你可以将上面代码实际运行一下。 278 | 279 | > 你就假装此刻你看到了。 280 | 281 | 282 | 283 |
284 | 285 | 用我们上面讲过的理论可解释这个现象:即 红色平面一侧颜色深度大于黄色平面一侧,当完全覆盖住之后黄色平面那一侧就不会再进行渲染,所以我们就看不到了。 286 | 287 | 288 | 289 |
290 | 291 | **解决方案:将上面 2 个平面拆分成 4 个平面,这样可以确保每个平面都会被渲染。** 292 | 293 | 由于我们这个场景 2 个平面十字交叉,所以我们就直接创建 4 个小的平面,然后将这 4 个小平面组合成 “2 个十字相交的平面”。 294 | 295 | 具体实现的方式是: 296 | 297 | 1. 将原本 较大的 1 个平面拆分成 2 个小平面 298 | 299 | 2. 设置这 2 个小平面的纹理贴图偏移,各自占一半 300 | 301 | > 这样可以最终让 2 个小平面贴合成 1 个完整的平面(纹理) 302 | 303 | 3. 为了方便我们计算旋转,好让他们形成十字交叉,所以我们可以将 每组小平面放置在同一个空间中 304 | 305 | > 这需要使用到 Three.Object3D 306 | 307 | 308 | 309 |
310 | 311 | **实际代码:** 312 | 313 | ``` 314 | const planeDataArr = [ 315 | { 316 | color: 'red', 317 | ratation: 0, 318 | imgsrc: 'https://threejsfundamentals.org/threejs/resources/images/happyface.png' 319 | }, 320 | { 321 | color: 'yellow', 322 | ratation: Math.PI * 0.5, 323 | imgsrc: 'https://threejsfundamentals.org/threejs/resources/images/hmmmface.png' 324 | } 325 | ] 326 | 327 | planeDataArr.forEach((value) => { 328 | const base = new Three.Object3D() 329 | base.rotation.y = value.ratation 330 | scene.add(base) 331 | 332 | const plane_size = 2 333 | const half_size = plane_size / 2 334 | const geometry = new Three.PlaneBufferGeometry(half_size, plane_size) 335 | const arr = [-1, 1] 336 | arr.forEach((x) => { 337 | const textureLoader = new TextureLoader() 338 | const texture = textureLoader.load(value.imgsrc) 339 | texture.offset.x = x < 1 ? 0 : 0.5 340 | texture.repeat.x = 0.5 341 | const material = new Three.MeshBasicMaterial({ 342 | color: value.color, 343 | map: texture, 344 | transparent: true, 345 | opacity: 0.5, 346 | side: Three.DoubleSide 347 | }) 348 | const plane = new Three.Mesh(geometry, material) 349 | plane.position.x = x * half_size / 2 350 | base.add(plane) 351 | }); 352 | }) 353 | ``` 354 | 355 | >假设我们目标平面宽高均为 2,那么: 356 | > 357 | >1. 我们将该目标平面拆分成 2 个 宽 1、高 2 的平面 358 | >2. 获取并设置纹理贴图,并分别设置纹理的 offset.x 、repeat.x 各占 一半,也就是 0.5 359 | >3. 我们知道拆分出的 2 个小平面他们 x 轴相差 1 个小平面的宽度,由于我们设置的内部循环数组为 [-1,1],所以 2 个小平面的 x 值应该是 正负宽度一半的一半。 360 | 361 | 362 | 363 |
364 | 365 | 这一次,我们再运行就会发现,无论任何视角下,红色平面不再会这该黄色平面了。 366 | 367 | 368 | 369 |
370 | 371 | **这是第 2 种解决透明 "bug" 的方案:将对象进行拆分** 372 | 373 | **但是请注意,该解决方式只适合那些简单,且位置相对固定的 3D 对象。** 374 | 375 | > 若物体本身就比较复杂,面比较多,还要再拆分,那么就太消耗渲染性能 376 | > 377 | > 并且位置必须相对固定,若不固定会增加我们拼接的难度 378 | 379 | 380 | 381 |
382 | 383 | 接下来讲解第 3 种解决方案。 384 | 385 | ### 启用alphaTest来避免遮挡问题 386 | 387 | 首先我们回顾一下上面的例子,当时示例中 2 个平面的纹理贴图背景是不透明的。 388 | 389 | 那我们可以尝试另外 2 个图片贴图,他们是背景透明的 PNG 图片。 390 | 391 | 我们要使用材质(Three.Material) 的一个属性 .alphaTest。 392 | 393 | 394 | 395 |
396 | 397 | **.alphaTest属性介绍** 398 | 399 | .alphaTest 是一个透明度检测值,值得类型是 Number,取值范围为 0 - 1。 400 | 401 | 若透明度低于该值,则不会进行渲染。 402 | 403 | > 反之,只有某个点透明度高于该值的才会进行渲染 404 | 405 | .alphaTest 默认值为 0。 406 | 407 | > 也就是说默认情况下即使透明度为 0 也会进行渲染 408 | > 409 | > 假设我们给材质设置有 color,那么肯定就会渲染出内容 410 | 411 | 412 | 413 |
414 | 415 | 我们在最初 2 个平面的代码基础上进行修改。 416 | 417 | 1. 修改纹理贴图资源,这次使用背景透明的 PNG 图片 418 | 2. 材质不再设置 .opacity 属性,改设置 .alphaTest 属性 419 | 420 |
421 | 422 | 修改后的代码如下: 423 | 424 | ``` 425 | const planeDataArr = [ 426 | { 427 | color: 'red', 428 | ratation: 0, 429 | imgsrc: 'https://threejsfundamentals.org/threejs/resources/images/tree-01.png' 430 | }, 431 | { 432 | color: 'yellow', 433 | ratation: Math.PI * 0.5, 434 | imgsrc: 'https://threejsfundamentals.org/threejs/resources/images/tree-02.png' 435 | } 436 | ] 437 | 438 | planeDataArr.forEach((value) => { 439 | const geometry = new Three.PlaneBufferGeometry(2, 2) 440 | const textureLoader = new TextureLoader() 441 | const material = new Three.MeshBasicMaterial({ 442 | color: value.color, 443 | map: textureLoader.load(value.imgsrc), 444 | alphaTest: 0.5, 445 | transparent: true, 446 | side:Three.DoubleSide 447 | }) 448 | const plane = new Three.Mesh(geometry, material) 449 | plane.rotation.y = value.ratation 450 | scene.add(plane) 451 | }) 452 | ``` 453 | 454 | > 请注意,上面代码中我们取了一个透明度的中间值,将 .alphaTest 属性值设置为 0.5。 455 | > 456 | > 你可以尝试将 .alphaTest 分别设置为 0.2 或 0.8 看看结果会有什么变化。 457 | > 458 | > > 最终边缘清晰度取决于贴图图片中抠图的精细程度。若边缘越不清晰(也就是越模糊),最终呈现出的白边会越严重。 459 | 460 | 461 | 462 |
463 | 464 | 实际运行后就会发现,2 棵不同颜色的树木,彼此十字交叉,可以透过前面树的枝叶看到另外一颗树。 465 | 466 | 467 | 468 | ### 本文小节 469 | 470 | 我们讲解了为什么会在某些视角下,某些半透明的物体个别地方三角面不会被渲染的原因。 471 | 472 | 通过几个示例,讲解了 3 种解决方案: 473 | 474 | 1. 将物体添加 2 份,1份负责渲染前面,另外一份负责渲染后面 475 | 476 | > 缺点:增加渲染工作量 477 | 478 | 2. 将物体(或平面)进行拆分,已确保每 1 份均会有机会被渲染 479 | 480 | > 缺点:只适合简单的物体,且位置固定容易拼凑 481 | 482 | 3. 通过设置 .alphaTest,以实现透明渲染 483 | 484 | > 缺点:若贴图抠图不够精细,容易出现白边 485 | 486 | 487 | 488 |
489 | 490 | 就像上面我们提到的,每一种解决方案都有各自的使用场景和缺点。 491 | 492 | 我们今后在实际的项目中,一定要根据实际情况来作出选择,看使用哪种方案。 493 | 494 | > 说白了,无非就是在性能、复杂度、精细化方面进行取舍,最终找出合适的方案。 495 | 496 | 497 | 498 |
499 | 500 | > 你可能会注意到本章节我并没有贴出完整的示例代码,而仅仅贴出了核心的代码。 501 | > 502 | > 我是这样认为的,如果到了今天你依然无法自己写出完整的代码,还需要靠复制我完整的示例代码,那你干脆别学 Three.js了,放弃吧。 503 | 504 | 505 | 506 |
507 | 508 | 至此,本章结束。 509 | 510 | 目前我们所有的示例都是基于 1 个 画布(canvas) 和 1 个 镜头(camera),下一节我们讲解同一个网页中渲染多个 画布 和多个镜头。 511 | 512 | -------------------------------------------------------------------------------- /17 Three.js技巧之按需渲染.md: -------------------------------------------------------------------------------- 1 | # 17 Three.js技巧之按需渲染 2 | 3 | **灵魂拷问:什么叫按需渲染?按哪个需?** 4 | 5 | 答:就是字面意思——需要的时候才渲染,不需要的时候不渲染。 6 | 7 | 在 基础篇 中,我们所有的示例中,都有以下代码: 8 | 9 | ``` 10 | const render = () =>{ 11 | ... 12 | renderer.render(scene,camera) 13 | window.requestAnimationFrame(render) 14 | } 15 | window.requestAnimationFrame(render) 16 | ``` 17 | 18 | 也就是意味着,无论任何时候,我们都会在每一帧上进行场景渲染。 19 | 20 | 假设场景本身就是静止的,没有任何物体变化,此时依然进行不间断的循环渲染,其实是对客户端设备性能、电量的一种浪费。 21 | 22 | 23 | 24 | ## 按需渲染示例:RenderingOnDemand 25 | 26 | > 在基础篇中,所有示例都是以 HelloXxxx 来命名 React 组件的,但是以后我们不会继续使用这种命名方式,而是会根据实际讲解内容来定义 React 组件名。 27 | 28 | #### 只渲染一次的一个示例: 29 | 30 | scr/components/rendering-on-demand/index.tsx 31 | 32 | ``` 33 | import { useEffect, useRef } from 'react' 34 | import * as Three from 'three' 35 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' 36 | 37 | import './index.scss' 38 | 39 | const RenderingOnDemand = () => { 40 | const canvasRef = useRef(null) 41 | useEffect(() => { 42 | if (canvasRef.current === null) { return } 43 | 44 | const renderer = new Three.WebGLRenderer({ canvas: canvasRef.current }) 45 | const scene = new Three.Scene() 46 | const camera = new Three.PerspectiveCamera(45, 2, 1, 100) 47 | camera.position.z = 20 48 | const light = new Three.DirectionalLight(0xFFFFFF, 1) 49 | light.position.set(5, 5, 10) 50 | scene.add(light) 51 | 52 | const colors = ['blue', 'red', 'green'] 53 | const cubes: Three.Mesh[] = [] 54 | colors.forEach((color, index) => { 55 | const material = new Three.MeshPhongMaterial({ color }) 56 | const geometry = new Three.BoxBufferGeometry(4, 4, 4) 57 | const mesh = new Three.Mesh(geometry, material) 58 | mesh.position.x = (index - 1) * 6 59 | scene.add(mesh) 60 | cubes.push(mesh) 61 | }) 62 | 63 | const render = () => { 64 | renderer.render(scene, camera) 65 | } 66 | window.requestAnimationFrame(render) 67 | 68 | const controls = new OrbitControls(camera, canvasRef.current) 69 | controls.update() 70 | 71 | const handleResize = () => { 72 | if (canvasRef.current === null) { return } 73 | const width = canvasRef.current.clientWidth 74 | const height = canvasRef.current.clientHeight 75 | camera.aspect = width / height 76 | camera.updateProjectionMatrix() 77 | renderer.setSize(width, height, false) 78 | } 79 | handleResize() 80 | window.addEventListener('resize', handleResize) 81 | 82 | return () => { 83 | window.removeEventListener('resize', handleResize) 84 | } 85 | }, [canvasRef]) 86 | return ( 87 | 88 | ) 89 | } 90 | 91 | export default RenderingOnDemand 92 | ``` 93 | 94 | 95 | 96 | **请注意上面代码中的这一段:** 97 | 98 | ``` 99 | const render = () => { 100 | renderer.render(scene, camera) 101 | } 102 | ``` 103 | 104 | 以往示例中,我们还会在 render 里添加:`window.requestAnimationFrame(render)` 不停的循环渲染场景。 105 | 106 | 当我们这次没有添加这行代码后,实际运行,会得到以下结果: 107 | 108 | 1. 场景只在初始化时,渲染一次 109 | 2. 尽管添加有 OrbitControls,但是任何鼠标操作,场景并不会进行更新渲染 110 | 3. 尽管添加有浏览器窗口尺寸变化监听,但是浏览器只会针对 canvas 进行变形拉伸,场景并不会进行更新渲染 111 | 112 | 113 | 114 | **我们肯定是需要当 OrbitControl发生改变、浏览器窗口发生改变时,重新渲染场景。** 115 | 116 | **如何实现这 2 个需求呢?** 117 | 118 | 119 | 120 | **当 OrbitControls 发生变化时,我们添加对应事件处理函数,调用 render 函数即可。** 121 | 122 | ```diff 123 | const controls = new OrbitControls(camera, canvasRef.current) 124 | + controls.addEventListener('change',render) //添加事件处理函数,触发重新渲染 125 | controls.update() 126 | ``` 127 | 128 | 129 | 130 | **当浏览器窗口尺寸发生变化时,我们在 handleResize 函数中调用 render 函数即可。** 131 | 132 | ```diff 133 | const handleResize = () => { 134 | if (canvasRef.current === null) { return } 135 | const width = canvasRef.current.clientWidth 136 | const height = canvasRef.current.clientHeight 137 | camera.aspect = width / height 138 | camera.updateProjectionMatrix() 139 | renderer.setSize(width, height, false) 140 | + window.requestAnimationFrame(render) //触发重新渲染 141 | //注意,这里并不建议直接调用 render(),而是选择执行 window.requestAnimationFrame(render) 142 | } 143 | ``` 144 | 145 | > 就这么简单,就这么 easy 。 146 | 147 | 实际修改后的代码,调试运行后,就做到了 按需渲染。 148 | 149 | 150 | 151 | **为什么不建议直接调用 render() ?** 152 | 153 | 答:因为直接 render() 是在当前帧中执行的代码,这样可能会让浏览器 卡顿一下,而选择执行 window.requestAnimationFrame(render) 则明确告知浏览器,在下一帧中执行,确保用户体验流畅一些。 154 | 155 | 156 | 157 | #### 完美? 158 | 159 | 仔细观察你会发现,当我们拖拽鼠标会触发重新渲染,鼠标拖拽和重新渲染几乎是 同时发生又同时结束的。 160 | 161 | 停止鼠标拖拽,场景变化(渲染)戛然而止。 162 | 163 | 你想象一下这个场景: 164 | 165 | 1. 你手拉着一个绳子,绳子另外一头拴在一个比较重的铁球上面,此时你拉着绳子拽着铁球前进。 166 | 167 | 2. 假如说你突然停止脚步,那么应该发生什么? 168 | 169 | A、铁球和你同时停止(分秒不差) 170 | 171 | B、尽管你停下了脚步,但是铁球由于惯性,依然会往前移动一点点 172 | 173 | > 哪怕铁球特别沉,多少总会表现出往前一点点的移动的迹象的 174 | 175 | 我们都相信,B 选项更加符合我们日常的感知预期。 176 | 177 | 把话题拉回到 鼠标控制轨道 上面来,事实上当我们拖拽鼠标移动停止后,不应该立即停止场景渲染,而是应该让场景继续往后渲染一点点。 178 | 179 | 180 | 181 | #### 如何实现"惯性"? 182 | 183 | 答:开启 轨道控制器的 enableDamping 属性。 184 | 185 | > damping 单词的意思是 阻尼,也就是 惯性 186 | 187 | ``` 188 | controls.enableDamping = true 189 | ``` 190 | 191 | > enableDamping 默认值为 false 192 | 193 | 194 | 195 | 但是,设置 enableDamping 为 true 之后,会引发新的问题。 196 | 197 | 1. enableDamping 设置为 true 之后,需要继续调用 OrbitControls 实例 的 update() 函数,以便相机能够 “靠着惯性继续往前移动轨道”。 198 | 199 | 2. 但是虽然我们已停止了鼠标拖拽,但是由于惯性,controls 会继续触发 change 事件。 200 | 201 | 3. 而 change 事件又会调用 render 函数 202 | 203 | 4. 最终演变成了一个 无限循环 的状况 204 | 205 | > 尽管是无限循环渲染,但是请放心,并不会因此造成客户端崩溃,因为在之前的示例中,我们本身就是不断的无限循环调用 render 函数的。 206 | 207 | 208 | 209 | **如何解决惯性引发的无限循环渲染?** 210 | 211 | 答:我们可以添加一个 Boolean 类型的参数,用来区分出究竟是 惯性引发的渲染,还是我们主动鼠标拖拽引发的渲染。 212 | 213 | 请注意,不要用 useState 来创建 这个 Boolean 参数,因为 useState 是异步的,并且每次执行 useState 改变 boo 的值都会引发重新渲染。 214 | 215 | 我们采用的是在 组件外部声明 的方式来定义 boo。 216 | 217 | 具体的做法是: 218 | 219 | ```diff 220 | + let boo = false 221 | const RenderingOnDemand = () => { 222 | useEffect(() =>{ 223 | 224 | ... 225 | 226 | const render = () => { 227 | + boo = false 228 | + controls.update() 229 | renderer.render(scene, camera) 230 | } 231 | window.requestAnimationFrame(render) 232 | 233 | + const handleChange = () => { 234 | + if (boo === false) { 235 | + boo = true 236 | + window.requestAnimationFrame(render) 237 | + } 238 | + } 239 | 240 | const controls = new OrbitControls(camera, canvasRef.current) 241 | - controls.addEventListener('change', render) 242 | + controls.addEventListener('change', handleChange) 243 | + controls.enableDamping = true 244 | controls.update() 245 | 246 | return () =>{ 247 | window.removeEventListener('resize', handleResize) 248 | } 249 | },[canvasRef]) 250 | } 251 | ``` 252 | 253 | 254 | 255 | 由于存在 “惯性”,所以会在 惯性 期间继续不断调用 controls.update(),直至惯性消失,不再触发 change 时间,此时才会停止调用 controls.update(),从而中断了 无限循环渲染。 256 | 257 | 258 | 259 | **完整的示例代码:** 260 | 261 | ``` 262 | import { useEffect, useRef } from 'react' 263 | import * as Three from 'three' 264 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' 265 | 266 | import './index.scss' 267 | 268 | let boo = false 269 | 270 | const RenderingOnDemand = () => { 271 | const canvasRef = useRef(null) 272 | 273 | useEffect(() => { 274 | if (canvasRef.current === null) { return } 275 | 276 | const renderer = new Three.WebGLRenderer({ canvas: canvasRef.current }) 277 | const scene = new Three.Scene() 278 | const camera = new Three.PerspectiveCamera(45, 2, 1, 100) 279 | camera.position.z = 20 280 | const light = new Three.DirectionalLight(0xFFFFFF, 1) 281 | light.position.set(5, 5, 10) 282 | scene.add(light) 283 | 284 | const colors = ['blue', 'red', 'green'] 285 | const cubes: Three.Mesh[] = [] 286 | colors.forEach((color, index) => { 287 | const material = new Three.MeshPhongMaterial({ color }) 288 | const geometry = new Three.BoxBufferGeometry(4, 4, 4) 289 | const mesh = new Three.Mesh(geometry, material) 290 | mesh.position.x = (index - 1) * 6 291 | scene.add(mesh) 292 | cubes.push(mesh) 293 | }) 294 | 295 | const render = () => { 296 | boo = false 297 | controls.update() 298 | renderer.render(scene, camera) 299 | } 300 | window.requestAnimationFrame(render) 301 | 302 | const handleChange = () => { 303 | if (boo === false) { 304 | boo = true 305 | window.requestAnimationFrame(render) 306 | } 307 | } 308 | 309 | const controls = new OrbitControls(camera, canvasRef.current) 310 | controls.addEventListener('change', handleChange) 311 | controls.enableDamping = true 312 | controls.update() 313 | 314 | const handleResize = () => { 315 | if (canvasRef.current === null) { return } 316 | const width = canvasRef.current.clientWidth 317 | const height = canvasRef.current.clientHeight 318 | camera.aspect = width / height 319 | camera.updateProjectionMatrix() 320 | renderer.setSize(width, height, false) 321 | 322 | window.requestAnimationFrame(render) 323 | } 324 | handleResize() 325 | window.addEventListener('resize', handleResize) 326 | 327 | return () => { 328 | window.removeEventListener('resize', handleResize) 329 | } 330 | }, [canvasRef]) 331 | return ( 332 | 333 | ) 334 | } 335 | 336 | export default RenderingOnDemand 337 | ``` 338 | 339 | 340 | 341 | 342 | 343 | ## 补充一个 OrbitControls 的知识 344 | 345 | 我也就是今天才知道,原来 OrbitControls 除了鼠标可改变轨迹之外,还可以通过键盘上的 4 个方向键(上下左右)来更改视图。 346 | 347 | 但是我在 React 中试验,发现键盘事件并不触发。于是我自己新建一个 React 组件,进一步测试: 348 | 349 | ``` 350 | import { useRef, useEffect } from 'react' 351 | 352 | const TestKeydown = () => { 353 | const canvasRef = useRef(null) 354 | 355 | const handleKeydown = (eve: KeyboardEvent) => { 356 | console.log(eve) 357 | } 358 | 359 | useEffect(() => { 360 | if (canvasRef.current === null) { return } 361 | canvasRef.current.addEventListener('keydown', handleKeydown, false) 362 | }, [canvasRef]) 363 | 364 | return ( 365 | 366 | ) 367 | } 368 | 369 | export default TestKeydown 370 | ``` 371 | 372 | 实际运行发现,确实根本不会触发键盘事件。 373 | 374 | 后经过查阅资料,才知道对于 React 来说,更加倾向于使用 React 合成事件,例如 而不是 通过 addEventListener('keydown',xxx)。 375 | 376 | 上面的代码若想触发键盘事件,需要修改成: 377 | 378 | ```diff 379 | import { useRef, useEffect } from 'react' 380 | 381 | const TestKeydown = () => { 382 | const canvasRef = useRef(null) 383 | 384 | const handleKeydown = (eve: KeyboardEvent) => { 385 | console.log(eve) 386 | } 387 | 388 | useEffect(() => { 389 | if (canvasRef.current === null) { return } 390 | + canvasRef.current.focus() 391 | canvasRef.current.addEventListener('keydown', handleKeydown, false) 392 | }, [canvasRef]) 393 | 394 | return ( 395 | + 396 | ) 397 | } 398 | 399 | export default TestKeydown 400 | ``` 401 | 402 | 1. 添加 canvas 自动获取焦点 403 | 2. 给 canvas 添加 tabIndex 值,该值是 -1、0、1 都可以,但是必须添加 404 | 405 | 406 | 407 | 但是对于 OrbitControls 来说,若想在 React 中也使用键盘事件,则只能依此修改。 408 | 409 | 不过当 canvas 失去焦点后,则键盘事件就会失效。 410 | 411 | **Three.js 官方示例使用的是原生的 html + js,是完全支持键盘事件的。** 412 | 413 | **React 则对原生键盘事件支持度不高。** 414 | 415 | > 不过我们完全不必介意这件事情,我们继续使用鼠标来控制场景变换角度好了。 416 | 417 | 418 | 419 | 下一节,我们学习如何调试Three.js。 420 | 421 | -------------------------------------------------------------------------------- /08 Three.js基础之场景.md: -------------------------------------------------------------------------------- 1 | # 08 Three.js基础之场景 2 | 3 | 再次回顾一下 Three.js 3 大 核心要素:场景、镜头、渲染器 4 | 5 | **本文主要将 Three.js 中的 场景,但是请注意,本文讲的场景实际上是指 场景图(scene graph),而不是单指 我们之前示例代码中用到过的 场景 Three.Scene。** 6 | 7 | 但是请注意,本文讲的场景实际上是指 场景图(scene graph),而不是单指 我们之前示例代码中用到过的 场景 Three.Scene。 8 | 9 | 10 | 11 | ## 场景图(scene graph)的概念解释 12 | 13 | ### 场景与场景图的关系: 14 | 15 | **SceneGraph 准确的翻译应该是叫:场景图,但是本文中,我有时依然倔强得把他叫做 "场景"。** 16 | 17 | **但无论我怎么称呼它,请你记得:场景(Three.Scene) 只是 场景图 中的一种。** 18 | 19 | 20 | 21 | ### 场景图的数据结构: 22 | 23 | 抛开 Three.js 不谈,我们先看一下在数据结构中,树与图 的概念区分。 24 | 25 | **树:一种 分层 数据的抽象模型** 26 | 27 | > 呈现出的是像大树枝一样的结构,根据结构特征还可以划分为 二叉树、红黑树、大顶树、小顶树等等 28 | 29 | **图:网络结构的抽象模型,是一组由边连接的节点** 30 | 31 | > 呈现出的是像蜘蛛网、道路网、航班线路一样的结构 32 | 33 | 34 | 35 | 回到 Three.js 中。 36 | 37 | **请务必记得:** 38 | 39 | 1. 场景图 中的 图,并非数据结构中的图 40 | 2. **场景图的数据结构并非 图,而是 树** 41 | 42 | 43 | 44 | **补充一下:** 45 | 46 | 在有一些教程示例代码中,当循环遍历 场景 中物体对象时,你或许会看到这样的代码: 47 | 48 | ``` 49 | 他使用的是:xxx.forEach((node) => { node ....}) 50 | 51 | 而不是:xxx.forEach((itme) => { item ...}) 52 | ``` 53 | 54 | 尽管无论数组元素变量名是叫 node 还是 item,实际上效果是相同的,但是**他为什么会用 node 这个单词呢?** 55 | 56 | 答:因为**场景图的数据结构是树,而场景上的物体对象实际就是树结构中的一个节点**,节点对应的单词就是 node。 57 | 58 | 59 | 60 | 61 | 62 | ### 场景图(空间)的含义: 63 | 64 | **在 Three.js 中,场景即空间,而 空间 包含以下几种情况**: 65 | 66 | 1. 由 Scene 创建的普通场景、普通场景中还可以添加雾(Fog、FogExp2)从而变成具有雾化效果的场景 67 | 68 | > 无论哪种场景下,都可以添加 Object3D、Mesh 69 | 70 | > Scene 场景下,距离镜头越远的物体看上去越小,但清晰度不变 71 | > 包含 雾(Fog、FogExp2) 场景下,距离镜头越远的物体不光看上去越小,同时被雾气环绕 72 | 73 | > 对于现阶段的我们来说,目前主要以使用 Scene 场景为主,Fog、FogExp2 会在以后学习和使用 74 | 75 | 2. 由 Object3D 创建的 空白空间 76 | 77 | > 可以添加 Mesh 78 | 79 | 3. 由 Mesh 创建的 具体的物体所在的网格空间 80 | 81 | > 可以添加其他的 Mesh 82 | 83 | > 理论上,Object3D 和 Mesh 是可以互相添加,互相嵌套的,最终会构成一个复杂的空间体系 84 | 85 | 86 | 87 | > 请注意,为了避免 “场景图” 这 3 个字过于绕口,以及为了方便理解,在下面文字中,我会将 场景图 称呼为 场景或空间 88 | 89 | ### 场景的几个概念 90 | 91 | ### 概念1:一个局部的相对空间,即为一个场景 92 | 93 | 例如太阳系就是一个空间(场景) 94 | 95 | 96 | 97 | ### 概念2:一个空间(场景) 又可能是由 几个子空间(场景) 组合而成 98 | 99 | 太阳系由 8 大行星构成 100 | 101 | 行星除了本身之外还包卫星,例如地球和月球 102 | 103 | 地球上又包含陆地和海洋 104 | 105 | 陆地上又包含中国,中国包含你此刻所处的空间 106 | 107 | 108 | 109 | ### 概念3:表面上添加某场景,但实际上执行的是合并场景 110 | 111 | 例如 sceneA.add(sceneB),表面上看 sceneA 添加了 sceneB,sceneB 称为了 sceneA 的子场景,但事实上根本并不是这样! 112 | 113 | **什么?这岂不是和 概念 2 完全相悖?** 114 | 115 | **没错!确实是即合并又互相独立。** 116 | 117 | **所谓独立:sceneB 中的元素(物体、灯光)的坐标位置继续保持独立** 118 | 119 | **所谓合并:sceneB中的元素(物体、灯光)被复制添加到其他场景中,例如 场景B 中的灯光会影响 场景C** 120 | 121 | 122 | 123 | **举一个很容易犯错的例子:** 124 | 125 | 假设有 环境灯光 lightB、lightC,和 场景 sceneA、sceneB、sceneC 126 | 127 | ``` 128 | sceneB.add(lightB) //场景B 中添加 灯光B 129 | sceneC.add(lightC) //场景C 中添加 灯光C 130 | 131 | sceneA.add(sceneB) //场景A 中添加 场景B 132 | sceneA.add(sceneC) //场景A 中添加 场景C 133 | 134 | renderer.render(sceneA,camera) //使用场景渲染器,将 场景A 渲染出来 135 | ``` 136 | 137 | **你可能以为 灯光B 只在 场景B 中起作用、灯光C 只在 场景C 中起作用。** 138 | 139 | **但事实根本不是这样,上面代码渲染过后,你会发现:场景B 和 场景C 中,分别都受到 环境灯光B 和 环境灯光C。** 140 | 141 | **因为环境灯光是全局的、环境灯光在场景中无处不在、会影响场景中全部的物体。** 142 | 143 | **假设不是环境灯光,而是普通的平行灯光,事实上依然会影响(照耀)到其他 “子场景”上的物体,只不过可能因为距离设定原因,不会像全局环境光那样影响明显。** 144 | 145 | 146 | 147 | **为什么会这样?** 148 | 149 | 我们查看一下 scene.add() 函数源码: 150 | 151 | > 注意:Scene 继承于 Object3D,所以 scene.add() 方法实际上是由 Object3D 定义的。 152 | 153 | ``` 154 | add: function (object) { 155 | 156 | if (arguments.length > 1) { 157 | for (let i = 0; i < arguments.length; i++) { 158 | this.add(arguments[i]); 159 | } 160 | return this; 161 | } 162 | 163 | if (object === this) { 164 | console.error("THREE.Object3D.add: object can't be added as a child of itself.", object); 165 | return this; 166 | } 167 | 168 | if ((object && object.isObject3D)) { 169 | if (object.parent !== null) { 170 | object.parent.remove(object); 171 | } 172 | 173 | object.parent = this; 174 | this.children.push(object); 175 | 176 | object.dispatchEvent(_addedEvent); 177 | 178 | } else { 179 | console.error("THREE.Object3D.add: object not an instance of THREE.Object3D.", object); 180 | } 181 | return this; 182 | } 183 | ``` 184 | 185 | **源码分析:** 186 | 187 | 1. if (object.parent !== null) { object.parent.remove(object); } //如果元素(物体、灯光)拥有父级,则将该元素从父级中删除 188 | 2. object.parent = this; //将元素(物体、灯光)的父级指向 this(自己) 189 | 3. this.children.push(object); //将元素(物体、灯光)添加到自己场景中的 children 中 190 | 191 | 经过以上 3 步操作,**add() 函数实现了 将 子场景元素拆散、合并到自己(最外层场景、顶场景)中**。 192 | 193 | 194 | 195 | **假设我就希望有若干个“子场景”,子场景中的灯光(哪怕是环境光)是独立,不会影响其他 子场景的,怎么实现?** 196 | 197 | 答:只能声明多个 渲染器(WebGLRenderer),每个渲染器渲染一个场景(Scene)、每个场景内添加一种光源。 198 | 199 | > 提前预告:在后续讲解 灯光 那一章节中,就会运用到这个知识点。 200 | 201 | 202 | 203 | ### 概念4:一个子空间(场景)只需要关注和他最紧密相关的空间即可 204 | 205 | 假设你此刻在家里,那么你的相对空间就只针对家里即可,尽管你此刻所处的地球正在自转,你无需关心这个事情。 206 | 207 | 月球也可能只关心它是否围着地球转,而不需要关心他在太阳系中的运动轨迹 208 | 209 | 210 | 211 | #### 概念4引申出来的另外一个概念:通过空间嵌套来改变原有的相对状态 212 | 213 | * **一个 空间A 嵌套进入另外一个 空间B,此时 空间A 将会拥有 空间B 的一些属性,例如 空间A 会随着 空间B 一起缩放** 214 | * **两个子空间 A和B 都嵌套进另外一个空间 C,此时 空间A、空间B 相对独立且共存** 215 | 216 | 217 | 218 | #### 举例说明1:修改文字对象的旋转中心点 219 | 220 | 默认情况下,Three.js 中创建的 TextBufferGeometry 对象旋转点位于左侧。 221 | 222 | 为了让 文字对象 看上去以 中心位置 为中心点旋转,那么可以这样操作: 223 | 224 | 1. 通过 new Object3D() 创建 空间A 225 | 226 | 2. 通过 new Mesh( new TextBufferGeometry({ ... } ), createMaterial() ) 创建文字对象 227 | 228 | 3. 修改文字的中心点 229 | 230 | ``` 231 | geometry.computeBoundingBox() 232 | geometry.boundingBox?.getCenter(mesh.position).multiplyScalar(-1) 233 | ``` 234 | 235 | 4. 将 文字对象(网格) 添加到 空间A 中,同时将 空间A 添加到场景中 236 | 237 | 经过这样操作过后,即可将 文字对象 文字对象的中心点改为中间。 238 | 239 | 240 | 241 | #### 举例说明2:创建月球与地球的相对空间 242 | 243 | 太阳和地球构成一个相对空间、地球与月亮也构成一个相对空间。 244 | 245 | 假设我们现在的目标是创建 月球与地球的相对空间,那么可以这样操作: 246 | 247 | 1. 创建地球对象 A、月球对象 B 248 | 249 | > “地球对象”,更加精准的描述应该是:地球对应的网格,也就是 “地球本身的空间” 250 | > 251 | > 为了不让月球和地球重叠在一起,通常情况下会给 月球对象 B 设置 .position.x = xx,好让地球和月球之间存在一定的距离 252 | 253 | 2. 通过 new Object3D() 创建空间 C 254 | 255 | 3. 将 A、B 都添加到 C 中 256 | 257 | 4. 将 C 添加到主场景中 258 | 259 | 经过这样操作后,主场景中包含 C,而 C 包含 A、B,至此形成了一个 地球和月球 共同存在的空间。 260 | 261 | 262 | 263 | ### 场景(空间)的最常见操作 264 | 265 | 1. 将 空间A 加入到 空间B:B.add(A) 266 | 2. 设置空间 A 在空间B 中的位置:A.position.x = xxx 267 | 268 | 269 | 270 | ## 场景的示例:太阳、地球、月亮 271 | 272 | #### 我们模拟出以下场景: 273 | 274 | 1. 月球自转的同时,围绕地球旋转 275 | 2. 地球自转的同时,围绕太阳旋转 276 | 3. 太阳仅自转,位置不变 277 | 278 | 279 | 280 | > 本文的重点在于讲解 场景 的概念,若对代码中某些 方法或属性的使用 不太能够理解也没有关系,将来会慢慢学习到。 281 | 282 | #### 代码文件说明: 283 | 284 | 1. 我们将在 src/components/hello-scene/ 目录下创建 index.stx 作为本次演示主文件。 285 | 286 | 2. 与以往代码不同,这次我们将创建 太阳、地球、月亮、以及 光源 的过程迁移到另外一个单独的文件中 ,好让我们在 useEffect 中的代码更加清爽一些。 287 | 288 | 对应的文件为 src/components/hello-scene/create-something.ts 289 | 290 | 291 | 292 | #### 代码核心说明: 293 | 294 | 1. 我们将创建一个球体,让太阳、地球、月亮都由这个球体创建而来,只不过每个球体网格在材质(颜色)、大小方面不同。 295 | 296 | 2. 我们将创建 3 个相对空间: 297 | 298 | 1. 月球相对地球的轨道空间 299 | 300 | > 这个空间中只有月球,因为设置了偏差(poisition.x = 2),所以月球会做圆形轨道运动 301 | 302 | 2. 地球(含月球)相对太阳的轨道空间 303 | 304 | > 这个空间中有地球(含月球),同样因为设置了偏差(position.x = 10),所以会整体做圆形轨道运动 305 | 306 | 3. 太阳与地球轨道构成的相对空间 307 | 308 | > 这个空间包含太阳、地球(含月球) 309 | 310 | 311 | 312 | #### 补充说明: 313 | 314 | 1. 为了让我们更加容易看到 球体 的自转,所以无论是太阳还是地球或月亮,外形都设置成一个 六边形的球体。 315 | 316 | 2. 我们只是为了演示 相对空间 的使用,所以 太阳、月亮、地球 的尺寸、自转频率、位置关系等是随意设置的值,并不是真实中的大小比例。 317 | 318 | > 科普一下:实际中,太阳直径是地球直径的 109 倍、地球直径是月球直径的 4 倍 319 | 320 | 321 | 322 | #### 具体的代码: 323 | 324 | #### create-something.js 325 | 326 | ``` 327 | import { Mesh, MeshPhongMaterial, Object3D, PointLight, SphereBufferGeometry } from "three" 328 | 329 | //创建一个球体 330 | const sphere = new SphereBufferGeometry(1, 6, 6) //球体为6边形,目的是为了方便我们观察到他在自转 331 | 332 | //创建太阳 333 | const sunMaterial = new MeshPhongMaterial({ emissive: 0xFFFF00 }) 334 | const sunMesh = new Mesh(sphere, sunMaterial) 335 | sunMesh.scale.set(4, 4, 4) //将球体尺寸放大 4 倍 336 | 337 | //创建地球 338 | const earthMaterial = new MeshPhongMaterial({ color: 0x2233FF, emissive: 0x112244 }) 339 | const earthMesh = new Mesh(sphere, earthMaterial) 340 | 341 | //创建月球 342 | const moonMaterial = new MeshPhongMaterial({ color: 0x888888, emissive: 0x222222 }) 343 | const moonMesh = new Mesh(sphere, moonMaterial) 344 | moonMesh.scale.set(0.5, 0.5, 0.5) //将球体尺寸缩小 0.5 倍 345 | 346 | 347 | //创建一个 3D 空间,用来容纳月球,相当于月球轨迹空间 348 | export const moonOribit = new Object3D() 349 | moonOribit.position.x = 2 350 | moonOribit.add(moonMesh) 351 | 352 | //创建一个 3D 空间,用来容纳地球,相当于地球轨迹空间 353 | export const earthOrbit = new Object3D() 354 | earthOrbit.position.x = 10 355 | earthOrbit.add(earthMesh) 356 | earthOrbit.add(moonOribit) 357 | 358 | //创建一个 3D 空间,用来容纳太阳和地球(含月球) 359 | export const solarSystem = new Object3D() 360 | solarSystem.add(sunMesh) 361 | solarSystem.add(earthOrbit) 362 | 363 | //创建点光源 364 | export const pointLight = new PointLight(0xFFFFFF, 3) 365 | 366 | export default {} 367 | ``` 368 | 369 | 370 | 371 | #### index.tsx 372 | 373 | ``` 374 | import { useRef, useEffect } from 'react' 375 | import * as Three from 'three' 376 | import { solarSystem, earthOrbit, moonOribit, pointLight } from '@/components/hello-scene/create-something' 377 | 378 | import './index.scss' 379 | 380 | const nodeArr = [solarSystem, earthOrbit, moonOribit] //太阳、地球、月亮对应的网格 381 | 382 | const HelloScene = () => { 383 | 384 | const canvasRef = useRef(null) 385 | const rendererRef = useRef(null) 386 | const cameraRef = useRef(null) 387 | const sceneRef = useRef(null) 388 | 389 | useEffect(() => { 390 | 391 | //创建渲染器 392 | const renderer = new Three.WebGLRenderer({ canvas: canvasRef.current as HTMLCanvasElement }) 393 | rendererRef.current = renderer 394 | 395 | //创建镜头 396 | const camera = new Three.PerspectiveCamera(40, 2, 0.1, 1000) 397 | camera.position.set(0, 50, 0) 398 | camera.up.set(0, 0, 1) 399 | camera.lookAt(0, 0, 0) 400 | cameraRef.current = camera 401 | 402 | //创建场景 403 | const scene = new Three.Scene() 404 | scene.background = new Three.Color(0x111111) 405 | sceneRef.current = scene 406 | 407 | //将太阳系、灯光添加到场景中 408 | scene.add(solarSystem) 409 | scene.add(pointLight) 410 | 411 | //创建循环渲染的动画 412 | const render = (time: number) => { 413 | time = time * 0.001 414 | nodeArr.forEach((item) => { 415 | item.rotation.y = time 416 | }) 417 | renderer.render(scene, camera) 418 | window.requestAnimationFrame(render) 419 | } 420 | window.requestAnimationFrame(render) 421 | 422 | //添加窗口尺寸变化的监听 423 | const resizeHandle = () => { 424 | const canvas = renderer.domElement 425 | camera.aspect = canvas.clientWidth / canvas.clientHeight 426 | camera.updateProjectionMatrix() 427 | renderer.setSize(canvas.clientWidth, canvas.clientHeight, false) 428 | } 429 | resizeHandle() 430 | window.addEventListener('resize', resizeHandle) 431 | 432 | return () => { 433 | window.removeEventListener('resize', resizeHandle) 434 | } 435 | }, [canvasRef]) 436 | 437 | return ( 438 | 439 | ) 440 | } 441 | 442 | export default HelloScene 443 | ``` 444 | 445 | 446 | 447 | #### 上述代码共同构建出的空间体系: 448 | 449 | 1. 主场景 Scene 包含 太阳系 450 | 2. 太阳系:太阳系本身 + 太阳 + 地球系(含月球系) 451 | 3. 地球系:地球系本身 + 地球 + 月球系 452 | 4. 月球系:月球系本身 + 月球 453 | 454 | **每一个空间体系都是相互独立运作,但在他们共同作用下,构成了一个复杂的空间体系。** 455 | 456 | 457 | 458 | > 思考题:如何实现一辆简单的,有 4 个滚动轮子的汽车? 459 | 460 | 461 | 462 | ## 补充一个类:AxesHelper 463 | 464 | 在传统的 3D 制作软件中,都会直观的显示出 X、Y、Z 网格线,帮助我们比较直观的查看 物体所在网格的位置。 465 | 466 | 在 Three.js 中,可以通过给空间网格添加 AxesHeler 实例来让渲染的时候,显示出 XYZ 网格。 467 | 468 | 469 | 470 | **具体用法:请将以下代码,添加到本文的示例代码中** 471 | 472 | ``` 473 | useEffect(() => { 474 | 475 | ... 476 | 477 | //显示轴线 478 | nodeArr.forEach((item) => { 479 | const axes = new Three.AxesHelper() 480 | const material = axes.material as Three.Material 481 | material.depthTest = false 482 | axes.renderOrder = 1 // renderOrder 的该值默认为 0,这里设置为 1 ,目的是为了提高优先级,避免被物体本身给遮盖住 483 | item.add(axes) 484 | }) 485 | 486 | ... 487 | 488 | }, [canvasRef]) 489 | ``` 490 | 491 | 492 | 493 | 关于 Three.js 中 场景、空间 的概念和基本用法,先讲解到这里。在后续稍微复杂点的项目中,都会有大量 空间 相互嵌套 的使用需求。 494 | 495 | **空间的相互嵌套才构建出了复杂的 3D 场景。** 496 | 497 | 学习到本篇,是否有些心累的?感觉贴出来的示例代码越来越长,越来越复杂了? 打起精神,继续加油吧。 498 | 499 | 500 | 501 | 下一节,开始讲一下 决定物体外观被渲染成什么样子的 “材质” 。 -------------------------------------------------------------------------------- /09 Three.js基础之材质.md: -------------------------------------------------------------------------------- 1 | # 09 Three.js基础之材质 2 | 3 | 材质(material) 即 线段属性或物体表面的一些颜色、贴图、光亮程度、反光特性、粗糙度等属性。 4 | 5 | 按照用途,所以材质大体上可以划分为: 6 | 7 | 1. 点材质(应用来点、粒子上) 8 | 2. 线性材质(应用在线段或虚线上) 9 | 3. 基础材质(应用在面上的各种材质) 10 | 4. 特殊用途的材质(例如阴影) 11 | 5. 自定义材质 12 | 13 | > 上面的分类划分,仅仅是我个人观点,事实上并没有明确的种类划分规定。 14 | 15 | 16 | 17 | #### 材质基础 18 | 19 | | 材质名称 | 解释说明 | 20 | | -------- | -------------- | 21 | | Material | 所有材质的父类 | 22 | 23 | 24 | 25 | #### 点材质 26 | 27 | | 材质名称 | 解释说明 | 28 | | -------------- | ---------------- | 29 | | PointsMaterial | 点材质(粒子材质) | 30 | 31 | 32 | 33 | #### 线性材质 34 | 35 | | 材质名称 | 解释说明 | 36 | | ------------------ | ---------------------------------------- | 37 | | LineBasicMaterial | 线段材质(颜色、宽度、断点、连接点等属性) | 38 | | LineDashedMaterial | 虚线材质 | 39 | 40 | 41 | 42 | #### 基础材质(针对”面“) 43 | 44 | | 材质名称 | 解释说明 | 45 | | -------------------- | -------------------------------------------------- | 46 | | MeshBasicMaterial | 最基础的材质,不反射光,仅显示材质本身颜色 | 47 | | MeshLambertMaterial | 仅顶点处反射光 | 48 | | MeshMatcapMaterial | 自带光效(明暗)的材质 | 49 | | MeshPhongMaterial | 任何点都反射光,拥有光泽度 | 50 | | MeshToonMaterial | 卡通着色 | 51 | | MeshStandardMaterial | 除光泽度外,还有粗糙度和金属度 | 52 | | MeshPhysicalMaterial | 除光泽度、粗糙度、金属度外,还有清漆度和清漆粗糙度 | 53 | 54 | 55 | 56 | #### 特殊用途材质 57 | 58 | | 材质名称 | 解释说明 | 59 | | -------------------- | -------------------- | 60 | | ShadowMaterial | 阴影材质 | 61 | | MeshDistanceMaterial | 另外一种阴影投射材质 | 62 | | MeshDeptMaterial | 远近距离深度着色材质 | 63 | | MeshNormalMaterial | 网格法向量材质 | 64 | | SpriteMaterial | 精灵材质/雪碧材质 | 65 | 66 | 67 | 68 | #### 自定义材质 69 | 70 | | 材质名称 | 解释说明 | 71 | | ----------------- | -------------- | 72 | | ShaderMaterial | 着色器材质 | 73 | | RawShaderMaterial | 原始着色器材质 | 74 | 75 | 76 | 77 | 注意:本文只是大体上讲解一些 Three.js 中的各个材质特性,具体每个材质的详细参数和用法,需要自己查阅 Three.js 官方文档:https://threejs.org/docs/index.html#api/zh/materials/Material 78 | 79 | 80 | 81 | ## 点材质:PointsMaterial 82 | 83 | #### PointsMaterial:点材质/粒子材质 84 | 85 | 用来创建 粒子 材质。 86 | 87 | 88 | 89 | ## 线性材质:LineBasicMaterial、LineDashedMaterial 90 | 91 | #### LineBasicMaterial:基础的线段材质 92 | 93 | 用来创建 线段 的材质,属性包括:颜色、宽度、断点、连接点等。 94 | 95 | 96 | 97 | #### LineDashedMaterial:虚线材质 98 | 99 | LineDashedMaterial 继承于 LineBasicMaterial,用来绘制虚线。 100 | 101 | 102 | 103 | ## 基础材质讲解说明 104 | 105 | Three.js 中基础材质类型,我们先从 MeshPhongMaterial 说起。 106 | 107 | #### 为什么要先讲 MeshPhongMaterial? 108 | 109 | 因为 MeshPhongMaterial 是使用最频繁,且处于特殊位置的材质。 110 | 111 | MeshPhongMaterial 可以作为其他材质的参考对象: 112 | 113 | 1. 比 MeshPhongMaterial 简单的有 MeshBasicMaterial、MeshLambertMaterial 114 | 2. 和 MeshPhongMaterial 相似的有 MeshToonMaterial 115 | 3. 比 MeshPhongMaterial 复杂的有 MeshStandardMaterial、MeshPhysicalMaterial 116 | 117 | 118 | 119 | ## MeshPhongMaterial 120 | 121 | ### Phong光照模型: 122 | 123 | Phong光照模型是最简单、最基础的光照模型,该模型只考虑物体对直线光的反射作用,不考虑物体之间的漫反射光(环境光)。 124 | 125 | 126 | 127 | ### Phong的假设前提: 128 | 129 | 1. 物体通常被设置为不透明 130 | 2. 物体表面反射率相同 131 | 132 | 133 | 134 | ### Phone的简单用法: 135 | 136 | **新建一个 MeshPhongMaterial** 137 | 138 | ``` 139 | const material = new Three.MeshPhongMaterial({ 140 | color:0xFF0000, 141 | flatShading:true 142 | }) 143 | ``` 144 | 145 | 或者 146 | 147 | ``` 148 | const material = new Three.MeshPhoneMaterial() 149 | material.color.set(0xFF0000) 150 | material.flatShading = true 151 | ``` 152 | 153 | 154 | 155 | **设置颜色的N种方式:** 156 | 157 | ``` 158 | new Three.MeshPhoneMaterial({color:0xFF0000}) 159 | new Three.MeshPhoneMaterial({color:'red'}) 160 | new Three.MeshPhoneMaterial({color:'#F00'}) 161 | new Three.MeshPhoneMaterial({color:'rgb(255,0,0)'}) 162 | new Three.MeshPhoneMaterial({color:'hsl(0,100%,50%)'}) 163 | ``` 164 | 165 | 修改颜色: 166 | 167 | ``` 168 | material.color.set(0xFF0000) 169 | material.color.set('red') 170 | material.color.set('#F00') 171 | material.color.set('rgb(255,0,0)') 172 | material.color.set('hsl(0,100%,50%)') 173 | 174 | material.color.setHSL(0,1,0.5) 175 | material.color.setRGB(1,0,0) 176 | ``` 177 | 178 | 179 | 180 | ### Phong的属性:flatShading(平面着色) 181 | 182 | **特别强调:flatShading 属性并不是由 MeshPhongMaterial 定义的,而是有父类 Material 定义的。** 183 | 184 | 只不过由于 flatShading 比较重要,因此这里特别讲解一下该属性的作用。 185 | 186 | flatShading 的值为 布尔值,该值指 是否启用 平面着色 模式。 187 | 188 | > 默认值为 false,即不启用 平面着色 模式。 189 | 190 | 191 | 192 | #### 补充说明 3 大着色模式: 193 | 194 | **什么叫 着色?** 195 | 196 | 在三维图形学中,“着色” 的含义为:根据光照条件重建 物体各表面明暗效果的过程,就叫着色。 197 | 198 | 这个 "着色" 过程中,就牵扯到不同的着色算法,也就是不同的着色模式。 199 | 200 | 201 | 202 | #### 最常见的 3 种着色算法(模式): 203 | 204 | **Flat Shading:平面着色** 205 | 206 | 根据每个三角形的法线计算着色效果,每个面只计算一次,也就是说相同的面采用同一个计算结果。 207 | 208 | 这种模式对于 立方体 来说会减小计算量,因为立方体每个面都是平整且唯一的。 209 | 210 | 211 | 212 | **Gouraud Shading:逐顶着色** 213 | 214 | 针对每个顶点计算,而后对每个顶点的结果颜色进行线性插值得到片源的颜色。 215 | 216 | 217 | 218 | **Phong Shading:补色渲染** 219 | 220 | 对每个三角形的每个片元进行着色计算。所以 Phong Shading 又被称为 **逐片元着色** 221 | 222 | 由于颜色是按片元着色的,得到的结果比 逐顶着色(Gouraud Shading) 要更加细腻,尤其是用于光亮表面效果更加真实。 223 | 224 | Phong 并不是传统的英文单词,而是 生活在美国的越南籍科学家 Bui Tuong Phong (裴祥风) 的名字。 225 | 226 | 所以 Phong Shading 又被称为 冯氏着色。 227 | 228 | 229 |
230 | 231 | > 以下内容更新于 2022.02.02 232 | 233 | 在图形学中有一个被应用非常广泛的简单光照模型:冯氏光照模型 234 | 235 | > 注:这里的 简单 是指计算量非常小,但却可以模拟出简单的高光和漫反射。 236 | 237 | 238 |
239 | 240 | **冯氏光照模型、冯氏着色法 简介:** 241 | 242 | **裴祥风** (1942-1975),出生于越南,1973 年在美国 尤他大学 取得博士学位,并发明了冯氏光照模型和冯氏着色法,被广大 CG 界采用。 243 | 244 | 245 | 冯氏光照模型主要有 3 个分量组成: 246 | 247 | 1. 环境光照(Ambient Lighting):物体几乎永远不会是完全黑暗的,环境光照一般是一个常量。 248 | 249 | 2. 漫反射光照(Diffuse Lighting):模拟光源对物体的方向性影响,越是正对着光源的地方越亮。 250 | 251 | > 通过计算物体平面某个点的法向量与该点与光源的单位向量进行乘积,得到该点的亮度。 252 | > 253 | > 当这两个向量相互重叠时该点最亮(亮度值为1),当这两个向量成九十度则最暗(亮度值为0) 254 | > 255 | > 向量、法线、乘积(也称内积) 这些都是 线性代数 中的词语,属于图形学中需要掌握的基础知识。如果学会了基础的 Three.js 后一定要去学习图形学,否则以后也做不出什么好的应用。 256 | 257 | 3. 镜面光照(Specular Lighting):模拟有光泽物体上面出现的亮点。 258 | 259 | 最终物体呈现的样子就是以上 3 种光照结果直接叠加后的样子。 260 | 261 | 262 | 263 |
264 | 265 | 冯氏着色法(Phong Shading):每个片元(fragment)或者每个点计算一次光照,点的法向量是通过顶点的法向量插值得到的。冯氏着色更加接近真实,当然计算开销也大。 266 | 267 | 与冯氏着色法相对的有:平面着色法(Flat Shading)、高洛德着色法(Gouraud Shading) 268 | 269 | 270 | 271 | > 以上内容更新于 2022.02.02 272 | 273 | 274 |
275 | 276 | 277 | > Phong 光照反射模型 也被称为 冯氏反射模型 278 | 279 | **特别补充:** 280 | 281 | 1. MeshPhongMaterial 中的 Phong 是指 Phong 光照反射模型 282 | 2. Phong Shading 中的 Phong 只指 补色渲染 283 | 3. Phong光照反射模型、Phong补色渲染 这 2 个理论都是由 科学家 Bui Tuong Phong 提出的,所以也都以他的名字命名。 284 | 285 | **目前来说,补色渲染/逐片元着色,也就是 Phong Sharding 是最好、最复杂的着色方式。** 286 | 287 | 288 | 289 | ### Phong的属性:emissive(发光颜色) 290 | 291 | color 指材质的基本颜色,而 emissive 指材质的发光色。 292 | 293 | 注意:若将材质的 color 设置为黑色、emissive 设置为 某色 (例如 紫色),那么此时材质呈现出的是 emissive 颜色。 294 | 295 | 296 | 297 | ### Phong的属性:shininess(光泽度) 298 | 299 | shininess 的值为数字。 300 | 301 | 1. 最小值可设置为 0,即无光泽度,此时呈现出的效果和 Lambert 相同 302 | 2. 默认值为 30 303 | 3. 该值越大,光泽度越高,呈现的效果越接近 高清玻璃或钢琴烤漆的那种光泽感 304 | 305 | 注意:若将材质的 color 设置为黑色、emissive 设置为 某色(例如 紫色)、光泽度 设置为 0,那么此时材质呈现出的是 emissive 颜色,且无光泽度。 306 | 307 | 308 | 309 | ## MeshBasicMaterial、MeshLambertMaterial 310 | 311 | 当我们对 MeshPhongMaterial 一些特性有所了解后,通过对 光感反射 特性的对比,可以学习了解到 MeshBasicMaterial 和 MeshLambertMaterial。 312 | 313 | 314 | 315 | #### 几种材质的光感对比: 316 | 317 | * **MeshBasicMaterial:不反射任何光,仅显示材质本身颜色** 318 | 319 | * **MeshLambertMaterial:仅顶点处反射光** 320 | 321 | * **MeshPhongMaterial:任何地方都可反射光** 322 | 323 | > Lambert 虽然顶点也可以反光,但是相对 Phong 而言,Lambert 整体反光度极其微小、不明显 324 | 325 | 326 | 327 | #### 性能提示: 328 | 329 | 关于颜色,以下 3 种情况所呈现出的最终效果完全相同: 330 | 331 | 1. 基于 MeshBasicMaterial,color 设置为紫色 332 | 2. 基于 MeshLambertMaterial,color 设置为黑色,emissive 设置为 紫色 333 | 3. 基于 MeshPhongMaterial,color 设置为黑色,emissive 设置为 紫色,shininess 设置为 0 334 | 335 | 以上 3 种设置下,最终所呈现出的颜色效果完全相同:都是紫色且无光泽。 336 | 337 | 但是,从渲染性能上来讲,从上往下 所需要的性能越来越高,因此假设材质不需要 Phone 反光模式 或 镜面高光(光泽)的情况下,应优先选择 较低性能 的材质。 338 | 339 | 340 | 341 | ## MeshToonMaterial 342 | 343 | MeshToonMaterial 和 MeshPhongMaterial 类似但又不同。 344 | 345 | MeshToonMaterial 并不会像 MeshPhongMaterial 那样使用平滑着色,而是使用渐变贴图( X 乘 1 的纹理) 来决定如何着色。 346 | 347 | > 注意:"渐变贴图( X 乘 1 的纹理) " 这句话我是从参考教程中看到的,我并没理解这句话的含义。 348 | 349 | 并不是说必须要设置纹理图片,若不设置则会采用默认的渐变策略: 350 | 351 | 1. 前 70% 区域 亮度为 70% 352 | 2. 后 30% 区域亮度为 100% 353 | 354 | 最终呈现出的效果,看起来特别像卡通动画的风格。所以 **MeshToonMaterial 又被称为 卡通网格材质**。 355 | 356 | > 卡通动画通常大面积为纯色,只在底部增加深色的颜色,以此来表现出立体效果。 357 | 358 | 359 | 360 | **补充一点:** 361 | 362 | 网上很多教程在讲解 MeshToonMaterial 时会提到: 363 | 364 | `“MeshToonMaterial 是 MeshPhongMaterial 的扩展”` 365 | 366 | 但是我自己通过 MeshToonMaterial.d.ts 源码查询,并未发现 MeshToonMaterial 是继承于 MeshPhongMaterial 的,所以我认为这句话并不正确。 367 | 368 | 369 | 370 | ## MeshMatcapMaterial 371 | 372 | 一种自带光效(明暗)的材质。 373 | 374 | 375 | 376 | ## MeshStandardMaterial、MeshPhysicalMaterial 377 | 378 | Phong 材质有一个属性 shininess(光泽度),而 **MeshStandardMaterial** 有 2 个相对应的属性: 379 | 380 | 1. roughness:粗糙度,取值范围为 0 - 1,即 0 为粗糙度最低,此时表现出的光泽度最高 381 | 2. metalness:金属度,取值范围为 0 - 1,即 0 为非金属、金属度最高为 1 382 | 383 | 在 粗糙度和金属度 共同的作用下,呈现出 更加细腻、可控 的光泽度。 384 | 385 | 386 | 387 | **MeshPhysicalMaterial** 继承于 MeshStandardMaterial ,新增加 2 个属性: 388 | 389 | 1. clearcoat:添加(应用)透明涂层的程度,取值范围为 0 - 1 390 | 2. clearCoatRoughness:透明涂层的粗糙度,取值范围为 0 - 1 391 | 392 | 额外添加的透明涂层,在装修上有一个专业的名词:**清漆**(又名 凡立水) 393 | 394 | **清漆的含义为:**用透明涂料涂抹在物体表面,形成光滑薄膜,由于是透明的所以原有物体表面的纹理依然清晰可见不受影响。 395 | 396 | > 清漆 会让物体呈现出更加光泽的效果。 397 | 398 | **因此:** 399 | 400 | 1. clearcoat 可以翻译成:添加 清漆 的程度 401 | 2. clearCoatRoughness 翻译成:清漆粗糙度 402 | 403 | 404 | 405 | ## 基础材质小总结 406 | 407 | 从各种材质渲染所需性能,也就是渲染所需时间的快慢排序,依次是: 408 | 409 | **MeshBasicMaterial > MeshLambertMaterial > MeshPhongMaterial > MeshStandardMaterial > MeshPhysicalMaterial** 410 | 411 | 上面排序中,越靠后的材质所呈现出的 细节 越多、真实感越强。 412 | 413 | 414 | 415 | ## 特殊材质:ShadowMaterial、MeshDistanceMaterial、MeshDepthMaterial、MeshNormalMaterial 416 | 417 | #### ShadowMaterial:阴影类型的材质 418 | 419 | 我们目前还从未在示例中使用过 ShadowMaterial 材质,ShadowMaterial 是用来创建 阴影 的。 420 | 421 | 422 | 423 | #### MeshDistanceMaterial:另外一种阴影投射材质 424 | 425 | 相对于 ShadowMaterial 的另外一种阴影投射材质,可以确保内部不透明部分不投射阴影。 426 | 427 | 428 | 429 | #### MeshDepthMaterial:以像素的深度来着色的材质 430 | 431 | **所谓 “像素的深度” 是指物体距离 镜头(摄像机) 的远近距离。**不同的距离决定不同的着色效果。 432 | 433 | 当物体距离镜头越近时会呈现白色、当距离越远时会呈现黑色。 434 | 435 | > 你可以想象成在黑夜中去看远方的发光物体,越近的物体所发出的光眼睛看到的越多(显得物体越亮),越远的物体所发出的光越暗,直至完全消失在黑暗中。 436 | 437 | > 创建镜头的时候,会有 2 个 参数:near(最近距离)、far(最远距离) 438 | 439 | 440 | 441 | #### MeshNormalMaterial:网格法向量材质 442 | 443 | 该材质是根据 三角面 的 法向量 方向的不同,从而赋予不同的颜色。 444 | 445 | 当物体旋转的时候由于各个面的法向量不断发生变化,物体的颜色也是不断发生变化。 446 | 447 | 448 | 449 | #### SpriteMaterial:精灵材质/雪碧材质 450 | 451 | 精灵材质,也叫 雪碧材质。 452 | 453 | 大体来说,就是在场景中,可以加载图片,并且将图片当做纹理贴图,使用在材质上。 454 | 455 | 456 | 457 | ## 自定义材质:ShaderMaterial、RawShaderMaterial 458 | 459 | #### ShaderMaterial:使用 Three.js 着色器制作自定义材质 460 | 461 | #### RawShaderMaterial:完全自定义着色器所创建的自定义材质 462 | 463 | 464 | 465 | **特殊材质、自定义材质具体的用法,此刻都不必深究,道路漫漫,时间还长,以后再慢慢研究。** 466 | 467 | 468 | 469 | ## 材质通用、常用的2个属性:flatShading、side 470 | 471 | #### flatShading:是否平面着色 472 | 473 | 默认值为 false,即使用 渐变过渡着色。 474 | 475 | 若设置值为 true,则使用平面着色。 476 | 477 | > 若启用平面着色,会让物体看起来更像是多面体,而不是光滑体。 478 | 479 | > 本文在讲解 MeshPhongMaterial 的时候已经提到过此属性。 480 | 481 | 482 | 483 | #### side:显示三角形的哪侧边(面) 484 | 485 | 默认值为 Three.FrontSide,即 只显示(渲染) 前面一侧的面。 486 | 487 | 若设置值为 Three.BackSide,则 只显示(渲染) 里面一侧的面。 488 | 489 | > 对于绝大多数 图元 来说,通常 内部是不可见的,例如 球体或立方体的内部 你是看不见的,只能看见外面。side 通常是针对平面或非实体对象才有效果,例如 一个平面圆形,则背对 镜头的那一面即内面,在物体旋转过程中是可以看到内测那一面的。 490 | 491 | 若设置值为 Three.DoubleSide,则 两侧(外面和里面) 都将被显示(渲染)。 492 | 493 | > 对于实体物体对象(非平面物体) 设置值为 Three.BackSide 或 Three.DoubleSide 都是无意义的。 494 | 495 | 496 | 497 | ## 材质不常用的1个属性:needsUpdate 498 | 499 | #### 第1种情况:材质种类发生了重大变化 500 | 501 | **针对 面 的材质**,之前已经提到过,大致分文 3 个类别:基础材质、特殊用途材质、自定义材质 502 | 503 | 在实际项目中,通常情况下我们并不会将某个物体的材质进行 3 大类别之间的转换。 504 | 505 | 例如我们不太会将某个 物体的材质 由某种基础材质突然变更为 阴影材质。 506 | 507 | 尽管实际中发生几率非常小,但万一要发生了呢? 508 | 509 | 510 | 511 | #### 第2种情况:材质种类没变,但设置发生了变化 512 | 513 | 若材质在被使用过后,发生了以下 2 种设置变化: 514 | 515 | 1. flatShading 属性值的改变 516 | 2. 添加或删除 纹理(texture) 517 | 1. 从不使用纹理变为使用纹理 518 | 2. 从使用纹理变为不使用纹理 519 | 3. 纹理的变更是允许的,并不属于 “添加或删除纹理” 的范畴中 520 | 521 | 522 | 523 | #### 设置 needsUpdate 属性 524 | 525 | **当上述 2 种情况发生后,此时就需要设置 needsUpdate 属性:** 526 | 527 | ``` 528 | material.needsUpdate = true 529 | ``` 530 | 531 | 明确告知 Three.js 材质发生了重大变化,请使用新的材质重新渲染。 532 | 533 | > 更换新的材质并重新渲染,这个过程将消耗比较多的计算性能。 534 | 535 | 536 | 537 | **补充说明:** 538 | 539 | 在官方教程中,讲解 needsUpdate 属性时还有一句话: 540 | 541 | `在从纹理过渡到无纹理的情况下,通常最好使用1x1像素的白色纹理。` 542 | 543 | 由于目前还没有学习过纹理,所以我暂时没理解这句话具体的含义是什么。 544 | 545 | 546 | 547 | 关于 材质 的一些基础知识,本文已经讲完。 548 | 549 | **具体的每个材质都需要阅读官方文档,以及经过大量的练习才能掌握。** 550 | 551 | 同一个材质在不同光照、纹理的作用下,可能呈现出的效果相差很大。 552 | 553 | 下一节,学习 纹理(Texture)。 554 | 555 | -------------------------------------------------------------------------------- /16 Three.js基础之自定义几何体.md: -------------------------------------------------------------------------------- 1 | # 16 Three.js基础之自定义几何体 2 | 3 | 4 | 5 | **重要说明:本文部分内容已经过时** 6 | 7 | > .以下内容更新于2021年4月15日 8 | 9 | 10 | 11 | 本文写的时候还使用的是 0.124.0版本,但是在 0.125.2 以后(目前最新的是 0.127.0 ),关于 几何体 官方做了重大调整: 12 | 13 | 1. 官方已经将 Geometry 从核心库中移除,新的位置改为: 14 | 15 | ``` 16 | import { Geometry } from 'three/examples/jsm/deprecated/Geometry' 17 | ``` 18 | 19 | 请注意 目录名为 deprecated,这个单词的意思就是:已弃用、不建议使用。 20 | 21 | 也就是说官方已经不再建议你使用 Geometry 这个类了,那它的替代者是谁呢? 22 | 23 | 2. 在 r124 版本的时候,几何体(例如 BoxGeometry) 他们都继承的是 Geometry,但是在新版本中它们继承的是 BufferGeometry。 24 | 25 | 也就是说如果你想自定义几何体,现在应该使用的是 BufferGeometry 26 | 27 | > Three.BufferGeometry 28 | 29 | > 换句话说,就是 BufferGeometry 替代了 Geometry 30 | 31 | 3. Three.Mesh() 函数中的参数也发生了变化 32 | 4. 或许还有其他更多地方发生了变化... 33 | 34 | 35 | 36 |
37 | 38 | 本文在编写的时候,还采用的是 Geometry,所以本文内容过时了。 39 | 40 | 目前先暂且不做修改,等到以后有时间了再将本文中的代码 Geometry 修改为 BufferGeometry。 41 | 42 | 43 | 44 |
45 | 46 | 你可以先跳过本章,继续后面章节的学习。 47 | 48 | > 以下内容更新于2021年4月15日 49 | 50 | 51 | 52 |
53 | 54 | 接下来开始本文(已过时)的内容。 55 | 56 | ____ 57 | 58 | 59 | 60 | 61 | 62 |
63 | 64 | 本文说的 几何体(geometry),也就是之前 “05 Three.js基础之图元.md” 中的 图元(primitives)。 65 | 66 | > 图元和几何体只是同一个对象(事物)的不同的叫法而已。 67 | 68 | 自定义几何体也就相当于自定义图元。 69 | 70 | > 自定义几何体的这个过程,在传统 3D 软件中被称为 建模 71 | 72 | 73 | 74 | ## 自定义几何体(custom geometry)概述 75 | 76 | 先来一个灵魂拷问。 77 | 78 | ### 有必要在 Three.js 中自定义几何体吗? 79 | 80 | **答:似乎没有必要,因为实际项目中,绝大多数情况下,我们都会通过专业的 3D 软件中来创建物体模型(建模),而不是通过 Three.js 自定义建模。** 81 | 82 | 传统专业的 3D 转件包括: 83 | 84 | 1. Blender:开源免费的 3D 软件 85 | 2. Maya:侧重动画渲染的 3D 软件 86 | 3. Cinema4D(C4D):轻量级的 3D 软件 87 | 4. 3D Sudio Max 88 | 5. ... 89 | 90 | > 以上软件中,都有学习成本,相对而言 C4D 更加轻量、更加简单。 91 | 92 | 当 3D 模型创建好后,我们将模型导出为 gLTF 或 .obj 的文件,然后在 Three.js 中加载并使用它们。 93 | 94 | 尽管如此,但 Three.js 依然提供了自定义几何体(自定义建模)的方法。 95 | 96 | > 可以让我们在不导入 建模文件 的前提下,通过 Three.js 来自定义几何体。 97 | 98 | > 所有的传统 3D 软件建模过程都是可视化的,也就是说你可以时时看到物体,并且进行细微调整。而 Three.js 则是通过代码来建模的,整个建模过程是不可见的。 99 | 100 | 尽管实际中我们可能会很少机会在 Three.js 中自定义几何体,但是学习这方面的知识还是非常有必要的。 101 | 102 | 103 | 104 | ### Three.js中如何自定义几何体? 105 | 106 | 答:在 Three.js 中,一共可以有 2 种方式自定义几何体。 107 | 108 | **第1种:继承于 Geometry** 109 | 110 | 优点:创建和使用的难度小 111 | 112 | 缺点:渲染启动速度慢、占更多内存 113 | 114 | 115 | 116 | **第2种:继承于 BufferGeometry** 117 | 118 | 优点:渲染启动速度快、占更少内存 119 | 120 | 缺点:创建和使用的难度大 121 | 122 | 123 | 124 | **补充说明:** 125 | 126 | 上面说的 渲染启动速度 慢,是指当场景第一次被渲染、后续修改后重新渲染的速度慢。 127 | 128 | 并不是说 绘制速度慢。 129 | 130 | > 无论选择 Geometry 还是 BufferGeometry,绘制过程速度是相同的,他们的 “快慢” 主要体现在 第一次渲染启动速度 这方面。 131 | 132 | 当然,你不需花太多精力去理解 慢 的细节,你只需知道 Geometry 相对而言 渲染速度更慢一些即可。 133 | 134 | 135 | 136 | **如何选择?** 137 | 138 | 答:对于要创建的自定义几何体各个面的三角形总和小于 1000,则优先选择继承于 Geometry。三角形数量超过这个范围的则推荐继承于 BufferGeometry。 139 | 140 | 事实上以上的选择仅供参考,重点是你实际项目中 是否觉得渲染启动速度、修改响应速度慢,如果慢则可进行优化改进。 141 | 142 | > 如果由于客户端硬件配置比较高,感知不到慢或卡顿,那么可以而继续使用 Geometry。 143 | 144 | 145 | 146 | ## 自定义几何体示例:HelloCustomGeometry 147 | 148 | #### 示例目标 149 | 150 | 1. 通过自定义几何体,来实现一个 立方体 151 | 2. 自定义 立方体 6 个面的颜色 152 | 3. 自定义 立方体 8 个顶点的颜色 153 | 4. 给 立方体 添加 光照法线,让立方体可以反光 154 | 155 | 156 | 157 | #### 代码思路 158 | 159 | **如何自定义一个立方体?** 160 | 161 | 答:主要分为 3 步 162 | 163 | 1. 第1步:实例化一个 Three.Geometry 164 | 165 | ``` 166 | const geometry = new Three.Geometry() 167 | ``` 168 | 169 | 170 | 171 | 2. 第2步:按照立方体相应的坐标,添加 8 个顶点 172 | 173 | ``` 174 | geometry.vertices.push( 175 | new Three.Vector3(-1, -1, 1), // 1 176 | new Three.Vector3(1, -1, 1), // 2 177 | new Three.Vector3(-1, 1, 1), // 3 178 | new Three.Vector3(1, 1, 1), // 4 179 | new Three.Vector3(-1, -1, -1), // 5 180 | new Three.Vector3(1, -1, -1), // 6 181 | new Three.Vector3(-1, 1, -1), // 7 182 | new Three.Vector3(1, 1, -1) // 8 183 | ) 184 | ``` 185 | 186 | 187 | 188 | 3. 第3步:将相邻的 3 个顶点,依次按照逆时针顺序,构建成一个个三角形。 189 | 190 | > 立方体一共有 6 个面,每个面由 2 个三角形构成,因此一共需要构建 12 个三角形 191 | 192 | > 为什么必须是逆时针?这是 Three.js 规定的。 193 | 194 | ``` 195 | geometry.faces.push( 196 | //前面 197 | new Three.Face3(0, 3, 2), 198 | new Three.Face3(0, 1, 3), 199 | //右面 200 | new Three.Face3(1, 7, 3), 201 | new Three.Face3(1, 5, 7), 202 | //后面 203 | new Three.Face3(5, 6, 7), 204 | new Three.Face3(5, 4, 6), 205 | //左面 206 | new Three.Face3(4, 2, 6), 207 | new Three.Face3(4, 0, 2), 208 | //顶面 209 | new Three.Face3(2, 7, 6), 210 | new Three.Face3(2, 3, 7), 211 | //底面 212 | new Three.Face3(4, 1, 0), 213 | new Three.Face3(4, 5, 1) 214 | ) 215 | ``` 216 | 217 | 218 | 219 | 至此,就以成功构建出一个立方体的基本骨架。 220 | 221 | 222 | 223 | **如何自定义 6 个面的颜色额?** 224 | 225 | ``` 226 | geometry.faces[0].color = geometry.faces[1].color = new Three.Color('red') 227 | geometry.faces[2].color = geometry.faces[3].color = new Three.Color('yello') 228 | geometry.faces[4].color = geometry.faces[5].color = new Three.Color('green') 229 | geometry.faces[6].color = geometry.faces[7].color = new Three.Color('cyan') 230 | geometry.faces[8].color = geometry.faces[9].color = new Three.Color('blue') 231 | geometry.faces[10].color = geometry.faces[11].color = new Three.Color('magenta') 232 | ``` 233 | 234 | 235 | 236 | **如何自定义 8 个顶点的颜色?** 237 | 238 | ``` 239 | geometry.faces.forEach((face, index) => { 240 | face.vertexColors = [ 241 | (new Three.Color()).setHSL(index / 12, 1, 0.5), 242 | (new Three.Color()).setHSL(index / 12 + 0.1, 1, 0.5), 243 | (new Three.Color()).setHSL(index / 12 + 0.2, 1, 0.5) 244 | ] 245 | }) 246 | ``` 247 | 248 | 249 | 250 | **如何开启顶点着色?** 251 | 252 | ``` 253 | const material = new THREE.MeshBasicMaterial({vertexColors: true}) 254 | ``` 255 | 256 | ``` 257 | const material = new Three.MeshPhongMaterial({ vertexColors: true }) 258 | ``` 259 | 260 | > vertexColors 默认值为 false,即默认显示材质 color 的颜色。 261 | > 262 | > 如果没有给材质设置 color 值,那么默认颜色值为 白色 263 | 264 | 265 | 266 | 特别说明,在 Three.js 之前的版本中,vertexColors 的值并不是 Boolean,而是进行以下设置: 267 | 268 | ``` 269 | //export enum Colors {} 270 | //export const NoColors: Colors; 271 | //export const FaceColors: Colors; 272 | //export const VertexColors: Colors; 273 | 274 | const material = new THREE.MeshBasicMaterial({vertexColors: THREE.FaceColors}); 275 | ``` 276 | 277 | 在目前比较新的版本中,vertexColors 的值改为 Boolean 类型。 278 | 279 | 280 | 281 | **如何添加光照法线?** 282 | 283 | 答:一共有 3 种方式 284 | 285 | 1. 给每一个 face 设置 normal 属性值 286 | 287 | ``` 288 | face.normal = new Three.Vector3(...) 289 | ``` 290 | 291 | 2. 通过 vertexNormals 属性来设置 292 | 293 | ``` 294 | face.vertexNormals = { 295 | new Three.Vector3(...), 296 | new Three.Vector3(...), 297 | ... 298 | new Three.Vector3(...) 299 | } 300 | ``` 301 | 302 | 3. 通过 computeFaceNormals() 和 computeVertexNormals() 这 2 个方法自动帮我们计算出光照法线。 303 | 304 | 但是对于立方体而言,只执行 computeFaceNormals() 方法即可。 305 | 306 | ``` 307 | geometry.computeFaceNormals() 308 | //geometry.computeVertexNormals() // 这个方法并不适用于立方体 309 | ``` 310 | 311 | > 第 3 种 方法最为简便,也比较常用。 312 | > 313 | > 本示例就采用第 3 种方式。 314 | 315 | 316 | 317 | **补充说明:computeVertexNormals() 为什么不适用于立方体?** 318 | 319 | 答:因为 computeVertexNormals() 会从每个顶点共享的所有面的法线中计算得出法线,这样的发现会让立方体的顶点看上去更像一个球体。 320 | 321 | > 如果你执行了 computeVertexNormals(),并不会报错,仅仅是立方体顶点处看似更加圆润,像球一样。 322 | 323 | 324 | 325 | #### 代码示例: 326 | 327 | ``` 328 | import { useEffect, useRef } from 'react' 329 | import * as Three from 'three' 330 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' 331 | 332 | import './index.scss' 333 | 334 | const HelloCustomGeometry = () => { 335 | const canvasRef = useRef(null) 336 | 337 | useEffect(() => { 338 | 339 | if (canvasRef.current === null) { 340 | return 341 | } 342 | 343 | const renderer = new Three.WebGLRenderer({ canvas: canvasRef.current }) 344 | const scene = new Three.Scene() 345 | 346 | const camera = new Three.PerspectiveCamera(45, 2, 0.1, 100) 347 | camera.position.z = 8 348 | 349 | const light = new Three.DirectionalLight(0xFFFFFF, 1) 350 | light.position.set(2, 2, 4) 351 | scene.add(light) 352 | 353 | const helper = new Three.DirectionalLightHelper(light) 354 | scene.add(helper) 355 | 356 | const controls = new OrbitControls(camera, canvasRef.current) 357 | controls.update() 358 | 359 | //自定义一个立方体几何体 360 | const geometry = new Three.Geometry() 361 | geometry.vertices.push( 362 | new Three.Vector3(-1, -1, 1), // 1 363 | new Three.Vector3(1, -1, 1), // 2 364 | new Three.Vector3(-1, 1, 1), // 3 365 | new Three.Vector3(1, 1, 1), // 4 366 | new Three.Vector3(-1, -1, -1), // 5 367 | new Three.Vector3(1, -1, -1), // 6 368 | new Three.Vector3(-1, 1, -1), // 7 369 | new Three.Vector3(1, 1, -1) // 8 370 | ) 371 | geometry.faces.push( 372 | //前面 373 | new Three.Face3(0, 3, 2), 374 | new Three.Face3(0, 1, 3), 375 | //右面 376 | new Three.Face3(1, 7, 3), 377 | new Three.Face3(1, 5, 7), 378 | //后面 379 | new Three.Face3(5, 6, 7), 380 | new Three.Face3(5, 4, 6), 381 | //左面 382 | new Three.Face3(4, 2, 6), 383 | new Three.Face3(4, 0, 2), 384 | //顶面 385 | new Three.Face3(2, 7, 6), 386 | new Three.Face3(2, 3, 7), 387 | //底面 388 | new Three.Face3(4, 1, 0), 389 | new Three.Face3(4, 5, 1) 390 | ) 391 | 392 | geometry.faces[0].color = geometry.faces[1].color = new Three.Color('red') 393 | geometry.faces[2].color = geometry.faces[3].color = new Three.Color('yello') 394 | geometry.faces[4].color = geometry.faces[5].color = new Three.Color('green') 395 | geometry.faces[6].color = geometry.faces[7].color = new Three.Color('cyan') 396 | geometry.faces[8].color = geometry.faces[9].color = new Three.Color('blue') 397 | geometry.faces[10].color = geometry.faces[11].color = new Three.Color('magenta') 398 | 399 | geometry.faces.forEach((face, index) => { 400 | face.vertexColors = [ 401 | (new Three.Color()).setHSL(index / 12, 1, 0.5), 402 | (new Three.Color()).setHSL(index / 12 + 0.1, 1, 0.5), 403 | (new Three.Color()).setHSL(index / 12 + 0.2, 1, 0.5) 404 | ] 405 | }) 406 | 407 | geometry.computeFaceNormals() 408 | //geometry.computeVertexNormals() //对于立方体而言,无需执行此方法 409 | 410 | //const material = new Three.MeshBasicMaterial({ color: 'red' }) 411 | const material = new Three.MeshPhongMaterial({ vertexColors: true }) 412 | //const material = new Three.MeshPhongMaterial({ color: 'red' }) 413 | const cube = new Three.Mesh(geometry, material) 414 | scene.add(cube) 415 | 416 | const render = (time: number) => { 417 | cube.rotation.x = cube.rotation.y = time * 0.001 418 | renderer.render(scene, camera) 419 | window.requestAnimationFrame(render) 420 | } 421 | window.requestAnimationFrame(render) 422 | 423 | const handleResize = () => { 424 | if (canvasRef.current === null) { 425 | return 426 | } 427 | 428 | const width = canvasRef.current.clientWidth 429 | const height = canvasRef.current.clientHeight 430 | camera.aspect = width / height 431 | camera.updateProjectionMatrix() 432 | renderer.setSize(width, height, false) 433 | } 434 | handleResize() 435 | window.addEventListener('resize', handleResize) 436 | 437 | return () => { 438 | window.removeEventListener('resize', handleResize) 439 | } 440 | }, [canvasRef]) 441 | 442 | return ( 443 | 444 | ) 445 | } 446 | 447 | export default HelloCustomGeometry 448 | ``` 449 | 450 | 实际运行后,就会看到一个 炫彩的立方体。 451 | 452 | 453 | 454 | #### 本文小结: 455 | 456 | 通过自定义一个立方体的示例,可以看出,尽管是一个很简单的立方体,可我们都需要非常复杂的空间坐标计算配置,因此还是本文开头那段话: 457 | 458 | 1. 如非必要,不要在 Three.js 中自定义几何体。 459 | 2. 使用传统的 3D 软件建模,更香。 460 | 461 | 462 | 463 | #### 补充说明: 464 | 465 | 本系列教程,实际上是我一边学习 https://threejsfundamentals.org/threejs/lessons/ ,一边使用 React + TypeScript + 自己的语言和理解 重写一遍的。 466 | 467 | 468 | 469 | 本文对应的英文教程为:https://threejsfundamentals.org/threejs/lessons/threejs-custom-geometry.html 470 | 471 | 在原版的英文教程中,还有另外一个 通过一张图片来获得 纹理坐标(UV),进而生成一张地图的例子。 472 | 473 | 我个人感觉没有必要去这么深入学习自定义几何体,所以本文略过这个示例。 474 | 475 | 476 | 477 | 除此之外,官方还有单独一篇,使用 BufferGeometry 来自定义几何体的教程: 478 | 479 | https://threejsfundamentals.org/threejs/lessons/threejs-custom-buffergeometry.html 480 | 481 | 我认为现阶段,没有必要如此这般的深入去学习自定义几何体,因为暂停这部分的学习。 482 | 483 | > 如果你还有精力,可以去学习一下。 484 | 485 | 486 | 487 | ## Three.js基础知识总结 488 | 489 | 通过前面一系列的学习,我们终于将 Three.js 基础知识学习完成。 490 | 491 | 回顾一下我们都学习了哪些知识点: 492 | 493 | 1. Three.js 简介、项目初始化、入门示例 494 | 2. 图元、3D文字、场景、材质、纹理、灯光、镜头、阴影、雾、离屏渲染、自定义几何体 495 | 3. 辅助对象(灯光辅助对象 LightHelper 、镜头辅助对象 XxxxCameraHelper、坐标轴辅助对象 AxesHelper)、镜头轨道控制类(OrbitControls) 496 | 497 | 498 | 499 | 真心不容易,给自己一朵小红花! 500 | 501 | ... 502 | 503 | 我们本系列教程整体的规划是: 504 | 505 | 1. 基础篇 (✓) 506 | 2. 技巧篇 (x) 507 | 3. 优化篇 (x) 508 | 4. 解决方案 (x) 509 | 5. WebVR (x) 510 | 6. 实例篇 (x) 511 | 512 | 目前我们已经学习了基础篇,对 Three.js 已经有了足够的基础知识掌握,后面的学习都是建立在这些基础知识之上的。 513 | 514 | 515 | 516 | **接下来,进入技巧篇——按需渲染。** 517 | 518 | 519 | 520 | #### 稍等,再啰嗦几句: 521 | 522 | 我们后续的讲解文章中,将加快进度,不再像 基础篇 这样如此细致,甚至是啰嗦。 523 | 524 | 因此,我希望你不看教程示例代码,而是自己独立敲出示例代码。如果做不到,那么你先不要着急进入下一篇,而是应该再回过头,反复阅读,反复敲几遍代码。 525 | 526 | > 在做(动手敲代码)的过程中学习,而不是只看不动手。 -------------------------------------------------------------------------------- /24 Three.js解决方案之加载.gLTF模型.md: -------------------------------------------------------------------------------- 1 | # 24 Three.js解决方案之加载.gltf模型 2 | 3 | 上一章节,我们讲解了加载 .obj 模型,本文将讲解加载 .gltf 模型。 4 | 5 | 6 | 7 | > 注:虽然我们标题写的是 “加载.gltf模型”,但更加准确地说法应该是 "加载 gtTF 文件" 8 | 9 | 10 | 11 |
12 | 13 | 首先我们先回顾一下 .obj 文件格式的模型一些特征:文件格式简单(纯文本)、除模型外无法提供其他场景元素(例如摄像机、灯光等)。 14 | 15 | 本文要讲解的 .gltf 格式文件可以包含的数据内容和类型要比 .obj 多很多。 16 | 17 | 18 | 19 |
20 | 21 | ## 常见 3D 文件格式 和 gltf 的区别 22 | 23 | **我们先将常见的 3D文件格式进行划分** 24 | 25 | 26 | 27 | **第1类(原始文件):** 28 | 29 | 3D 建模软件本身特有的、原始的文件格式,例如: 30 | 31 | 1. Blender 对应的是 .blend 32 | 2. 3D Max 对应的是 .max 33 | 3. Maya 对应的是 .ma 34 | 4. C4D 对应的是 .c4d 35 | 36 | 37 | 38 |
39 | 40 | **第2类(中转文件):** 41 | 42 | 多个 3D 建模软件彼此都可以打开,能够读取的文件格式,例如: 43 | 44 | 1. .obj 45 | 2. .dae 46 | 3. .fbx 47 | 48 | > 所谓“中转”,是指这些格式的作用实际上相当于将某个模型从 A 软件 导出 然后再 B 软件中可以打开并读取。 49 | 50 | 51 | 52 |
53 | 54 | **第3类(特定格式):** 55 | 56 | 某些 3D 应用独有的文件格式。 57 | 58 | 例如王者荣耀这款游戏中 3D 模型就可能是自己独有的文件格式。 59 | 60 | 61 | 62 |
63 | 64 | **第4类(传输格式):** 65 | 66 | > 这里的 “传输” 是英文单词 “Transmission” 的翻译 67 | 68 | gltf 就属于传输格式类型的文件。gltf格式可以做到其他格式都无法做到的事情。 69 | 70 | 71 | 72 |
73 | 74 | ### GLTF文件格式简介 75 | 76 | **GLTF是英文:Graphics Language Transmission Format 的缩写** 77 | 78 | > WebGL、OpenGL 中的 “GL” 和 GLTF 中的 “GL” 是相同的单词。 79 | 80 | **GLTF中文全称为:图形语言传输格式** 81 | 82 | > GLTF 本身就是由 OpenGL 和 Vulkan 背后的 3D 图形标准组织 Khronos 定义的。 83 | 84 | 所以你可以想象得到,gltf 本身就是为了网络传输、浏览器渲染 3D 而生的。 85 | 86 | 87 | 88 | 关于更多 gltf 信息,可以查看其官网:https://www.khronos.org/gltf/ 89 | 90 | 91 | 92 |
93 | 94 | **GLTF的支持度:几乎所有的 Web 3D 图形框架都支持 GLTF** 95 | 96 | > 除了 Three.js 框架外 ,其他 3D JS 引擎框架也都支持 GLTF 97 | 98 | 99 | 100 |
101 | 102 | **gltf优点 1:体积小,便于传输** 103 | 104 | gltf 文件中模型的数据都以二进制存储,当下载(使用) gltf 文件时可以将这些二进制数据直接在 GPU 中使用。 105 | 106 | 反观 vrml、.obj 或 .dae 等格式,他们是将数据存储为文本(例如纯文本或JSON格式),也就是说 GPU 在读取这些模型文件时还需要进行文本解析。 107 | 108 | 在文件体积方面,通常相同的模型顶点数据如果用文本形式存储,要比二进制存储体积大 3 到 5 倍。 109 | 110 | 111 | 112 |
113 | 114 | **gltf优点 2:直接渲染** 115 | 116 | gltf 文件中模型的数据是直接要渲染的,而不是要再次编辑的。 117 | 118 | > 换句话说 gltf 文件中的模型是不可以再次编辑的 119 | 120 | > 而其他类型的文件,例如 .obj 中模型是可以在 Three.js 中加载完成后二次编辑的 121 | 122 | > 你可以简单的把 gltf 想象成 jpg 图片,而其他格式的 3D 文件是 PSD 文件,当我们仅仅是为了看到图片,无需编辑该图片时,肯定是 .jpg 图片体积小,打开速度快。 123 | 124 | 正因为是不可编辑,所以一些对于渲染而言不重要的数据通常都已被删除,例如多边形都已转化为三角形。 125 | 126 | 127 | 128 |
129 | 130 | **gltf优点 3:内嵌材质信息** 131 | 132 | gltf 文件中模型的材质信息是被内嵌进去。 133 | 134 | 请注意我们这里说的是 “材质信息”,也就是相当于 .obj 对应的 .mtl 中的数据,但是对于纹理图片资源(xxx.jpg)本身来说,并不会内嵌进去。 135 | 136 | > 所以,这里隐含的一个事情就是,我们依然需要将 .gltf 文件对应的纹理图片资源 .jpg 放在 pulic 目录中。 137 | 138 | 139 | 140 |
141 | 142 | **结论:gltf 格式非常有针对性,是专门为渲染而设计的,文件体积小,且 GPU 读取快速。** 143 | 144 | **因此,推荐使用 gltf 格式。** 145 | 146 | 147 | 148 |
149 | 150 | ### 在Blender中导出gltf文件 151 | 152 | 讲了这么多 gltf 文件的优点,那么我们打开之前创建的 hello.blend 文件,导出一下 gltf 文件看看。 153 | 154 | **导出步骤:** 155 | 156 | 1. 打开 hello.blend 157 | 158 | 2. 文件 > 导出 > glTF 2.0(.glb/.gltf) 159 | 160 | 3. 在弹窗对话框中,使用默认导出项,我们直接点 `导出` 161 | 162 | > 尽管我们使用的是默认导出项,但是还请你留意一下这几项内容: 163 | > 164 | > `包括`:选定的物体(未勾选)、自定义属性(未勾选)、相机(未勾选)、精确灯光(未勾选) 165 | > 166 | > `变换`:Y 向上(已勾选) 167 | > 168 | > `几何数据`: 应用修改器(未勾选)、UV(已勾选)、法向(已勾选)、切向(未勾选)、顶点色(已勾选)、材质(导出)、图像(自动)、压缩(未勾选) 169 | > 170 | > `动画`:动画(已勾选)、形态键(已勾选)、蒙皮(已勾选) 171 | > 172 | > > 尽管我们创建的 hello.blend 中并未设置任何动画,你可以选择取消动画相关的勾选 173 | 174 | 175 | 176 |
177 | 178 | 此时去导出目录里,我们会发现多出来了一个 `hello.glb` 的文件。 179 | 180 | > 特别强调:由于纹理图片资源 metal_texture.jpg 本身就在目录中,所以我们只是从直观上感觉多出了 1 个文件而已。 181 | 182 | 183 | 184 |
185 | 186 | **.glb ?不是 .gltf ?** 187 | 188 | 额~,别着急,我们补充一下 GLTF 格式的知识。 189 | 190 | 191 | 192 |
193 | 194 | #### GLTF是一种3D文件格式规范,但是却有 3 种表现形式 195 | 196 | **3 种表现形式分别为:分离式、二进制、嵌入式** 197 | 198 | 199 | 200 | **第1种表现形式(分离式):.gltf + .bin + 纹理贴图资源(.jpg、.png)** 201 | 202 | 1. gltf:3D 场景的所有概要信息,包括灯光、纹理贴图等信息 203 | 204 | > 该文件的内容具体形式为 JSON 205 | 206 | 2. .bin:模型的二进制数据 207 | 208 | 3. 纹理贴图资源:这个就不多说了,就是纹理图片 xxx.jpg 或 .png 209 | 210 | 211 | 212 |
213 | 214 | **第2种表现形式(二进制):.glb** 215 | 216 | .glb:包含场景所有的信息的二进制数据。 217 | 218 | > .glb === .gltf + .bin + 纹理图片资源 219 | 220 | 221 | 222 |
223 | 224 | **第3种表现形式(嵌入式):.gltf** 225 | 226 | .gltf:以 JSON 形式保存所有场景信息数据,包括材质和纹理信息。 227 | 228 | > 这种形式由于文件内容是 json,因此是可以通过文本再次编辑的 229 | 230 | 231 | 232 |
233 | 234 | **Blender 默认导出 glTF 2.0 格式时,采用的是 .glb 后缀形式。** 235 | 236 | 想要更改成别的导出形式,我们可以在 Blender 导出项 `格式`下拉框中更改为 “.gltf 分离(.gltf + .bin + 纹理)” 或 "glTF嵌入式(.gltf)"。 237 | 238 | 那么此时**导出的文件格式就是 .gltf 后缀形式。** 239 | 240 | 241 | 242 |
243 | 244 | **3种形式的对比:** 245 | 246 | > 以下纯粹是我个人的观点,仅供参考 247 | 248 | 比较常见的是前 2 种:分离式(.gltf + .bin + 纹理)、二进制形式(.glb) 249 | 250 | 如果你的项目中,模型数据不会发生变化,但是纹理贴图可能容易发生变化,那么可以选择 分离式的。 251 | 252 | > 分离式的贴图资源本身就是单独存在的,因此方便替换修改。 253 | 254 | 如果你是要发送给其他人使用、且不会发生材质变更的,则可以采用 .glb 形式的。 255 | 256 | > 由于所有数据都只在 .glb 文件中,就 1 个文件也利于文件发送。 257 | 258 | 259 | 260 |
261 | 262 | 补充一点:有一个网站 https://gltf-viewer.donmccurdy.com/ ,他可以提供 .glb 文件在线预览。 263 | 264 | 同时在 NPM 上面,有很多针对 .glb 和 .gltf 格式互转的工具包,例如:[gltf-import-export](https://www.npmjs.com/package/gltf-import-export) 265 | 266 | 267 | 268 |
269 | 270 | 对于 Three.js 来说,加载 glTF 格式的文件,无论哪种形式,均支持。 271 | 272 | 273 | 274 |
275 | 276 | ## 使用GLTFLoader加载glTF文件的示例 277 | 278 | 在 Three.js 中负责加载 glTF 格式文件的加载器为 GLTFLoader。 279 | 280 | 用法和之前 OBJLoader 用法完全相同,废话不多说,直接看代码。 281 | 282 | 283 | 284 |
285 | 286 | 我们先加载 .glb 格式的文件,代码如下: 287 | 288 | src/components/hello-gltfloader 289 | 290 | > 由于 .glb 文件是单独 1 个存在,所以我们这次可以将 hello.glb 文件放在 src/assces/model/ 目录下了。 291 | 292 | ``` 293 | import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader" 294 | 295 | ... 296 | 297 | const loader = new GLTFLoader() 298 | loader.load(require('@/assets/model/hello.glb').default, (gltf) => { 299 | scene.add(gltf.scene) 300 | }) 301 | 302 | ``` 303 | 304 | > 请注意当加载完成后,执行的是 scene.add(gltf.scene) 305 | > 306 | > 加载完成得到的 gltf 中包含的数据有: 307 | > 308 | > 1. gltf.animations; // Array 309 | > 2. gltf.scene; // THREE.Group 310 | > 3. gltf.scenes; // Array 311 | > 4. gltf.cameras; // Array 312 | > 5. gltf.asset; // Object 313 | > 314 | > 我们此刻只是将 gltf.scene 添加到了场景中而已,其他数据暂时并未使用到。 315 | 316 | 317 | 318 |
319 | 320 | 和加载 .glb 类似,如果我们的 3D 数据文件为 分离式的 .gltf,则将上述代码修改为: 321 | 322 | ``` 323 | loader.load('./model/hello.gltf', (gltf) => { 324 | scene.add(gltf.scene) 325 | }) 326 | ``` 327 | 328 | > 注意:我们只需将 hello.gltf 传递给 loader 即可,loader 会读取 .gltf 中的数据,自动去加载对应的 hello.bin 和 纹理图片 hello.jpg。 329 | 330 | > 由于牵扯到不同的文件 webpack 编译,所以我们选择将 .gltf、.bin、.jpg 文件放在 src/public/ 目录中。 331 | 332 | 333 | 334 |
335 | 336 | 至此,加载 .gltf 文件讲解完成。 337 | 338 | 就这?明明就几行代码的事情,为什么还要花这样大的篇幅来讲解 .obj 和 .glb、gltf ? 339 | 340 | 答:要想学得深入,就一定要知道原理,知道 obj 和 gltf 的差异,知其然也要知其所以然。 341 | 342 | 343 | 344 |
345 | 346 | 我这里提供几个上找到的 glTF 文件资源,方便自己练习使用。 347 | 348 | **一个黄色的小鸭子:** 349 | 350 | 1. https://vr.josh.earth/assets/models/duck/duck.gltf 351 | 2. https://vr.josh.earth/assets/models/duck/duck.bin 352 | 3. https://vr.josh.earth/assets/models/duck/duck.png 353 | 354 | 355 | 356 |
357 | 358 | **一个简易3D社区** 359 | 360 | https://threejsfundamentals.org/threejs/resources/models/cartoon_lowpoly_small_city_free_pack/scene.gltf 361 | 362 | > 这个小区模型比较大,你需要适当调整一下镜头参数,才可以看清楚全貌 363 | 364 | 365 | 366 |
367 | 368 | **一个酷酷的头盔** 369 | 370 | https://cdn.khronos.org/assets/api/gltf/DamagedHelmet.glb 371 | 372 | 373 | 374 |
375 | 376 | **一个宇航员** 377 | 378 | https://modelviewer.dev/shared-assets/models/Astronaut.glb 379 | 380 | > 真的好酷! 381 | 382 | 383 | 384 | 385 | 386 |
387 | 388 | ## 谷歌开源的一个JS库:model-viewer 389 | 390 | 在搜索 glTF 相关文章时,我无意中发现另外谷歌公司开源的一个 JS 项目: model-viewer 391 | 392 | 393 | 394 | 项目 Github 地址:https://github.com/google/model-viewer 395 | 396 | 项目官网:https://modelviewer.dev/ 397 | 398 | 项目介绍:Easily display interactive 3D models on the web & in AR 399 | 400 | > 简单来说就是:在 Web 或 AR 中,一个简单的用来显示 3D 模型的 JS 库。 401 | 402 | 403 | 404 |
405 | 406 | 具体用法: 407 | 408 | ``` 409 | 410 | 411 | 412 | ``` 413 | 414 | > 确实够简单了,就是引入 viewer ,然后可以使用 标签插入模型渲染显示标签。 415 | > 416 | > 简直和插入图片标签 没啥区别。 417 | 418 | 419 | 420 |
421 | 422 | 交互效果:除了可以渲染出 3D 模型文件,还默认配备有类似 OrbitControls 相同的交互效果。 423 | 424 | 425 | 426 |
427 | 428 | 兼容性:目前 苹果浏览器 Safari、火狐 Firefox 并不支持。 429 | 430 | 431 | 432 |
433 | 434 | 至此,关于如何加载 glTF 文件已讲解完毕。 435 | 436 | 437 | 438 | **但是有一点我们没有提到,就是使用 glTF 中自带的灯光、镜头、动画等内容。** 439 | 440 | 由于目前我还不会在 Blender 创建动画,所以这一块我们暂且保留,等待以后有机会再继续学习。 441 | 442 | 443 | 444 |
445 | 446 | 在 Three.js 中,还有很多其他文件格式的加载器,我们就不逐个讲解了,具体的可以查阅官方文档。 447 | 448 | 449 | 450 |
451 | 452 | 你以为本文结束了?没有! 453 | 454 | 在上面示例中,我们实际上漏掉了一个非常重要的知识点:加载被压缩过的 .glb 文件 455 | 456 | 457 | 458 |
459 | 460 | ## glTF文件压缩和加载(解压)——Draco 461 | 462 | 在本文的示例中,所演示加载的 .glb 文件是我自己在 Blender 中创建导出的。 463 | 464 | 如同图片文件一样,也有专门针对 .glb 文件压缩的工具,最为著名的就是谷歌公司开源的:draco 465 | 466 | 467 | 468 | ### Draco简介 469 | 470 | Draco 是一种库,用于压缩和解压缩 3D 几何网格(geometric mesh) 和 点云(point cloud) 471 | 472 | draco官网:https://google.github.io/draco/ 473 | 474 | draco源码:https://github.com/google/draco 475 | 476 | 477 | 478 |
479 | 480 | draco 底层是使用 c++ 编写的。 481 | 482 | draco 可以在不牺牲模型效果的前提下,将 .glb 文件压缩体积减小很多。 483 | 484 | > 就好像将普通文件压缩成 .zip 一样 485 | 486 | > 至于文件减少多少,这个暂时没有查询到 487 | 488 | 489 | 490 |
491 | 492 | #### Draco使用流程是: 493 | 494 | 1. 使用 Draco 将模型压缩,最终压缩后的文件格式为 .drc 或 .glb 495 | 496 | > Draco 可以压缩众多 3D 格式文件,.glb 仅仅是其中一种 497 | 498 | 2. 在 .glb 文件内部有一个特殊字段,用来表述本文件是否经过了 draco 压缩 499 | 500 | 3. 当客户端(JS) 使用 GLTFLoader 去加载某个 .glb 文件时会去读取该标识 501 | 502 | 4. 若判断该 .glb 文件未被压缩则直接进行加载和解析 503 | 504 | 5. 若判断该 .glb 文件是被 draco 压缩过的,则会尝试调用 draco 解压类,下载 .glb 文件的同时进行解压,最终将下载、解压后的 .glb 数据传递给 GLTFLoader 使用 505 | 506 | > 这就引申出来一个事情:我们需要提前将负责 draco 解压的类传递给 GLTFLoader,具体如何做请看后面的讲解。 507 | 508 | 509 | 510 |
511 | 512 | #### 如何使用 Draco 压缩 .glb 文件? 513 | 514 | 具体如何操作实现,暂时我也没有学习,先搁置一下。 515 | 516 | > 敬请期待以后的更新 517 | 518 | 519 | 520 |
521 | 522 | #### 如何在Three.js 中加载压缩过的 .glb 文件? 523 | 524 | 关于 Draco 的介绍,可以查看 Three.js 对于 Draco 的介绍描述: 525 | 526 | https://github.com/mrdoob/three.js/tree/dev/examples/js/libs/draco 527 | 528 | 529 | 530 | Three.js 源码包中 draco 针对 gltf 文件的解压文件库: 531 | 532 | 1. draco/ 目录下有 4 个文件:draco_decoder.js、draco_decoder.wasm、draco_encoder.js、draco_wasm_wrapper.js 533 | 534 | 2. draco/gltf/ 目录下面同样有 4 个文件 535 | 536 | > 请注意 draco/ 和 draco/gltf/ 目录下的 4 个文件虽然是名字一样,但是他们内容并不相同。 537 | 538 | 分别解释一下这 4 个文件: 539 | 540 | 1. draco_decoder.js 541 | 542 | > draco 解压(解码) 相关 js 543 | 544 | 2. draco_decoder.wasm 545 | 546 | > .wasm 文件是 WebAssembly 解码器 547 | > 548 | > 关于 WebAssembly 更多知识,请执行查阅:https://www.wasm.com.cn/ 549 | 550 | 3. draco_encoder.js 551 | 552 | > draco 压缩(编码) 相关 js 553 | 554 | 4. draco_wasm_wrapper.js 555 | 556 | > 用于封装 .wasm 解码器的 js 557 | 558 | 559 | 560 | 重点来了... 561 | 562 |
563 | 564 | #### 第1步:拷贝 draco 文件到项目 public 中 565 | 566 | 我们将 Three.js 中 examples/js/libs/draco 目录拷贝到 React 项目的 public 目录中。 567 | 568 | > draco 属于第 3 方库,我们目前暂时采用拷贝到 public 目录中这种形式 569 | > 570 | > 请记得一定拷贝的是 draco/,其中包含 draco/gltf/ 目录 571 | 572 | 573 | 574 | #### 第2步:实例化一个 DRACOLoader,并传递给 GLTFLoader 575 | 576 | > 关于 DRACOLoader 的详细解释,请参考官方文档: 577 | > 578 | > https://threejs.org/docs/#examples/zh/loaders/DRACOLoader 579 | 580 |
581 | 582 | 我们将之前 GLTFLoader 的代码修改如下: 583 | 584 | ```diff 585 | + import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader' 586 | 587 | const gltfLoader = new GLTFLoader() 588 | 589 | + const dracoLoader = new DRACOLoader() 590 | + dracoLoader.setDecoderPath('./examples/js/libs/draco/') 591 | + dracoLoader.setDecoderConfig({ type: 'js' }) 592 | + gltfLoader.setDRACOLoader(dracoLoader) 593 | 594 | gltfLoader.load('./model/vivo.glb', (gltf) => { 595 | scene.add(gltf.scene) 596 | }) 597 | ``` 598 | 599 | 600 | 601 |
602 | 603 | 下面就针对上面 4 行核心代码进行解释说明: 604 | 605 | 1. `const dracoLoader = new DRACOLoader()` 606 | 607 | 实例化一个 DRACOLoader 608 | 609 | 2. `dracoLoader.setDecoderPath('./examples/js/libs/draco/')` 610 | 611 | 设置 dracoLoader 应该去哪个目录里查找 解压(解码) 文件 612 | 613 | 3. `dracoLoader.setDecoderConfig({ type: 'js' })` 614 | 615 | 设置 dracoLoader 的配置项 616 | 617 | 4. `gltfLoader.setDRACOLoader(dracoLoader)` 618 | 619 | 将 dracoLoader 传递给 gltfLoader,供 gltfLoader 使用 620 | 621 | 至此,结束! 622 | 623 | 624 | 625 |
626 | 627 | 虽然 draco 非常复杂,但是对于我们使用者而言却很简单,仅仅上面 4 行代码即可实现加载被 draco 压缩过的 .glb 文件。 628 | 629 | 630 | 631 |
632 | 633 | ## 加载.drc模型文件 634 | 635 | 在上面示例中,我们加载的是被 draco 压缩过的 .glb 文件。 636 | 637 | 那如果是被 draco 压缩过的 .drc 文件呢? 638 | 639 | 答:更加简单,直接使用 DRACOLoader即可。 640 | 641 | 642 | 643 |
644 | 645 | DRACOLoader使用示例代码如下: 646 | 647 | ``` 648 | const loader = new DRACOLoader(); 649 | loader.setDecoderPath( '/examples/js/libs/draco/' ); 650 | loader.preload(); 651 | 652 | loader.load('./xxx/model.drc', 653 | function ( geometry ) { 654 | const material = new THREE.MeshStandardMaterial( { color: 0x606060 } ); 655 | const mesh = new THREE.Mesh( geometry, material ); 656 | scene.add( mesh ); 657 | } 658 | } 659 | ``` 660 | 661 | 662 | 663 |
664 | 665 | ## 补充说明:修改模型位置偏差 666 | 667 | 无论加载 .obj 文件,还是本章讲解的加载 .gltf 文件,假设模型在建模软件中位置中心并不是原点,而是非常偏远的位置。 668 | 669 | 那么文件加载完成后,将模型添加到场景中,模型的位置并不在场景视角的中心位置,如果位置过于偏远,甚至有可能根本看不见模型。 670 | 671 | 我们可以通过以下方式,计算模型的位置偏差,并修正模型的位置,使其出现在视野中心位置。 672 | 673 | ``` 674 | const loader = new GLTFLoader() 675 | loader.load('./model/lddq.gltf', (gltf) => { 676 | const group = gltf.scene 677 | 678 | const box = new Three.Box3().setFromObject(group) 679 | const center = box.getCenter(new Three.Vector3()) 680 | 681 | group.position.x += (group.position.x - center.x) 682 | group.position.y += (group.position.y - center.y) 683 | group.position.z += (group.position.z - center.z) 684 | 685 | scene.add(group) 686 | }) 687 | ``` 688 | 689 | > Box3 的介绍请执行查阅官方文档。 690 | 691 | 692 | 693 |
694 | 695 | 下一章节,我们要学习如何添加 场景背景,呵, VR 看房效果要来了! -------------------------------------------------------------------------------- /07 图元之3D文字.md: -------------------------------------------------------------------------------- 1 | # 07 图元之3D文字 2 | 3 | 在 Three.js 所有内置的图元中,TextBufferGeometry 是最为特殊的一个。 4 | 5 | **特殊之处在于:在使用 TextBufferGeometry 创建 文字几何对象之前,需要先加载 3D 字体数据。 ** 6 | 7 | **字体数据文件通常为 .json 文件,Three.js 提供了一个专门负责加载字体数据的类:FontLoader** 8 | 9 | **由于需要加载外部字体数据文件,所以创建 3D 文字这个过程是异步的。** 10 | 11 | 12 | 13 | **字体数据的补充说明:** 14 | 15 | 1. 字体数据 准确来说是描述字体轮廓的 16 | 2. 字体数据 究竟包含哪些字符由 制作 3D 软件决定的,例如有些字体数据只针对字母,并不支持汉字。 17 | 3. 若某个字符并不包含在 字体数据中,那么 Three.js 会将该字符替换为 问号(?) 18 | 19 | 20 | 21 | 我们暂且先不考虑 字体数据文件 是如何在第 3 方 3D 软件中创建、导出的,先看一下如何加载字体数据文件。 22 | 23 | ## FontLoader用法分析 24 | 25 | ### FontLoader: 26 | 27 | 我先看一下 FontLoader.d.ts 的内容: 28 | 29 | > 这是本系列文章 第一次 从 .d.ts 文件角度来分析、推理 某个类的用法。 30 | > 31 | > 这也体现了使用 TypeScript 的好处,你可以随时去查看对应的 .d.ts 文件,去查看各种类的具体的使用方法 32 | 33 | ``` 34 | import { Loader } from './Loader'; 35 | import { LoadingManager } from './LoadingManager'; 36 | import { Font } from './../extras/core/Font'; 37 | 38 | export class FontLoader extends Loader { 39 | 40 | constructor( manager?: LoadingManager ); 41 | 42 | load( 43 | url: string, 44 | onLoad?: ( responseFont: Font ) => void, 45 | onProgress?: ( event: ProgressEvent ) => void, 46 | onError?: ( event: ErrorEvent ) => void 47 | ): void; 48 | parse( json: any ): Font; 49 | 50 | } 51 | ``` 52 | 53 | **从上面可以看出:** 54 | 55 | 1. FontLoader 继承于 Loader 56 | 57 | > 不难想象,在 Three.js 中一定还有负责加载其他资源类型的 Loader 58 | 59 | 2. 构造函数接收一个 LoadingManager 实例 60 | 61 | 3. 方法 load( url, onLoad, onProgress, onError ),从字面上就能推测出: 62 | 63 | 1. url:资源加载地址 64 | 2. onLoad:加载完成后,触发的事件回调函数 65 | 3. onProgress:加载过程中,触发的事件回调函数 66 | 4. onError:加载失败,触发的事件回调函数 67 | 68 | 4. 方法 parse( json ) ,用来解析 JSON 数据,并返回 Font 实例 69 | 70 | 71 | 72 | **延展说明:** 73 | 74 | FontLoader 中牵扯到了另外 3 个类:Loader、LoadingManager、Font。 75 | 76 | Loader 和 LoadingManager 内部封装了加载和解析数据的过程,我们暂时不用深究他们的源码和用法,接下来重点看一下 Font。 77 | 78 | 79 | 80 | ### Font: 81 | 82 | ``` 83 | import { Shape } from './Shape'; 84 | 85 | export class Font { 86 | 87 | constructor( jsondata: any ); 88 | 89 | /** 90 | * @default 'Font' 91 | */ 92 | type: string; 93 | 94 | data: string; 95 | 96 | generateShapes( text: string, size: number ): Shape[]; 97 | 98 | } 99 | ``` 100 | 101 | **从上面可以看出:** 102 | 103 | 1. Font 类是将 原始的字体数据 从 JSON 转化为 Three.js 内部可识别的 字体数据。 104 | 105 | 2. Font 构造函数接收的参数就是 JSON 数据 106 | 107 | 3. 属性 type 默认值为 'Font' 108 | 109 | 4. 属性 data 数据类型为字符串,我猜出 data 就是用来保存构造函数中 jsondata 数据的 110 | 111 | 5. 方法 generateShapes( text, size ): Shape[],根据参数来生成所有的 形状(shape) 112 | 113 | > Shape 这个类在前面示例中使用过多次,shape 单词的本意就是 形状 114 | > 115 | > Shape[] 表示这是一个 元祖数组,数组的每一个元素都必须是 Shape 实例 116 | 117 | 118 | 119 | 至此,对于 FontLoader、Font 已有大致了解,接下来该去尝试如何使用他们了。 120 | 121 | 122 | 123 | ## 使用 FontLoader 加载字体数据 124 | 125 | **我们使用 FontLoader 加载线上的一个字体数据:https://threejsfundamentals.org/threejs/resources/threejs/fonts/helvetiker_regular.typeface.json** 126 | 127 | 128 | 129 | ### 示例1:使用基础的方式进行加载 130 | 131 | ``` 132 | const loader = new FontLoader() 133 | const url = 'https://threejsfundamentals.org/threejs/resources/threejs/fonts/helvetiker_regular.typeface.json' 134 | 135 | const onLoadHandle = (responseFont: Font) => { 136 | console.log(responseFont) 137 | } 138 | const onProgressHandle = (event: ProgressEvent) => { 139 | console.log(event) 140 | } 141 | const onErrorHandle = (error: ErrorEvent) => { 142 | console.log(error) 143 | } 144 | 145 | loader.load(url, onLoadHandle, onProgressHandle, onErrorHandle) 146 | ``` 147 | 148 | 以上代码中,采用最原始,基础的方式来加载 字体数据。 149 | 150 | 字体数据加载完成对应的 onLoadHandle 处理函数中,可以放置后续的操作。 151 | 152 | 153 | 154 | ### 示例2:使用 async/await 封装加载过程 155 | 156 | **我们封装的目标:将异步加载过程封装好,然后就可以像写同步代码一样去获取异步结果。** 157 | 158 | 159 | 160 | **首先分析一下 示例1 中几个关键点:** 161 | 162 | 1. new FontLoader() 实例化一个 加载器 163 | 2. url:加载地址 164 | 3. onLoadHandle、onProgressHandle、onErrorHandle 3 个加载事件处理函数 165 | 166 | 167 | 168 | **封装思路分析:** 169 | 170 | 1. 实现方式肯定使用 promise + async/awiat 171 | 172 | 2. promise 中的 resolve 刚好对应 onLoadHandle 173 | 174 | 3. promise 中的 reject 刚好对应 onErrorHandle 175 | 176 | 4. 至于加载过程 onProgressHandle,我们基本用不到他,所以直接选择忽略该回到函数 177 | 178 | > 届时我们会传递一个 undefined 来替代 onProgressHandle 179 | 180 | 181 | 182 | **封装加载过程:** 183 | 184 | ``` 185 | const loadFont: (url: string) => Promise = (url) => { 186 | const loader = new FontLoader() 187 | return new Promise((resolve, reject: (error: ErrorEvent) => void) => { 188 | loader.load(url, resolve, undefined, reject) 189 | }) 190 | } 191 | ``` 192 | 193 | 只有在 async 函数中才可以使用到 Promise,所以我们还需要定义以下函数: 194 | 195 | ``` 196 | const createText = async () => { 197 | 198 | const url = 'https://threejsfundamentals.org/threejs/resources/threejs/fonts/helvetiker_regular.typeface.json' 199 | 200 | const font = await loadFont(url) //请注意这行代码,我们可以想使用同步编写的方式,获取到 字体数据 201 | 202 | //开始创建 3D 字体 几何对象 203 | ... 204 | } 205 | 206 | createText() 207 | ``` 208 | 209 | 210 | 211 | ## 改造我们之前写的HelloPrimitives 212 | 213 | ### 改造原因: 214 | 215 | 1. 由于 TextBufferGeometry 创建过程为异步,async/await 具有函数异步传染性,因此我们需要将 index.tsx 中的代码也修改成异步 216 | 2. 之前 index.tsx 中 useEffect( ... ) 内容稍显复杂,我们特意将其中 随机生成材质、获得摆放位置 的响应代码从 useEffect 中提取出来,放到外部。 217 | 218 | 219 | 220 | ### my-text.ts 221 | 222 | ``` 223 | import { Font, FontLoader, Mesh, Object3D, TextBufferGeometry } from "three"; 224 | import { createMaterial } from './index' 225 | 226 | const loadFont: (url: string) => Promise = (url) => { 227 | const loader = new FontLoader() 228 | return new Promise((resolve, reject: (error: ErrorEvent) => void) => { 229 | loader.load(url, resolve, undefined, reject) 230 | }) 231 | } 232 | 233 | const createText = async () => { 234 | 235 | const url = 'https://threejsfundamentals.org/threejs/resources/threejs/fonts/helvetiker_regular.typeface.json' 236 | 237 | const font = await loadFont(url) //异步加载 字体数据 238 | 239 | //第一个参数 'puxiao' 可以替换成任何其他的英文字母 240 | //特别注意:由于目前我们加载的 字体数据 只是针对英文字母的字体轮廓描述,并没有包含中文字体轮廓 241 | //所以如果设置成 汉字,则场景无法正常渲染出文字 242 | //对于无法渲染的字符,会被渲染成 问号(?) 作为替代 243 | //第二个参数对应的是文字外观配置 244 | const geometry = new TextBufferGeometry('puxiao', { 245 | font: font, 246 | size: 3.0, 247 | height: .2, 248 | curveSegments: 12, 249 | bevelEnabled: true, 250 | bevelThickness: 0.15, 251 | bevelSize: .3, 252 | bevelSegments: 5, 253 | }) 254 | 255 | const mesh = new Mesh(geometry, createMaterial()) 256 | 257 | //Three.js默认是以文字左侧为中心旋转点,下面的代码是将文字旋转点位置改为文字中心 258 | //实现的思路是:用文字的网格去套进另外一个网格,通过 2 个网格之间的落差来实现将旋转中心点转移到文字中心位置 259 | //具体代码细节,会在以后 场景 中详细学习,此刻你只需要照着以下代码敲就可以 260 | geometry.computeBoundingBox() 261 | geometry.boundingBox?.getCenter(mesh.position).multiplyScalar(-1) 262 | 263 | const text = new Object3D() 264 | text.add(mesh) 265 | 266 | return text 267 | } 268 | 269 | export default createText 270 | ``` 271 | 272 | 273 | 274 | ### index.tsx 275 | 276 | ``` 277 | import { useRef, useEffect, useCallback } from 'react' 278 | import * as Three from 'three' 279 | 280 | import './index.scss' 281 | 282 | import myBox from './my-box' 283 | import myCircle from './my-circle' 284 | import myCone from './my-cone' 285 | import myCylinder from './my-cylinder' 286 | import myDodecahedron from './my-dodecahedron' 287 | import myEdges from './my-edges' 288 | import myExtrude from './my-extrude' 289 | import myIcosahedron from './my-icosahedron' 290 | import myLathe from './my-lathe' 291 | import myOctahedron from './my-octahedron' 292 | import myParametric from './my-parametric' 293 | import myPlane from './my-plane' 294 | import myPolyhedron from './my-polyhedron' 295 | import myRing from './my-ring' 296 | import myShape from './my-shape' 297 | import mySphere from './my-sphere' 298 | import myTetrahedron from './my-tetrahedron' 299 | import myTorus from './my-torus' 300 | import myTorusKnot from './my-torus-knot' 301 | import myTube from './my-tube' 302 | import myWireframe from './my-wireframe' 303 | import createText from './my-text' 304 | 305 | const meshArr: (Three.Mesh | Three.LineSegments | Three.Object3D)[] = [] //保存所有图形的元数组 306 | 307 | export const createMaterial = () => { 308 | const material = new Three.MeshPhongMaterial({ side: Three.DoubleSide }) 309 | 310 | const hue = Math.floor(Math.random() * 100) / 100 //随机获得一个色相 311 | const saturation = 1 //饱和度 312 | const luminance = 0.5 //亮度 313 | 314 | material.color.setHSL(hue, saturation, luminance) 315 | 316 | return material 317 | } 318 | 319 | //定义物体在画面中显示的网格布局 320 | const eachRow = 5 //每一行显示 5 个 321 | const spread = 15 //行高 和 列宽 322 | 323 | const getPositionByIndex = (index: number) => { 324 | //我们设定的排列是每行显示 eachRow,即 5 个物体、行高 和 列宽 均为 spread 即 15 325 | //因此每个物体根据顺序,计算出自己所在的位置 326 | const row = Math.floor(index / eachRow) //计算出所在行 327 | const column = index % eachRow //计算出所在列 328 | 329 | const x = (column - 2) * spread //为什么要 -2 ? 330 | //因为我们希望将每一行物体摆放的单元格,依次是:-2、-1、0、1、2,这样可以使每一整行物体处于居中显示 331 | const y = (2 - row) * spread 332 | 333 | return { x, y } 334 | } 335 | 336 | const HelloPrimitives = () => { 337 | const canvasRef = useRef(null) 338 | const rendererRef = useRef(null) 339 | const cameraRef = useRef(null) 340 | 341 | const createInit = useCallback( 342 | async () => { 343 | 344 | if (canvasRef.current === null) { 345 | return 346 | } 347 | 348 | meshArr.length = 0 //以防万一,先清空原有数组 349 | 350 | //初始化场景 351 | const scene = new Three.Scene() 352 | scene.background = new Three.Color(0xAAAAAA) 353 | 354 | //初始化镜头 355 | const camera = new Three.PerspectiveCamera(40, 2, 0.1, 1000) 356 | camera.position.z = 120 357 | cameraRef.current = camera 358 | 359 | //初始化渲染器 360 | const renderer = new Three.WebGLRenderer({ canvas: canvasRef.current as HTMLCanvasElement }) 361 | rendererRef.current = renderer 362 | 363 | //添加 2 盏灯光 364 | const light0 = new Three.DirectionalLight(0xFFFFFF, 1) 365 | light0.position.set(-1, 2, 4) 366 | scene.add(light0) 367 | 368 | const light1 = new Three.DirectionalLight(0xFFFFFF, 1) 369 | light0.position.set(1, -2, -4) 370 | scene.add(light1) 371 | 372 | //获得各个 solid 类型的图元实例,并添加到 solidPrimitivesArr 中 373 | const solidPrimitivesArr: Three.BufferGeometry[] = [] 374 | solidPrimitivesArr.push(myBox, myCircle, myCone, myCylinder, myDodecahedron) 375 | solidPrimitivesArr.push(myExtrude, myIcosahedron, myLathe, myOctahedron, myParametric) 376 | solidPrimitivesArr.push(myPlane, myPolyhedron, myRing, myShape, mySphere) 377 | solidPrimitivesArr.push(myTetrahedron, myTorus, myTorusKnot, myTube) 378 | 379 | //将各个 solid 类型的图元实例转化为网格,并添加到 primitivesArr 中 380 | solidPrimitivesArr.forEach((item) => { 381 | const material = createMaterial() //随机获得一种颜色材质 382 | const mesh = new Three.Mesh(item, material) 383 | meshArr.push(mesh) //将网格添加到网格数组中 384 | }) 385 | 386 | //创建 3D 文字,并添加到 mesArr 中,请注意此函数为异步函数 387 | meshArr.push(await createText()) 388 | 389 | //获得各个 line 类型的图元实例,并添加到 meshArr 中 390 | const linePrimitivesArr: Three.BufferGeometry[] = [] 391 | linePrimitivesArr.push(myEdges, myWireframe) 392 | 393 | //将各个 line 类型的图元实例转化为网格,并添加到 meshArr 中 394 | linePrimitivesArr.forEach((item) => { 395 | const material = new Three.LineBasicMaterial({ color: 0x000000 }) 396 | const mesh = new Three.LineSegments(item, material) 397 | meshArr.push(mesh) 398 | }) 399 | 400 | //配置每一个图元实例,转化为网格,并位置和材质后,将其添加到场景中 401 | meshArr.forEach((mesh, index) => { 402 | const { x, y } = getPositionByIndex(index) 403 | 404 | mesh.position.x = x 405 | mesh.position.y = y 406 | 407 | scene.add(mesh) //将网格添加到场景中 408 | }) 409 | 410 | //添加自动旋转渲染动画 411 | const render = (time: number) => { 412 | time = time * 0.001 413 | meshArr.forEach(item => { 414 | item.rotation.x = time 415 | item.rotation.y = time 416 | }) 417 | 418 | renderer.render(scene, camera) 419 | window.requestAnimationFrame(render) 420 | } 421 | window.requestAnimationFrame(render) 422 | }, 423 | [canvasRef], 424 | ) 425 | 426 | const resizeHandle = () => { 427 | //根据窗口大小变化,重新修改渲染器的视椎 428 | if (rendererRef.current === null || cameraRef.current === null) { 429 | return 430 | } 431 | 432 | const canvas = rendererRef.current.domElement 433 | cameraRef.current.aspect = canvas.clientWidth / canvas.clientHeight 434 | cameraRef.current.updateProjectionMatrix() 435 | rendererRef.current.setSize(canvas.clientWidth, canvas.clientHeight, false) 436 | } 437 | 438 | //组件首次装载到网页后触发,开始创建并初始化 3D 场景 439 | useEffect(() => { 440 | createInit() 441 | resizeHandle() 442 | window.addEventListener('resize', resizeHandle) 443 | return () => { 444 | window.removeEventListener('resize', resizeHandle) 445 | } 446 | }, [canvasRef, createInit]) 447 | 448 | return ( 449 | 450 | ) 451 | } 452 | 453 | export default HelloPrimitives 454 | ``` 455 | 456 | 457 | 458 | 特别提醒:虽然针对 index.tsx 进行了修改,但是并不影响之前创建的其他图元,其他图元并不需要修改任何代码。 459 | 460 | 461 | 462 | > 你是否想也赶紧自己去创建一份可以显示中文的 3D 字体数据? 463 | > 这需要你会一些 3D 软件,例如 C4D(收费软件)、blender(免费开源软件) 464 | > 在后续的学习中,一定会涉及到自定义字体样式、自定义几何图形,自己建模的,目前主要任务还是先系统学习 Three.js。 465 | 466 | 467 | 468 | 至此,Three.js 中内置的 22 种图元,均逐一尝试完毕。 469 | 470 | 同时也意味着,我们 Three.js 的 hello world 之旅完成,通过 HelloThreejs、HelloPrimitives,我们该体验的代码也都体验过了。 471 | 472 | **接下来就要逐个开始深入、详细学习 具体的各个模块的用法。** 473 | 474 | **加油!** 475 | 476 | -------------------------------------------------------------------------------- /04 添加一些自适应.md: -------------------------------------------------------------------------------- 1 | # 04 添加一些自适应 2 | 3 | 在上一节中,已经实现了 HelloThree 最为基础的示例。本节将进一步优化那个示例。 4 | 5 | 我们将给示例中的 canvas 添加宽高自适应,让它充满整个浏览器。 6 | 7 | 8 | 9 |
10 | 11 | ### 优化第一项:修改 canvas 尺寸 12 | 13 | 默认 canvas 尺寸为 高150像素,宽300像素。我们现在把 canvas 修改为撑满屏幕。 14 | 15 |
16 | 17 | #### 第1处修改: 18 | 19 | 打开项目中 src/indes.scss ,修改 html、body、root 样式: 20 | 21 | ```diff 22 | body{ 23 | - margin:0; 24 | } 25 | 26 | html,body { 27 | + margin: 0; 28 | + padding: 0; 29 | + height: 100%; 30 | + width: 100%; 31 | } 32 | 33 | #root { 34 | + height: inherit; 35 | + width: inherit; 36 | } 37 | ``` 38 | 39 | > 由于我们已经给 html, body 设置了 宽高 100%,所以 root 宽高 设置为 inherit 即可。 40 | 41 | 42 | 43 |
44 | 45 | #### 第2处修改: 46 | 47 | 新建文件 src/components/hello-threejs/index.scss,添加 canvas 样式: 48 | 49 | ``` 50 | .full-screen { 51 | display: block; 52 | width: inherit; 53 | height: inherit; 54 | } 55 | ``` 56 | 57 | > canvas 宽高继承于 root,root 继承于 body,而 body 宽高均为 100%,所以最终 canvas 宽高也为 100%,撑满整个屏幕。 58 | 59 | 60 | 61 |
62 | 63 | **特别提醒:** 在上面样式中,我们设置了 display 为 block,让 canvas 由 内联元素 改为 块级元素。 64 | 65 | 为什么要这么做? 66 | 67 | 因为我们在后面代码中,需要获取 canvas 的 clientWidth(内部实际宽度) 和 clientHeight(内部实际高度),而内联元素是无法获取到这 2 两个属性值的,因此我们要将画布修改为块级元素。 68 | 69 | > 内联元素和没有 CSS 样式的元素,获取到的 clientWidht 和 clientHeight 的值永远为 0 70 | 71 | 72 | 73 |
74 | 75 | #### 第3处修改: 76 | 77 | 在 src/components/hello-threejs/index.stx 中引入并添加样式 78 | 79 | ```diff 80 | + import './index.scss' 81 | 82 | const HelloThreejs: React.FC = () => { 83 | ... 84 | return ( 85 | 86 | ) 87 | } 88 | ``` 89 | 90 | 此时,再次执行预览 `yarn start`,就会发现 canvas 已全屏,充满整个浏览器可见区域。 91 | 92 | 93 | 94 |
95 | 96 | #### 目前存在的问题: 97 | 98 | 可以观察到 canvas 是被硬生生由原本的 高150、像素 宽 300 像素给硬生生拉伸成 100%。 99 | 100 | 所以立方体出现了 扭曲、模糊、锯齿。 101 | 102 | 那我们继续修改代码。 103 | 104 | 105 | 106 |
107 | 108 | #### 第4处修改: 109 | 110 | 修改 src/components/hello-threejs/index.stx 中 render 函数的代码,让镜头宽高比跟随着 canvas 宽高比,确保立方体不变形。 111 | 112 | ```diff 113 | ... 114 | const render = (time: number) => { 115 | time = time * 0.001 116 | 117 | + const canvas = renderer.domElement //获取 canvas 118 | + camera.aspect = canvas.clientWidth / canvas.clientHeight //设置镜头宽高比 119 | + camera.updateProjectionMatrix() //通知镜头更新视椎(视野) 120 | 121 | cubes.map(cube => { ... } 122 | } 123 | ... 124 | ``` 125 | 126 | 127 | 128 |
129 | 130 | #### 第5处修改: 131 | 132 | 第4步立方体已经不再变形,但是依然模糊,锯齿感比较明显。原因是渲染器(renderer) 渲染出的画面尺寸小于实际网页 canvas 尺寸。 133 | 134 | 继续修改 src/components/hello-threejs/index.tsx 中 render 函数的代码。 135 | 136 | ```diff 137 | ... 138 | const render = (time: number) => { 139 | time = time * 0.001 140 | 141 | const canvas = renderer.domElement //获取 canvas 142 | camera.aspect = canvas.clientWidth / canvas.clientHeight //设置镜头宽高比 143 | camera.updateProjectionMatrix() //通知镜头更新视椎(视野) 144 | 145 | + renderer.setSize(canvas.clientWidth, canvas.clientHeight, false) 146 | + //第3个参数为可选参数,默认值为 true,false 意思是阻止因渲染内容尺寸发生变化而去修改 canvas 尺寸 147 | 148 | cubes.map(cube => { ... } 149 | } 150 | ... 151 | ``` 152 | 153 | 经过上面一番修改,浏览器中 canvas 里的立方体会变得不变形,且非常清晰。 154 | 155 | 156 | 157 |
158 | 159 | **关于 renderer.setSize() 第 3 个参数的补充说明:** 160 | 161 | 在本示例中 renderer 是 WebGLRenderer 实例。 162 | 163 | 我查看了一下 WebGLRenderer setSize() 源码:https://github.com/mrdoob/three.js/blob/master/src/renderers/WebGLRenderer.js 164 | 165 | 发现了其中以下代码片段: 166 | 167 | ``` 168 | this.setSize = function ( width, height, updateStyle ) { 169 | ... 170 | 171 | if ( updateStyle !== false ) { 172 | _canvas.style.width = width + 'px'; 173 | _canvas.style.height = height + 'px'; 174 | } 175 | 176 | ... 177 | } 178 | ``` 179 | 180 | 可以看出,假设第 3 个参数不传值,那么该参数值实际调用时为 undefined,undefined !==false 的值为 true 。 181 | 182 | 因此我们可以得出结论:**setSize() 第 3 个参数的默认值为 true**,当我们希望控制尺寸的主动权完全由 canvas 决定时,那么一定要设置第 3 个参数为 false。 183 | 184 | 185 | 186 |
187 | 188 | ## 如何应对高清屏? 189 | 190 | 从上面示例可以看出,浏览器中渲染的画面尺寸,完全是按照 CSS 样式尺寸来显示的。 191 | 192 | 对于高清屏(HD-DPI)来说,那 Three.js 渲染的画面又该有何应对呢? 193 | 194 | 195 | 196 |
197 | 198 | #### 第1种策略(推荐):不做任何策略 199 | 200 | 假设 HD-DP 比例为 3x,即原本 1 像素 则由 3 x 3 ,共 9 个像素来显示。 201 | 202 | 也就是说原本只需渲染 1 像素,现在需要渲染 9 像素,所消耗的性能是原来的 9 倍。 203 | 204 | 假设 3D 场景内容稍微复杂一些,那所带来的渲染性能要求会非常高,画面清晰的代价是更高性能的消耗,引起的卡顿 会带来不好的用户体验。 205 | 206 | 事实上高清屏本身都会做显示优化,即使不做任何处理,画面清晰度并不会明显特别差。 207 | 208 | 因此,什么都不做,其实是一个非常好的策略。 209 | 210 | 211 | 212 |
213 | 214 | 假设就是想设置成高清屏,那又该如何操作呢? 215 | 216 | #### 第2种策略(强烈不推荐):通过 renderer.setPixelRatio 来配置渲染分辨率倍数 217 | 218 | 在浏览器中,通过 window.devicePixelRatio 可获得当前屏幕物理分辨率与 CSS 样式分辨率的比值。 219 | 220 | 然后告知渲染器,以后任何 renderer.setSize 都按照此 比值(倍数) 进行渲染 221 | 222 | ``` 223 | renderer.setPixelRatio(window.devicePixelRatio) 224 | ``` 225 | 226 | **强烈不推荐这种做法。** 227 | 228 | 229 | 230 |
231 | 232 | #### 第3种策略(勉强推荐):按屏幕分辨率比值,计算出对应渲染尺寸 233 | 234 | 这种策略思路是:通过分辨率比值,计算出实际上应该渲染的最大尺寸,然后渲染出这个尺寸,再将画面内容渲染到 canvas 中。 235 | 236 | 举例:假设 HD-DP 比例为 3x,即 普通宽 1 像素对应高清屏宽 3 像素。那么可以将 renderer 渲染出比 canvas 实际大 3 倍的画面,然后再将画面以 “压缩” 3 倍的形式填充到 canvas 中,从而实现所谓的 “高清屏渲染”。 237 | 238 | 这样的操作,会使 渲染器 renderer 像正常渲染一样来执行各种渲染操作。 239 | 240 | **对应的渲染代码为:** 241 | 242 | ``` 243 | const canvas = renderer.domElement 244 | const ratio = window.devicePixelRatio 245 | const newWidth = Math.floor(canvas.clientWidth * ratio) 246 | const newHeight = Math.floor(canvas.clientHeight * ratio) 247 | renderer.setSize(newWidth,newHeight,false) //特别注意,第 3 个参数一定要为 false 248 | ``` 249 | 250 |
251 | 252 | **尽管第 3 种策略相对第 2 种好一些,但是还是建议选择第 1 种策略,即什么也不做。** 253 | 254 | > 你看在线视频时,关于清晰度会做哪种选择? 255 | > A:蓝光 1080P,画面超级清晰,但播放时会有点卡顿 256 | > B:高清 720 P,画面清晰度能够接受,播放时也非常流畅 257 | 258 |
259 | 260 | 至此,关于 Three.js 的入门演示示例,已经结束。 261 | 262 | 263 | 264 |
265 | 266 | ## 等一等,我们现在的代码正确吗? 267 | 268 | 目前来说,虽然实际运行没有一点问题,但代码实际上并不是最优的。 269 | 270 | 现在做给渲染器添加尺寸发生变化的代码是放在了 window.requestAnimationFrame() 中,每一次浏览器刷新都重新计算并设置一次,事实上在浪费着性能。 271 | 272 | 我们需要改进的地方时:仅在浏览器窗口尺寸发生 resize 事件时去修改 渲染器 即可。 273 | 274 |
275 | 276 | **需要说明的地方:** 277 | 278 | 1. 监听浏览器窗口尺寸变化,对应的是 window.addEventListener('resize', xxxx) 279 | 2. 当 React 卸载后,一定记得移除监听 window.removeEventListener('resize', xxxx) 280 | 3. 为了在移除监听时可以找到 在 useEffect中定义的 resize 事件处理函数,我们会在示例代码中,再通过 useRef 创建一个变量指向 事件处理函数。 281 | 282 | 283 | 284 | **最终修改后的代码:** 285 | 286 | ``` 287 | import React, { useRef, useEffect } from 'react' 288 | import { WebGLRenderer, PerspectiveCamera, Scene, BoxGeometry, Mesh, DirectionalLight, MeshPhongMaterial } from 'three' 289 | 290 | import './index.scss' 291 | 292 | const HelloThreejs: React.FC = () => { 293 | const canvasRef = useRef(null) 294 | const resizeHandleRef = useRef<() => void>() 295 | 296 | useEffect(() => { 297 | if (canvasRef.current) { 298 | //创建渲染器 299 | const renderer = new WebGLRenderer({ canvas: canvasRef.current }) 300 | 301 | //创建镜头 302 | //PerspectiveCamera() 中的 4 个参数分别为: 303 | //1、fov(field of view 的缩写),可选参数,默认值为 50,指垂直方向上的角度,注意该值是度数而不是弧度 304 | //2、aspect,可选参数,默认值为 1,画布的高宽比,例如画布高300像素,宽150像素,那么意味着高宽比为 2 305 | //3、near,可选参数,默认值为 0.1,近平面,限制摄像机可绘制最近的距离,若小于该距离则不会绘制(相当于被裁切掉) 306 | //4、far,可选参数,默认值为 2000,远平面,限制摄像机可绘制最远的距离,若超出该距离则不会绘制(相当于被裁切掉) 307 | //以上 4 个参数在一起,构成了一个 “视椎”,关于视椎的概念理解,暂时先不作详细描述。 308 | const camera = new PerspectiveCamera(75, 2, 0.1, 5) 309 | 310 | //创建场景 311 | const scene = new Scene() 312 | 313 | //创建几何体 314 | const geometry = new BoxGeometry(1, 1, 1) 315 | 316 | //创建材质 317 | //我们需要让立方体能够反射光,所以不使用MeshBasicMaterial,而是改用MeshPhongMaterial 318 | //const material = new MeshBasicMaterial({ color: 0x44aa88 }) 319 | const material1 = new MeshPhongMaterial({ color: 0x44aa88 }) 320 | const material2 = new MeshPhongMaterial({ color: 0xc50d0d }) 321 | const material3 = new MeshPhongMaterial({ color: 0x39b20a }) 322 | 323 | //创建网格 324 | const cube1 = new Mesh(geometry, material1) 325 | cube1.position.x = -2 326 | scene.add(cube1)//将网格添加到场景中 327 | 328 | const cube2 = new Mesh(geometry, material2) 329 | cube2.position.x = 0 330 | scene.add(cube2)//将网格添加到场景中 331 | 332 | const cube3 = new Mesh(geometry, material3) 333 | cube3.position.x = 2 334 | scene.add(cube3)//将网格添加到场景中 335 | 336 | const cubes = [cube1, cube2, cube3] 337 | 338 | //创建光源 339 | const light = new DirectionalLight(0xFFFFFF, 1) 340 | light.position.set(-1, 2, 4) 341 | scene.add(light)//将光源添加到场景中 342 | 343 | //设置透视镜头的Z轴距离,以便我们以某个距离来观察几何体 344 | //之前初始化透视镜头时,设置的近平面为 0.1,远平面为 5 345 | //因此 camera.position.z 的值一定要在 0.1 - 5 的范围内,超出这个范围则画面不会被渲染 346 | camera.position.z = 2 347 | 348 | //渲染器根据场景、透视镜头来渲染画面,并将该画面内容填充到 DOM 的 canvas 元素中 349 | //renderer.render(scene, camera)//由于后面我们添加了自动渲染渲染动画,所以此处的渲染可以注释掉 350 | 351 | //添加自动旋转渲染动画 352 | const render = (time: number) => { 353 | time = time * 0.001 354 | // cube.rotation.x = time 355 | // cube.rotation.y = time 356 | 357 | cubes.forEach(cube => { 358 | cube.rotation.x = time 359 | cube.rotation.y = time 360 | }) 361 | 362 | renderer.render(scene, camera) 363 | window.requestAnimationFrame(render) 364 | } 365 | window.requestAnimationFrame(render) 366 | 367 | 368 | const handleResize = () => { 369 | const canvas = renderer.domElement 370 | camera.aspect = canvas.clientWidth / canvas.clientHeight 371 | camera.updateProjectionMatrix() 372 | 373 | renderer.setSize(canvas.clientWidth, canvas.clientHeight, false) 374 | } 375 | 376 | handleResize() //默认打开时,即重新触发一次 377 | 378 | resizeHandleRef.current = handleResize //将 resizeHandleRef.current 与 useEffect() 中声明的函数进行绑定 379 | window.addEventListener('resize', handleResize) //添加窗口 resize 事件处理函数 380 | } 381 | return () => { 382 | if (resizeHandleRef && resizeHandleRef.current) { 383 | window.removeEventListener('resize', resizeHandleRef.current) 384 | } 385 | } 386 | }, [canvasRef]) 387 | 388 | return ( 389 | 390 | ) 391 | } 392 | 393 | export default HelloThreejs 394 | ``` 395 | 396 |
397 | 398 | **再次补充说明:** 399 | 400 | 尽管代码已经有所改进,但上述代码中,创建 3D 场景的代码都集中在 useEffect(() => { if (canvasRef.current) { ... } }, [canvasRef] ) ,这很显然并不是合理的。 401 | 402 | 合理的应该是通过 useState() 去将 renderer、camera、scene 等都独立出来定义。 403 | 404 | 将原本集中的代码分散到更多小的 代码块 中。 405 | 406 | 包括浏览器窗口 resize 事件处理,都应该添加 防抖 策略。 407 | 408 | 这里就先暂时这样,不再做改进,等到将来再去做稍微复杂点的 场景应用 时,会再次优化代码结构。 409 | 410 | 411 | 412 |
413 | 414 | > 以下内容更新于 2021.05.11 415 | 416 | #### 通过 ResizeObserver 来监听画布尺寸变化 417 | 418 | 在本文以及本教程的所有后面章节中,我们都是通过监听 window resize 事件,在 handleResize 处理函数中重新设置 相机和渲染器 的一些属性配置的。 419 | 420 | 由于这些示例中实际上只存在一个 标签,画布(canvas) 的尺寸是充满整个浏览器窗口,画布尺寸发生变化的情况只有一种,即 浏览器窗口尺寸发生变化。 421 | 422 | 但是在实际的项目中,有可能 标签仅仅只占 document.body 中的一部分而已,造成 画布(canvas) 尺寸发生变化,还有以下几种可能: 423 | 424 | 1. 通过 CSS 修改 标签的宽高 425 | 2. 在 flex 布局下,当其他元素尺寸发生变化时,影响到 ,从而造成画布发生尺寸变化。 426 | 3. ... 427 | 428 | 很明显,通过 CSS 的变化造成 画布尺寸变化,和 window resize 完全不相关联。 429 | 430 | 因此我们要寻找其他监听 画布 标签尺寸发生变化的方式。 431 | 432 | 433 | 434 |
435 | 436 | **我们可以通过浏览器最新的 ResizeObserver 来监听 尺寸变化。** 437 | 438 | 439 | 440 |
441 | 442 | **ResizeObserver简介** 443 | 444 | ResizeObserver 是现代浏览器 API 中一个新的内置类,它可以监控某个 DOM 元素尺寸变化。 445 | 446 | > 在 ResizeObserver 出现之前,只能对 window 添加 resize 监听,无法对 DOM 元素添加尺寸变化监听。 447 | 448 | 449 | 450 |
451 | 452 | > observer 单词意思是 “观察”,也就是设计模式中的 “观察模式”,但是我个人习惯性有时候称呼为 “监控模式” 453 | 454 | 455 | 456 |
457 | 458 | ResizeObserver 一共有 3 个方法: 459 | 460 | 1. observe():开始监控(观察)某元素尺寸变化 461 | 2. unobserve():停止监控(观察)某元素尺寸变化 462 | 3. disconnect():取消和结束目标元素上所有的监控(观察) 463 | 464 |
465 | 466 | 更多详细介绍,请查阅: 467 | 468 | https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver 469 | 470 | 471 | 472 |
473 | 474 | **实际示例代码:** 475 | 476 | ```diff 477 | const handleResize = () => { 478 | const canvas = renderer.domElement 479 | camera.aspect = canvas.clientWidth / canvas.clientHeight 480 | camera.updateProjectionMatrix() 481 | 482 | renderer.setSize(canvas.clientWidth, canvas.clientHeight, false) 483 | } 484 | handleResize() 485 | 486 | //我们不再添加 window resize 监控(观察) 487 | - window.addEventListener('resize', handleResize) 488 | 489 | //改为使用 ResizeObserver 来监控(观察)尺寸变化 490 | + const resizeObserver = new ResizeObserver(() => { 491 | + handleResize() 492 | + }) 493 | + resizeObserver.observe(canvasRef.current) 494 | 495 | //当我们卸载组件前,一定要 清除掉 监控(观察) 496 | return () =>{ 497 | - window.removeEventListener('resize', resizeHandleRef.current) 498 | + resizeObserver.disconnect() 499 | } 500 | ``` 501 | 502 | > 请注意,resizeObserver.observe() 方法中,可以有第 2 个可选参数。 503 | > 504 | > 例如:resizeObserver.observe(canvasRef.current, { box: 'border-box' }) 505 | > 506 | > 如果第 2 个可选参数不填,那么默认值为 { box: 'content-box' } 507 | 508 | 509 | 510 |
511 | 512 | **与本文无关的事情** 513 | 514 | 我在查阅 MDN 关于 [ResizeObserver.observer()](https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver/observe) 介绍时,发现 简体中文(zh-cn) 介绍页中缺少对第 2 个参数,也就是可选参数的中文介绍,于是我就向 MDN 提交了 PR,添加上了该部分。 515 | 516 | https://github.com/mdn/translated-content/pull/817 517 | 518 | 目前该 PR 已经被合并进 main 中,但是正常访问的 MDN 网页中还未更新过来,估计过一段时间就会看到。 519 | 520 | > 或许此刻已经更新了。 521 | 522 | 523 | 524 |
525 | 526 | > 以上内容更新于 2021.05.11 527 | 528 | 529 | 530 |
531 | 532 | 那么接下来,会系统学习一下 Three.js 的一些基础理论。 533 | 534 | **大楼究竟能改多高,取决于地基有多深,加油!** 535 | -------------------------------------------------------------------------------- /25 Three.js解决方案之添加背景和天空盒.md: -------------------------------------------------------------------------------- 1 | # 25 Three.js解决方案之添加背景和天空盒 2 | 3 | 在我们之前所有演示的案例中,场景中的背景往往使用默认的黑色,或者是其他纯颜色。 4 | 5 | 下面我们讲解一下 Three.Screen 的背景设置方式。 6 | 7 | 8 | 9 |
10 | 11 | ## 设置场景背景 12 | 13 | 场景(Three.Screen)有一个属性 .background。我们可以通过设置这个属性来给场景添加背景。 14 | 15 | 16 | 17 |
18 | 19 | **.background属性值类型** 20 | 21 | 场景背景属性值一共有 3 种类型: 22 | 23 | 1. 默认为 null 24 | 25 | > .background 属性值为 null,场景显示为黑色 26 | 27 | 2. 某颜色 Three.Color 28 | 29 | > Three.Color 可接受 字符串或数字类型的颜色值,例如: 30 | > 31 | > 1. new Three.Color('#333') 32 | > 2. new Three.Color('green') 33 | > 3. new Three.Color(0x333333) 34 | 35 | 3. 某纹理 Three.Texture 36 | 37 | 38 | 39 |
40 | 41 | **设置背景色示例代码:** 42 | 43 | ``` 44 | const scene = new Three.Scene() 45 | scene.background = new Three.Color(0x333333) 46 | ``` 47 | 48 | 49 | 50 |
51 | 52 | **设置背景纹理图片示例代码:** 53 | 54 | ``` 55 | const scene = new Three.Scene() 56 | 57 | const textureLoader = new Three.TextureLoader() 58 | scene.background = textureLoader.load(require('@/assets/imgs/blue_sky.jpg').default) 59 | ``` 60 | 61 | 或者是 62 | 63 | ``` 64 | const textureLoader = new Three.TextureLoader() 65 | textureLoader.load(require('@/assets/imgs/blue_sky.jpg').default, (texture) => { 66 | scene.background = texture 67 | }) 68 | ``` 69 | 70 | 71 | 72 |
73 | 74 | 上面设置场景纹理背景图,实际运行后你会发现虽然背景图显示了,但是背景图却有可能是变形着的。 75 | 76 | 这是由于背景图片本身就一个宽高比,而画布(Canvas)本身也有一个宽高比。 77 | 78 | > 实际上是渲染器渲染尺寸的宽高,例如每次浏览器窗口尺寸发生变化时,我们都会重新设置 渲染尺寸 79 | > 80 | > ``` 81 | > renderer.setSize(width, height, false) 82 | > ``` 83 | 84 | 85 | 86 |
87 | 88 | **判断高宽比,不让背景图变形且可以铺满整个背景** 89 | 90 | 假设我们不能接受背景图变形,那么我们就需要计算一下 2 者的宽高比,然后找出合适的比例进行修改。 91 | 92 | 这个不让背景图变形的计算过程是: 93 | 94 | 1. 计算出画布宽高比,例如 canvasAspect 95 | 2. 计算出背景图宽高比,例如 imgAspect 96 | 3. 然后计算 imgAspect/canvasAspect,得到 最终背景图在不变形的前提下的缩放比,例如 const resultAspect = imgAspect / canvasAspect 97 | 4. 然后依次设置背景图纹理的偏移(offset.x、offset.y),以及判断是否需要重复平铺背景图(repeat.x、repeat.y) 98 | 99 | 100 | 101 |
102 | 103 | **示例代码如下:** 104 | 105 | ``` 106 | const textureRef = useRef(null) 107 | 108 | ... 109 | 110 | const textureLoader = new Three.TextureLoader() 111 | textureLoader.load(require('@/assets/imgs/blue_sky.jpg').default, (texture) => { 112 | textureRef.current = texture 113 | scene.background = textureRef.current 114 | handleResize() //此处是当纹理图片加载完成后,需要调用执行一下 handleResize() 115 | }) 116 | 117 | ... 118 | 119 | const handleResize = () => { 120 | const canvasAspect = width / height //第1步:计算出画布宽高比 121 | if (textureRef.current !== null) { 122 | const bgTexture = textureRef.current 123 | const imgAspect = bgTexture.image.width / bgTexture.image.height //第2步:计算出背景图宽高比 124 | 125 | const resultAspect = imgAspect / canvasAspect //第3步:计算出最终背景图宽缩放宽高比 126 | 127 | //第4步:设置背景图纹理的偏移和重复 128 | bgTexture.offset.x = resultAspect > 1 ? (1 - 1 / resultAspect) / 2 : 0 129 | bgTexture.repeat.x = resultAspect > 1 ? 1 / resultAspect : 1 130 | 131 | bgTexture.offset.y = resultAspect > 1 ? 0 : (1 - resultAspect) / 2 132 | bgTexture.repeat.y = resultAspect > 1 ? 1 : resultAspect 133 | } 134 | } 135 | ``` 136 | 137 | 138 | 139 |
140 | 141 | **完整的示例代码如下:** 142 | 143 | ``` 144 | import { useRef, useEffect } from "react" 145 | import * as Three from 'three' 146 | import { OrbitControls } from "three/examples/jsm/controls/OrbitControls" 147 | import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader" 148 | 149 | import './index.scss' 150 | 151 | const HelloSkybox = () => { 152 | const canvasRef = useRef(null) 153 | const textureRef = useRef(null) 154 | 155 | useEffect(() => { 156 | 157 | if (canvasRef.current === null) { return } 158 | 159 | const renderer = new Three.WebGLRenderer({ canvas: canvasRef.current }) 160 | 161 | const scene = new Three.Scene() 162 | const textureLoader = new Three.TextureLoader() 163 | textureLoader.load(require('@/assets/imgs/blue_sky.jpg').default, (texture) => { 164 | textureRef.current = texture 165 | scene.background = textureRef.current 166 | handleResize() 167 | }) 168 | scene.background = textureRef.current 169 | 170 | const camera = new Three.PerspectiveCamera(45, 2, 0.1, 100) 171 | camera.position.set(10, 0, 10) 172 | 173 | const light = new Three.HemisphereLight(0xFFFFFF, 0x333333, 1) 174 | scene.add(light) 175 | 176 | const loader = new GLTFLoader() 177 | loader.load(require('@/assets/model/hello.glb').default, (gltf) => { 178 | scene.add(gltf.scene) 179 | }) 180 | 181 | const control = new OrbitControls(camera, canvasRef.current) 182 | control.update() 183 | 184 | const render = () => { 185 | renderer.render(scene, camera) 186 | window.requestAnimationFrame(render) 187 | } 188 | window.requestAnimationFrame(render) 189 | 190 | const handleResize = () => { 191 | if (canvasRef.current === null) { return } 192 | 193 | const width = canvasRef.current.clientWidth 194 | const height = canvasRef.current.clientHeight 195 | const canvasAspect = width / height 196 | 197 | if (textureRef.current !== null) { 198 | const bgTexture = textureRef.current 199 | const imgAspect = bgTexture.image.width / bgTexture.image.height 200 | 201 | const resultAspect = imgAspect / canvasAspect 202 | 203 | bgTexture.offset.x = resultAspect > 1 ? (1 - 1 / resultAspect) / 2 : 0 204 | bgTexture.repeat.x = resultAspect > 1 ? 1 / resultAspect : 1 205 | 206 | bgTexture.offset.y = resultAspect > 1 ? 0 : (1 - resultAspect) / 2 207 | bgTexture.repeat.y = resultAspect > 1 ? 1 : resultAspect 208 | } 209 | 210 | camera.aspect = canvasAspect 211 | camera.updateProjectionMatrix() 212 | renderer.setSize(width, height, false) 213 | } 214 | handleResize() 215 | window.addEventListener('resize', handleResize) 216 | 217 | return () => { 218 | window.removeEventListener('resize', handleResize) 219 | } 220 | }, []) 221 | 222 | return ( 223 | 224 | ) 225 | } 226 | 227 | export default HelloSkybox 228 | ``` 229 | 230 | 231 | 232 |
233 | 234 | 请注意,上面讲述的是将背景图片加载进 Three.js 中,并当做纹理来使用。我们可以通过修改纹理各种属性来修改和控制背景图。 235 | 236 | > 上面示例代码中仅仅是对纹理的偏移和重复进行了设置 237 | 238 | 但是,假设就仅仅为了达到上述效果,实际上我们根本不用搞这么复杂,直接给网页中 标签设置一个背景图片即可。 239 | 240 | 241 | 242 |
243 | 244 | **第1步:添加渲染器参数 alpha:true** 245 | 246 | ``` 247 | const renderer = new Three.WebGLRenderer({ canvas: canvasRef.current, alpha: true }) 248 | ``` 249 | 250 | 251 | 252 |
253 | **第2步:给画布标签()添加背景图** 254 | 255 | 第1种添加方式:通过 css 添加 256 | 257 | ``` 258 | .full-screen { 259 | display: block; 260 | width: inherit; 261 | height: inherit; 262 | background: url(./imgs/blue_sky.jpg) no-repeat center center; 263 | background-size: cover; 264 | } 265 | ``` 266 | 267 | > 请注意:上面 .scss 中我们给背景设置的图片路径,其实指向项目的 public 目录 268 | 269 | 270 | 271 |
272 | 273 | 第2种添加方式:通过 JS 添加 274 | 275 | ``` 276 | const canvasStyle = { 277 | background: `url(${require('@/assets/imgs/blue_sky.jpg').default}) center center no-repeat`, 278 | backgroundSize: 'cover' 279 | } 280 | 281 | 282 | ``` 283 | 284 | > React 在编译时,会自动将 `style={canvasStyle}` 中的样式转化为 CSS 样式 285 | 286 | 287 | 288 |
289 | 290 | 至此,关于如何设置场景背景图片,讲解完毕。 291 | 292 | 接下来要讲解一个常见的 Three.js 应用场景:SkyBox(天空盒)。 293 | 294 | 295 | 296 |
297 | 298 | ## 天空盒(Skybox) 299 | 300 | 假设我们身处一个立方体内部,我们可以观察到立方体内部 6 个面的背景贴图。 301 | 302 | > 这不就是我们身处某个房间内吗? 303 | 304 | 这类应用场景,通常被称呼为 Skybox,也就是 天空盒。 305 | 306 | 这也是我们日常听到对最多的 Web 3D 应用:VR 看房 307 | 308 | 309 | 310 |
311 | 312 | 上面对于天空盒的解释正确吗? 313 | 314 | 答:正确但不严谨! 315 | 316 | 317 | 318 |
319 | 320 | 通常我们所说天空盒(Skybox) 一个非常重要的特性就是:像天空一样大的盒子 321 | 322 | 进一步解释就是:这个盒子空间像天空一样无边无际,永远不到头。 323 | 324 | > 说直白点,天空盒就好像我们平时的场景(Three.Scene),无论缩小到什么限度,还是放大到什么限度,永远走不出场景之外。 325 | 326 | 而本文下面所有的示例,其实都是针对场景背景添加纹理贴图,所以下面示例中的天空盒(skybox)空间等同于场景本身。 327 | 328 | 329 | 330 |
331 | 332 | **天空盒一共有 2 种形式的贴图资源:** 333 | 334 | 1. 全景图(hdri),又名 天空图 335 | 2. 立方体贴图(cubemap) 336 | 337 | 338 | 339 |
340 | 341 | #### 第1种实现天空盒的方法:全景图(hdri) 342 | 343 | 很明显,我们最容易想到的实现方式为: 344 | 345 | 1. 我们把整个 Three.Screen 当做立方体,也就是将整个场景当做立方体 346 | 347 | > 再次重复一遍:我们并不是在场景中创建一个立方体,而是直接将整个场景当做立方体 348 | > 349 | > 假设你要给场景中某个立方体设置类似的效果,那么你要做的事情是: 350 | > 351 | > 1. 创建纹理,使用立方体纹理加载器(Three.CubeTextureLoader)加载图片资源 352 | > 353 | > 2. 创建材质,除了设置材质的纹理之外,还要设置 .side 属性,将值为 Three.BackSide,例如 354 | > 355 | > ``` 356 | > new MeshPhongMaterial({ map:xxxx, side: Three.BackSide }) 357 | > ``` 358 | > 359 | > 3. 最终创建立方体网格(Three.Mesh) 360 | 361 | > 请注意:绝大多数 VR 看房,都是将场景当做立方体即可。 362 | 363 | 2. 按照指定顺序,获取(加载) 6 个面的纹理贴图,得到纹理 364 | 365 | > 请注意,这次加载我们并不使用 Three.TextureLoader,而是采用立方体专有的纹理加载器 Three.CubeTextureLoader 366 | 367 | 3. 将得到的纹理作为场景背景 368 | 369 | 4. 设置相机坐标 z 的值,确保我们可以看到物体,例如 370 | 371 | ``` 372 | camera.position.set(0, 0, 10) 373 | ``` 374 | 375 | 5. 然后正常渲染,我们就会感觉此刻身在房间中 376 | 377 | 378 | 379 |
380 | 381 | **示例代码:** 382 | 383 | 房间图片素材: 384 | 385 | 我们使用网上找到的某房间 6 个面的纹理贴图 386 | 387 | 1. https://threejsfundamentals.org/threejs/resources/images/cubemaps/computer-history-museum/pos-x.jpg 388 | 2. https://threejsfundamentals.org/threejs/resources/images/cubemaps/computer-history-museum/neg-x.jpg 389 | 3. https://threejsfundamentals.org/threejs/resources/images/cubemaps/computer-history-museum/pos-y.jpg 390 | 4. https://threejsfundamentals.org/threejs/resources/images/cubemaps/computer-history-museum/neg-y.jpg 391 | 5. https://threejsfundamentals.org/threejs/resources/images/cubemaps/computer-history-museum/pos-z.jpg 392 | 6. https://threejsfundamentals.org/threejs/resources/images/cubemaps/computer-history-museum/neg-z.jpg 393 | 394 | 395 | 396 |
397 | 398 | 实际代码: 399 | 400 | ``` 401 | const cubeTextureLoader = new Three.CubeTextureLoader() 402 | cubeTextureLoader.load([ 403 | 'https://threejsfundamentals.org/threejs/resources/images/cubemaps/computer-history-museum/pos-x.jpg', 404 | 'https://threejsfundamentals.org/threejs/resources/images/cubemaps/computer-history-museum/neg-x.jpg', 405 | 'https://threejsfundamentals.org/threejs/resources/images/cubemaps/computer-history-museum/pos-y.jpg', 406 | 'https://threejsfundamentals.org/threejs/resources/images/cubemaps/computer-history-museum/neg-y.jpg', 407 | 'https://threejsfundamentals.org/threejs/resources/images/cubemaps/computer-history-museum/pos-z.jpg', 408 | 'https://threejsfundamentals.org/threejs/resources/images/cubemaps/computer-history-museum/neg-z.jpg' 409 | ], (texture) => { 410 | scene.background = texture 411 | }) 412 | ``` 413 | 414 | 415 | 416 |
417 | 418 | **针对贴图资源顺序的补充说明:** 419 | 420 | 在上面示例代码中,我们可以看到加载立方体 6 面图片贴图资源的顺序是固定的,依次为: 421 | 422 | pos-x.jpg、neg-x.jpg、pos-y.jpg、neg-y.jpg、pos-z.jpg、neg-z.jpg 423 | 424 |
425 | 426 | **"pos" 是单词 "positive" 的缩写,在 3D 坐标系中意思是 正** 427 | 428 | > positive:积极的、正向的、乐观的 429 | 430 | **"neg" 是单词 "negative" 的缩写,在 3D 坐标系中意思是 负** 431 | 432 | > negative:消极的、负面的 433 | 434 | 435 | 436 |
437 | 438 | ### 左手坐标系统 VS 右手坐标系统 439 | 440 | 我们先介绍一下 右手坐标系统,你就自然明白什么是左手坐标系统了。 441 | 442 | 我们看一下百度百科的介绍: 443 | 444 | 右手系(right-hand system)是在空间中规定直角坐标系的方法之一。此坐标系中x轴,y轴和z轴的正方向是如下规定的:把右手放在原点的位置,使大拇指,食指和中指互成直角,把大拇指指向x轴的正方向,食指指向y轴的正方向时,中指所指的方向就是z轴的正方向。 445 | 446 | 447 | 448 |
449 | 450 | 同样的操作你换做左手,那么就是左手坐标系统。 451 | 452 | **左手坐标系统和右手坐标系统在 Y 轴、Z 轴 方面没有区别,但是在 X 轴上是彼此相反的。** 453 | 454 | 455 | 456 |
457 | 458 | **对于立方体贴图,使用的是左手系统!** 459 | 460 | 因此上面图片名称的含义为: 461 | 462 | | 名称 | 含义 | 对应立方体内部的面来说 | 对于站在立方体内部中间的人来说 | 463 | | ----- | ---------- | ---------------------- | ------------------------------ | 464 | | pos-x | X 轴正方向 | 左面 | 视觉左方 | 465 | | neg-x | X 轴负方向 | 右面 | 视觉右方 | 466 | | pos-y | Y 轴正方向 | 上面 | 视觉上方 | 467 | | neg-y | Y 轴负方向 | 下面 | 视觉下方 | 468 | | pos-z | Z 轴正方向 | 后面 | 视觉后方 | 469 | | neg-z | Z 轴负方向 | 前面 | 视觉前方 | 470 | 471 | > 请注意,立方体的前面或后面完全是由观察者所处的位置来决定的。 472 | > 473 | > 如果你在立方体外面去看立方体,那么正面看到的是立方体的正面,看不到的是立方体的背面。 474 | > 475 | > 但是我们现在做的是站在立方体内部去观察立方体,所以此刻 “立方体的正面” 实际上对于我们观察者而言是在我们身后,也就是我们的视觉后方。 476 | 477 | 478 | 479 |
480 | 481 | **对于Three.js渲染使用的是 右手坐标系统!** 482 | 483 | **不过你不用担心左右方向相反这个事情,因为在 Three.js 内部在渲染的时候会自动帮我们将左右对调。** 484 | 485 | > 重复一遍: 486 | > 487 | > 1. 一般立方体模型贴图使用左手坐标系统 488 | > 2. Three.js 整体使用右手坐标系统 489 | > 3. 但在渲染立方体内部贴图时,Three.js 会自动帮我们做好左右兑换 490 | > 4. 因此我们在传递纹理贴图时,贴图顺序使用的是左手坐标系统 491 | 492 | 493 | 494 |
495 | 496 | > 糟糕,我也是今天学习到这里才彻底明白坐标系统,我可能在之前的文章中对于 上下左右前后 讲解错了,但是我暂时记不清是哪一章节。 497 | 498 | 499 | 500 |
501 | 502 | #### 第2种实现天空盒的方法:立方体贴图(cubemap) 503 | 504 | 我们第1种实现天空盒,实际上使用的是 6 个面的图片资源组合成了一个 3D 空间。 505 | 506 | 接下来我们学习使用一张 360° 球形相机拍摄的照片,来实现 3D 空间立方体。 507 | 508 | 509 | 510 |
511 | 512 | 首先你从网上找到一张 360° 的场景图片资源: 513 | 514 | https://threejsfundamentals.org/threejs/resources/images/equirectangularmaps/tears_of_steel_bridge_2k.jpg 515 | 516 | > 请注意,这类图片尺寸宽高比例为 2:1,经常称呼这类图片为 “全景图” 517 | 518 | 519 | 520 |
521 | 522 | **实现思路:** 523 | 524 | 1. 使用纹理加载器加载该图片资源 525 | 526 | > 这种 2:1 的图片,被称为 “等矩形图像” 527 | 528 | 2. 实例化一个 Three.WebGLCubeRenderTarget,构造函数中的 size 属性为图片资源的高 529 | 530 | > WebGLCubeRenderTarget 继承于 WebGLRenderTarget,属于离屏渲染的一种特例(专门针对立方体模型) 531 | 532 | > 在 WebGLRenderTarget 的源码中可以看到这句代码:super( size, size, options ); 533 | > 534 | > WebGLRenderTarget 构造函数需要传递 width 和 height,但是 WebGLCubeRenderTarget 构造函数只需传入 1 个 size,因为正方体,所以宽高一样。 535 | 536 | 3. 调用该实例化对象的 fromEquirectangularTexture() 函数 537 | 538 | > 将等距图像 转化为 立方体模型贴图 539 | > 540 | > > 可以简单理解成:就是将 1 整张图片转化为 6个面的立方体模型贴图,并进行渲染 541 | 542 | 4. 将场景背景设置为该实例对象 543 | 544 | > 这样相当于将场景背景图的值设置为 WebGLCubeRenderTarget 的渲染结果 545 | 546 | 547 | 548 |
549 | 550 | **具体代码:** 551 | 552 | ``` 553 | const textureLoader = new Three.TextureLoader() 554 | textureLoader.load(require('@/assets/imgs/tears_of_steel_bridge.jpg').default, 555 | (texture) => { 556 | const crt = new Three.WebGLCubeRenderTarget(texture.image.height) 557 | crt.fromEquirectangularTexture(renderer,texture) 558 | scene.background = crt.texture 559 | } 560 | ) 561 | ``` 562 | 563 | > 补充说明:scene.background 的类型为 WebGLBackground 564 | > 565 | > 请留意上述代码中 `scene.background = crt.texture`,事实上在以前的一些教程中可以写成 `scene.background = crt`,WebGLBackground 会在内部进行判断,如果 background 类型为 WebGLRenderTarget,则使用该实例的 .texture 属性值。 566 | > 567 | > 但是在最新版 r127 中已经删除了该判断代码,所以现在必须写成 `scene.background = crt.texture`。 568 | > 569 | > 就这个问题,我已向官网教程进行了修改提交:https://github.com/gfxfundamentals/threejsfundamentals/pull/205 570 | 571 | 572 | 573 |
574 | 575 | ### 补充说明:全景图(hdri) 与 立方体贴图(cubemap) 互转 576 | 577 | 网上有人提供了 全景图与立方体模型图 之间的转化工具包: 578 | 579 | 在线地址:https://matheowis.github.io/HDRI-to-CubeMap/ 580 | 581 | 项目源码:https://github.com/aunyks/hdri-to-cubemap 582 | 583 | 584 | 585 |
586 | 587 | 你以为本文结束了?没有! 588 | 589 | 我们上面示例都是 天空盒(skybox),那如果是真的一个立方体呢? 590 | 591 | > 天空盒 是没有尺寸,空间无限大的,而普通立方体则是有尺寸的。 592 | 593 | 下面示例我们将创建一个立方体,然后对立方体内部进行贴图,并渲染和观察立方体盒子内部。 594 | 595 | 596 | 597 |
598 | 599 | ## 普通立方体内部贴图和渲染 600 | 601 | 602 | 603 | -------------------------------------------------------------------------------- /12 Three.js基础之镜头.md: -------------------------------------------------------------------------------- 1 | # 12 Three.js基础之镜头 2 | 3 | 在之前所有的示例中,关于镜头,我们使用的都是 PerspectiveCamera(透视镜头)。 4 | 5 | > 再次强调一下,我个人偏好是喜欢将 Camera 称为 “镜头”,但是 Three.js 官方或其他教程中称呼其为 “相机” 6 | 7 | **在 Three.js 中,一共有 5 种镜头:** 8 | 9 | | 镜头类型(都继承于Three.Camera) | 镜头名称 | 解释说明 | 10 | | ------------------------------ | -------- | --------------------------------------------- | 11 | | ArrayCamera | 镜头阵列 | 一组已预定义的镜头 | 12 | | CubeCamera | 立方镜头 | 6个面的镜头(前、后、左、右、顶、底) | 13 | | OrthographicCamera | 正交镜头 | 无论物体距离镜头远近,最终渲染出的大小不变 | 14 | | PerspectiveCamera | 透视镜头 | 像人眼睛一样的镜头,远大近小,最常用的镜头 | 15 | | StereoCamera | 立体镜头 | 双透视镜头,常用于创建 3D 立体影像或 视差屏障 | 16 | 17 | 18 | 19 | **所有镜头的辅助对象都是:Three.CameraHelper** 20 | 21 | 22 | 23 | 由于 透视镜头(PerspectiveCamera) 是日常中使用最频繁的镜头类型,因此我们先从 透视镜头 开始讲解。 24 | 25 | 26 | 27 | ## 镜头的一些知识 28 | 29 | > 我们通过透视镜头,来讲解一些镜头的知识 30 | > 透视镜头(PerspectiveCamera) 所呈现出的效果,和我们用有眼睛观察世界是一模一样的。 31 | 32 | #### 平截面 33 | 34 | 无论所观察的物体是什么类型,例如球体、立方体、椎体等,在我们的视野中都会形成 2 个截面: 35 | 36 | 1. **远截面(far):物体最远处的截面** 37 | 2. **近截面(near):物体最近处的截面** 38 | 39 | 若以某个特定角度,当镜头(眼睛)观察物体时,物体远截面和近截面完全相同,那么此时近截面就会遮挡远截面,我们只能看到近截面。 40 | 41 | 例如我们在一个立方体的正前方,此时近截面完全遮挡住远截面,此时我们观察到的立方体更像是一个平面。 42 | 43 | > 但是由于可能存在阴影,我们依然能够感知到这是一个 “3D立体物体”。 44 | 45 | 46 | 47 | #### 视椎 48 | 49 | 想象一下,假设把我们的镜头(眼睛) 当做一个点,由这个点依次与物体的近截面、远截面的顶点进行连接,就会形成一个 椎体,而这个虚构出来的椎体,就是我们镜头(眼睛)与物体在空间上存在的视椎。 50 | 51 | 如果我们眼睛不是与物体,而是与 **“场景(Three.Scene)的近截面、远截面”** 形成的视椎,就是正常 Three.js 场景中可见空间。 52 | 53 | 请注意,上面提到的 场景的远截面和近截面 是加了引号,事实上没有办法直接设置场景的远近截面,场景的近远视椎是由镜头的以下几个参数最终计算出的: 54 | 55 | 1. 镜头的观察角度(fov) 56 | 2. 镜头画面的宽高比(aspect) 57 | 3. 镜头的最近可见距离(far) 58 | 4. 镜头的最远可见距离(near) 59 | 5. 一个隐含因素:镜头本身的位置(camera.position) 60 | 61 | > 近截面和远截面决定了物体是否在镜头内可见 62 | > 物体与镜头的距离决定物体在视觉上的大小 63 | 64 | 当我们初始化一个透视镜头时,构造函数需要传递的 4 个参数,就是上面前 4 个元素。 65 | 66 | **透视镜头默认参数值:fov=50、aspect=1、near=0.1、far=2000** 67 | 68 | 69 | 70 | **补充说明:** 71 | 72 | 在 Three.js 官方文档中对以上 4 个参数的解释是: 73 | 74 | 1. fov:摄像机视椎体垂直视野角度 75 | 2. aspect:摄像机视椎体长宽比(宽高比) 76 | 3. near:摄像机视椎体近端面 77 | 4. far:摄像机视椎体远端面 78 | 79 | 80 | 81 | > 虽然我的描述和官方描述文字上存在差异,但意思相同。我认为我的用词更加口语化,容易理解,所以在本系列文章中,我会继续使用我的描述语言。 82 | > 83 | > 因为受到自己对 Three.js 的理解程度,或许偏个人化语言描述或许是不正确的。 84 | 85 | 86 | 87 | ## 关于镜头近截面与远截面的补充说明:计算量与性能 88 | 89 | 我们知道透视镜头默认参数值:fov=50、aspect=1、near=0.1、far=2000,而我们之前文章中的示例,通常镜头设置参数为:new Three.PerspectiveCamera(45,2,0.1,1000) 90 | 91 | > 也就是说,近截面(镜头最近可见距离)通常设置为 0.1、远截面(镜头最远可见距离)通常为 1000 92 | 93 | 若超出这个范围内的物体或物体局部则都将不可见。 94 | 95 | #### 思考一下 96 | 97 | 假设我们直接将 near 由 0.1 修改为 0.0001、far 由 1000 修改为 1000000,那是不是场景最近可见度更加精细、可见范围变得更大,能够承载更多的物体呢? 98 | 99 | 答案是肯定的,但场景越大,可见度越微观,渲染所需计算量也越大。 100 | 101 | **请记得:当计算量大到一定程度后,就会出现渲染异常,画面会出现一些意外的、不符合预期结果。** 102 | 103 | **通常表现为物体表面像素紊乱、破碎、闪烁、像素前后失调,这是因为 GPU 没有足够的精度来确认哪些像素应该在前,哪些在后。** 104 | 105 | > 我十分确信此刻我是在讲解 Three.js,而不是 大姨妈。 106 | 107 | 108 | 109 | #### 解决办法(并不推荐):将渲染器的logarithmicDepthBuffer设置为true 110 | 111 | **logarighmicDepthBuffer:对数深度缓存器** 112 | 113 | ``` 114 | const renderer = new Three.WebGLRenderer({ 115 | canvas:xxxx, 116 | logarithmicDepthBuffer:true 117 | }) 118 | ``` 119 | 120 | 121 | 122 | **注意事项:** 123 | 124 | 1. 通常电脑浏览器都已支持 logarithmicDepthBuffer 属性,但很多手机目前还不支持。 125 | 2. logarithmicDepthBuffer 为 true 只是在一定程度上能够缓解问题,但若 near 足够小、far 足够大时,依然会出现 GPU 计算精度不够,造成画面渲染紊乱。 126 | 127 | 128 | 129 | #### 解决办法(推荐做法):不解决 130 | 131 | **请记得:你本就不应该把 near 设置过小、far 设置过大!** 132 | 133 | 134 | 135 | #### near 和 far 正确的设定原则 136 | 137 | 尽可能让 near 和 far 更接近镜头不远的位置,当然前提是不让任何物体超出消失的范围内。 138 | 139 | 注意,这里的 “更接近镜头不远的位置” 是指 “合适、适当的位置”,并不是指小数点后精确多少位。 140 | 141 | 在保证精度的前提下,尽可能设置合适的 近截面和远截面,这样让 镜头与物体产生的视椎 “更小、更接近”,以节省渲染所需计算量和性能。 142 | 143 | 144 | 145 | **举一个例子:** 146 | 147 | 假设你需要渲染出一个 足球 的特写,那么把足球放置在一个 比较小的平台或地面即可,而不是创建一个城市一样大小的场景,却只渲染出一个 足球的近距离特写。 148 | 149 | > 但是假设你的场景确确实实需要非常大,此时就需要多参考网上其他人是如何处理类似场景的。 150 | > 或许后续文章中也会有讲解,此时此刻你只需知道 near 和 far 设置合适即可,没必要过于精细或巨大。 151 | 152 | 153 | 154 | ## 镜头示例1:使用CameraHelper来观察镜头 155 | 156 | 关于 透视镜头 PerspectiveCamera 我们之前示例中已经使用多次。 157 | 158 | 本示例主要演示 通过 镜头辅助对象(CameraHelper) 来观察镜头。 159 | 160 | 161 | 162 | #### 思考一下: 163 | 164 | 在正常情况下,一个镜头只能看到别的物体但无法看到自己。 165 | 166 | 就好像我们的眼睛可以看到这个世界,但是眼睛本身自己没法看到自己(眼睛)。 167 | 168 | > 当然除非对着镜子,不过对着镜子的本质依然是眼睛去看别的物体。 169 | 170 | 更加直白一点:就算你眼睛长得再大,你也做不到左眼直接看到自己右眼。 171 | 172 | > 我们人虽然是 2 个眼球,但是在 Three.js 的相关举例中,是将 左右两个眼睛当成 是 1 个镜头来阐述的。 173 | 174 | 175 | 176 | ### HelloCamera示例目标 177 | 178 | 1. 创建一个包含物体的场景 179 | 2. 创建 2 个镜头,镜头A和镜头B 180 | 3. 将网页画面一分为二 181 | 4. 左侧显示 镜头A 所看到的场景 182 | 5. 右侧使用使用 镜头B 来观察 镜头A 183 | 184 | **补充说明:** 185 | 186 | 1. 事实上是 镜头B 观察并显示 镜头A 对应的辅助对象 (CameraHelper) 187 | 2. 为了省事,我们直接使用前文讲解灯光时编写的 create-scene.ts 来创建场景 188 | 189 | 190 | 191 | ### 代码实现思路 192 | 193 | #### 关键点 1:同一个场景渲染出 2 个不同的画面 194 | 195 | **实现 2 个画面:**添加左右 2 个镜头,每个镜头设置不同,最终呈现出的场景不同 196 | 197 | ``` 198 | const leftCamera = new Three.PerspectiveCamera(45, 2, 5, 100) 199 | leftCamera.position.set(0, 10, 20) 200 | 201 | const rightCamera = new Three.PerspectiveCamera(60, 2, 0.1, 200) 202 | rightCamera.position.set(40, 10, 30) 203 | rightCamera.lookAt(0, 5, 0) 204 | ``` 205 | 206 | 207 | 208 | **实现 2 个交互:**添加 左右 2 个 div、2 个 OrbitControls,覆盖于canvas 之上 209 | 210 | ``` 211 | const leftControls = new OrbitControls(leftCamera, leftViewRef.current) 212 | leftControls.target.set(0, 5, 0) 213 | leftControls.update() 214 | 215 | const rightControls = new OrbitControls(rightCamera, rightViewRef.current) 216 | rightControls.target.set(0, 5, 0) 217 | rightControls.update() 218 | 219 | ... 220 | 221 |
222 |
223 |
224 |
225 |
226 | 227 |
228 | ``` 229 | 230 | 231 | 232 | **渲染 2 个画面:**使用渲染器的 裁减 功能 233 | 234 | 渲染器裁减功能,涉及到的方法有 setScissor()、setScissorTarget()、setViewport() 。 235 | 236 | > 我们之前示例中,为了适应浏览器窗口大小的改变,我们使用过 渲染器的 setSize() 237 | 238 | **setScissor ( x : Integer, y : Integer, width : Integer, height : Integer ) : null** 239 | 将剪裁区域设为(x, y)到(x + width, y + height) Sets the scissor area from 240 | 241 | **setScissorTest ( boolean : Boolean ) : null** 242 | 启用或禁用剪裁检测. 若启用,则只有在所定义的裁剪区域内的像素才会受之后的渲染器影响。 243 | 244 | **setViewport ( x : Integer, y : Integer, width : Integer, height : Integer ) : null** 245 | 将视口大小设置为(x, y)到 (x + width, y + height) 246 | 247 | 248 | 249 | **请额外留意在后面实际代码中,我们定义的 setScissorForElement() 函数。** 250 | 251 | > 多敲几遍,记住 setScissorForElement() 函数中获得裁减区域的代码套路 252 | 253 | 254 | 255 | #### 关键点 2:镜头辅助对象 256 | 257 | 镜头辅助对象为 Three.CameraHelper,他的用法很简单: 258 | 259 | ``` 260 | const helper = new THREE.CameraHelper( leftCamera ) 261 | scene.add( helper ) 262 | ``` 263 | 264 | 265 | 266 | #### 关键点 3:如何渲染 267 | 268 | 在之前所有的示例代码中,渲染代码都为: 269 | 270 | ``` 271 | renderer.render(scene, camera) 272 | ``` 273 | 274 | 但本示例中,我们是同一个 scene,但是 2 个不同的 camera,因此与之对应的渲染代码也要发生变化。 275 | 276 | 需要依次分别渲染出 左侧镜头视角 和 右侧镜头视角。 277 | 278 | ``` 279 | //leftCamera一些更新操作 280 | ... 281 | renderer.render(sceneRef.current, leftCamera) 282 | 283 | 284 | //rightCamera一些更新操作 285 | ... 286 | renderer.render(sceneRef.current, rightCamera) 287 | ``` 288 | 289 | 290 | 291 | ### 具体的代码 292 | 293 | #### create-scene.ts 294 | 295 | 我们直接使用之前 **“11 Three.js基础之灯光.md”** 中已写好的代码。 296 | 297 | 298 | 299 | #### index.scss 300 | 301 | ``` 302 | .full-screen,canvas { 303 | display: block; 304 | height: inherit; 305 | width: inherit; 306 | } 307 | 308 | .split { 309 | position: fixed; 310 | display: flex; 311 | width: inherit; 312 | height: inherit; 313 | } 314 | 315 | .split div { 316 | width: inherit; 317 | height: inherit; 318 | } 319 | ``` 320 | 321 | 322 | 323 | #### index.tsx 324 | 325 | ``` 326 | import { useRef, useEffect } from 'react' 327 | import * as Three from 'three' 328 | import createScene from '@/components/hello-light/create-scene' 329 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' 330 | 331 | import './index.scss' 332 | 333 | const HelloCamera = () => { 334 | 335 | const canvasRef = useRef(null) 336 | const sceneRef = useRef(null) 337 | const leftViewRef = useRef(null) 338 | const rightViewRef = useRef(null) 339 | 340 | useEffect(() => { 341 | if (canvasRef.current === null || leftViewRef.current === null || rightViewRef.current === null) { 342 | return 343 | } 344 | 345 | const renderer = new Three.WebGLRenderer({ canvas: canvasRef.current as HTMLCanvasElement }) 346 | renderer.setScissorTest(true) 347 | 348 | const scene = createScene() 349 | scene.background = new Three.Color(0x000000) 350 | sceneRef.current = scene 351 | 352 | const light = new Three.DirectionalLight(0xFFFFFF, 1) 353 | light.position.set(0, 10, 0) 354 | light.target.position.set(5, 0, 0) 355 | scene.add(light) 356 | scene.add(light.target) 357 | 358 | const leftCamera = new Three.PerspectiveCamera(45, 2, 5, 100) 359 | leftCamera.position.set(0, 10, 20) 360 | 361 | const helper = new Three.CameraHelper(leftCamera) 362 | scene.add(helper) 363 | 364 | const leftControls = new OrbitControls(leftCamera, leftViewRef.current) 365 | leftControls.target.set(0, 5, 0) 366 | leftControls.update() 367 | 368 | const rightCamera = new Three.PerspectiveCamera(60, 2, 0.1, 200) 369 | rightCamera.position.set(40, 10, 30) //为了能够看清、看全镜头,所以将右侧镜头的位置设置稍远一些 370 | rightCamera.lookAt(0, 5, 0) 371 | 372 | const rightControls = new OrbitControls(rightCamera, rightViewRef.current) 373 | rightControls.target.set(0, 5, 0) 374 | rightControls.update() 375 | 376 | const setScissorForElement = (div: HTMLDivElement) => { 377 | if (canvasRef.current === null) { 378 | return 379 | } 380 | 381 | //获得 canvas 和 div 的矩形框尺寸和位置 382 | const canvasRect = canvasRef.current.getBoundingClientRect() 383 | const divRect = div.getBoundingClientRect() 384 | 385 | //计算出裁切框的尺寸和位置 386 | const right = Math.min(divRect.right, canvasRect.right) - canvasRect.left 387 | const left = Math.max(0, divRect.left - canvasRect.left) 388 | const bottom = Math.min(divRect.bottom, canvasRect.bottom) - canvasRect.top 389 | const top = Math.max(0, divRect.top - canvasRect.top) 390 | const width = Math.min(canvasRect.width, right - left) 391 | const height = Math.min(canvasRect.height, bottom - top) 392 | 393 | //将剪刀设置为仅渲染到画布的该部分 394 | const positiveYUpBottom = canvasRect.height - bottom 395 | renderer.setScissor(left, positiveYUpBottom, width, height) 396 | renderer.setViewport(left, positiveYUpBottom, width, height) 397 | 398 | //返回外观 399 | return width / height 400 | } 401 | 402 | const render = () => { 403 | 404 | if (leftCamera === null || rightCamera === null || sceneRef.current === null) { 405 | return 406 | } 407 | 408 | const sceneBackground = sceneRef.current.background as Three.Color 409 | 410 | //渲染 左侧 镜头 411 | const leftAspect = setScissorForElement(leftViewRef.current as HTMLDivElement) 412 | 413 | leftCamera.aspect = leftAspect as number 414 | leftCamera.updateProjectionMatrix() 415 | 416 | helper.update() 417 | helper.visible = false 418 | 419 | sceneBackground.set(0x000000) 420 | renderer.render(sceneRef.current, leftCamera) 421 | 422 | //渲染 右侧 个镜头 423 | const rightAspect = setScissorForElement(rightViewRef.current as HTMLDivElement) 424 | 425 | rightCamera.aspect = rightAspect as number 426 | rightCamera.updateProjectionMatrix() 427 | 428 | helper.visible = true 429 | 430 | sceneBackground.set(0x000040) 431 | renderer.render(sceneRef.current, rightCamera) 432 | 433 | window.requestAnimationFrame(render) 434 | } 435 | window.requestAnimationFrame(render) 436 | 437 | const handleResize = () => { 438 | if (canvasRef.current === null) { 439 | return 440 | } 441 | 442 | const width = canvasRef.current.clientWidth 443 | const height = canvasRef.current.clientHeight 444 | 445 | renderer.setSize(width, height, false) 446 | } 447 | handleResize() 448 | window.addEventListener('resize', handleResize) 449 | 450 | return () => { 451 | window.removeEventListener('resize', handleResize) 452 | } 453 | }, [canvasRef]) 454 | 455 | return ( 456 |
457 |
458 |
459 |
460 |
461 | 462 |
463 | ) 464 | } 465 | 466 | export default HelloCamera 467 | ``` 468 | 469 | 470 | 471 | 执行以后,就会看到浏览器中左右 2 个可交互的画面。其中右侧画面中包含左侧灯光辅助对象。 472 | 473 | 474 | 475 | ## 镜头示例2:OrthographicCamera 476 | 477 | 第二个比较经常用的镜头是 OrthographicCamera(正交镜头)。 478 | 479 | 正交镜头与透视镜头最大的区别点在于: 480 | 481 | 1. 正交镜头的视椎体不是 椎体,而是立方体 482 | 483 | 2. 正交镜头看到的都是一个“面” 484 | 485 | 3. 因此,正交镜头没有 “透视(近大远小)”这个概念 486 | 487 | > 我对于以上 2 点理解并不深,先记住以后再慢慢研究 488 | 489 | 490 | 491 | #### OrthographicCamera的用途 492 | 493 | 1. 用途1:作为 2D 画布 494 | 2. 作为 3D 建模程序 的 上、下、左、右、前、后 视图。 495 | 496 | 497 | 498 | #### OrthographicCamera基本用法 499 | 500 | ``` 501 | const camera = new Three.OrthographicCamera(-1, 1, 1, -1, 5, 50) 502 | camera.zoom = 0.2 503 | camera.position.set(0,10,20) 504 | ``` 505 | 506 | 初始化时,构造函数内的参数依次是: 507 | 508 | OrthographicCamera( left : Number, right : Number, top : Number, bottom : Number, near : Number, far : Number ) 509 | 510 | 1. left:视椎体左侧面 511 | 2. right:视椎体右侧面 512 | 3. top:视椎体顶面 513 | 4. bottom:视椎体底面 514 | 5. near:视椎体近端面 515 | 6. far:视椎体远端面 516 | 517 | 518 | 519 | **从实际的角度来看,一定要注意以下几点:** 520 | 521 | 1. left 的值不能大于 right,同理 bottom 的值不能大于 top。 如果没有按照这个约定,例如 bottom 大于 top 相当于颠倒了相机。 522 | 2. near 设置越小,投影的映像越大 523 | 3. left 与 right 之间的距离、top 与 bottom 之间的距离的比例一定要和 canvas 比例相同,否则会导致投影的物体形状变形 524 | 525 | 526 | 527 | #### 正交镜头与透视镜头的几点不同地方 528 | 529 | **渲染时,对应的设置不同。** 530 | 531 | 透视镜头渲染时,需要修改的是 camera.aspect = newAspect 532 | 533 | 正交镜头渲染时,需要修改的是 camera.left = - new Aspect、camera.right = new Aspect 534 | 535 | 536 | 537 | ### 假设我们在 HelloCamera 示例中使用 OrthographicCamera 538 | 539 | **需要修改的地方为:** 540 | 541 | ```diff 542 | - const leftCamera = new Three.PerspectiveCamera(45, 2, 5, 100) 543 | - leftCamera.position.set(0, 10, 20) 544 | + const leftCamera = new Three.OrthographicCamera(-1, 1, 1, -1, 5, 50) 545 | + leftCamera.zoom = 0.2 546 | leftCamera.position.set(0,10,20) 547 | 548 | ... 549 | 550 | const leftAspect = setScissorForElement(leftViewRef.current as HTMLDivElement) 551 | - leftCamera.aspect = leftAspect as number 552 | + leftCamera.left = -(leftAspect as number) 553 | + leftCamera.right = leftAspect as number 554 | leftCamera.updateProjectionMatrix() 555 | ``` 556 | 557 | 其他代码无需修改,发布调试,即可看到正交镜头辅助对象,此时的视椎不再是椎体,而是一个立方体。 558 | 559 | 560 | 561 | 关于其他镜头:ArrayCamera、CubeCamera、StereoCamera 本文不再讲解,以后用到的时候再深入研究。 562 | 563 | 具体的用法,可查阅:https://threejs.org/docs/index.html#api/zh/cameras/Camera 564 | 565 | 566 | 567 | 至此,关于镜头的基础知识讲解完毕。 568 | 569 | > 虽然本文是在讲镜头,但本文的核心知识点却是在讲 渲染器的裁切 功能。 570 | > 571 | > 一定要多多复习,熟练掌握 渲染器的 setScissor()、setScissorTest()、setViewport() 方法。 572 | 573 | 574 | 575 | 下一节,我们将讲解 Shardown(阴影) 576 | 577 | --------------------------------------------------------------------------------