import './style.css'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
/**
* Base
*/
// Canvas
const canvas = document.querySelector('canvas.webgl')
// Scene
const scene = new THREE.Scene()
/**
* Textures
*/
const textureLoader = new THREE.TextureLoader()
const displacementTexture = textureLoader.load('/textures/displacementMap.png')
/**
* Sizes
*/
const sizes = {
width: window.innerWidth,
height: window.innerHeight
}
window.addEventListener('resize', () =>
{
// Update sizes
sizes.width = window.innerWidth
sizes.height = window.innerHeight
// Update camera
camera.aspect = sizes.width / sizes.height
camera.updateProjectionMatrix()
// Update renderer
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
})
/**
* Camera
*/
// Base camera
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100)
camera.position.set(2, 2, 6)
scene.add(camera)
// Controls
const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true
/**
* Renderer
*/
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
powerPreference: 'high-performance',
antialias: true
})
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(window.devicePixelRatio)
/**
* Test meshes
*/
const cube = new THREE.Mesh(
new THREE.BoxBufferGeometry(2, 2, 2),
new THREE.MeshStandardMaterial()
)
cube.castShadow = true
cube.receiveShadow = true
cube.position.set(- 5, 0, 0)
scene.add(cube)
const torusKnot = new THREE.Mesh(
new THREE.TorusKnotBufferGeometry(1, 0.4, 128, 32),
new THREE.MeshStandardMaterial()
)
torusKnot.castShadow = true
torusKnot.receiveShadow = true
scene.add(torusKnot)
const sphere = new THREE.Mesh(
new THREE.SphereBufferGeometry(1, 32, 32),
new THREE.MeshStandardMaterial()
)
sphere.position.set(5, 0, 0)
sphere.castShadow = true
sphere.receiveShadow = true
scene.add(sphere)
const floor = new THREE.Mesh(
new THREE.PlaneBufferGeometry(10, 10),
new THREE.MeshStandardMaterial()
)
floor.position.set(0, - 2, 0)
floor.rotation.x = - Math.PI * 0.5
floor.castShadow = true
floor.receiveShadow = true
scene.add(floor)
/**
* Lights
*/
const directionalLight = new THREE.DirectionalLight('#ffffff', 1)
directionalLight.castShadow = true
directionalLight.shadow.mapSize.set(1024, 1024)
directionalLight.shadow.camera.far = 15
directionalLight.shadow.normalBias = 0.05
directionalLight.position.set(0.25, 3, 2.25)
scene.add(directionalLight)
/**
* Animate
*/
const clock = new THREE.Clock()
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
// Update test mesh
torusKnot.rotation.y = elapsedTime * 0.1
// Update controls
controls.update()
// Render
renderer.render(scene, camera)
// Call tick again on the next frame
window.requestAnimationFrame(tick)
}
tick()
我们需要直观看到页面运行性能,而不能单单仅靠我们的眼睛去观察。
安装JavaScript性能监视器stats.js
npm install --save stats.js
引入并实例化:
import Stats from 'stats.js'
const stats = new Stats()
stats.showPanel(0) // 显示面板 0: fps, 1: ms, 2: mb, 3+: custom
document.body.appendChild(stats.dom)
在动画函数中调用begin()
和end()
方法:
const tick = () =>
{
stats.begin()
// ...
stats.end()
}
Draw-calls网格描绘调用是GPU绘制三角形的指令。当我们场景中的对象越复杂,Draw-calls次数也越多。
通常来讲,Draw-call次数越少,性能越好,因此我们希望对Draw-call进行监控。
有一个Chrome扩展叫Spector.js可以帮助我们做到这点。
渲染器可以提供有关场景中的内容和正在绘制的内容的一些信息:
console.log(renderer.info)
这一点不用多讲
当你的场景中不再需要某个对象,就要将其废置掉。好比你开发一个带有不同场景的关卡游戏,当你进入下一关,便要将上一关场景中的对象给清除废置。
Three.js官网有对应文档专门讲这一问题:
How to dispose of objects
举个例子:
scene.remove(cube)
cube.geometry.dispose()
cube.material.dispose()
尽可能避免使用Three.js中的灯光,虽然它们简单易上手,但同时也可以轻易显著降低性能。
如果实在需要使用,则越少越好,并且尽可能使用最廉价的灯光像是环境光AmbientLight和平行光DirectionalLight
在场景中添加或移除灯光时,必须重新编译所有支持灯光的材质。如果你的场景很复杂,这个行为可以直接让你屏幕卡死。
道理同第七点,影响运行性能。尽可能使用烘焙阴影等替代方案,例如在贴图纹理中加入烘焙阴影。
如果实在得使用阴影的话,则尽可能去优化阴影贴图。
使用相机助手CameraHelper查看将由阴影贴图相机渲染的区域,并将其尽可能缩小到最小范围。
directionalLight.shadow.camera.top = 3
directionalLight.shadow.camera.right = 6
directionalLight.shadow.camera.left = - 6
directionalLight.shadow.camera.bottom = - 3
directionalLight.shadow.camera.far = 10
const cameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(cameraHelper)
同时尽量使用低分辨率贴图尺寸:
directionalLight.shadow.mapSize.set(1024, 1024)
有些对象可以投射阴影,有些对象可以接收阴影,有些可能两者兼有。尝试在尽可能少的对象上激活castShadow和receiveShadow:
cube.castShadow = true
cube.receiveShadow = false
torusKnot.castShadow = true
torusKnot.receiveShadow = false
sphere.castShadow = true
sphere.receiveShadow = false
floor.castShadow = false
floor.receiveShadow = true
现在我们的场景中,阴影贴图在每次渲染之前都会更新。
我们可以将shadowMap的autoUpdate
属性设为false以此来停用阴影自动更新。
同时告诉Three.js只有必要时候才进行阴影贴图更新。
当needsUpdate
属性被设为true, 场景中的阴影贴图会在下次render调用时刷新:
renderer.shadowMap.autoUpdate = false
renderer.shadowMap.needsUpdate = true
可以看到阴影不再旋转了。
纹理贴图非常吃GPU内存,mipmap甚至更糟糕。
纹理贴图文件的大小于此无关,分辨率才是相关的,因此尽可能降低分辨率。
当调整贴图纹理尺寸时,尽可能为2的幂次方,这对于mipmap非常重要。
如果不这样做,渲染时候进行mipmap时Three.js会尝试通过将图像调整到最接近2的幂次方的分辨率来修复此问题,但此过程将占用资源,并可能导致质量较差的纹理贴图效果。
虽然说过文件格式不会改变GPU的内存使用情况,但使用正确的文件格式可以有效减少加载时间。
可以根据图像和压缩程度以及alpha通道,选择使用.jpg
或.png
。
可以使用TinyPNG等在线工具来进一步减轻文件大小。同时也可以尝试特殊格式,如basis
。
Basis是一种类似.jpg和.png的格式,但压缩功能更强,GPU更容易读取该格式。我们不会介绍它,因为它很难生成,但如果你愿意的话也可以尝试一下。
可以在下面这个链接找到创建.basis文件的信息和工具:https://github.com/BinomialLLC/basis_universal
始终使用缓冲区几何图形 buffer geometries
而不是经典几何图形。使用 BufferGeometry
可以有效减少向 GPU 传输上述数据所需的开销。
更新几何体的顶点会影响性能。创建几何图形时可以执行一次,但避免在动画函数中执行。
如果需要为顶点设置动画,请使用顶点着色器。
如果你有非常多网格使用了相同的几何体图形,那就只创建一个geometry
,然后在所有网格中去使用它:
const geometry = new THREE.BoxBufferGeometry(0.5, 0.5, 0.5)
for(let i = 0; i < 50; i++)
{
const material = new THREE.MeshNormalMaterial()
const mesh = new THREE.Mesh(geometry, material)
mesh.position.x = (Math.random() - 0.5) * 10
mesh.position.y = (Math.random() - 0.5) * 10
mesh.position.z = (Math.random() - 0.5) * 10
mesh.rotation.y = (Math.random() - 0.5) * Math.PI * 2
mesh.rotation.z = (Math.random() - 0.5) * Math.PI * 2
scene.add(mesh)
}
如果几何体不需要进行移动等操作,可以使用BufferGeometryUtils合并它们。
import { BufferGeometryUtils } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
不需要实例化,可以直接使用它的方法。mergeBufferGeometries(...)
将一组几何体作为参数,以获得一个合并后的几何体。然后,我们可以将该几何体与单个网格一起使用:
const geometries = []
for(let i = 0; i < 50; i++)
{
const geometry = new THREE.BoxBufferGeometry(0.5, 0.5, 0.5)
geometry.rotateX((Math.random() - 0.5) * Math.PI * 2)
geometry.rotateY((Math.random() - 0.5) * Math.PI * 2)
geometry.translate(
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10
)
geometries.push(geometry)
}
const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries)
console.log(mergedGeometry)
const material = new THREE.MeshNormalMaterial()
const mesh = new THREE.Mesh(mergedGeometry, material)
scene.add(mesh)
虽然过程复杂了些,但是我们只有一次Draw-call
同理,如果有多个网格使用相同的材质,那就只创建一次material
,在第18点上进行优化:
const geometry = new THREE.BoxBufferGeometry(0.5, 0.5, 0.5)
const material = new THREE.MeshNormalMaterial()
for(let i = 0; i < 50; i++)
{
const mesh = new THREE.Mesh(geometry, material)
mesh.position.x = (Math.random() - 0.5) * 10
mesh.position.y = (Math.random() - 0.5) * 10
mesh.position.z = (Math.random() - 0.5) * 10
mesh.rotation.x = (Math.random() - 0.5) * Math.PI * 2
mesh.rotation.y = (Math.random() - 0.5) * Math.PI * 2
scene.add(mesh)
}
一些像 MeshStandardMaterial
或MeshPhysicalMaterial
的材质需要比像MeshBasicMaterial
,MeshLambertMaterial
和MeshPhongMaterial
的材质消耗更多的资源。
如果需要独立控制各个网格而无法合并几何体,但它们却使用相同的几何体和材质,这时候可以使用实例化网格InstancedMesh。
它就像是一个网格Mesh,但是你只能创建一个实例化网格InstancedMesh
,然后可以为该网格的每个“实例”提供变换矩阵。
矩阵必须是四维矩阵Matrix4,可以使用各种方法来进行变换:
const geometry = new THREE.BoxBufferGeometry(0.5, 0.5, 0.5)
const material = new THREE.MeshNormalMaterial()
const mesh = new THREE.InstancedMesh(geometry, material, 50)
scene.add(mesh)
for(let i = 0; i < 50; i++)
{
const position = new THREE.Vector3(
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10,
(Math.random() - 0.5) * 10
)
const quaternion = new THREE.Quaternion()
quaternion.setFromEuler(new THREE.Euler((Math.random() - 0.5) * Math.PI * 2, (Math.random() - 0.5) * Math.PI * 2, 0))
const matrix = new THREE.Matrix4()
matrix.makeRotationFromQuaternion(quaternion)
matrix.setPosition(position)
mesh.setMatrixAt(i, matrix)
}
我们得到的结果几乎和合并几何体一样好,但是我们仍然可以通过改变矩阵来移动网格。
如果要在动画函数中更改这些矩阵,请将下列代码添加到实例化网格InstancedMesh
:
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage)
使用低多边形模型,多边形越少,帧速率越好。如果需要更多细节,则尝试使用法线贴图,它们在性能消耗方面很优秀,同时在处理纹理时可以获得出色的细节。
如果模型有很多细节和非常复杂的几何图案,请使用Draco压缩,它可以大大减少文件体积。
缺点在于解压几何体时可能会页面卡住,并且还必须加载Draco库。
Gzip是发生在服务端的压缩。大多数服务器不支持gzip文件,如.glb、.gltf、.obj等。根据自身的服务器寻找合适方案。
当对象不在视野中时,它们将不会被渲染,这叫做视锥体剔除Frustum Culling。
虽然感觉有点low,但是缩小相机的视野,让屏幕中显示的对象越少个,我们要渲染的三角形个数也就越少。
像相机视野一样,可以减少相机的near
近端面属性和far
远端面属性。
比如有一个非常广阔的世界,有山有水,那我们可能会看不到远在山后的小房子,将far值降到合适的值,让这些房子甚至不会被渲染。
一些设备有非常高的像素比,但要知道,渲染的像素越多,消耗的性能越巨大,帧率也越差。
因此最好尝试将渲染器的像素比限制为2:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
一些设备可能能够在不同GPU使用之间切换。我们可以通过指定powerPreference
属性来提示用户代理怎样的配置更适用于当前WebGL环境:
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
powerPreference: 'high-performance'
})
如果没有性能问题,则将此属性设置为default
只有在有可见的锯齿且不会导致性能问题的时候才去添加抗锯齿。
每个后期处理过程都将使用与渲染器分辨率(包括像素比率)相同的像素进行渲染。
如果分辨率为1920x1080,有4个通道,像素比为2,则需要渲染19202108024=33177600像素。
合理一点,可以的话将其整合为一个通道。
可以通过更改材质中着色器的精度precision
属性来强制材质中着色器精度:
const shaderMaterial = new THREE.ShaderMaterial({
precision: 'lowp',
// ...
})
设置好后检查是否性能降低或者出现故障。
这对于RawShaderMaterial
是无效的,必须自己添加精度。
监控着色器代码的异常是非常困难的,因此尽量拆分写得简单写,不要写过于复杂的语句,避免使用if语句,应该充分使用各种内置函数。
虽然使用柏林噪声函数很酷,但它会显著影响性能。有时,最好使用纹理来表示噪波。使用texture2D()比柏林噪声函数要廉价得多,并且可以使用photoshop等工具非常高效地生成这些纹理。
uniform是很有作用的,因为我们可以设置它们的值并且在动画函数中去调整值,但是uniform有性能成本。如果某个值不会改变,则可以使用defines。
第一种,直接在着色器代码中:
#define uDisplacementStrength 1.5
第二种,在着色器材质ShaderMaterial
的defines
属性中,它们会自动加到GLSL代码里边:
const shaderMaterial = new THREE.ShaderMaterial({
// ...
defines:
{
uDisplacementStrength: 1.5
},
// ...
}
尽可能在顶点着色器中进行计算,并将结果发送到片元着色器。