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 的加载器了。

需要做的其实只有两步:

    1. 读取的模型后为用 geometryShaderMaterial 创建新的 Mesh
    1. ShaderMaterialunifroms.progressrequestAnimationFrame 里做更新。

直接来看下着色器怎么写:

顶点着色器:

js 复制代码
let vertexShader = /* glsl */`
varying vec2 vUv;
void main()
    {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
`;

主要是定义了 vUv 这个可传递变量,为了把内置的纹理坐标传递到 fragmentShader

片元着色器:

js 复制代码
 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 填充到里面,原理是一样的。最后输出颜色。

这是出现的逻辑,如果是要消失呢?控制下边界条件就可以了:

js 复制代码
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 端程序重新改变变化方向即可,我们按照一个状态,关注边界条件,分别从正向和逆向进行思考,给出两个版本分别的代码。

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

用 Exclidraw 画下示意图:

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

写成代码:

js 复制代码
void main() {
	...
	
	float restProgress = 1.0 - dissolveProgress;
	if(noiseValue < restProgress) {
		discard;
	}
	if(noiseValue - edgeWidth < restProgress ) {
		gl_FragColor = finalColor;
	}

	...
}

反向来思考,随着阈值增加,出现的图像越来多,往前减掉过渡值(edgeWidth), 这部分呈现过渡色;小于当前 noiseValue 的部分舍弃,是还没出现的部分。

写成代码:

js 复制代码
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 搜索下:

右下角点进去看代码:

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

调试

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

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

接下来直接移植代码:

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

没有线性转换:

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

拓展

再换一种写法

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

看代码,只有 fragmentShader 不一样:

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

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

整活

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

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

思考

  • 能否和环境做交互?

后面有时间再把代码搞到 掘金在线 代码里吧,写文章不易,点个赞再走呗~

相关推荐
zqx_71 分钟前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己18 分钟前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称41 分钟前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色1 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2341 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河1 小时前
CSS总结
前端·css
BigYe程普2 小时前
我开发了一个出海全栈SaaS工具,还写了一套全栈开发教程
开发语言·前端·chrome·chatgpt·reactjs·个人开发
余生H2 小时前
前端的全栈混合之路Meteor篇:关于前后端分离及与各框架的对比
前端·javascript·node.js·全栈
程序员-珍2 小时前
使用openapi生成前端请求文件报错 ‘Token “Integer“ does not exist.‘
java·前端·spring boot·后端·restful·个人开发
axihaihai2 小时前
网站开发的发展(后端路由/前后端分离/前端路由)
前端