提高渲染真实性
色调映射Tone Mapping
色调映射,直接作用于webGL
渲染器,意在将低画质的贴图效果模拟成高画质贴图效果,官方文档的描述如下:
通常来说,如果我们使用HDR材质的环境贴图,会导致加载慢等问题,但是使用LDR(Low Dynamic Range)
的贴图(比如使用jpg
格式柱状全景图),又会觉得其效果不够好,所以threejs
贴心的准备了这一选项
const renderer = new THREE.WebGLRenderer({
canvas: canvas
})
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
// tone mapping
gui.add(renderer, 'toneMapping', {
No: THREE.NoToneMapping,
Linear: THREE.LinearToneMapping,
Reinhard: THREE.ReinhardToneMapping,
Cineon: THREE.CineonToneMapping,
ACESFilmic: THREE.ACESFilmicToneMapping,
AgX: THREE.AgXToneMapping,
neutral: THREE.NeutralToneMapping,
custom: THREE.CustomToneMapping
})
默认情况下,webGL
渲染器的色调映射模式为NoToneMapping
,通过gui
来控制每一种toneMapping
的值方便我们测试其效果:
经过测试大致可以得到以下结论:
- THREE.NoToneMapping
描述:禁用色调映射。
特点:直接输出场景的颜色值,不进行任何调整或压缩。
应用场景:适用于不需要HDR效果的情况。
- THREE.LinearToneMapping
描述:线性色调映射。
特点:简单地将颜色值从线性空间转换到sRGB空间。
应用场景:基础的色彩管理,适合于不需要复杂HDR处理的场合。
- THREE.ReinhardToneMapping
描述:基于Reinhard的色调映射算法。
特点:能够有效地处理非常亮的区域,同时保持暗部细节。
应用场景:广泛应用于游戏和CGI中,因为它能够很好地平衡亮度和对比度。
- THREE.CineonToneMapping
描述:模仿电影胶片的效果。
特点:提供了更柔和的过渡效果,适合追求电影感的画面。
应用场景:电影制作和高端视觉特效项目。
- THREE.ACESFilmicToneMapping
描述:基于Academy Color Encoding System (ACES) 的色调映射。
特点:模拟了现代数字电影摄影机的响应曲线,提供了非常自然的视觉效果。
应用场景:专业级视频制作和要求极高的图形应用。
- THREE.AgXToneMapping
描述:一种模拟胶片感光特性的算法。
特点:提供了类似于传统黑白胶片的效果。
应用场景:艺术创作和特定风格化的项目。
THREE.NeutralToneMapping
描述:中性色调映射。
特点:尽量保持颜色的真实性,减少过度处理带来的失真。
应用场景:需要真实色彩再现的应用。
- THREE.CustomToneMapping
描述:自定义色调映射。
特点:允许开发者实现自己的色调映射逻辑。
应用场景:当上述预设不能满足特定需求时,可以使用此选项来实现个性化的色调处理。
此外,我们还可以使用toneMappingExposure
来控制色调的曝光程度,画面的体现上来看就是对于亮度的调节而已
抗锯齿(Anti-Aliasing)
首先要明白为什么会有锯齿(aliasing)
,计算机的渲染是按像素进行的,像素的最小单位就是1x1的矩形,假如要渲染一个非矩形的形状,计算机就可能会因为计算有误导致某个像素没有出来,进而形成阶梯状的边缘,比如:
所以,为了提高画质,避免这些边缘的阶梯状效果,就需要抗锯齿技术来对画面进行优化
SSAA
超采样抗锯齿,全名为Super-Sampling-Anti-Aliasing
,它的原理简单粗暴,就是将原本要渲染的分辨率放大一倍,渲染后再进行缩放;比如要将场景渲染到1920 * 1080的画面上,开启抗锯齿后就是先将场景渲染到3840 * 2160的画面上后再缩放至1920 * 1080,这样子的抗锯齿效果显著,但是性能开销显然就非常大了
2X SSAA,就代表渲染到2倍于原始分辨率的场景,每一个像素相当于放大了 2 x 2 = 4倍,渲染的像素总数就是原来的4倍
4X SSAA,则代表每个像素变为原来的 4 * 4 = 16倍,渲染的像素总数就是原来的16倍
优点:
- 提供高质量的抗锯齿效果。
- 能够消除几乎所有的锯齿现象。
缺点:
- 计算成本非常高,渲染时间显著增加。
- 不适用于性能要求较高的场景。
MSAA
Multi-Sample Anti-Aliasing
,多重采样抗锯齿,原理是在每个像素周围进行多次采样来减少锯齿现象,实现上相对简单,但是因为只在像素中心附近进行采样,所以无法完全消除所有锯齿
引用自
https://zhuanlan.zhihu.com/p/133511752
如图,红框框起的绿色像素就是MSAA会额外处理的像素部分
优点:
- 相对简单且易于实现。
- 对于线性和边缘锯齿效果较好。
缺点:
- 对于点状和纹理锯齿效果较差。
- 只能在像素中心附近进行采样,无法完全消除所有锯齿。
FXAA
自适应抗锯齿Fast Approximate Anti-Aliasing
,一种后处理技术,原理是通过检测边缘并对其进行模糊处理来减少锯齿,它不需要额外的采样,而是在最终图像上进行处理。
优点:
- 实现简单且计算成本低。
- 对于大多数情况下的抗锯齿效果较好。
缺点:
- 效果不如 MSAA 和 SSAA 高质量。
- 可能会出现轻微的模糊效果。
阴影投射
添加阴影也是让渲染更真实的一种方案,但是之前说的环境贴图是无法投射阴影的,要实现阴影需要额外的外部光源,下面尝试给这个场景添加阴影:
首先,要通过给渲染器设置shadowMap.enabled
来开启场景中的阴影效果:
/**
* Renderer
*/
const renderer = new THREE.WebGLRenderer({
canvas: canvas
})
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.shadowMap.enabled = true // 开启阴影效果
// PCFSoftShadowMap以展现更好效果的阴影
renderer.shadowMap.type = THREE.PCFSoftShadowMap
PCFSoftShadowMap
的性价比较高,设置阴影时可以优先考虑
然后,需要对场景中能投射阴影的物体和接收阴影的物体进行设置,对于当前场景而言,接收和投射阴影的物体都是导入的模型,模型经过threejs
的loader
解析后会转成threejs
中内置的Object3D
L类,所以可以通过traverse
快速遍历整个模型的场景,给场景里的所有组成模型的物体设置阴影:
gltfLoader.load(
'/models/FlightHelmet/glTF/FlightHelmet.gltf',
(gltf) =>
{
gltf.scene.scale.set(10, 10, 10)
scene.add(gltf.scene)
gltf.scene.traverse(child => {
if(child.isMesh && child.material.isMeshStandardMaterial)
{
child.castShadow = true
child.receiveShadow = true
}
})
}
)
接着,往场景中添加一个平行光源,并为其添加辅助器helper
与gui
控制面板,方便我们调整光的投射位置、强度等属性
const directionalLight = new THREE.DirectionalLight('#ffffff', 3)
directionalLight.castShadow = true // 开启灯光的阴影投射
const helper = new THREE.DirectionalLightHelper(directionalLight)
const lightCameraHelper = new THREE.CameraHelper(directionalLight.shadow.camera)
scene.add(lightCameraHelper)
scene.add(directionalLight,helper)
gui.add(directionalLight, 'intensity').min(0).max(10).step(0.001).name('directionalLightIntensity')
gui.add(directionalLight.position, 'x').min(-10).max(10).step(0.001).name('directionalLightX')
gui.add(directionalLight.position, 'y').min(-10).max(10).step(0.001).name('directionalLightY')
gui.add(directionalLight.position, 'z').min(-10).max(10).step(0.001).name('directionalLightZ')
现在的效果:
现在场景中添加了两个helper
,DirectionalLightHelper
用于标识平行光的位置(目前它被模型挡住了),THREE.CameraHelper
用于标识阴影投射的方向
现在移动灯光的位置,就可以看到灯光的照射方向以及阴影的投射方向了:
默认情况下directionalLight
是从上往下照射的,如果要修改光照的方向,按照官方文档中的说法,就得这么操作:
scene.add(directionalLight.target)
directionalLight.target.position.set(40,40,0)
下面是修改了灯光投射的方向后的结果
这里只是演示,进行下一步之前请先把修改灯光位置的代码删掉
阴影的投射也要考虑性能,通常其出发点有两个,一个是修改阴影投射摄影机的远距,使其恰好涵盖物体本身即可;一个是修改投射出的阴影质量
directionalLight.shadow.camera.far = 15 // 阴影投射摄像机的最远视野
directionalLight.shadow.mapSize.set(512, 512) // 阴影解析度
在512 * 512
的分辨率下,阴影边缘会比较模糊,把解析度改为4096
,效果会好很多:
directionalLight.shadow.mapSize.set(4096, 4096) // 阴影解析度
最后,根据环境贴图的样子,我们需要把光调整到一个正确的位置,毕竟总不能出现“太阳在左上角,光从左下角打过来”的情况吧
比如在当前场景中,光是从天上打下来的,大概设置光的位置为
directionalLight.position.set(-4,6.5,2.5)
当然,过高的解析度也意味着更大的性能开销,在不同的场景下需要有不同的取舍(通常低解析度的阴影也够用了,因为用户通常不会刻意去观察这些细节)
伪影
Shadow Acne
,伪影是一种在计算机图形学中常见的渲染问题,特别是在使用阴影贴图(shadow mapping)技术时。
原因:光源和物体表面之间的采样不精确导致。
导入项目中的另一个汉堡包模型,同时调整模型的缩放比例使其符合画面缩放程度:
gltfLoader.load(
'/models/hamburger.glb',
(gltf) =>
{
gltf.scene.scale.set(0.4, 0.4, 0.4) // 缩放调整
scene.add(gltf.scene)
gltf.scene.traverse(child => {
if(child.isMesh && child.material.isMeshStandardMaterial)
{
// 模型投射阴影
child.castShadow = true
child.receiveShadow = true
}
})
}
)
乍一看似乎没什么问题:
但是放大看,会发现其表面出现了一些不对劲的噪点和波纹
当把场景亮度调低后,这些问题就更明显了:
大致的原理就是模型本身既能接收阴影,又能投射阴影,所以它的表面也会有覆盖上自己产生的阴影,因此影响了其表面的形状,产生波纹和噪点,别忘了GPU是按照像素去渲染的:
要解决这个问题,threejs
提供了一个最简单的办法,设置directionalLight
的阴影贴图偏差属性
directionalLight.shadow.bias = 0.0001
现在再看,波纹效果明显减少了很多,但多少还存在一些
并且还导致了一个新的问题,芝士的阴影也没了:
这是因为我们给阴影贴图移动了错误的方向,应该把bias
设置为-0.0001
,设置成-0.0001
,芝士的阴影就回来了
就接下来继续解决剩下的可见的“波纹”,除了bias
外,还有另一个normalBias
属性可以配合一起调整,normalBias
参数用于定义查询阴影贴图的位置沿物体法线方向的偏移量。增加这个值可以减少shadow acne
,尤其是在光线以较小角度照射到几何体上的大场景中
使用gui
控制directionalLight
的normalBias
属性,直到找到一个合适的值即可:
gui.add(directionalLight.shadow, 'bias').min(-0.05).max(0.05).step(0.0001)
gui.add(directionalLight.shadow, 'normalBias').min(-0.05).max(0.05).step(0.0001)
设置到0.007就差不多了:
另外可以看到模型的边缘有锯齿,这就要通过之前所说的抗锯齿来解决了,threejs
里直接通过给renderer
开启抗锯齿即可:
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true
})
抗锯齿ON:
抗锯齿OFF:
纹理
一个获取纹理的网址:https://polyhaven.com
,不过需要注册patreon
和付费才能下载,但是纹理的质量确实高,而且格式选择上足够丰富
视觉疲劳问题
长时间专注于渲染一个场景过久必然会出现视觉疲劳的问题,也许会影响你对于空间中物体的位置、颜色渲染的判断,此时最好是切换场景,去看一下看其他的有色物体来调整自己的视觉,之后再回来调整可能会发现自己原本调色不合理的地方
着色器
在Web3D
的范畴内来说,着色器是一种程序,由WebGL
运行,需要使用GLSL
语言去编写;
在threeJs
中,我们编写好着色器程序之后,threeJs
将其传给WebGL
解析,WebGL
将他解析成二进制代码给GPU
进行计算,最后GPU就可以将图像等渲染出来
所以,着色器是用来”绘制“(实际上是记录,然后交给GPU去绘制)几何体顶点的位置和每个像素的颜色
在ThreeJs
着色器的代码中,我们可以向其发送如以下信息:
- 顶点位置
- 几何体的变换信息(旋转、缩放等)
- 摄像机的信息
- 颜色、纹理、灯光、雾等
在WebGL
的范畴内,主要使用的是顶点着色器(Vertex Shaders
)和片段着色器(Fragment Shaders
)
顶点着色器
通过GLSL
语言,我们可以用顶点位置(position
)、几何体的变换(geometry transformation
)、摄像机等数据创建出顶点着色器程序
,最终GPU运行这个顶点着色器,将这些东西渲染出来:
在threejs
里,Data
阶段就是我们通过创建THREE.Geometry
获取到的几何体的坐标、uv等数据,他们会以attribute
、uniforms
的形式传到顶点着色器
中
这个意思是在GLSL语言中,把几何体的坐标等属性用
attribute
、uniforms
等关键字来表示和获取,下文的glsl
语法讲解会说到
总结来说就是:顶点着色器用于处理3D模型中的每个“顶点”,可以改变顶点的位置、颜色、纹理坐标等属性。
将顶点着色器交给GPU运行后,GPU就知道了当前的几何体有哪些顶点、线、面是在画布上”可见的“,然后就可以进行下一个阶段——使用片段着色器
上色了
片段着色器
Fragment Shaders
,也称为像素着色器,负责计算屏幕上每个像素的颜色值,针对的是一块区域内的上色(毕竟画布是一个一个小方块组成的,最小片段应该是1px * 1px
)
顶点着色器的atrribute
无法传给片段着色器,只能传uniforms
,但是可以通过varying
关键字将attribute数据传给片段着色器,如图:
片段着色器通过uniforms
、varying
获取到顶点相关的数据后,将他们进行混合(或者不混合,混合是为了实现渐变等效果);然后我们在片段着色器中声明片段的颜色,此时我们就得到了一个可用的片段着色器了
将片段着色器、顶点着色器交给WebGL
编译成二进制代码传给GPU,我们的几何体就被渲染出来了
vite-plugin-glsl
https://www.npmjs.com/package/vite-plugin-glsl
npm install glslify webpack-glsl-loader --save-dev
编写着色器代码
搭建基本场景
首先搭建一个demo
环境,在页面中画出一个平面几何体
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import GUI from 'lil-gui'
/**
* Base
*/
// Debug
const gui = new GUI()
// Canvas
const canvas = document.querySelector('canvas.webgl')
// Scene
const scene = new THREE.Scene()
/**
* Textures
*/
const textureLoader = new THREE.TextureLoader()
/**
* Test mesh
*/
// Geometry
const geometry = new THREE.PlaneGeometry(1, 1, 32, 32)
// Material
const material = new THREE.MeshBasicMaterial()
// Mesh
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
/**
* 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(0.25, - 0.25, 1)
scene.add(camera)
// Controls
const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true
/**
* Renderer
*/
const renderer = new THREE.WebGLRenderer({
canvas: canvas
})
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
/**
* Animate
*/
const clock = new THREE.Clock()
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
// Update controls
controls.update()
// Render
renderer.render(scene, camera)
// Call tick again on the next frame
window.requestAnimationFrame(tick)
}
tick()
替换材质
threejs
里有两个着色器材质:ShaderMaterial
和RawShaderMaterial
,两者的区别仅在于ShaderMaterial
会自动地将一些内置的attributes
和uniforms
添加到着色器程序顶部
将代码里的材质替换为RawShaderMaterial
,然后从头编写着色器代码:
// const material = THREE.MeshBasicMaterial()
const material = new THREE.RawShaderMaterial({
vertexShader: `
uniform mat4 projectionMatrix; // 引入投射矩阵
uniform mat4 viewMatrix; // 引入视图矩阵
uniform mat4 modelMatrix; // 引入模型矩阵
attribute vec3 position; // 每个顶点的位置
void main() {
// 几何体的位置
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
precision mediump float; // 声明中等浮点数精度
void main () {
// 片段颜色
gl_FragColor = vec4(1.0,0,0,1.0);
}
`,
});
至此,页面上就渲染出了一个红色的平面几何体:
配置开发环境
在material
里写模板字符串对于我们开发效率和质量都有比较大的影响,不利于我们的开发,所以我们需要类似JS、TS的代码颜色支持
VSCode就有这样的插件,名为Shader languages support for VS Code
着色器的代码是用glsl
语言编写的,现在分别为顶点着色器和片段着色器创建两个单独的文件,并把着色器代码丢到里面,可以看到语法就有正常的高亮了:
这里着色器的文件后缀可以是.glsl
也可以是.vs
或.fs
等,如下表:
主要是glsl lint
无法对.glsl
后缀的文件生效,所以文件命名建议以.vs
和.fs
命名
除了高亮外,我们还要安装代码格式化和代码检查的插件,来确保代码准确性,所以安装插件GLSL-LINT
,与GLSL-CANVAS
然后来这里:https://github.com/KhronosGroup/glslang/releases
,下载glsl linter
程序:
找个位置解压后,打开VSCODE
设置,搜索GLSL LINT
将validatorPath
定位到bin目录下的validator
程序即可:
重启vscode
,以后我们就有了代码自动格式化(glsl-canvas
)、代码语法检查(glsl-lint
)以及着色器效果预览(glsl-canvas
)了
最后,语法提示的配置,去这个地址:https://gist.github.com/lewislepton/8b17f56baa7f1790a70284e7520f9623
,把第一篇json
代码全部复制下来:
打开vscode - 文件 - 首选项 - 配置代码片段
搜索glsl.json
,然后代码复制进去,重启vscode
即可
glsl文件引入
按照目前的配置,直接引入是不行的,需要对应的文件解析器来解析对应的文件
在vite
环境中,需要使用vite-plugin-glsl
,先安装该插件:npm install vite-plugin-glsl
,然后编辑vite.config.js
:
import glsl from 'vite-plugin-glsl'; // 导入插件
export default {
root: 'src/',
publicDir: '../static/',
base: './',
server: {
host: true, // Open to local network and display URL
open: !('SANDBOX_URL' in process.env || 'CODESANDBOX_HOST' in process.env), // Open if it's not a CodeSandbox
},
build: {
outDir: '../dist', // Output in the dist/ folder
emptyOutDir: true, // Empty the folder first
sourcemap: true, // Add sourcemap
},
plugins: [glsl()], // 导入插件
};
然后我们就可以引入shader
文件了:
import VS from './shaders/test/vertex.vs';
import FS from './shaders/test/fragment.fs';
const material = new THREE.RawShaderMaterial({
vertexShader: VS,
fragmentShader: FS,
});
如果是webpack
环境,需要使用raw-loader
将着色器代码文件当作string
引入使用,比如在umi
项目中要这样配置:
// .umirc.ts
import { defineConfig } from 'umi';
export default defineConfig({
nodeModulesTransform: {
type: 'none',
},
routes: [{ path: '/', component: '@/pages/index' }],
fastRefresh: {},
chainWebpack(memo) {
memo.module
.rule('glsl')
.test(/\.(glsl|vs|fs|vert|frag)$/)
.use('raw-loader')
.loader('raw-loader')
.end();
},
});
正常的webpack
环境则直接配置即可:
module.exports = {
rules: [
{
test: /\.(glsl|vs|fs|vert|frag)$/,
exclude: /node_modules/,
use: ['raw-loader']
}
]
}
glsl语言
GLSL:OpenGL Shading Language
,这是一种类型语言,每个变量、表达式或值都有一个明确的数据类型,并且这些类型在编译或运行时会被严格检查的语言,主要有以下特点:
- 无法打印,因为代码是由GPU去运行的;比如对于顶点着色器的代码而言,代码定义了每个顶点的位置计算方法,一个几何体有N个顶点需要渲染,那么GPU就会对应运行N次代码来计算出每个顶点的位置
- 每一行结尾必须加分号
int
和float
有严格的区分:如int a = 1; float b = 0.002
,不能混合运算- 不能缺少
main
函数实现,主入口必定是main
函数 - 运算的顺序是从右往左进行的(下文会有案例演示)
- 这个语言没有所谓的官方文档,但有一些民间总结而来的指引网站:
https://thebookofshaders.com/
https://shaderific.com/glsl/common_functions.html
- 另外一个好用的学习openGL的英文网站:
https://learnopengl.com/
float类型
即浮点数,和C语言中的float
类似,可以进行数学运算,但必须加小数点,不能和整形数进行运算
float a = 1.0;
float b = 2.0;
float c = a * b;
float d = 1; // 报错
float f = a + 2; // 报错
int类型
即整形数据,和C语言中的int
类似,声明时必须为整数,int
和float
不能混用,如:
int a = 1;
float b = 0.2;
float c = a * b; -> 报错
// 计算前需要进行转换
float c = float(a) * b; // √
vec2
二维向量,由x,y
组成,可以类比为threejs里的Vector2
,一些要点如下:
vec2 foo = vec2(1.0, 2.0); // 基础创建方式
vec2 foo = vec2(1.0); // 等价于 vec2(1.0, 1.0)
float x = foo.x; // 获取值
foo *= 2.0; // 同时对x、y乘2.0
vec2 foo = vec2(); // 报错 不能创建空的vec2
vec3
三维向量,在x、y的基础上多一个z,类似threejs
的Vector3
,与vec2
的操作类似:
vec3 foo = vec2(1.0, 2.0, 3.0); // 基础创建方式
vec3 foo = vec2(1.0); // 等价于 vec3(1.0, 1.0, 1.0)
float z = foo.z; // 获取值
foo *= 2.0; // 同时对x、y、z乘2.0
vec3 foo = vec3(); // 报错 不能创建空的vec2
除了可以使用x、y、z,还可以使用r、g、b获取值,这个对于创建一个rgb
的颜色比较方便
vec3 pupleColor = vec3(0)
purpleColor.r = 0.5
purpleColor.g = 1.0
另外,可以快捷通过vec2
创建一个vec3
vec2 v1 = vec2(2.0, 1.0)
vec3 v2 = vec3(v1, 3.0) // 相当于加一个z轴坐标
vec4
可以理解为四维向量,多出来的你那个记为w
,也可以通过a
访问:
vec4 foo = vec4(1.0,1.0,1.0,0.5);
float x = foo.x;
float y = foo.y;
float z = foo.z;
float w = foo.w;
// 或使用rgba访问:
float r = foo.r;
float g = foo.g;
float b = foo.b;
float a = foo.a;
所以vec4
通常会用来声明rgba
颜色(带alpha通道即"透明度"的颜色)
与vec3
、vec2
类似,vec4
也可以进行类似的一些运算,这里就不做赘述
此外,向量存在一种运算成为swizzle
操作,它是一种对向量的分量进行操作的运算,支持混合分量与重复分量:
vec4 color = vec4(1.0, 0.5, 0.2, 1.0);
// 提取单个分量
float red = color.r; // 1.0
float green = color.g; // 0.5
// 创建新的向量
vec2 rg = color.rg; // (1.0, 0.5)
vec3 bgr = color.bgr; // (0.2, 0.5, 1.0)
vec4 rgba = color.rgba; // (1.0, 0.5, 0.2, 1.0)
// 重复分量
vec2 rr = color.rr; // (1.0, 1.0)
vec3 ggg = color.ggg; // (0.5, 0.5, 0.5)
// 混合分量
vec3 rgb = color.rgb; // (1.0, 0.5, 0.2)
vec4 abgr = color.abgr; // (1.0, 0.2, 0.5, 1.0)
function
即函数,类似于c语言里的函数声明:
float re() {
float a = 1.0
float b = 2.0
return a+b; // 必须返回浮点数,否则报错
}
void main(){
float result = re();
}
当然,也可以进行函数传参等操作:
float re(float a, float b) {
return a+b
}
void main(){
float result = re(1.0, 2.0)
gl_Position=projectionMatrix*viewMatrix*modelMatrix*vec4(position,1.);
}
同时,其内置了很多数学函数如pow
、max
、min
等,均与Math.pow
、Math.max
的作用一致,这里不做赘述
从GLSL代码视角理解顶点着色器
// vertex.vs
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
attribute vec3 position;
void main(){
gl_Position=projectionMatrix*viewMatrix*modelMatrix*vec4(position,1.);
}
-
uniform
是glsl
语言中的关键字,用于定义一个外部传入的不可变的变量,类似于const
声明 -
mat4
代表一个4x4的矩阵类型 -
projectionMatrix
代表”投影矩阵“,用于将场景从相机视图转换到标准化设备坐标系中 -
viewMatrix
代表”视图矩阵“,用于将场景从像世界坐标系转换到相机坐标系 -
modelMatrix
代表”模型矩阵“,用于将物体从局部坐标系转换到世界坐标系 -
attribute
也是glsl
里的关键字,用于定义一个”属性“,每个顶点调用时position
的值会自动设置为顶点的位置(以顶点的局部坐标系为准) -
gl_Position
是glsl
语言里的一个特殊内置变量,用于存储顶点着色器计算后的顶点位置,这个位置会在光栅化阶段(被片段着色器)生成片段,并最终绘制
注意,projectionMatrix、viewMatrix、modelMatrix是
threeJs
内部声明的变量,而不是glsl
本身定义的变量,在其他3D引擎中名称不一定是这些(比如在Unity引擎中,投影矩阵命名为UNITY_MATRIX_P
)
所以整个顶点着色器的代码执行流程如下:
首先我们在threejs
中创建Geometry
、ShaderMaterial
,使用这两个要素生成Mesh
。
渲染开始前,投影矩阵
、视图矩阵
、模型矩阵
的值已经由threejs
内部计算完成并以uniform mat4 xxxMatrix
变量的形式注入完成
矩阵的计算是由threejs通过我们设置的旋转、缩放、位移计算得来的,至于计算公式就请自行查看源码了,这个并不重要
然后渲染开始,webGL
根据几何体的顶点数量,告诉GPU顶点着色器代码需要执行多少次,position
的值等于threejs
注入的每个几何体的顶点坐标
注意,在glsl
中,计算是从右往左进行的,也就是说,计算最后的顶点位置时需要:
- 首先将顶点的三维坐标转为四维向量,其中第四个分量为1,代表这是一个位置向量
- 进行模型矩阵变换,计算
modelMatrix * vec4
,将局部坐标系的顶点坐标转换到世界坐标系的顶点坐标。 - 进行视图矩阵变换,计算
viewMatrix * vec4(上一步计算得来)
,将世界坐标系的顶点坐标转换为相机坐标系的顶点坐标 - 进行投影矩阵变换,计算
projectionMatrix * vec4(上一步计算得来)
,将相机坐标系的顶点坐标转换到标准化设备坐标系的顶点坐标
所以实际上代码可以按照执行顺序改写成:
void main(){
vec4 positionVec4 = vec4(position, 1.0);
vec4 modelPosition=modelMatrix*positionVec4;
vec4 viewPosition=viewMatrix*modelPosition;
vec4 projectedPosition=projectionMatrix*viewPosition;
gl_Position=projectedPosition;
}
然后gl_Position
就会被传到片段着色器中进行下一步的上色计算
各种坐标系的讲解可以参考如下文章:
局部坐标系与全局坐标系
相机坐标系
标准化设备坐标系(NDC)
其实你不需要知道是怎么变换的,你只需要知道其中经历了这些个变换就好
从GLSL代码视角理解片段着色器
precision mediump float;
void main(){
gl_FragColor=vec4(1.,0,0,1.);
}
片段着色器相对比较简单,gl_FragColor
和gl_Position
一样,是内置的变量,设置的是每个片段(像素)的颜色,vec4(1.,0,0,1.)
就代表纯红色,不透明
precision
用于声明浮点数的精度,不同的硬件(显卡)对浮点数的处理能力不同,支持的设置有三种:
precision lowp float
-
- 范围:[-2, 2]
精度:至少10位有效数字
通常用于颜色和纹理坐标,适用于大多数移动设备。
- 范围:[-2, 2]
precision mediump float
-
- 范围:[-2^14, 2^14]
精度:至少16位有效数字
适用于大多数颜色和纹理坐标计算,以及一些简单的数学运算。
- 范围:[-2^14, 2^14]
precision highp float
-
- 范围:[-2^62, 2^62]
精度:至少23位有效数字
适用于复杂的数学运算和需要高精度的场景,但可能会降低性能。
- 范围:[-2^62, 2^62]
lowp
通常用于移动端设备,可以有效地优化移动端的运行性能
mediump
则适用于绝大多数桌面和高端移动设备
highp
则适用于需要复杂的数学运算和高精度的场景,比如物理模拟、高级光照计算的场景
对于大多数WebGL应用,mediump
是一个不错的选择,因为它在性能和精度之间取得了良好的平衡,优化性能时才考虑使用lowp
在GLSL里操纵几何体的变换
几何体的位置、形状等可以通过顶点着色器来修改,比如之前在顶点着色器里这样子分开每一步来写了:
void main(){
vec4 positionVec4 = vec4(position, 1.0);
vec4 modelPosition=modelMatrix*positionVec4;
vec4 viewPosition=viewMatrix*modelPosition;
vec4 projectedPosition=projectionMatrix*viewPosition;
gl_Position=projectedPosition;
}
移动几何体
通过"模型矩阵"计算出来的modelPosition
是顶点在世界坐标系中的坐标,即此时可以通过修改modelPosition
的值来移动物体,比如:
vec4 modelPosition=modelMatrix*positionVec4;
modelPosition.z += 0.5;
就可以把物体往脸上移动0.5个距离:
要记得:每个顶点都会执行一次着色器代码的计算
创建波浪效果
波浪效果用三角函数可以很轻易地实现,比如对每个顶点的x坐标取正弦值赋给z坐标:(需要增大振幅效果才明显)
vec4 modelPosition=modelMatrix*positionVec4;
modelPosition.z=sin(modelPosition.x*20.);
就可以得到:
但是目前因为振幅太大导致点过于靠近,可以通过在外层缩小整体的值来让画面变缓:
vec4 modelPosition=modelMatrix*positionVec4;
modelPosition.z=sin(modelPosition.x*20.)*.1;
在glsl里,
.0
可以缩写为.
随机数控制
曾经我们有个操作是自己创建一个BufferGeometry
并且自定义顶点坐标绘制图形,当时是通过下面的代码实现的:
const geo = new THREE.BufferGeometry()
const positions = new Float32Array(3000)
// 填充positions的每一个值
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry
里的attributes
属性都会被threejs
注入到着色器中,并且以同名变量的方式声明,我们可以打印geometry.atrributes
查看里面的属性:
const geometry = new THREE.PlaneGeometry(1, 1, 32, 32);
console.log(geometry.attributes)
看到上面的图,BufferAttribute
里有count
属性,代表顶点的个数,即当前平面几何体的顶点个数是1089
个,这就意味着顶点着色器代码至少会执行1089
次;在顶点着色器中,就可以通过attribute
来访问这些同名的变量,比如:
attribute vec3 position;
attribute vec2 uv;
attribute vec3 normal;
void main(){
vec4 positionVec4=vec4(position,1.);
vec4 modelPosition=modelMatrix*positionVec4;
modelPosition.x+=uv.x;
modelPosition.y+=uv.y;
// ...
}
position
以vec3
的形式创建,意味着顶点着色器计算时会自动去position.array
中3个3个地取值作为顶点的坐标进行计算;对于uv、normal
也是同理
假设我们需要传一个自定义的值,那么就可以这样写:
const geometry = new THREE.PlaneGeometry(1, 1, 32, 32);
const count = geometry.attributes.position.count;
const randoms = new Float32Array(count);
for (let i = 0; i < count; i++) {
randoms[i] = Math.random();
}
// 核心代码
geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1));
console.log(geometry.attributes)
我们创建了一个长度为顶点个数的数组,数组值用随机数填充,规定attribute
里取值的步长为1,打印geomery.attributes
可以看到我们自己创建的变量aRandom
:
在顶点着色器中就需要通过float
进行取值,而不是vec2或vec3,这取决于attribute
的步长
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
attribute vec3 position;
attribute float aRandom;
void main(){
vec4 positionVec4=vec4(position,1.);
vec4 modelPosition=modelMatrix*positionVec4;
// modelPosition.z=sin(modelPosition.x*20.)*.1;
modelPosition.z+=aRandom;
vec4 viewPosition=viewMatrix*modelPosition;
vec4 projectedPosition=projectionMatrix*viewPosition;
gl_Position=projectedPosition;
}
这样会得到下面的结果,有点”恐怖“
别忘了我们可以通过比例来控制振幅:
modelPosition.z+=aRandom * 0.1;
这样就得到还行的效果:
顶点着色器与片段着色器通信
在threejs
的两个着色器间进行数据传递有且仅有一种办法,那就是通过varying
关键字实现
比如我们可以把上一步的random
值传到片段着色器中控制颜色:
// vertex.vs
// ...
attribute float aRandom;
varying float vRandom;
void main(){
// ...
vRandom=aRandom;
// ...
}
// fragment.fs
precision mediump float;
varying float vRandom;
void main(){
gl_FragColor=vec4(1,vRandom,1,1.);
}
然后我们就得到了这样的效果:
threejs与顶点着色器通信
在threejs
中,ShaderMaterial
与RawShaderMaterial
可以通过unifroms
属性向顶点着色器传参,比如我们可以通过threejs
来控制平面的波浪效果振幅:
const material = new THREE.RawShaderMaterial({
vertexShader: VS,
fragmentShader: FS,
uniforms: {
uFrequency: {value: 20}
}
});
// vertex.vs
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
attribute vec3 position;
uniform float uFrequency;
void main(){
vec4 positionVec4=vec4(position,1.);
vec4 modelPosition=modelMatrix*positionVec4;
modelPosition.z=sin(modelPosition.x*uFrequency)*.1;
vec4 viewPosition=viewMatrix*modelPosition;
vec4 projectedPosition=projectionMatrix*viewPosition;
gl_Position=projectedPosition;
}
这样我们就可以在threejs
修改对应的参数来进行调试或构造动画了;另外可以看到threeJs
中给uFrequency
设置的值是20,对于着色器而言是整型,但是却可以用float
接收,因为threejs
会根据需要进行对应数据的转换
除了传单一的值,还可以传THREE.Vector2
,比如同时控制x、y
方向的振幅就可以这样子写:
const material = new THREE.RawShaderMaterial({
vertexShader: VS,
fragmentShader: FS,
uniforms: {
uFrequency: { value: new THREE.Vector2(10, 5)}
}
});
// vertex.vs
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
attribute vec3 position;
uniform vec2 uFrequency;
void main(){
vec4 positionVec4=vec4(position,1.);
vec4 modelPosition=modelMatrix*positionVec4;
modelPosition.z=sin(modelPosition.x*uFrequency.x)*.1;
modelPosition.z=sin(modelPosition.y*uFrequency.y)*.1;
vec4 viewPosition=viewMatrix*modelPosition;
vec4 projectedPosition=projectionMatrix*viewPosition;
gl_Position=projectedPosition;
}
threejs与片段着色器通信
uniform
创建的变量是可以同时在片段着色器、顶点着色器中通信的,所以也可以通过uniforms
变量来控制平面的颜色,比如:
const material = new THREE.RawShaderMaterial({
vertexShader: VS,
fragmentShader: FS,
uniforms: {
uFrequency: { value: new THREE.Vector2(20, 10) },
uTime: {value: 0},
uColor: { value: new THREE.Color('cyan') }
}
});
precision mediump float;
uniform vec3 uColor;
void main(){
gl_FragColor=vec4(uColor,1.);
}
这里可以直接传THREE.Color
,他在着色器中的数据是vec3
的形式,所以除了用vec3
直接生成vec4
,也可以访问uColor.r
、uColor.g
、uColor.b
来设置颜色,这里就不赘述了
着色器加载纹理
比如我们要给平面加上国旗的纹理:
那么同样的,也是通过uniforms
将纹理传入到着色器中:
const textureLoader = new THREE.TextureLoader();
const flagTexture = textureLoader.load('/flag-china.png')
const material = new THREE.RawShaderMaterial({
vertexShader: VS,
fragmentShader: FS,
uniforms: {
uFrequency: { value: new THREE.Vector2(10, 0) },
uTime: {value: 0},
uColor: { value: new THREE.Color('cyan') },
uTexture: { value: flagTexture },
}
});
在片段着色器中要使用sample2D
这个数据类型来获取texture
,并使用texture2D
函数来将纹理转换成对应的vec4
颜色:
// fragment.fs
precision mediump float;
uniform vec3 uColor;
varying vec2 vUV;
uniform sampler2D uTexture;
void main(){
vec4 texutreColor=texture2D(uTexture,vUV);
gl_FragColor=texutreColor;
// gl_FragColor=vec4(uColor,1.);
}
sampler2D
是glsl
内置的数据类型,主要用于表示2D纹理,他会在着色器中访问纹理的数据,如纹理的引用、采样状态等
texture2D
是glsl
内置的函数,用于从2D纹理中采样颜色值,接收一个sampler2D
和一个二维纹理坐标作为参数,最后返回对应坐标的颜色值vec4
这里的vUV
来自于顶点着色器,交互的关键代码如下:
// vertex.vs
// ...
attribute vec2 uv;
varying vec2 vUV;
void main(){
// ...
vUV = uv;
// ...
}
虽然attribute
不能在片段着色器中读取,但是可以通过varying
将其传到片段着色器里,而arrtibute uv
对应的就是几何体的uv
坐标,它会被threejs
自动当成attribute
注入,所以可以直接通过attribute uv
来读取
最后,就成功地将颜色换成了国旗纹理图:
控制动画
在threeJs
的逐帧渲染中,可以通过内置的Clock
获取从第一帧运行开始到当前帧经过了多少秒
,如:
const clock = new THREE.Clock();
const tick = () => {
const elapsedTime = clock.getElapsedTime();
console.log('elapsedTime', elapsedTime);
// Update controls
controls.update();
// Render
renderer.render(scene, camera);
// Call tick again on the next frame
window.requestAnimationFrame(tick);
};
tick();
所以,如果把这个值传到顶点着色器中,基本的动画就可以实现了;
创建一个uniforms
变量,在每一帧的渲染函数中更新它:
const material = new THREE.RawShaderMaterial({
vertexShader: VS,
fragmentShader: FS,
uniforms: {
uFrequency: { value: new THREE.Vector2(20, 10) },
uTime: { value: 0 }
}
});
const clock = new THREE.Clock();
const tick = () => {
const elapsedTime = clock.getElapsedTime();
material.uniforms.uTime.value = elapsedTime;
// Update controls
controls.update();
// Render
renderer.render(scene, camera);
// Call tick again on the next frame
window.requestAnimationFrame(tick);
};
tick();
在顶点着色器中用同名变量获取值,控制z方向的形变:
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
attribute vec3 position;
uniform vec2 uFrequency;
uniform float uTime;
void main(){
vec4 positionVec4=vec4(position,1.);
vec4 modelPosition=modelMatrix*positionVec4;
modelPosition.z+=sin(modelPosition.x*uFrequency.x+uTime)*.1;
modelPosition.z+=sin(modelPosition.y*uFrequency.y+uTime)*.1;
vec4 viewPosition=viewMatrix*modelPosition;
vec4 projectedPosition=projectionMatrix*viewPosition;
gl_Position=projectedPosition;
}
基本的波浪动画就有了:
总结
-
顶点着色器的作用是将“顶点坐标”从“局部坐标系”转换到“标准化设备坐标系”来让GPU实现渲染
-
threejs
与glsl
的数据交互通过uniforms
、attribute
、varying
关键字来完成,uniform
可以同时被两个着色器读取,attribute
只能在顶点着色器中读取,然后通过varying
传入片段着色器 -
ShaderMaterial
和RawShaderMateral
的区别在于后者需要你自己手动声明projectionMatrix
、modelMatrix
等变量,所以使用后者通常可以节省一定的性能开销(因为不用初始化过多的无用变量)
-
不要使用
Date.now()
来触发shaders
的变化计算,因为它的值对于shader
来说太大了
最后附上一个shaders
实现案例的社区:https://www.shadertoy.com/
补充
理解modelMatrix
modelMatrix
是threeJs
内置的变换矩阵,它在默认情况下是单位矩阵
:
| 1 0 0 0 |
| 0 1 0 0 |
| 0 0 1 0 |
| 0 0 0 1 |
我们在顶点着色器里,需要先经过模型矩阵变换:
vec4 modelPosition=modelMatrix*vec4(position,1.);
对于任一矩阵变换的计算公式是:
| m00 m01 m02 m03 |
| m10 m11 m12 m13 |
| m20 m21 m22 m23 |
| m30 m31 m32 m33 |
modelPosition.x = m00 * 1 + m01 * 0 + m02 * 0 + m03 * 1
modelPosition.y = m10 * 1 + m11 * 0 + m12 * 0 + m13 * 1
modelPosition.z = m20 * 1 + m21 * 0 + m22 * 0 + m23 * 1
modelPosition.w = m30 * 1 + m31 * 0 + m32 * 0 + m33 * 1
即:
modelPosition.x = m00 + m03
modelPosition.y = m10 + m13
modelPosition.z = m20 + m23
modelPosition.w = m30 + m33
所以,假如position
的值为(1, 0, 1)
,那么计算出来的modelPosition
就仍然是(1,0,1)
当我们在threejs
里通过mesh.rotateX()
、mesh.position.x = 1
、mesh.scale.y = 2
等操作修改了物体后,在GPU的渲染阶段,threejs
会自动计算出由缩放矩阵
、平移矩阵
、旋转矩阵
相乘得到的模型矩阵
,以此来计算出每个顶点实际的位置