注册
web

three 写一个溶解特效,初探 three 着色系统

Fire.gif

背景

溶解特效是一个在游戏里非常常见的特效,通常用来表示物体消失或者出现,它的原理也比较简单,这次就来实现一下这个效果,并且通过它来探究下 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 的加载器了。

需要做的其实只有两步:

    1. 读取的模型后用 Geometry 和 ShaderMaterial 创建新的 Mesh 。
    1. 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 是原始材质贴图,类型是 vec4noiseValue 是读取的噪波贴图取 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);
}

效果立竿见影:

Spot.gif

再想一遍

写着色器和通用程序不大一样,单纯按上面这么讲可能不是很清晰,我们更深度地分析下,培养一下 rgb 思维。

已知出现和消失是互为逆过程,通过 CPU 端程序重新改变变化方向即可,我们按照一个状态,关注边界条件,分别从正向和逆向进行思考,给出两个版本分别的代码。

按照上面说的,我们关注,比如就 出现 的状态吧,边界条件是 阈值 和 噪波值 的比较结果。也就是 progress 和 noiseValue

用 Exclidraw 画下示意图:

Exclidraw_explain.png

考虑 出现 的情况,剩余的进度或者叫阈值(越来越小), 与当前片元噪声值比较大小,如果更大则舍弃掉表示还没出现的部分;与当前值往前剪掉的部分比较,如果更大则使用这个过渡色;其他情况是已经出现的部分,直接保留就可以了。

写成代码:

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 值过大:

Over_Thickness.png

其他格式

我们拿更常用的其他格式来研究一下。通常 web 端会使用 gltf, fbx 等通用格式,我们这里拿 web 端最通用的 gltf 格式模型来说明,其他通用模型类型道理一样。

对于 gltf 格式来说,加载完模型就赋予了材质,可能的类型有 MeshStandardMaterial, MeshPhongMaterial, MeshBasicMaterial 等,我用封面的士兵模型,使用的是 MeshStandardMaterial 类型的材质,接下来看如何修改内置着色器而实现效果。

ShaderChunk 和 ShaderLib

来看下 three 的目录,较新版本的 three 把核心代码安排在 src 目录下,/examples/jsm 目录下则是以 插件addons的形式引入的额外功能,比如 GLTFLoader 之类比较通用的功能。而内部着色器的实现在 src/renderers/shaders 目录下:

Three-Shaders.png

我们直接打开 ShaderLib.js 文件找下模型使用的 MeshStandardMaterial 的定义:

Standard_Shader.png

可以看到是复用了 meshphysical 的着色器,这对着色器还在 MeshPhysicalMaterial 材质里被使用,通过材质类定义的 defines 字段来开启相应的计算,这样的做法使得 MeshStandardMaterial 作为 MeshPhysicalMaterial 的回退选项。到 ShaderChunk目录下打开 meshphysical.glsl.js 看下宏定义:

Macro_Define.png

OK,已经了解了材质定义和对应着色器的关系了,接下来就是如何把我们的逻辑加到相应着色器字符串里了。

onBeforeCompile

官方文档约等于没写,还是去看 examples 的代码吧,关键字 onBeforeCompile 搜索下:

OnBeforeCompile_Case.png

右下角点进去看代码:

OnBeforeCompile_Code.png

这下就明白了,顾名思义,这个函数可以在编译着色器程序之前允许我们插入自己的代码, 我们可以根据功能对相应模块进行覆写或者添加功能,我们不希望修改修改默认着色器的内容,直接把溶解效果加到最后,接下来看下怎么做。

调试

按照这个做法,非常依赖 javascript 的 replace 方法,我们需要小心操作,经过实验,把所有代码放到同一串里是没问题的,这里需要反复打印调试,如果有问题请使用双引号来使用原始字符串。

如果没有处理格式,直接塞进去不会对齐的,很好辨认:

Log_Shader.png

接下来直接移植代码:

Code_No_Linear_Convertion.png

看到注释的那句话了吗,如果注释掉,并把阈值开到最大覆盖全部范围,可以明显看到和设置的颜色不一样,原因是因为之前的 shader 代码处理结果是转化到线性输出显示的,我们在标准着色器最后处理,一样要做线性转化。这个线性转化的意思是 gamma 变换的逆变换, gamma 变换是由于人眼对于颜色的感知非线性,非线性的原因和视锥细胞,视杆细胞数量比例不一样有关,省略一万字,大家有兴趣自己去搜~

没有线性转换:

Result_No_Linear_Convertion.png

线性转换后颜色就正常了:

Result_Linear_Convertion.png

拓展

再换一种写法

之前我们用直接舍弃片元的方法来实现过渡,接下来我们使用更 shader 风格的写法来重写,因为这个效果显示和消失具有二值性(要么有颜色要么透明),可以用 step(x,y) 函数来写,这个函数比较 y > x 的结果,true 则返回 1,否则返回 0 , 正好可以来表达透明度。

看代码,只有 fragmentShader 不一样:

Use_GLSL_STEP.png

这里的想法是先控制是否显示颜色,找的边界就是 noiseValue - edgeWidth,然后再判断使用原来的像素或者过渡色,如果大于 noiseValue 使用原来的像素,否则使用过渡的颜色,然后 mix 函数这里的第三个变量刚好是 step 函数的结果,所以就可以切换这两颜色了。

哦对,记得设置这个 material.transparent = true; ,否则会使用默认的混合颜色白色:

No_Transparent.png

整活

昨天在沸点发了两张图,其实很简单,到这里把过渡色换成贴图采样就行了,比如这样:

Flower_Body.png

学会了吗?赶紧搬到项目里惊艳领导吧。

思考

  • 能否和环境做交互?

更新

代码: github.com/wwjll/three…

在线 Demo: wwjll.github.io/three-pract…

写文章不易,点赞收藏是最好的支持~


作者:网格体
来源:juejin.cn/post/7344958089429254182

0 个评论

要回复文章请先登录注册