08.04. 添加一些自适应

04 添加一些自适应

在上一节中,已经实现了 HelloThree 最为基础的示例。本节将进一步优化那个示例。

我们将给示例中的 canvas 添加宽高自适应,让它充满整个浏览器。


优化第一项:修改 canvas 尺寸

默认 canvas 尺寸为 高150像素,宽300像素。我们现在把 canvas 修改为撑满屏幕。


第1处修改:

打开项目中 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 即可。


第2处修改:

新建文件 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


第3处修改:

在 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%。

所以立方体出现了 扭曲、模糊、锯齿。

那我们继续修改代码。


第4处修改:

修改 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 => { ... }
    }
...


第5处修改:

第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 渲染的画面又该有何应对呢?


第1种策略(推荐):不做任何策略

假设 HD-DP 比例为 3x,即原本 1 像素 则由 3 x 3 ,共 9 个像素来显示。

也就是说原本只需渲染 1 像素,现在需要渲染 9 像素,所消耗的性能是原来的 9 倍。

假设 3D 场景内容稍微复杂一些,那所带来的渲染性能要求会非常高,画面清晰的代价是更高性能的消耗,引起的卡顿 会带来不好的用户体验。

事实上高清屏本身都会做显示优化,即使不做任何处理,画面清晰度并不会明显特别差。

因此,什么都不做,其实是一个非常好的策略。


假设就是想设置成高清屏,那又该如何操作呢?

第2种策略(强烈不推荐):通过 renderer.setPixelRatio 来配置渲染分辨率倍数

在浏览器中,通过 window.devicePixelRatio 可获得当前屏幕物理分辨率与 CSS 样式分辨率的比值。

然后告知渲染器,以后任何 renderer.setSize 都按照此 比值(倍数) 进行渲染

renderer.setPixelRatio(window.devicePixelRatio)

强烈不推荐这种做法。


第3种策略(勉强推荐):按屏幕分辨率比值,计算出对应渲染尺寸

这种策略思路是:通过分辨率比值,计算出实际上应该渲染的最大尺寸,然后渲染出这个尺寸,再将画面内容渲染到 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 事件时去修改 渲染器 即可。


需要说明的地方:

  1. 监听浏览器窗口尺寸变化,对应的是 window.addEventListener('resize', xxxx)
  2. 当 React 卸载后,一定记得移除监听 window.removeEventListener('resize', xxxx)
  3. 为了在移除监听时可以找到 在 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

通过 ResizeObserver 来监听画布尺寸变化

在本文以及本教程的所有后面章节中,我们都是通过监听 window resize 事件,在 handleResize 处理函数中重新设置 相机和渲染器 的一些属性配置的。

由于这些示例中实际上只存在一个 <canvas > 标签,画布(canvas) 的尺寸是充满整个浏览器窗口,画布尺寸发生变化的情况只有一种,即 浏览器窗口尺寸发生变化。

但是在实际的项目中,有可能 <canvas > 标签仅仅只占 document.body 中的一部分而已,造成 画布(canvas) 尺寸发生变化,还有以下几种可能:

  1. 通过 CSS 修改 <canvas > 标签的宽高
  2. 在 flex 布局下,当其他元素尺寸发生变化时,影响到 <canvas > ,从而造成画布发生尺寸变化。
  3. ...

很明显,通过 CSS 的变化造成 画布尺寸变化,和 window resize 完全不相关联。

因此我们要寻找其他监听 画布 标签尺寸发生变化的方式。


我们可以通过浏览器最新的 ResizeObserver 来监听 <canvas > 尺寸变化。


ResizeObserver简介

ResizeObserver 是现代浏览器 API 中一个新的内置类,它可以监控某个 DOM 元素尺寸变化。

在 ResizeObserver 出现之前,只能对 window 添加 resize 监听,无法对 DOM 元素添加尺寸变化监听。


observer 单词意思是 “观察”,也就是设计模式中的 “观察模式”,但是我个人习惯性有时候称呼为 “监控模式”


ResizeObserver 一共有 3 个方法:

  1. observe():开始监控(观察)某元素尺寸变化
  2. unobserve():停止监控(观察)某元素尺寸变化
  3. 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,添加上了该部分。

https://github.com/mdn/translated-content/pull/817

目前该 PR 已经被合并进 main 中,但是正常访问的 MDN 网页中还未更新过来,估计过一段时间就会看到。

或许此刻已经更新了。


以上内容更新于 2021.05.11


那么接下来,会系统学习一下 Three.js 的一些基础理论。

大楼究竟能改多高,取决于地基有多深,加油!