在上一节中,已经实现了 HelloThree 最为基础的示例。本节将进一步优化那个示例。
我们将给示例中的 canvas 添加宽高自适应,让它充满整个浏览器。
默认 canvas 尺寸为 高150像素,宽300像素。我们现在把 canvas 修改为撑满屏幕。
打开项目中 src/indes.scss ,修改 html、body、root 样式:
body{
- margin:0;
}
html,body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ width: 100%;
}
#root {
+ height: inherit;
+ width: inherit;
}
由于我们已经给 html, body 设置了 宽高 100%,所以 root 宽高 设置为 inherit 即可。
新建文件 src/components/hello-threejs/index.scss,添加 canvas 样式:
.full-screen {
display: block;
width: inherit;
height: inherit;
}
canvas 宽高继承于 root,root 继承于 body,而 body 宽高均为 100%,所以最终 canvas 宽高也为 100%,撑满整个屏幕。
特别提醒: 在上面样式中,我们设置了 display 为 block,让 canvas 由 内联元素 改为 块级元素。
为什么要这么做?
因为我们在后面代码中,需要获取 canvas 的 clientWidth(内部实际宽度) 和 clientHeight(内部实际高度),而内联元素是无法获取到这 2 两个属性值的,因此我们要将画布修改为块级元素。
内联元素和没有 CSS 样式的元素,获取到的 clientWidht 和 clientHeight 的值永远为 0
在 src/components/hello-threejs/index.stx 中引入并添加样式
+ import './index.scss'
const HelloThreejs: React.FC = () => {
...
return (
<canvas ref={canvasRef} className='full-screen' />
)
}
此时,再次执行预览 yarn start
,就会发现 canvas 已全屏,充满整个浏览器可见区域。
可以观察到 canvas 是被硬生生由原本的 高150、像素 宽 300 像素给硬生生拉伸成 100%。
所以立方体出现了 扭曲、模糊、锯齿。
那我们继续修改代码。
修改 src/components/hello-threejs/index.stx 中 render 函数的代码,让镜头宽高比跟随着 canvas 宽高比,确保立方体不变形。
...
const render = (time: number) => {
time = time * 0.001
+ const canvas = renderer.domElement //获取 canvas
+ camera.aspect = canvas.clientWidth / canvas.clientHeight //设置镜头宽高比
+ camera.updateProjectionMatrix() //通知镜头更新视椎(视野)
cubes.map(cube => { ... }
}
...
第4步立方体已经不再变形,但是依然模糊,锯齿感比较明显。原因是渲染器(renderer) 渲染出的画面尺寸小于实际网页 canvas 尺寸。
继续修改 src/components/hello-threejs/index.tsx 中 render 函数的代码。
...
const render = (time: number) => {
time = time * 0.001
const canvas = renderer.domElement //获取 canvas
camera.aspect = canvas.clientWidth / canvas.clientHeight //设置镜头宽高比
camera.updateProjectionMatrix() //通知镜头更新视椎(视野)
+ renderer.setSize(canvas.clientWidth, canvas.clientHeight, false)
+ //第3个参数为可选参数,默认值为 true,false 意思是阻止因渲染内容尺寸发生变化而去修改 canvas 尺寸
cubes.map(cube => { ... }
}
...
经过上面一番修改,浏览器中 canvas 里的立方体会变得不变形,且非常清晰。
关于 renderer.setSize() 第 3 个参数的补充说明:
在本示例中 renderer 是 WebGLRenderer 实例。
我查看了一下 WebGLRenderer setSize() 源码:https://github.com/mrdoob/three.js/blob/master/src/renderers/WebGLRenderer.js
发现了其中以下代码片段:
this.setSize = function ( width, height, updateStyle ) {
...
if ( updateStyle !== false ) {
_canvas.style.width = width + 'px';
_canvas.style.height = height + 'px';
}
...
}
可以看出,假设第 3 个参数不传值,那么该参数值实际调用时为 undefined,undefined !==false 的值为 true 。
因此我们可以得出结论:setSize() 第 3 个参数的默认值为 true,当我们希望控制尺寸的主动权完全由 canvas 决定时,那么一定要设置第 3 个参数为 false。
从上面示例可以看出,浏览器中渲染的画面尺寸,完全是按照 CSS 样式尺寸来显示的。
对于高清屏(HD-DPI)来说,那 Three.js 渲染的画面又该有何应对呢?
假设 HD-DP 比例为 3x,即原本 1 像素 则由 3 x 3 ,共 9 个像素来显示。
也就是说原本只需渲染 1 像素,现在需要渲染 9 像素,所消耗的性能是原来的 9 倍。
假设 3D 场景内容稍微复杂一些,那所带来的渲染性能要求会非常高,画面清晰的代价是更高性能的消耗,引起的卡顿 会带来不好的用户体验。
事实上高清屏本身都会做显示优化,即使不做任何处理,画面清晰度并不会明显特别差。
因此,什么都不做,其实是一个非常好的策略。
假设就是想设置成高清屏,那又该如何操作呢?
在浏览器中,通过 window.devicePixelRatio 可获得当前屏幕物理分辨率与 CSS 样式分辨率的比值。
然后告知渲染器,以后任何 renderer.setSize 都按照此 比值(倍数) 进行渲染
renderer.setPixelRatio(window.devicePixelRatio)
强烈不推荐这种做法。
这种策略思路是:通过分辨率比值,计算出实际上应该渲染的最大尺寸,然后渲染出这个尺寸,再将画面内容渲染到 canvas 中。
举例:假设 HD-DP 比例为 3x,即 普通宽 1 像素对应高清屏宽 3 像素。那么可以将 renderer 渲染出比 canvas 实际大 3 倍的画面,然后再将画面以 “压缩” 3 倍的形式填充到 canvas 中,从而实现所谓的 “高清屏渲染”。
这样的操作,会使 渲染器 renderer 像正常渲染一样来执行各种渲染操作。
对应的渲染代码为:
const canvas = renderer.domElement
const ratio = window.devicePixelRatio
const newWidth = Math.floor(canvas.clientWidth * ratio)
const newHeight = Math.floor(canvas.clientHeight * ratio)
renderer.setSize(newWidth,newHeight,false) //特别注意,第 3 个参数一定要为 false
尽管第 3 种策略相对第 2 种好一些,但是还是建议选择第 1 种策略,即什么也不做。
你看在线视频时,关于清晰度会做哪种选择?
A:蓝光 1080P,画面超级清晰,但播放时会有点卡顿
B:高清 720 P,画面清晰度能够接受,播放时也非常流畅
至此,关于 Three.js 的入门演示示例,已经结束。
目前来说,虽然实际运行没有一点问题,但代码实际上并不是最优的。
现在做给渲染器添加尺寸发生变化的代码是放在了 window.requestAnimationFrame() 中,每一次浏览器刷新都重新计算并设置一次,事实上在浪费着性能。
我们需要改进的地方时:仅在浏览器窗口尺寸发生 resize 事件时去修改 渲染器 即可。
需要说明的地方:
- 监听浏览器窗口尺寸变化,对应的是 window.addEventListener('resize', xxxx)
- 当 React 卸载后,一定记得移除监听 window.removeEventListener('resize', xxxx)
- 为了在移除监听时可以找到 在 useEffect中定义的 resize 事件处理函数,我们会在示例代码中,再通过 useRef 创建一个变量指向 事件处理函数。
最终修改后的代码:
import React, { useRef, useEffect } from 'react'
import { WebGLRenderer, PerspectiveCamera, Scene, BoxGeometry, Mesh, DirectionalLight, MeshPhongMaterial } from 'three'
import './index.scss'
const HelloThreejs: React.FC = () => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const resizeHandleRef = useRef<() => void>()
useEffect(() => {
if (canvasRef.current) {
//创建渲染器
const renderer = new WebGLRenderer({ canvas: canvasRef.current })
//创建镜头
//PerspectiveCamera() 中的 4 个参数分别为:
//1、fov(field of view 的缩写),可选参数,默认值为 50,指垂直方向上的角度,注意该值是度数而不是弧度
//2、aspect,可选参数,默认值为 1,画布的高宽比,例如画布高300像素,宽150像素,那么意味着高宽比为 2
//3、near,可选参数,默认值为 0.1,近平面,限制摄像机可绘制最近的距离,若小于该距离则不会绘制(相当于被裁切掉)
//4、far,可选参数,默认值为 2000,远平面,限制摄像机可绘制最远的距离,若超出该距离则不会绘制(相当于被裁切掉)
//以上 4 个参数在一起,构成了一个 “视椎”,关于视椎的概念理解,暂时先不作详细描述。
const camera = new PerspectiveCamera(75, 2, 0.1, 5)
//创建场景
const scene = new Scene()
//创建几何体
const geometry = new BoxGeometry(1, 1, 1)
//创建材质
//我们需要让立方体能够反射光,所以不使用MeshBasicMaterial,而是改用MeshPhongMaterial
//const material = new MeshBasicMaterial({ color: 0x44aa88 })
const material1 = new MeshPhongMaterial({ color: 0x44aa88 })
const material2 = new MeshPhongMaterial({ color: 0xc50d0d })
const material3 = new MeshPhongMaterial({ color: 0x39b20a })
//创建网格
const cube1 = new Mesh(geometry, material1)
cube1.position.x = -2
scene.add(cube1)//将网格添加到场景中
const cube2 = new Mesh(geometry, material2)
cube2.position.x = 0
scene.add(cube2)//将网格添加到场景中
const cube3 = new Mesh(geometry, material3)
cube3.position.x = 2
scene.add(cube3)//将网格添加到场景中
const cubes = [cube1, cube2, cube3]
//创建光源
const light = new DirectionalLight(0xFFFFFF, 1)
light.position.set(-1, 2, 4)
scene.add(light)//将光源添加到场景中
//设置透视镜头的Z轴距离,以便我们以某个距离来观察几何体
//之前初始化透视镜头时,设置的近平面为 0.1,远平面为 5
//因此 camera.position.z 的值一定要在 0.1 - 5 的范围内,超出这个范围则画面不会被渲染
camera.position.z = 2
//渲染器根据场景、透视镜头来渲染画面,并将该画面内容填充到 DOM 的 canvas 元素中
//renderer.render(scene, camera)//由于后面我们添加了自动渲染渲染动画,所以此处的渲染可以注释掉
//添加自动旋转渲染动画
const render = (time: number) => {
time = time * 0.001
// cube.rotation.x = time
// cube.rotation.y = time
cubes.forEach(cube => {
cube.rotation.x = time
cube.rotation.y = time
})
renderer.render(scene, camera)
window.requestAnimationFrame(render)
}
window.requestAnimationFrame(render)
const handleResize = () => {
const canvas = renderer.domElement
camera.aspect = canvas.clientWidth / canvas.clientHeight
camera.updateProjectionMatrix()
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false)
}
handleResize() //默认打开时,即重新触发一次
resizeHandleRef.current = handleResize //将 resizeHandleRef.current 与 useEffect() 中声明的函数进行绑定
window.addEventListener('resize', handleResize) //添加窗口 resize 事件处理函数
}
return () => {
if (resizeHandleRef && resizeHandleRef.current) {
window.removeEventListener('resize', resizeHandleRef.current)
}
}
}, [canvasRef])
return (
<canvas ref={canvasRef} className='full-screen' />
)
}
export default HelloThreejs
再次补充说明:
尽管代码已经有所改进,但上述代码中,创建 3D 场景的代码都集中在 useEffect(() => { if (canvasRef.current) { ... } }, [canvasRef] ) ,这很显然并不是合理的。
合理的应该是通过 useState() 去将 renderer、camera、scene 等都独立出来定义。
将原本集中的代码分散到更多小的 代码块 中。
包括浏览器窗口 resize 事件处理,都应该添加 防抖 策略。
这里就先暂时这样,不再做改进,等到将来再去做稍微复杂点的 场景应用 时,会再次优化代码结构。
以下内容更新于 2021.05.11
在本文以及本教程的所有后面章节中,我们都是通过监听 window resize 事件,在 handleResize 处理函数中重新设置 相机和渲染器 的一些属性配置的。
由于这些示例中实际上只存在一个 <canvas > 标签,画布(canvas) 的尺寸是充满整个浏览器窗口,画布尺寸发生变化的情况只有一种,即 浏览器窗口尺寸发生变化。
但是在实际的项目中,有可能 <canvas > 标签仅仅只占 document.body 中的一部分而已,造成 画布(canvas) 尺寸发生变化,还有以下几种可能:
- 通过 CSS 修改 <canvas > 标签的宽高
- 在 flex 布局下,当其他元素尺寸发生变化时,影响到 <canvas > ,从而造成画布发生尺寸变化。
- ...
很明显,通过 CSS 的变化造成 画布尺寸变化,和 window resize 完全不相关联。
因此我们要寻找其他监听 画布 标签尺寸发生变化的方式。
我们可以通过浏览器最新的 ResizeObserver 来监听 <canvas > 尺寸变化。
ResizeObserver简介
ResizeObserver 是现代浏览器 API 中一个新的内置类,它可以监控某个 DOM 元素尺寸变化。
在 ResizeObserver 出现之前,只能对 window 添加 resize 监听,无法对 DOM 元素添加尺寸变化监听。
observer 单词意思是 “观察”,也就是设计模式中的 “观察模式”,但是我个人习惯性有时候称呼为 “监控模式”
ResizeObserver 一共有 3 个方法:
- observe():开始监控(观察)某元素尺寸变化
- unobserve():停止监控(观察)某元素尺寸变化
- disconnect():取消和结束目标元素上所有的监控(观察)
更多详细介绍,请查阅:
https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver
实际示例代码:
const handleResize = () => {
const canvas = renderer.domElement
camera.aspect = canvas.clientWidth / canvas.clientHeight
camera.updateProjectionMatrix()
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false)
}
handleResize()
//我们不再添加 window resize 监控(观察)
- window.addEventListener('resize', handleResize)
//改为使用 ResizeObserver 来监控(观察)尺寸变化
+ const resizeObserver = new ResizeObserver(() => {
+ handleResize()
+ })
+ resizeObserver.observe(canvasRef.current)
//当我们卸载组件前,一定要 清除掉 监控(观察)
return () =>{
- window.removeEventListener('resize', resizeHandleRef.current)
+ resizeObserver.disconnect()
}
请注意,resizeObserver.observe() 方法中,可以有第 2 个可选参数。
例如:resizeObserver.observe(canvasRef.current, { box: 'border-box' })
如果第 2 个可选参数不填,那么默认值为 { box: 'content-box' }
与本文无关的事情
我在查阅 MDN 关于 ResizeObserver.observer() 介绍时,发现 简体中文(zh-cn) 介绍页中缺少对第 2 个参数,也就是可选参数的中文介绍,于是我就向 MDN 提交了 PR,添加上了该部分。
目前该 PR 已经被合并进 main 中,但是正常访问的 MDN 网页中还未更新过来,估计过一段时间就会看到。
或许此刻已经更新了。
以上内容更新于 2021.05.11
那么接下来,会系统学习一下 Three.js 的一些基础理论。
大楼究竟能改多高,取决于地基有多深,加油!