20.Three.js 优化之合并对象
20 Three.js 优化之合并对象
前面学习了 Three.js 入门、基础、技巧,今天开始学习 Three.js 的性能优化。
关于性能优化有很多方式,最基础也是最常见的方式就是——合并几何对象。
在 “Three.js 基础之图元” 那篇文章中,我们将几何体称呼为 图元,现在我们修改一下这个称呼,本文以后,绝大多数情况下我们都使用 “几何体” 来代替 “图元”。
既然谈到性能优化,就不能使用简单的示例,要不然根本无法体现出 优化前和优化后 的区别。
激动人心的时刻到了。
本文我们的目标:制作并优化一个显示地球人口人数分布的可视化 3D 地球
你可以先访问以下网址,先感受一下我们本文要模仿的效果:
https://globe.chromeexperiments.com/
补充说明:这个网站,是谷歌浏览器为了向大众演示 WebGL 技术而制作的一个演示网页。
我相信你第一次看到这种基于浏览器的 3D 地球数据展示,一定会被震撼到的。
接下来我们就要逐步分析,找出实现方式。
我们先考虑怎么把这个场景实现出来,然后再考虑优化的事。
核心模块分析
我们要先搞明白这个 3D 数字地球的核心模块。
-
3D 地球
就是一个球体,添加一个地球纹理图片
图片为一个矩形地球展开图,本示例使用的地球纹理图片资源:
https://threejsfundamentals.org/threejs/resources/images/world.jpg -
表示人口多少的柱状物
某个地区人口多则柱状物就比较高,反之人口少则柱状物比较低
-
鼠标可交互
这个直接使用 OrbitControls 就可以 只不过本示例只允许左右、上下拖拽查看,但不允许修改镜头轨道的远近距离
上面的核心模块 1 、3 都很容易实现,重点我们进一步拆解一下 “表示人口多少的柱状物”。
如何实现“表示人口多少的柱状物”?
我们通过以下 3 个灵魂追问,来梳理思路。
第 1 问:人口数据从哪里来?
原网页提供 3 个年份的人口数量统计,分别是 1990、1995、2000 年。
美国国家航天局(NASA)提供的统计结果介绍页: https://sedac.ciesin.columbia.edu/data/set/gpw-v4-admin-unit-center-points-population-estimates-rev11/data-download
请注意,该页面还提供最近年份的统计结果,但是下载时候提示需要注册。
我们选择不使用最新的 2020 年数据,而是使用 2010 年的结果。
为了方便你获得到 2010 年男性人口统计结果,你可以直接点击下面这个地址,直接下载:
反正我们本文的重点是模仿效果,至于数据时效性不必纠结
该人口统计数据文件格式为 .asc,至于如何解析该文件,我们会稍后讲解
为啥是男性人口统计?女性人口呢?为什么不是全部人口统计呢?
因为在下一篇文章中,就会有女性人口统计,然后做出同一个地区 男女人口数量比较 的动画
为了简化,本文下面文字中,将忽略 “男性人口数量” 这个概念,统一称呼为 “人口数据”
第 2 问:人口数据和地区的对应关系,如何表现在地球上?
我们把刚才下载得到的人口统计数据文件,重命名为 gpw_v4_014mt_2010.asc,然后将该文件移动到:src/assets/data/ 目录中。
点击该文件,用记事本查看该文件内容,你会发现里面大致为以下内容:
ncols 360
nrows 145
xllcorner -180
yllcorner -60
cellsize 0.99999999999994
NODATA_value -9999
-9999 -9999 -9999 -9999 -9999 -999...
...
...
这里面的数据内容为:矩形地球地图上,不同点(经纬度)对应的数值(人口数量)。
补充:这里面的人口数量值并不是具体人口数量(比如 45932551 个人),而是具有一定比例单位的值(例如 458.6)
具体单位值对应的人口我还不清楚,或许是 万,也或许是百万,不过不影响我们本示例,你只需把他当成数字即可
我们需要将数据与地图纹理图片进行点对点的位置匹配。
.asc 后缀的文件有特别多种用途和场景,我们这里提到的 .asc 文件是指:以
PGP (Pretty Good Privacy) ASCII Armored File
形式存在的栅格化结构的数据文件。
.asc 栅格化结构的数据文件说明:
关键字 对应含义 ncols(number colos) 表示该数据内容有多少列 nrows(number rows) 表示该数据内容有多少行 xllcorner(x-low-left-corner) 栅格的左下角坐标 x 的值 yllcorner(y-low-left-corner) 栅格的左下角坐标 y 的值 cellsize(cell size) 每个单元格元的尺寸 NODATA_value 单元格内没有值时对应的值 你可以把 .asc 文件 想象成一个数据表格,每个单元格为一项,nrows 行 ncols 列 个单元格构建成了一个 数据网格。
除了 .asc 开头的属性值键对外,后面的就是依次填入数据单元网格中的数据。
第 3 问:根据人口多少,如何创建对应的柱状物?
当得到地球某个经纬度(地球纹理图片上的某个坐标)上对应的人口数据后,就可以根据人口数量按照一定比例,创建柱状物。
原理讲过后,那接下来就是实际操作了。
基础示例:HelloEarth
接下来,将通过以下几个步骤,逐步实现我们的目标示例。
特别说明:
以下几个步骤中的代码,重点是向你讲解具体的功能和思路,并不是最终的代码。
最终完整的示例代码中,会对这些每个步骤中的代码进行新的组织。
第 1 步:加载人口数据文件(gpw_v4_014mt_2010.asc)
-
数据文件路径为 ./src/assets/data/gpw_v4_014mt_2010.asc
-
由于我们使用 alias 来得到 .asc 文件编译后的路径,所以请记得:
-
tsconfig.pahts.json 的 paths 中添加
"@/assets/*": ["./src/assets/*"]
-
global.d.ts 中添加
declare module '*.asc';
-
以上 2 处均配置正确后,才可以让我们在代码中方便使用
require('@/assets/xx/xx.asc').default
来获取 .asc 资源的路径假设你并不是使用 react + typescript + alias,那么你可以忽略我提到的配置,直接请求一个固定的网络资源(.asc 文件)就好了。
-
-
通过 window.fetch() 这个函数来获取 .asc 文件内容
我们这里没有使用 xhr 或 axios 来请求获取文件资源,而是使用了 fetch 这个 Web API
关于 fetch 的用法,请参考:https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API
具体的代码:
const loadDataFile = async (url: string) => {
try {
const res = await window.fetch(url)
const text = await res.text() // text 就是 .asc 文件里的内容
} catch (error) {
console.log('加载数据出错')
}
}
const ascURL = require('@/assets/data/gpw_v4_014mt_2010.asc').default
loadDataFile(ascURL)
额外说一个事情,本文对应的是 Three.js 官方教程 https://threejsfundamentals.org/threejs/lessons/threejs-optimize-lots-of-objects.html
我在阅读英文原文时,当时他代码中使用的是:
async function loadFile(url) { const req = await fetch(url); return req.text(); }
我认为不应该将返回值使用变量 req(request),而应该是 res(response),于是我就提交了一个合并请求(PR),然后很快就得到 greggman 的回应,我的 PR 已被合并到 master 中。
呵,我也顺带成为了这个项目中的一名 贡献者(contributor)。
第 2 步:解析人口数据
-
为了方便我们以后代码提示,我们先使用 TypeScript 定义解析 .asc 数据后的格式
type DataType = (number | undefined)[][] type ASCData = { data: DataType, ncols: number, nrows: number, xllcorner: number, yllcorner: number, cellsize: number, NODATA_value: number, max: number, min: number, }
data 为栅格化的世界人口数据,一共 nrows 条,每一条是由 ncols 个数字构成
假设某个点对应有人口数据则值为具体的数字,若没有人口则值为 undefined。
请记得没有人口数据的值为 undefined,而不是 0。
max、min 分别为我们添加的自定义属性,用来记录所有地区人口数据中最多和最少的人口数量,以此我们方便计算出 柱状高度比例
-
开始解析 .asc 文件内容,大体步骤如下:
-
首先我们知道 .asc 中每一行对应一条数据,那么就可以使用换行符 ‘\n’ 来分隔出每一条数据,然后针对每一条数据进行解析
text.split('\n').forEach((line) => { ... })
-
被分隔出来的每一行数据,再进一步转化和分析:
-
由于可能存在多个连续空格,因此我们对每一条数据,再通过正则表达式
/\s+/
进一步分隔// 在正则表达式 ‘/\s+/’ 中 s 表示为空格,+ 表示 1个或多个 const parts = line.trim().split(/\s+/)
-
位于 .asc 文件开头,描述栅格化数据的一些属性,例如 ncols、nrows…,这些数据的结构为:
属性名 + 空格 + 值
构成的if (parts.length === 2) { ... }
-
位于 .asc 文件中间,一行行,一条条具体的数据值,这些数据的结构为:
数字 + 空格 + 数字 + ...
if (parts.length > 2) { ... }
-
位于 .asc 文件尾部,可能存在的、无用的空白换行,这些空白换行是需要被我们通过条件判断来忽略掉的
由于前面已经进行了 length === 2 或 > 2 的判断,那么剩下的就肯定是空白无用的换行,我们什么也不做处理就好。
-
-
在解析所有人口数据的过程中,我们要不断记录、得出 人口最大数值和最小数值
-
最终将解析好的数据结果对象,通过 TS 的 as 断言,对外返回出结果
-
补充一点:由于我们从 text 中读取到的 “数字” 其实是 字符串,所以在解析过程中都需要使用 parseFloat() 这个函数将 string 转化为 number
-
再补充一个细节,在初始化 max 和 min 时:
- 让 max 初始化值为 0,因为我们知道有人口数据的值一定是大于 0 的
- 让 min 初始化值为 99999,因为我们知道一定有人口数据的值一定是小于 99999 的,且人口数量一定不会是负数
具体的代码:
const parseData = (text: string) => { const data: (number|undefined)[][] = [] const settings: { [key: string]: any } = { data } let max:number = 0 let min:number = 99999 text.split('\n').forEach((line) => { const parts = line.trim().split(/\s+/) if (parts.length === 2) { settings[parts[0]] = parseFloat(parts[1]) } else if(parts.length > 2) { const values = parts.map((item) => { const value = parseFloat(item) if (value === settings['NODATA_value']) { return undefined } max = Math.max(max, value) min = Math.min(min, value) return value }) data.push(values) } }) return { ...settings, ...{ max, min } } as ASCData }
-
第 3 步:加载地球纹理图片
const loader = new Three.TextureLoader()
const texture = loader.load(require('@/assets/imgs/world.jpg').default,render)
const material = new Three.MeshPhongMaterial({
map: texture
})
const geometry = new Three.SphereBufferGeometry(2, 32, 32)
const earth = new Three.Mesh(geometry, material)
scene.add(earth)
请注意上述代码中,loader.load(xxx, render),我们希望当纹理图片加载完成后,才执行 render 渲染
第 4 步:将人口数据与地球纹理图片进行位置上的匹配
先不考虑球体,假设我们仅仅想获得一张显示人口数量分布、平面的世界地图,该如何做呢?
代码思路:
- 我们通过 第 2 步骤已经拿到了栅格化后的世界人口分布数据
- 并且我们知道栅格化的数据是由 nrow(145) 行、ncols(360) 列组成
- 假设 1 个数据点 对应 1 像素,那么栅格化的数据实际上对应的是一个 高 145 像素、宽 360 像素的图形
- 假设 数据点最小(人口最少)的地方,我们用黑色来填充,而数据点最大(人口最多)的地方用红色填充,处于中间数量的点按照比例依次进行颜色变化,那么就可以得到我们想要的图形了。
- 关于某个点填充的颜色,我们使用 HSL(色相、饱和度、亮度),其中当 人口少时 L 的值越接近于 0 (黑色)、人口多时 L 的值越接近 1 (红色)
- 向画布(canvas) 某个点填充颜色,需要用到 canvas 一些相关知识,请自行先学习了解一下 canvas 相关知识
对应的代码:
const hsl = (h: number, s: number, l: number) => {
return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`
}
const drawData = (ascData: ASCData) => {
if (canvasRef.current === null) { return }
const ctx = canvasRef.current.getContext('2d')
if (ctx === null) { return }
const range = ascData.max - ascData.min
ctx.canvas.width = ascData.ncols
ctx.canvas.height = ascData.nrows
ctx.fillStyle = '#444'
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
ascData.data.forEach((row, rowIndex) => {
row.forEach((value, colIndex) => {
if (value === undefined) { return }
const amount = (value - ascData.min) / range
const hue = 1
const saturation = 1
const lightness = amount
ctx.fillStyle = hsl(hue, saturation, lightness)
ctx.fillRect(colIndex,rowIndex,1,1)
})
})
}
为了让你比较直观看清,这里贴出目前我们已经写出来的代码。
请注意下面的代码并不是我们真正示例的代码,你可以实际运行以下,查看效果
import { useEffect, useRef } from 'react'
const loadDataFile = async (url: string) => {
const res = await window.fetch(url)
const text = await res.text()
return text
}
type DataType = (number | undefined)[][]
type ASCData = {
data: DataType,
ncols: number,
nrows: number,
xllcorner: number,
yllcorner: number,
cellsize: number,
NODATA_value: number,
max: number,
min: number,
}
const parseData = (text: string) => {
const data: DataType = []
const settings: { [key: string]: any } = { data }
let max: number = 0
let min: number = 99999
text.split('\n').forEach((line) => {
const parts = line.trim().split(/\s+/)
if (parts.length === 2) {
settings[parts[0]] = parseFloat(parts[1])
} else if (parts.length > 2) {
const values = parts.map((item) => {
const value = parseFloat(item)
if (value === settings['NODATA_value']) {
return undefined
}
max = Math.max(max, value)
min = Math.min(min, value)
return value
})
data.push(values)
}
})
return { ...settings, ...{ max, min } } as ASCData
}
const hsl = (h: number, s: number, l: number) => {
return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`
}
const HelloEarth = () => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const drawData = (ascData: ASCData) => {
if (canvasRef.current === null) { return }
const ctx = canvasRef.current.getContext('2d')
if (ctx === null) { return }
const range = ascData.max - ascData.min
ctx.canvas.width = ascData.ncols
ctx.canvas.height = ascData.nrows
ctx.fillStyle = '#444'
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
ascData.data.forEach((row, rowIndex) => {
row.forEach((value, colIndex) => {
if (value === undefined) { return }
const amount = (value - ascData.min) / range
const hue = 1
const saturation = 1
const lightness = amount
ctx.fillStyle = hsl(hue, saturation, lightness)
ctx.fillRect(colIndex,rowIndex,1,1)
})
})
}
useEffect(() => {
if (canvasRef.current === null) { return }
const ascURL = require('@/assets/data/gpw_v4_014mt_2010.asc').default
const doSomthing = async () => {
try {
const text = await loadDataFile(ascURL)
const ascData = parseData(text)
drawData(ascData)
} catch (error) {
console.log(error)
}
}
doSomthing()
return () => {
}
}, [canvasRef])
return (
<canvas ref={canvasRef} />
)
}
export default HelloEarth
实际运行后,就会看到一张 世界人口分布的地图
请注意这个 “看似是世界地图”的图片并不是真正的世界地理位置地图,而是人口数量分布图。
如果你已经看懂了上面的代码,那么接下来就可以真正去制作 3D 立体地球示例了。
本示例是我们做过的最复杂的例子,尽管我们已经进行了详细的思路解读,你一定要多看,多敲几遍,否则接下来的代码你可能更加难以理解。
我们需要将之前的 drawData() 修改 为 addBoxes(),并且不再是在 canvas 中绘制点,而是在球体上添加柱状物:
const addBoxes = (ascData: ASCData, scene: Three.Scene) => {
const geometry = new Three.BoxBufferGeometry(1, 1, 1)
geometry.applyMatrix4(new Three.Matrix4().makeTranslation(0, 0, 0.5))
const lonHelper = new Three.Object3D()
scene.add(lonHelper)
const latHelper = new Three.Object3D()
lonHelper.add(latHelper)
const positionHelper = new Three.Object3D()
positionHelper.position.z = 1
latHelper.add(positionHelper)
const range = ascData.max - ascData.min
const lonFudge = Math.PI * 0.5
const latFudge = Math.PI * -0.135
ascData.data.forEach((row, latIndex) => {
row.forEach((value, lonIndex) => {
if (value === undefined) { return }
const amount = (value - ascData.min) / range
const material = new Three.MeshBasicMaterial()
const hue = Three.MathUtils.lerp(0.7, 0.3, amount)
const saturation = 1
const lightness = Three.MathUtils.lerp(0.1, 1, amount)
material.color.setHSL(hue, saturation, lightness)
const mesh = new Three.Mesh(geometry, material)
scene.add(mesh)
lonHelper.rotation.y = Three.MathUtils.degToRad(lonIndex + ascData.xllcorner) + lonFudge
latHelper.rotation.x = Three.MathUtils.degToRad(latIndex + ascData.yllcorner) + latFudge
positionHelper.updateWorldMatrix(true, false)
mesh.applyMatrix4(positionHelper.matrixWorld)
mesh.scale.set(0.005, 0.005, Three.MathUtils.lerp(0.001, 0.5, amount))
})
})
}
上面代码中牵扯到了非常多新的、之前从未使用过的一些函数或属性。
解释说明:
-
栅格化数据 和 纹理图片 均可看作是 2D 矩形坐标,最终需要转化为 3D 球体坐标,转化过程中 lonFudge、latFudge 具体作用机理,暂时还没搞明白。
先记住转化公式,以后再慢慢研究
栅格化数据 为 360 _ 145、纹理图片为 2048 _ 1024
-
Three.Matrix4:WebGL 中的矩阵库
-
Three.MathUtils:Three.js 中内置的一些计算函数
关于这些新的对象具体详细介绍,请查阅 Three.js 官方文档
-
lonHelper 用于赤道上的经度旋转、latHelper 用于维度旋转、positionHelper 用于 Z 轴(地球地面)上的偏移。
-
lonFudge 的值为 Math.PI * 0.5,也就是相当于 1/4 个圆(地球 1/4 圈)
-
latFudge 的值为 Math.PI * -0.135,这里的 -0.135 不太清楚是怎么得出来的,但是大概率推测它是用来将柱状物与纹理图片对齐的
示例所需其他代码块:
- 创建 3D 地球、以及加载纹理图片
- 添加 OrbitControls 控制,并且开启 “弹性结束控制”
- 添加场景渲染函数 render,并且添加 “按需渲染” 相关代码
最终完整的示例代码:
import { useEffect, useRef } from 'react'
import * as Three from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import './index.scss'
const loadDataFile = async (url: string) => {
const res = await window.fetch(url)
const text = await res.text()
return text
}
type DataType = (number | undefined)[][]
type ASCData = {
data: DataType,
ncols: number,
nrows: number,
xllcorner: number,
yllcorner: number,
cellsize: number,
NODATA_value: number,
max: number,
min: number,
}
const parseData = (text: string) => {
const data: DataType = []
const settings: { [key: string]: any } = { data }
let max: number = 0
let min: number = 99999
text.split('\n').forEach((line) => {
const parts = line.trim().split(/\s+/)
if (parts.length === 2) {
settings[parts[0]] = parseFloat(parts[1])
} else if (parts.length > 2) {
const values = parts.map((item) => {
const value = parseFloat(item)
if (value === settings['NODATA_value']) {
return undefined
}
max = Math.max(max, value)
min = Math.min(min, value)
return value
})
data.push(values)
}
})
return { ...settings, ...{ max, min } } as ASCData
}
// const hsl = (h: number, s: number, l: number) => {
// return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`
// }
let renderRequested = false
const HelloEarth = () => {
const canvasRef = useRef<HTMLCanvasElement>(null)
// const drawData = (ascData: ASCData) => {
// if (canvasRef.current === null) { return }
// const ctx = canvasRef.current.getContext('2d')
// if (ctx === null) { return }
// const range = ascData.max - ascData.min
// ctx.canvas.width = ascData.ncols
// ctx.canvas.height = ascData.nrows
// ctx.fillStyle = '#444'
// ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
// ascData.data.forEach((row, rowIndex) => {
// row.forEach((value, colIndex) => {
// if (value === undefined) { return }
// const amount = (value - ascData.min) / range
// const hue = 1
// const saturation = 1
// const lightness = amount
// ctx.fillStyle = hsl(hue, saturation, lightness)
// ctx.fillRect(colIndex, rowIndex, 1, 1)
// })
// })
// }
const addBoxes = (ascData: ASCData, scene: Three.Scene) => {
const geometry = new Three.BoxBufferGeometry(1, 1, 1)
geometry.applyMatrix4(new Three.Matrix4().makeTranslation(0, 0, 0.5))
const lonHelper = new Three.Object3D()
scene.add(lonHelper)
const latHelper = new Three.Object3D()
lonHelper.add(latHelper)
const positionHelper = new Three.Object3D()
positionHelper.position.z = 1
latHelper.add(positionHelper)
const range = ascData.max - ascData.min
const lonFudge = Math.PI * 0.5
const latFudge = Math.PI * -0.135
ascData.data.forEach((row, latIndex) => {
row.forEach((value, lonIndex) => {
if (value === undefined) { return }
const amount = (value - ascData.min) / range
const material = new Three.MeshBasicMaterial()
const hue = Three.MathUtils.lerp(0.7, 0.3, amount)
const saturation = 1
const lightness = Three.MathUtils.lerp(0.1, 1, amount)
material.color.setHSL(hue, saturation, lightness)
const mesh = new Three.Mesh(geometry, material)
scene.add(mesh)
lonHelper.rotation.y = Three.MathUtils.degToRad(lonIndex + ascData.xllcorner) + lonFudge
latHelper.rotation.x = Three.MathUtils.degToRad(latIndex + ascData.yllcorner) + latFudge
positionHelper.updateWorldMatrix(true, false)
mesh.applyMatrix4(positionHelper.matrixWorld)
mesh.scale.set(0.005, 0.005, Three.MathUtils.lerp(0.001, 0.5, amount))
})
})
}
useEffect(() => {
if (canvasRef.current === null) { return }
const canvas = canvasRef.current
const renderer = new Three.WebGLRenderer({ canvas })
const camera = new Three.PerspectiveCamera(45, 2, 0.1, 100)
camera.position.z = 4
const scene = new Three.Scene()
scene.background= new Three.Color(0x000000)
const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true
controls.enablePan =false
controls.update()
const render = () => {
renderRequested = false
controls.update()
renderer.render(scene, camera)
}
const handleChange =() =>{
if(renderRequested === false){
renderRequested = true
window.requestAnimationFrame(render)
}
}
controls.addEventListener('change',handleChange)
const loader = new Three.TextureLoader()
const texture = loader.load(require('@/assets/imgs/world.jpg').default, render)
const material = new Three.MeshBasicMaterial({
map: texture
})
const geometry = new Three.SphereBufferGeometry(1, 64, 32)
const earth = new Three.Mesh(geometry, material)
scene.add(earth)
const handleResize = () => {
const width = canvas.clientWidth
const height = canvas.clientHeight
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height, false)
window.requestAnimationFrame(render)
}
handleResize()
window.addEventListener('resize', handleResize)
const ascURL = require('@/assets/data/gpw_v4_014mt_2010.asc').default
const doSomthing = async () => {
try {
const text = await loadDataFile(ascURL)
const ascData = parseData(text)
//drawData(ascData)
addBoxes(ascData, scene)
render()
} catch (error) {
console.log(error)
}
}
doSomthing()
return () => {
controls.removeEventListener('change',handleChange)
window.removeEventListener('resize', handleResize)
}
}, [canvasRef])
return (
<canvas ref={canvasRef} className='full-screen' />
)
}
export default HelloEarth
调试运行,首先就会看到一个 3D 立体地球,等待 1 秒左右,待 .asc 数据加载并解析、添加地球上的柱状物后,就会看到本示例所想演示的最终效果。
终于终于到这一步了
不过当你鼠标拖动地球时,会感受到略微卡顿,或者说不够流畅。
那么接下来,就到了本文的核心内容:通过 合并对象 来达到优化场景的目的。
补充:启用浏览器 调试工具 DevTool 的 Rendering 查看渲染性能
除了浏览器本身的 性能(Performance) 面板外,还有另外一个重要的、方便我们查看页面渲染性能的工具——Rendering。
通过谷歌调试工具 Rendering,查看当前页面渲染性能情况:
-
打开浏览器调试工具 DevTool
-
点击右侧 3 个小圆点
-
鼠标移动到 More tools
-
点击 Rendering
-
在新出现的 Rendering 面板中,勾选 Frame Rendering Stats
备注:在旧的谷歌浏览器中,应该勾选的是 Show FPS meter
这样就可以在网页左上角,实时看到当前渲染性能状况。
性能数据解读:
性能展示的数据,主要 2 个模块:Frames 和 GPU
GPU 相关:
-
GPU raster :on 表示 GPU 光栅化已开启
-
GPU memory:GPU 已用大小、GPU 最大可用大小
在本示例中,通常是当修改浏览器尺寸时,此时需要大量计算,会显示出 GPU memory
在普通的 鼠标拖拽 改变地球视角时,不会显示 GPU memory
Frames 相关:
假设某一时刻,渲染性能结果为 Frames:63% 1082(0m) dropped of 2737
对应的解读为:
第 1 个数字 63% —— 63% 的帧按时渲染完成
第 2 个数字 1082 —— 有 1082 个合成帧丢失(未渲染)
第 3 个数字 0m —— 有 0 个帧丢失
第 4 个数字 2737 —— 原本计划渲染 2737 个帧
数字之间的计算关系为 63% ≈ 1 - (1082 + 0 )/ 2737
也就是说 第 2 个数字(丢失的合成帧)越小,那么整体按时完成渲染帧的百分比(第 1 个数字)越大,意味着此刻网页越流畅。
优化代码:合并对象
核心代码分析
在上面的示例代码中,lonHelper 用于赤道上的经度旋转、latHelper 用于维度旋转、positionHelper 用于 Z 轴(地球地面)上的偏移。
默认 Three.js 中物体是有 1/2 位于 Z 轴之下的,通过 Z 轴的偏移让柱状物可以完全出现在地面上
每一个数据点(柱状物)都创建了一个 MeshBasicMaterial 和 Mesh。
我们的数据点一共为 145 行、360 列,那么就意味着假设全部数据点都有数据,那么数据点总数量为:145 * 360 = 52200,但是考虑到有非常多的数据点的值为 -9999(NODATA_value),也就是没有值,不需要绘制,那么减去这些没有数据点,最终需要绘制的数据点(柱状物)大约为 19000 个。
柱状物 19000 个,再加上对应的 3 个辅助对象(lonHelper、latHelper、positionHelper),相当于总绘制数量为 19000 * 4 = 76000。
也就是说每一次场景更新,大约需要绘制 7.6 万个对象,所以这才造成了卡顿现象。
如何解决卡顿?减少需要渲染对象的数量!
还记得我们刚才统计的渲染对象数量吗?
- 柱状体 约 19000 个
- 每个柱状体对应 3 个辅助对象 19000 * 3
我们需要做的就是把所有的柱状体合并成一个物体,也就是说原本需要渲染 19000 个柱状体,合并之后只需渲染 1 个,让柱状体数量减少 18999 个。
修改 addBoxes 函数代码:
import { BufferGeometryUtils } from 'three/examples/jsm/utils/BufferGeometryUtils'
const addBoxes = (ascData: ASCData, scene: Three.Scene) => {
//const geometry = new Three.BoxBufferGeometry(1, 1, 1)
//geometry.applyMatrix4(new Three.Matrix4().makeTranslation(0, 0, 0.5))
const lonHelper = new Three.Object3D()
scene.add(lonHelper)
const latHelper = new Three.Object3D()
lonHelper.add(latHelper)
const positionHelper = new Three.Object3D()
positionHelper.position.z = 1
latHelper.add(positionHelper)
const originHelper = new Three.Object3D()
originHelper.position.z = 0.5
positionHelper.add(originHelper)
const range = ascData.max - ascData.min
const lonFudge = Math.PI * 0.5
const latFudge = Math.PI * -0.135
const geometries: Three.BoxBufferGeometry[] = []
const color = new Three.Color()
ascData.data.forEach((row, latIndex) => {
row.forEach((value, lonIndex) => {
if (value === undefined) { return }
const amount = (value - ascData.min) / range
//const material = new Three.MeshBasicMaterial()
//const hue = Three.MathUtils.lerp(0.7, 0.3, amount)
//const saturation = 1
//const lightness = Three.MathUtils.lerp(0.1, 1, amount)
//material.color.setHSL(hue, saturation, lightness)
//const mesh = new Three.Mesh(geometry, material)
//scene.add(mesh)
const geometry = new Three.BoxBufferGeometry(1, 1, 1)
lonHelper.rotation.y = Three.MathUtils.degToRad(lonIndex + ascData.xllcorner) + lonFudge
latHelper.rotation.x = Three.MathUtils.degToRad(latIndex + ascData.yllcorner) + latFudge
//positionHelper.updateWorldMatrix(true, false)
//mesh.applyMatrix4(positionHelper.matrixWorld)
//mesh.scale.set(0.005, 0.005, Three.MathUtils.lerp(0.001, 0.5, amount))
positionHelper.scale.set(0.005, 0.005, Three.MathUtils.lerp(0.01, 0.5, amount))
originHelper.updateWorldMatrix(true, false)
geometry.applyMatrix4(originHelper.matrixWorld)
const hue = Three.MathUtils.lerp(0.7, 0.3, amount)
const saturation = 1
const lightness = Three.MathUtils.lerp(0.1, 1, amount)
color.setHSL(hue, saturation, lightness)
const rgb = color.toArray().map((value) => {
return value * 255
})
const numVerts = geometry.getAttribute('position').count
const itemSize = 3
const colors = new Uint8Array(itemSize * numVerts)
//这里有一个稍微奇葩点的写法,就是使用下划线 _ 来起到参数占位的作用
colors.forEach((_, index) => {
colors[index] = rgb[index % 3]
})
const normalized = true
const colorAttrib = new Three.BufferAttribute(colors, itemSize, normalized)
geometry.setAttribute('color', colorAttrib)
geometries.push(geometry)
})
})
const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries)
//const material = new Three.MeshBasicMaterial({ color: 'red' })
const material = new Three.MeshBasicMaterial({
vertexColors: true
})
const mesh = new Three.Mesh(mergedGeometry, material)
scene.add(mesh)
}
上述代码中,注释部分为之前 addBoxes() 函数的代码,除了 //const material = new Three.MeshBasicMaterial({ color: ‘red’ }) 这一行
代码解析:
-
合并所有的柱状物,使用到了一个新的函数 BufferGeometryUtils.mergeBufferGeometries()
注意:BufferGeometryUtils 并非来自 Three,而是来自 ’three/examples/jsm/utils/BufferGeometryUtils’
-
柱状物的颜色,不再使用 color 设定,而是启用了 “顶点着色”。
关于这 2 个大的知识点,可以去阅读 Three.js 官方文档
需要恶补官方文档,如果只是看了本教程,那么还会有大量的知识点未曾接触。
经过合并优化后的场景,在浏览器中运行,比之前的流畅非常多,没有卡顿的现象了。
我本机电脑硬件配置比较高,我分别记录了 Rendering 面板中 优化前后的 Frames 值。
优化前:顺利渲染帧的百分比约为 60%
优化后:顺利渲染帧的百分比约为 90%
可见网页流畅度确实提高了很多
本文小结:
在 Three.js 大型的场景中,绝大多数都需要采用合并对象的策略来优化渲染性能。
合并对象可以减少需要渲染的对象数量,并且还可以将有一些根本不可见的面进行删除,减少渲染面,提高渲染性能。
你以为就这样可以结束了?
事实上还有优化空间,本文先到这里结束。
下一篇将继续优化这个场景。