three 写一个溶解特效,初探 three 着色系统
背景
溶解特效是一个在游戏里非常常见的特效,通常用来表示物体消失或者出现,它的原理也比较简单,这次就来实现一下这个效果,并且通过它来探究下 three.js 的着色器系统。
原理
使用一张噪波图,根据时间动态改变进度 progress
, 根据这个值与噪波图数值做比较,决定使用过渡色还是舍弃当前片元。
过渡色
为了使用过渡色,我们定义一个作用范围变量 edgeWidth
用来表示当前进度和 噪波数值(noiseValue)
之间的区域,这个区域填充 过渡色(edgeColor)
。
变化速度
progress
的变化通过变化速度(DissolveSpeed)
来控制。
类型
溶解可以分为 出现和消失 两种类型,两种类型可以互相转换,我们可以通过判断 progress
的边界来重新设置 progress
的增加量符号(加号变减号,减号变加号),并重新设置 progress
的值等于 0 || 1
来重新设置变化边界。
原理讲完了,接下来进入实践。
实践
先从最简单的 wavefront
格式说起,再拓展到其他更通用模型或者材质的用法。
波前 wavefront 格式
作为 3D 模型最早的格式之一,.obj
后缀的格式是由 wavefront 公司开发的,由于容易和其他常见类型的文件比如 gcc 编译的过程文件 .obj
混淆,将其表述为 wavefront
模型格式。
对于这个格式来说,几何数据和材质数据是分开加载的,你需要先加载 .obj
格式的文件,然后再去加载材质数据文件 .mtl
。对于我们的示例来说是需要使用 ShaderMaterial
来自定义着色效果,因而我们直接加载对应的 材质贴图 做原理展示,就不使用 .mtl
的加载器了。
需要做的其实只有两步:
- 读取的模型后用
Geometry
和ShaderMaterial
创建新的Mesh
。
- 读取的模型后用
ShaderMaterial
的unifroms.progress
在requestAnimationFrame
里做更新。
直接来看下着色器怎么写:
顶点着色器:
let vertexShader = /* glsl */`
varying vec2 vUv;
void main()
{
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
主要是定义了 vUv
这个可传递变量,为了把内置的纹理坐标传递到 fragmentShader
。
片元着色器:
let fragShader = /* glsl */`
uniform float progress;
uniform float edgeWidth;
uniform vec3 edgeColor;
uniform sampler2D mainTexture;
uniform sampler2D noiseTexture;
varying vec2 vUv;void main(void){
vec4 originalColor = texture2D(mainTexture, vUv);
float noiseValue = texture2D(noiseTexture, vUv).r;
vec4 finalColor = originalColor;
if(noiseValue > progress)
{
discard;
}
if(noiseValue + edgeWidth > progress){
finalColor = vec4(edgeColor, 1.0);
}
gl_FragColor = finalColor;
}
`;
其中 originColor
是原始材质贴图,类型是 vec4
,noiseValue
是读取的噪波贴图取 r
通道的值,事实上,噪波图是灰度图,所以取 rgb
任意通道的都可以。然后对于 noiseValue
,随着 progress
逐渐增大,小于 progress
数值的噪波片元越来越少,模型出现。下面那句 + edgeWidth
则是把 edgeColor
填充到里面,原理是一样的。最后输出颜色。
这是出现的逻辑,如果是要消失呢?控制下边界条件就可以了:
function render() {
requestAnimationFrame(render);
controller.update();
// 出现
if (uniforms.progress.value < 0) {
uniforms.progress = 0;
stride = dissolveSpeed;
}
// 消失
if (uniforms.progress.value > 1) {
uniforms.progress = 1;
stride = -dissolveSpeed;
}
uniforms.progress.value += stride;
renderer.render(scene, camera);
}
效果立竿见影:
再想一遍
写着色器和通用程序不大一样,单纯按上面这么讲可能不是很清晰,我们更深度地分析下,培养一下 rgb
思维。
已知出现和消失是互为逆过程,通过 CPU
端程序重新改变变化方向即可,我们按照一个状态,关注边界条件,分别从正向和逆向进行思考,给出两个版本分别的代码。
按照上面说的,我们关注,比如就 出现 的状态吧,边界条件是 阈值 和 噪波值 的比较结果。也就是 progress
和 noiseValue
。
用 Exclidraw 画下示意图:
考虑 出现 的情况,剩余的进度或者叫阈值(越来越小), 与当前片元噪声值比较大小,如果更大则舍弃掉表示还没出现的部分;与当前值往前剪掉的部分比较,如果更大则使用这个过渡色;其他情况是已经出现的部分,直接保留就可以了。
写成代码:
void main() {
...
float restProgress = 1.0 - dissolveProgress;
if(noiseValue < restProgress) {
discard;
}
if(noiseValue - edgeWidth < restProgress ) {
gl_FragColor = finalColor;
}
...
}
反向来思考,随着阈值增加,出现的图像越来多,往前减掉过渡值(edgeWidth
), 这部分呈现过渡色;小于当前 noiseValue
的部分舍弃,是还没出现的部分。
写成代码:
void main() {
...
if(noiseValue > dissolveProgress)
{
discard;
}
if(noiseValue + edgeWidth > dissolveProgress){
gl_FragColor = vec4(edgeColor, 1.0);
}
...
}
这样,我们就用两种等价的方法实现了同一效果,后面的章节我们使用 glsl
函数把 条件判断 语句去掉。
这里其实叫 edgeWidth
有歧义,换成 edgeThickness
可能比较符合,如果这个值过大,就会超出变化范围出现异常,所以还是要把其限制在一个比较小的范围,这里为了调试先让它最大值等于 1。
edgeWidth
值过大:
其他格式
我们拿更常用的其他格式来研究一下。通常 web
端会使用 gltf, fbx
等通用格式,我们这里拿 web
端最通用的 gltf
格式模型来说明,其他通用模型类型道理一样。
对于 gltf
格式来说,加载完模型就赋予了材质,可能的类型有 MeshStandardMaterial, MeshPhongMaterial, MeshBasicMaterial
等,我用封面的士兵模型,使用的是 MeshStandardMaterial
类型的材质,接下来看如何修改内置着色器而实现效果。
ShaderChunk 和 ShaderLib
来看下 three
的目录,较新版本的 three
把核心代码安排在 src
目录下,/examples/jsm
目录下则是以 插件addons
的形式引入的额外功能,比如 GLTFLoader
之类比较通用的功能。而内部着色器的实现在 src/renderers/shaders
目录下:
我们直接打开 ShaderLib.js
文件找下模型使用的 MeshStandardMaterial
的定义:
可以看到是复用了 meshphysical
的着色器,这对着色器还在 MeshPhysicalMaterial
材质里被使用,通过材质类定义的 defines
字段来开启相应的计算,这样的做法使得 MeshStandardMaterial
作为 MeshPhysicalMaterial
的回退选项。到 ShaderChunk
目录下打开 meshphysical.glsl.js
看下宏定义:
OK,已经了解了材质定义和对应着色器的关系了,接下来就是如何把我们的逻辑加到相应着色器字符串里了。
onBeforeCompile
官方文档约等于没写,还是去看 examples
的代码吧,关键字 onBeforeCompile
搜索下:
右下角点进去看代码:
这下就明白了,顾名思义,这个函数可以在编译着色器程序之前允许我们插入自己的代码, 我们可以根据功能对相应模块进行覆写或者添加功能,我们不希望修改修改默认着色器的内容,直接把溶解效果加到最后,接下来看下怎么做。
调试
按照这个做法,非常依赖 javascript
的 replace
方法,我们需要小心操作,经过实验,把所有代码放到同一串里是没问题的,这里需要反复打印调试,如果有问题请使用双引号来使用原始字符串。
如果没有处理格式,直接塞进去不会对齐的,很好辨认:
接下来直接移植代码:
看到注释的那句话了吗,如果注释掉,并把阈值开到最大覆盖全部范围,可以明显看到和设置的颜色不一样,原因是因为之前的 shader
代码处理结果是转化到线性输出显示的,我们在标准着色器最后处理,一样要做线性转化。这个线性转化的意思是 gamma
变换的逆变换, gamma
变换是由于人眼对于颜色的感知非线性,非线性的原因和视锥细胞,视杆细胞数量比例不一样有关,省略一万字,大家有兴趣自己去搜~
没有线性转换:
线性转换后颜色就正常了:
拓展
再换一种写法
之前我们用直接舍弃片元的方法来实现过渡,接下来我们使用更 shader
风格的写法来重写,因为这个效果显示和消失具有二值性(要么有颜色要么透明),可以用 step(x,y)
函数来写,这个函数比较 y > x
的结果,true
则返回 1,否则返回 0 , 正好可以来表达透明度。
看代码,只有 fragmentShader
不一样:
这里的想法是先控制是否显示颜色,找的边界就是 noiseValue - edgeWidth
,然后再判断使用原来的像素或者过渡色,如果大于 noiseValue
使用原来的像素,否则使用过渡的颜色,然后 mix
函数这里的第三个变量刚好是 step
函数的结果,所以就可以切换这两颜色了。
哦对,记得设置这个 material.transparent = true;
,否则会使用默认的混合颜色白色:
整活
昨天在沸点发了两张图,其实很简单,到这里把过渡色换成贴图采样就行了,比如这样:
学会了吗?赶紧搬到项目里惊艳领导吧。
思考
- 能否和环境做交互?
更新
在线 Demo: wwjll.github.io/three-pract…
写文章不易,点赞收藏是最好的支持~
来源:juejin.cn/post/7344958089429254182