在图像渲染完成之后，进行进一步的处理的过程，就叫做图像处理（postprocessing）。其实就是滤镜。学过CSS的人都知道属性filter，这里面就包含了各式各样的滤镜：模糊、亮度、对比度、灰度、反转之类的。这些滤镜是是直接就能用的，一行简单的：filter: xxx();就搞定了。但是，在Shader中，并没有任何内置的滤镜函数可以给我们使用，想要实现上面的滤镜效果，我们只能亲自动手去写，不过，这样做会给我们极大的灵活性，我们可以不需要按照公式化的API去做效果。染色第一个滤镜，就是染色滤镜实现非常简单，把输出结果乘上染色的颜色值就可以了#iChannel0 "https://s2.loli.net/2024/07/10/oshNYlIBJ5eAu7k.gif" void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; vec3 tex = texture(iChannel0, uv).xyz; vec3 col = tex; vec3 tintColor=vec3(.220,.380,.651); col *= tintColor; fragColor = vec4(col, 1.); }这样就给整体图片笼罩了一层蓝色。RGB 位移看到这个标题你想到了什么？是的，就是抖音的 logo：抖音的 logo 就是中间一个白色的音符，两侧有青和红的投影，这种其实就是色调偏移的效果。RGB位移滤镜和这种就很像，他可以将 R、G、B 三个颜色分量进行偏移，产生一种色差的效果。我们来尝试实现一下色差的效果：// 复制三份uv vec2 rUv = uv; vec2 gUv = uv; vec2 bUv = uv; // 设置一个较大的偏移量数值，方便观察 float offset = .1; // 给R和B通道进行一正一负的偏移 rUv += offset; bUv -= offset; // 采样三次纹理 vec4 rTex = texture(iChannel0, rUv); vec4 gTex = texture(iChannel0, gUv); vec4 bTex = texture(iChannel0, bUv); vec4 col = vec4(rTex.r, gTex.g, bTex.b, gTex.a); fragColor = vec4(col);效果出现了，偏移量设置的 0.1，主要是看效果。我们把偏移量变更为 0.009，就能看到可以看出来的色差，但是不会突兀的感觉。除了一般的固定便宜之外，我们还可以把上一篇的“随机”拿出来用，给偏移整点噪声。完整的代码：#iChannel0 "https://s2.loli.net/2024/07/10/oshNYlIBJ5eAu7k.gif" // 随机函数 highp float random(vec2 co) { highp float a=12.9898; highp float b=78.233; highp float c=43758.5453; highp float dt=dot(co.xy,vec2(a,b)); highp float sn=mod(dt,3.14); return fract(sin(sn)*c); } void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; vec3 tex = texture(iChannel0, uv).xyz; // 为了将噪声值控制在0到1 float noise=random(uv)*.5+.5; // 使用sin和cos调整一下噪点的偏移量位置 vec2 offset=.0075*vec2(cos(noise),sin(noise)); // 复制三份uv vec2 rUv = uv; vec2 gUv = uv; vec2 bUv = uv; // 给R和B通道进行一正一负的偏移 rUv += offset; bUv -= offset; // 采样三次纹理 vec4 rTex = texture(iChannel0, rUv); vec4 gTex = texture(iChannel0, gUv); vec4 bTex = texture(iChannel0, bUv); vec4 col = vec4(rTex.r, gTex.g, bTex.b, gTex.a); fragColor = vec4(col); }把噪点的值设置为 0.075 是为了查看噪点的具体分布，可以将值自行调整观察。在这里贴一张噪点的图：挤压在 PS 里面，有一个叫做挤压滤镜的东西，大概是这样的：在 Shader 里面，我们一样可以通过代码来做出一样的效果。首先，我们创建一下负责膨胀或者挤压的函数bulgevec2 bulge(vec2 p) { return p; }咱们在主函数mainImage中应用膨胀函数bulge。void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; uv = bulge(uv); vec3 tex = texture(iChannel0, uv).xyz; fragColor = vec4(tex, 1.); }接下来，我们来编写膨胀函数吧： 1.用length计算uv上每个点到原点的距离，然后跟uv直接相乘。float d = length(p); p *= d;长这样了：这个效果是不太对的，因为UV的原点不在画布中心，在画布左下角，我们手动选择一个画布正中心的值吧：vec2 center = vec2(.5);在调用length函数之前，我们先把UV减去这个中点，然后调用之后加回去。p -= center; float d = length(p); p *= d; p += center;完美复刻了之前 PS 的效果：当然，我们还可以对效果做一下加强，我们把d的距离应用一下pow函数做一个指数增加，效果会更加明显。float dPow = pow(d, 2.); // p *= d; p *= dPow;效果有点鬼畜了，就不贴图了。截止到现在，我们做的挤压实际上都是外凸的，有什么办法能做到内凹吗？其实很简单，我们只需要给uv乘上距离的倒数就行了。float dRev = 1./dPow; p *= dRev;让我们来试一下：呃啊，辣眼睛的万花筒出现了，为什么会这样？因为dPow很可能是一个非常小的值，比如说(0.00001)乃至无穷小，那么它的倒数就会变成非常大，乃至无穷大。就会出现上述的万花筒。我们要解决它，其实也非常的简单，我们给底数加一个 1，让这个整体不至于非常的小就行：float dRev = 1. / (dPow + 1.)；最后，我们来微调一下这个内凹效果，并且加上与用户鼠标进行互动的逻辑，要怎么做？增加两个额外的参数，扭曲的半径radius以及扭曲的强度strength。#iChannel0 "https://s2.loli.net/2024/07/10/oshNYlIBJ5eAu7k.gif" // 挤压函数 vec2 bulge(vec2 p) { vec2 center = iMouse.xy / iResolution.xy; float radius = .9; float strength = 1.1; p -= center; float d = length(p); d /= radius; float dPow = pow(d, 2.); float dRev = strength / (dPow + 1.); p *= dRev; p += center; return p; } void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; uv = bulge(uv); vec3 tex = texture(iChannel0, uv).xyz; fragColor = vec4(tex, 1.); }来讲解一下代码，其中：radius表示扭曲效果的影响半径，radius的值越大，更多的区域会受到扭曲效果的影响。strength表示扭曲效果的强度，strength的值越大，扭曲效果越强，纹理坐标的变化越大。d /= radius，将距离d归一化，使其相对于 radius。如果d小于radius，则d 的值在[0, 1]范围内；如果d大于radius，则d的值大于 1。最后，来看看效果吧：还挺有意思的！像素化也就是我们经常看到的，马赛克。将特定区域变成方块，遮挡敏感信息。在 Shader 里，马赛克就是像素，就等于是把画面像素化。我们现在就会接触一个新的函数floor，函数的作用就是向下取整，比如说floor(114.514)就是114.我们来举一个例子，我们对uv的x轴进行输出，将x的坐标乘上像素化大小的值，并且使用 floor 函数来向下取整，然后再除以像素化大小的值。void mainImage(out vec4 fragColor,in vec2 fragCoord) { vec2 uv = fragCoord/iResolution.xy; float c = uv.x; c = floor(c * 10.) / 10.; fragColor = vec4(vec3(c), 1.0); }好，他现在长这样的：为什么？来我们去 Graphtoy 看看这个函数的效果：简单来说，举一个值的例子吧：比如我的x坐标位于(0, 0.09)的时候，x*10的值域是(0, 0.9)，但是floor函数是向下取整的，所以floor(x*10)的值是0，除以10还是0；但当x坐标位于0.1的时候，x*10就变为了1，这个时候的向下取整就成了1，除以10就变为了0.1；之后的范围值就会以0.1的步长递增，公式总体是阶梯的形状了。那我们回到一开始的采样纹理吧：#iChannel0 "https://s2.loli.net/2024/07/10/oshNYlIBJ5eAu7k.gif" void mainImage(out vec4 fragColor,in vec2 fragCoord) { vec2 uv = fragCoord/iResolution.xy; vec2 size = vec2(50., 50.); uv=floor(uv*size)/size; vec3 tex=texture(iChannel0,uv).xyz; fragColor=vec4(tex,1.); }心中无码，自然高清。马赛克，打好了！晕影在去澳门之前拿到了一副佳能相机，随之研究了一下摄影手法，其中，接触到一种特殊的手法叫做晕影在摄影和光学领域内，晕影或暗角是指图像的外围部分的亮度或饱和度比中心区域低。有时却因为需要创意风格而刻意加入，例如引起对图像中心区的注意。摄影师也会故意选择能产生晕影的镜头来制造特效，也可以使用特别的滤镜或以后期处理来制造晕影。比如：我们来用Shader来尝试实现一下吧。为了观察这个效果的实现，我们先把画布设置为白色。void mainImage (out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; vec3 col = vec3(1.); fragColor = vec4(col, 1.); }紧接着，我们在vec3 col = vec3(1.)下一行进行编码，我们来创建一个圆形的径向转变，这一步骤就是画个圆的步骤，详情可以参考本教程的 uv 相关的章节因为我们要把效果拉伸到整个画布，所以不需要对比例进行修改 vec2 p = uv; p -= .5; float d = length(p); col = vec3(d);这是一个简单的径向渐变，接下来，注释掉col = vec3(d);我们来使用smoothstep来把这个渐变效果拉伸一下，并且把边界值设置的稍微差距大一点，可以塑造一种模糊的感觉： col = vec3(d); float c = smoothstep(.4, .8, d); col = vec3(c);接下来是关键的一步，把这个渐变效果翻转过来，我们就可以得到晕影的效果了，要怎么做？有两种做法，效果是一样的，根据个人喜好即可 1.使用 1 减去c的值 2.调换smoothstep的边界值出于个人喜好，我选择了第一种，第二种也是完全没有问题的，原理也是非常的简单，前面的文章也充分介绍过了。之前演示的 GBC 的人物图背景黑色有点不是很明显，我们换回上一章节使用的那张图，并且加上晕影滤镜试一下：全部代码：#iChannel0 "https://s2.loli.net/2023/09/10/QozT59R6KsYmb3q.jpg" void mainImage (out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; vec3 tex = texture(iChannel0, uv).xyz; vec3 col = tex; vec2 p = uv; p -= .5; float d = length(p); col = vec3(d); float c = smoothstep(.4, .8, d); col = vec3(1. - c) * tex; fragColor = vec4(col, 1.); }嗯，效果很好，这就完成了。这里介绍的滤镜知识很简单，都是一些基础的滤镜，各位可以根据诸如 PS 的工具上的滤镜思考一下 Shader 如何去实现一样的效果，其实都是操作像素和画布，本质是一样的东西！
我们截止到目前为止，大部分图案都是有序和有规律的，但是现实里，大部分景色都是无序和混沌的。上面这张图，就是一个月光照在海面的景色。但是这张图，使用 Shader 画出来的。下面这张图，也是一样：一样是 Shader 写的。思考点在于：shader 怎么表现出无序？由于 shader 和数学是紧密相关的，在数学里，和这个概念接近的就是“随机”。随机函数在JavaScript中，我们可以使用内置的函数Math.random来模拟随机，但是，直到现在，GLSL好像还没有内置的随机函数可用，所幸，在Github上，已经有人封装了随机函数：glsl-random，来看看是什么样子的：highp float random(vec2 co) { highp float a=12.9898; highp float b=78.233; highp float c=43758.5453; highp float dt=dot(co.xy,vec2(a,b)); highp float sn=mod(dt,3.14); return fract(sin(sn)*c); }注意：highp 的意思就是高精度。这个随机函数的意义就是取sin函数偏后面的小数部分来模拟随机数。简单解释一下这个随机函数的意义吧：a、b、c 这些常数值是用于生成随机数的魔法数字，通常由经验或试验得出，目的是确保生成的随机数序列具有良好的随机性。这里 dot 函数计算 co 与 vec2(a, b) 的点积。点积操作将两个向量组合成一个标量值，这个值会用于后续的随机数生成。使用 mod 函数对 dt 取余，除以 3.14 使得结果限制在一个较小的范围内。这一步是为了将 dt 转换为一个周期性值，从而增强随机性。通过 sin 函数将 sn 转换成一个非线性值，然后乘以常数 c。fract 函数提取小数部分，使得返回值在 [0, 1) 范围内。跟一段 chatgpt 的总结：这个 random 函数生成一个基于输入向量 co 的伪随机数。它的原理是通过数学运算（点积、取余、正弦函数、小数部分）将输入映射到一个伪随机数，这种方法常用于着色器中生成伪随机值。这类随机函数的优点是计算简便且不需要保存状态，适合在 GPU 着色器中使用。不过，由于其伪随机性，它并不适合高要求的随机数生成场景。我们可以传入一个UV坐标，看看输出的效果：void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uv=fragCoord/iResolution.xy; float noise=random(uv); vec3 col=vec3(noise); fragColor=vec4(col,1.); }这就仿佛是一张坏掉的电视屏幕一般的噪点图。颗粒化滤镜如果我们将这些噪点和纹理叠加到一起，会怎么样？我们继续使用上一篇文章的纹理图链接，给我们的纹理加上噪点：（这里只附上一部分代码）vec3 col=texture(iChannel0,uv).xyz; col+=noise;可以看到噪点的强度非常的高，怎么办呢？我们可以给噪点降一些强度，也就是把噪点 noise 乘上一个 0.几的数值vec3 col=texture(iChannel0,uv).xyz; col+=noise*.2;诶，这样我们就得到了一个颗粒化的滤镜的效果，如果感觉白色太明显，我们可以再减去一个 0.5col+=(noise-.5)*.2;直接使用 noise，那么 noise 的范围在 [0, 1)，这意味着增加的值范围是 [0, 0.2)。这样会整体上增加颜色的亮度，使得画面偏白。通过 noise - 0.5，调整后的噪声值范围是 [-0.5, 0.5)，再乘以 0.2 后范围是 [-0.1, 0.1)。这使得对颜色的调整是有正有负的，部分像素的亮度会增加，部分会减少，整体上避免了颜色偏白的现象。这样就好多了，我们还可以给 random 函数加上 iTime 参数，让噪点运动起来！float noise=random(uv+iTime);但是有一个问题，因为我演示代码基本上都是用的 vscode 的 shader toy 工具，但是和浏览器对于精度的控制是不一样的，所以如果你在跟随我的代码进行编写的时候发现效果有差异，是正常的。这是 shader toy 的浏览效果：这是网页上的浏览效果：噪声噪声也是实现“随机”的一种手段，只不过它能使实现的效果更加偏向“自然”一些。平时我们在生活中看到的云雾、岩石、火焰等，都有噪声的存在。到这篇文章编写的时间为止，跟缺少内置的随机函数一样，GLSL 里同样也没有内置的噪声函数。但是在 GitHub 上，也已经有人封装好了一些常用的噪声函数——glsl-noise。我们选择其中比较常用的柏林噪声（Perlin Noise）的 3D 版本，看看它是什么样的。// // GLSL textureless classic 3D noise "cnoise", // with an RSL-style periodic variant "pnoise". // Author: Stefan Gustavson (stefan.gustavson@liu.se) // Version: 2011-10-11 // // Many thanks to Ian McEwan of Ashima Arts for the // ideas for permutation and gradient selection. // // Copyright (c) 2011 Stefan Gustavson. All rights reserved. // Distributed under the MIT license. See LICENSE file. // https://github.com/ashima/webgl-noise // vec3 mod289(vec3 x) { return x-floor(x*(1./289.))*289.; } vec4 mod289(vec4 x) { return x-floor(x*(1./289.))*289.; } vec4 permute(vec4 x) { return mod289(((x*34.)+1.)*x); } vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159-.85373472095314*r; } vec3 fade(vec3 t){ return t*t*t*(t*(t*6.-15.)+10.); } // Classic Perlin noise float cnoise(vec3 P) { vec3 Pi0=floor(P);// Integer part for indexing vec3 Pi1=Pi0+vec3(1.);// Integer part + 1 Pi0=mod289(Pi0); Pi1=mod289(Pi1); vec3 Pf0=fract(P);// Fractional part for interpolation vec3 Pf1=Pf0-vec3(1.);// Fractional part - 1.0 vec4 ix=vec4(Pi0.x,Pi1.x,Pi0.x,Pi1.x); vec4 iy=vec4(Pi0.yy,Pi1.yy); vec4 iz0=Pi0.zzzz; vec4 iz1=Pi1.zzzz; vec4 ixy=permute(permute(ix)+iy); vec4 ixy0=permute(ixy+iz0); vec4 ixy1=permute(ixy+iz1); vec4 gx0=ixy0*(1./7.); vec4 gy0=fract(floor(gx0)*(1./7.))-.5; gx0=fract(gx0); vec4 gz0=vec4(.5)-abs(gx0)-abs(gy0); vec4 sz0=step(gz0,vec4(0.)); gx0-=sz0*(step(0.,gx0)-.5); gy0-=sz0*(step(0.,gy0)-.5); vec4 gx1=ixy1*(1./7.); vec4 gy1=fract(floor(gx1)*(1./7.))-.5; gx1=fract(gx1); vec4 gz1=vec4(.5)-abs(gx1)-abs(gy1); vec4 sz1=step(gz1,vec4(0.)); gx1-=sz1*(step(0.,gx1)-.5); gy1-=sz1*(step(0.,gy1)-.5); vec3 g000=vec3(gx0.x,gy0.x,gz0.x); vec3 g100=vec3(gx0.y,gy0.y,gz0.y); vec3 g010=vec3(gx0.z,gy0.z,gz0.z); vec3 g110=vec3(gx0.w,gy0.w,gz0.w); vec3 g001=vec3(gx1.x,gy1.x,gz1.x); vec3 g101=vec3(gx1.y,gy1.y,gz1.y); vec3 g011=vec3(gx1.z,gy1.z,gz1.z); vec3 g111=vec3(gx1.w,gy1.w,gz1.w); vec4 norm0=taylorInvSqrt(vec4(dot(g000,g000),dot(g010,g010),dot(g100,g100),dot(g110,g110))); g000*=norm0.x; g010*=norm0.y; g100*=norm0.z; g110*=norm0.w; vec4 norm1=taylorInvSqrt(vec4(dot(g001,g001),dot(g011,g011),dot(g101,g101),dot(g111,g111))); g001*=norm1.x; g011*=norm1.y; g101*=norm1.z; g111*=norm1.w; float n000=dot(g000,Pf0); float n100=dot(g100,vec3(Pf1.x,Pf0.yz)); float n010=dot(g010,vec3(Pf0.x,Pf1.y,Pf0.z)); float n110=dot(g110,vec3(Pf1.xy,Pf0.z)); float n001=dot(g001,vec3(Pf0.xy,Pf1.z)); float n101=dot(g101,vec3(Pf1.x,Pf0.y,Pf1.z)); float n011=dot(g011,vec3(Pf0.x,Pf1.yz)); float n111=dot(g111,Pf1); vec3 fade_xyz=fade(Pf0); vec4 n_z=mix(vec4(n000,n100,n010,n110),vec4(n001,n101,n011,n111),fade_xyz.z); vec2 n_yz=mix(n_z.xy,n_z.zw,fade_xyz.y); float n_xyz=mix(n_yz.x,n_yz.y,fade_xyz.x); return 2.2*n_xyz; }代码有点长，但只要会用它就行，给它传入 UV，看看会输出什么？void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uv=fragCoord/iResolution.xy; float noise=cnoise(vec3(uv,1.)); vec3 col=vec3(noise); fragColor=vec4(col,1.); }一团白色，但是有点太大了，我们把 uv 缩放 20 倍，再给三维参数设置为 iTime 看看效果：float noise=cnoise(vec3(uv*20.,iTime));有没有一种扭曲黑暗爬行...的感觉？这就是我们可以明面上看到的噪声的图案了！噪声转场上一篇文章里，我们做了遮罩层的转场特效，我们现在可以尝试把噪声叠加到遮罩上。继续引入之前的图案纹理，编写转场函数transition，步骤和之前基本一样，只是我们要给progress加上噪声vec4 getFromColor(vec2 uv){ return texture(iChannel0,uv); } vec4 getToColor(vec2 uv){ return texture(iChannel1,uv); } float sdCircle(vec2 p,float r) { return length(p)-r; } vec4 transition(vec2 uv){ float progress=iMouse.x/iResolution.x; float ratio=iResolution.x/iResolution.y; vec2 p=uv; p-=.5; p.x*=ratio; float noise=cnoise(vec3(p*10.,0.)); float pr=progress+noise*.1; float d=sdCircle(p,pr*sqrt(2.4)); float c=smoothstep(-.1,-.05,d); return mix(getFromColor(uv),getToColor(uv),1.-c); } void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uv=fragCoord/iResolution.xy; vec4 col=transition(uv); fragColor=col; }效果图：这样一来我们的遮罩边缘就变成了噪声的不规则形状，就更加的有那种感觉了FBMFBM 全称是Fractal Brownian Motion，意思是“分形布朗运动”，它是将多个具有不同频率和振幅的噪声的结果值相叠加而产生结果的一种随机过程。它主要被用于构造自然界的云层、山脉、地貌等不规则的形体（开头的雨林景象用的就是这种技术）。上面我们用的是柏林噪声，在这里我把噪声换成了更加自然的Simplex噪声，直接从这里拷过来吧。来实现一下fbm函数：float fbm(vec3 p){ // 结果值 float value=0.; // 振幅 float amplitude=1.; // 频率 float frequency=1.; // 空隙率 float lacunarity=2.; // 持久度 float persistance=.5; // 缩放 float scale=1.; // 迭代次数 int octaves=8; for(int i=0;i<octaves;i++){ float noiseVal=snoise(p*frequency*scale); value+=amplitude*noiseVal; frequency*=lacunarity; amplitude*=persistance; } return value; }咱们这次只需要考虑使用噪音的库，不需要去理解噪音代码的程序原理，简而言之就是先会用，再去研究。我们来仔细解释一下fbm函数：先定义了几个变量：value代表结果值，初始化为 0.0，用于累加每个噪声层的值，最终返回的即为FBM的值。amplitude代表了振幅，控制每层噪声对最终值的贡献大小。每次循环中振幅乘以 persistance。frequency代表了频率，控制每层噪声的细节密度。每次循环中频率乘以 lacunarity。lacunarity代表了空隙率，控制频率的增长速率。通常设为 2，每层噪声的频率增加一倍。persistance代表的则是持久度，控制振幅的衰减速率。通常设为 0.5，每层噪声的振幅减半。scale就是缩放程度了，控制噪声输入的总体比例。通常设为 1，可以根据具体需求调整。octaves代表音度，也就是迭代次数，控制噪声层的数量。更多的层数会产生更复杂和详细的噪声图案。然后写个 for 循环来叠加噪声，叠加次数就是音度（迭代次数），在叠加的同时升高频率（持续增加细节密度，因为每一次循环都会乘以 2），降低振幅（乘上了持久度，设置的是 0.5，会持续衰减）。我们来看看实际效果：void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uv=fragCoord/iResolution.xy; float noise=fbm(vec3(uv,0.)); vec3 col=vec3(noise); fragColor=vec4(col,1.); }这种就是像云层一样的噪声图案了。消融效果什么是消融效果？举一个简单的例子：这是游戏《原神》里，玩家开启宝箱之后，宝箱产生的消融效果。那么，这种消融效果，要怎么实现？刚刚学到的FBM函数就可以做到：我们保留之前关于FBM函数相关的东西，我们在此之上新建一个dissolve函数，用于实现消融效果：vec4 getFromColor(vec2 uv){ return texture(iChannel0,uv); } vec4 getToColor(vec2 uv){ return texture(iChannel1,uv); } vec4 dissolve(vec2 uv) { float progress = iMouse.x / iResolution.x; float ratio = iResolution.x / iResolution.y; vec2 p = uv; p -= .5; p.x *= ratio; float noise = fbm(vec3(p, 0.)); noise = (noise * .5) + .5; float pr=smoothstep(progress-.01,progress,noise); vec4 col = getFromColor(uv); vec4 col1 = getToColor(uv); col.rgb=mix(col.rgb,col1.rgb,1.-pr); return col; }在消融函数里，我们复制了一个二维变量uv给变量p，然后咱们把它居中，并且使用它来创建fbm函数的噪声。使用smoothstep函数将progress与noise相比较，progress从左侧开始是 0，小于noise的时候，pr会返回一个 1，鼠标往右慢慢移动的时候，porgress会慢慢变大，pr也会慢慢变成小于 1 的值。这里.rgb 的意思是颜色向量的前三个分量，我们不做透明度的处理。另外，我们的代码里，为了避免鼠标拖到最左侧的时候，已经出现了噪声的问题，我们得让噪声的值域接近[0,1]，引发问题的原因在于值不在 0 和 1 之间变化，fbm生成的值域大概是在[-1, 1]。所以我们对Noise做了处理，乘以.5 之后再加上 .5，使其接近我们需要的值域。这里运行起来如果有点卡顿的话，可以考虑在 FBM 函数里面把迭代次数参数 octaves 改的小一点，比如改成 2这是 8 的效果：这是 2 的效果：经过上面的调整，我们实际上还是会发现有一点误差，尤其是噪点经过了计算处理之后，所以我们要重新设计一个新的函数来降值的值域映射到新的值域上：float remap(float a,float b,float c,float d,float t) { return clamp((t-a)/(b-a),0.,1.)*(d-c)+c; }我来解释下这个函数的具体作用：1.(t - a) / (b - a)：将输入值 t 归一化到 [0, 1] 范围内。假设 t 在 [a, b] 之间，计算得到的值为：当 t = a 时，结果为 0。当 t = b 时，结果为 1。当 a < t < b 时，结果在 0 和 1 之间。2.clamp((t - a) / (b - a), 0.0, 1.0)：对归一化后的结果进行裁剪，确保其在 [0, 1] 范围内。如果 t 超出了 [a, b] 范围，则结果被裁剪为 0 或 1：当 t < a 时，结果为 0。当 t > b 时，结果为 1。3.clamp((t - a) / (b - a), 0.0, 1.0) * (d - c) + c将裁剪后的值从 [0, 1] 范围缩放到 [c, d] 范围：当结果为 0 时，输出为 c。当结果为 1 时，输出为 d。当结果在 0 和 1 之间时，输出在 c 和 d 之间线性插值。这样一来，我们就实现了一个基本的消融效果。但是在游戏里，可以看到消融的时候，有一圈的亮边，我们来跟着一起也做一下吧我们接着定义两个变量：亮边的长度和亮边的颜色 float edgeWidth=.1; vec3 edgeColor=vec3(0.3255,0.6824,0.8);绘制一下亮边的遮罩看看，长度是edgeWidth，混合度就是噪声和位置的差，然后我们把遮罩输出一下看看：float edge=1.-smoothstep(0.,edgeWidth,noise-progress); col.rgb=vec3(edge);来吧我们来解释一下，根据smoothstep函数的定义，当我们的noise减去progress的值小于 0 的时候，也就是noise小于progress的时候，smoothstep就会返回 0，edge就会返回 1，这个时候呢，鼠标就应该是在画布的右侧，画布整体变成了白色，表示的阶段是消融已经结束；反之，如果相减的值，大于edgeWidth，返回 1，edge返回 0，鼠标在画布的左侧，整体是黑色，也就是表示的消融变化之前；相减的值，在 0 和edgeWidth之间，那就表示处在消融变化之中。总结之后，我们正式应用一下亮边元素。// col.rgb=vec3(edge); col.rgb=mix(col.rgb,edgeColor,1.-pr);咦，亮边的确是出现了：但是，消融部分也跟着消失了，原因在于，消融部分的调用顺序在前，跑到亮边的下面一层去了，加上亮边本身的范围要比消融的范围大，所以被遮盖了。我们重新写一下：vec4 dissolve(vec2 uv) { float progress = iMouse.x / iResolution.x; // float progress = sin(iTime); float ratio = iResolution.x / iResolution.y; vec2 p = uv; p -= .5; p.x *= ratio; float noise = fbm(vec3(p, 0.)); // noise = (noise * .5) + .5; noise = remap(-1., 1., 0., 1., noise); vec4 col = getFromColor(uv); vec4 col1 = getToColor(uv); float edgeWidth=.1; vec3 edgeColor=vec3(0.3255,0.6824,0.8); float edge=1.-smoothstep(0.,edgeWidth,noise-progress); col.rgb=mix(col.rgb,edgeColor,edge); float pr=smoothstep(progress-.01,progress,noise); col.rgb=mix(col.rgb.rgb,col1.rgb,1.-pr); return col; }现在的基本是对了，但是，还有个优化点，我们将鼠标拖到最左边。我们发现，左侧不该出现的亮边也出现了，这是因为这个时候的progress是 0，减去的值就是noise，但是，它的值不一定会大于 亮边长度edgeWidth，所以edge不一定等于 0。我们要确保在左侧混合因素的值得是 0 才行。怎么做？col.rgb=mix(col.rgb,edgeColor,edge*step(.0001,progress));咱们给混合因素上一个乘数值，当progress小于一个很小的值（如0.0001）时，这个值会返回 0，否则就返回 1，这样就能确保鼠标位于最左侧时没有多余的亮边出现了。下面是最终的效果：这一篇的内容有点多，我罗列一下主要的点： 1.随机函数random是一种实现“随机”的方法，我们可以通过它得到带有噪点的图形2.噪声noise也是实现“随机”的方式，得到的结果也是更“自然”。我们可以将噪声叠加到一些规则的图形之上，让他变得不规则。3.分形布朗运动FBM将多个不同频率，振幅的噪声的结果叠加，产生一种随机的过程，经常用于模拟自然界的山脉等等不规则物体，我们可以使用FBM来实现一种“消融”的效果。总的来说，“随机”效果，可以为我们的着色器应用添加各种有意思的效果，多加利用吧。
作为一个前端开发的人员，基本上离不开一个用来显示图片的标签，那就是img。业界有云：UI给出来的效果我实现不出来？那就让UI负责切图吧！在Shader里，没有img，取而代之的是纹理接下来这节内容就是把纹理，带入我们的程序中了。启动模板这边提供一个简单的启动模板和测试用图片链接：#iChannel0 "https://s2.loli.net/2023/09/10/QozT59R6KsYmb3q.jpg" void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uv=fragCoord/iResolution.xy; vec3 tex=texture(iChannel0,uv).xyz; fragColor=vec4(tex,1.); } 注意，关于上述的代码的一些补充说明，在之前的文章里，我们提到了Shader的编写方式分为：GLSL文件内，并且使用了vscode以及插件Shader Toy来预览Shadertoy网站上引入编写的那么，如果你是在GLSL文件内引入的，就可以直接参考我的代码来引入纹理如果，你是在Shadertoy网站上编写的Shader，步骤如下：点击右下角的黑框iChannel0点击“纹理”，然后从下面的图片列表中随便选一张即可，选完后点击右上方的叉叉关闭。接下来，引入了纹理，我们为了将它显示在屏幕上，要进行的就是采样操作，GLSL内置了一个采样函数texture，我们直接用这个将纹理给采样出来就行了，就是我上面发出的代码了。采样函数texture接受两个参数：第一个是我们的纹理iChannel0，第二个是UV坐标变量uv，采样的结果命名是tex，取得它的前3维度（暂时不需要考虑纹理的透明度），最后用变量vec4(tex, 1.)输出到屏幕上。顺利的话，我们看到的是这样的：这里要特别警告一下：对于用编辑器的朋友们来说，有可能有人在运用HTML搭载GLSL的时候，屏幕会变成黑色，并且控制台会报错，红色的错误。原因就在于，你的浏览器可能不支持WebGL2，有两个办法：1.升级你的浏览器；2.将纹理采样函数texture改名为texture2D。好，我们将纹理搬到了屏幕上，接下来就是要对他进行一些有意思的操作了！扭曲第一种操作是扭曲，通过改变纹理的UV坐标来扭曲整个图片的形状。让我们来创建一个扭曲函数，就叫做distort吧。同时将uv经过他的处理，再交由texture采样函数：#iChannel0 "https://s2.loli.net/2023/09/10/QozT59R6KsYmb3q.jpg" vec2 distort (vec2 uv) { uv.x += sin(uv.y); return uv; } void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uv=fragCoord/iResolution.xy; vec3 tex=texture(iChannel0,distort(uv)).xyz; fragColor=vec4(tex,1.); } 效果就是这样的：我们可以看到这图片被夸张地扭曲了，扭曲程度太大，我们需要对扭曲量进行调整调试函数这里就要用到一个非常不错的网站Graphtoy，是的，这也是iq几何大佬创建的网站，它可以非常方便的对Shader的函数进行可视化的调试。首次进入网站的时候，它会展示好几张函数的默认图像，我们直接跳过，点击Clear按钮清空，我们输入sin(x)：sin函数默认的变化幅度是蛮大的，我们要缩小幅度，要怎么做呢，那就是给x乘上一个数字，相当于变相的增加了sin函数的频率我们再整体除以50，其实相当于减少了sin函数的振幅如果我们再给sin函数内部的值加上t，也就是时间参数，他就会动起来：所以我们可以做一个有规律的律动效果：#iChannel0 "https://s2.loli.net/2023/09/10/QozT59R6KsYmb3q.jpg" vec2 distort (vec2 uv) { uv.x += sin(uv.y * 10. + iTime) / 50.; return uv; } void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uv=fragCoord/iResolution.xy; vec3 tex=texture(iChannel0,distort(uv)).xyz; fragColor=vec4(tex,1.); } 转场第二种操作是转场，简而言之就是从一张纹理转变成另外一张纹理。这种操作需要两张图片。引入第二张：#iChannel1 "https://s2.loli.net/2023/09/10/Jb8mIhZMBElPiuC.jpg"我们可以再声明一张。引入第二张纹理之后，创建两个采样函数，用于将刚刚声明的两个纹理采样出来。然后我们就会用到转场函数transition。转场函数其实就是通过mix函数将两张纹理混合了起来，混合程度是用户鼠标归一化之后的x轴位置。下面是示例代码：vec4 getFromColor(vec2 uv) { return texture(iChannel0, uv); } vec4 getToColor(vec2 uv) { return texture(iChannel1, uv); } vec4 transition(vec2 uv) { float progress = iMouse.x / iResolution.x; return mix(getFromColor(uv), getToColor(uv), progress); } void mainImage (out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; vec4 col = transition(uv); fragColor = col; }左右拖拽鼠标，就可以看到很简单的转场效果，也就是淡入淡出转场效果。滑动转场淡入淡出倒是很简单，那么，我们回忆一下之前学习uv的时候，学过的step函数。它接受 2 个参数：边界值edge和目标值x，如果目标值x大于边界值edge，则返回 1，反之返回 0。那我们给代码加上这个函数，会发生什么？vec4 getFromColor(vec2 uv) { return texture(iChannel0, uv); } vec4 getToColor(vec2 uv) { return texture(iChannel1, uv); } vec4 transition(vec2 uv) { float progress = iMouse.x / iResolution.x; return mix(getFromColor(uv), getToColor(uv), 1. - step(progress, uv.x)); } void mainImage (out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; vec4 col = transition(uv); fragColor = col; }效果图：我们得到了一个像帘子一般的左右滑动的转场效果。下面我标记一下这个效果的原理图：将画布分成两部分，位于鼠标左边的部分（左半），以及，位于鼠标右边的部分（右半），左半部分的uv.x是小于progress的，step函数会返回0，mix混合程度则返回1 - 0也就是1，表示的是第二张图片；右半部分的uv.x是大于progress的，step函数会返回1，那么，混合程度就是1 - 1也就是0，表示的是第一张图片。遮罩转场那么我们接下来，我们再来换一种形式。在progress变量的下方新增一个ratio变量，表示画布的比例。我们这个例子来一个圆形转场的遮罩，用我们的SDF函数以及上面提到的progress来设定圆形遮罩的半径，不够大的话，乘上一个数字（比方说根号2），然后对遮罩取反就设置为我们的混合程度。另外我们将smoothstep的范围再稍微调整一下，这样就可以实现比较完美的效果。全部代码如下：float sdCircle(vec2 p,float r) { return length(p)-r; } vec4 getFromColor(vec2 uv) { return texture(iChannel0, uv); } vec4 getToColor(vec2 uv) { return texture(iChannel1, uv); } vec4 transition(vec2 uv) { float progress = iMouse.x / iResolution.x; float ratio=iResolution.x/iResolution.y; vec2 p = uv; p -= .5; p.x *= ratio; float d = sdCircle(p, progress*sqrt(2.)); float c = smoothstep(-.2, 0., d); return mix(getFromColor(uv), getToColor(uv), 1. - c); } void mainImage (out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; vec4 col = transition(uv); fragColor = col; }效果图：这样，我们就得到了一个虚化的圆形遮罩转场效果。置换转场我们还可以用纹理本身来扭曲纹理的uv坐标，这样的操作称之为“置换”，用来扭曲的纹理称之为“置换纹理”。我们引入第三张纹理：#iChannel2 "https://s2.loli.net/2023/07/17/3GDlwcvehqQjTPH.jpg"来编写转场函数transitionvec4 transition(vec2 uv){ float progress=iMouse.x/iResolution.x; float ratio=iResolution.x/iResolution.y; vec2 dispVec=texture(iChannel2,uv).xy; vec2 uv1=vec2(uv.x-dispVec.x*progress,uv.y); vec2 uv2=vec2(uv.x+dispVec.x*(1.-progress),uv.y); return mix(getFromColor(uv1),getToColor(uv2),progress); }这里我们要做的就是用置换纹理去扭曲两张图片的uv将置换纹理采样成dispVec，取出前两个维度。根据我们在UV相关内容的学习中，往正方向（右）移动的话，不是加上，而是减去，所以我们给uv的x坐标减了x轴的偏移量dispVec.x，乘上progress之后，让他变得能够随着鼠标的位置变化而变化，第二张图片也是同理，方向取反，鼠标位置也要取反。为什么uv.x - dispVec.x就会让图片呈现纹理效果？答案：因为置换纹理本身的所有像素就存储了位移的距离，这是3D业界一种固有的做法，叫“凹凸映射”，将UV减去位于距离后自然就呈现了纹理的效果这样基本是可以了，再加一个strength变量吧，用于调节置换效果的强度，因为默认的强度可能会过于凸显置换纹理本身。最终的效果图：这么看来，我们得到了个比较有点感觉的转场特效。题外话：上面的那些转场特效仅仅是以学习为目的的简单特效。推荐GitHub上的这个库——gl-transitions，里面有大量的社区实现的Shader转场效果可供参考和学习。其实这种效果，如果是实装到HTML里面，我们或许是可以开发一个图片合成到视频的工具，也许就是简单版的web剪映（仅作为思考的问题产出）
mix函数到我们现在为止，我们做出的示例图基本都是黑色和白色，非常的单调，那么我该怎么做，才可以给这些图形染上颜色呢？这就是我们马上要提到的函数，mix的工作了。这是一个GLSL的内部函数。也被称之为混合函数其实现逻辑很简单，一行代码就可以望到头#define mix(x,y,t) x*(1.-t)+y*t它接受 3 个参数：前 2 个参数x和y分别对应 2 个值，最后一个参数t代表混合程度，如果t为 0，则值就等于x；如果t为 1，则值就等于y，如果t为 0 到 1 内的值，则值就等于x与y之间逐渐变化的值。创建渐变色void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uvCenter=(2. * fragCoord - iResolution.xy)/iResolution.y; vec2 uv = fragCoord / iResolution.xy; vec3 col1=vec3(1.,0.,0.); vec3 col2=vec3(0.,1.,0.); vec3 col=mix(col1,col2,uvCenter.x); fragColor=vec4(col, 1.); }我声明了两个uv，一个是居中以及根据画布尺寸做了调整的归一化坐标，另一个是没有处理过的归一化坐标，我们使用了mix方法来指定了两个颜色，红色和绿色，混合程度就是uv的x轴这样一来，我们就得到了一个沿着x轴渐变的颜色给图形染色mix可以给图形染色特定的颜色。前文中，我们聊到了SDF函数，在一个形状的外部，他的值为整数，在内部，就是负数。根据这个特性，我们可以定义两个颜色值，一个是Inner一个是Outer，分别代表了形状内部和外部的颜色，我们再使用mix函数将他们混合起来，混合程度就用SDF函数平滑之后的效果float sdBox( in vec2 p, in vec2 b ) { vec2 d = abs(p)-b; return length(max(d,0.0)) + min(max(d.x,d.y),0.0); } void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uv =(2. * fragCoord - iResolution.xy)/iResolution.y; float d = sdBox(uv, vec2(.6, .3)); float c = smoothstep(0., .002, d); vec3 colInner = vec3(1., 0., 0.); vec3 colOuter = vec3(1., 1., 1.); vec3 col = mix(colInner, colOuter, c); fragColor = vec4(col, 1.); } 这里解释一下为什么要把平滑处理之后的结果作为混合程度：smoothstep(0., .002, d)生成了一个从0到1平滑过渡的值，具体来说：当d接近0时，c接近0，这表示该点非常接近或在矩形内部。当d接近0.002时，c接近1，这表示该点接近矩形边缘。这样做的目的是为了在矩形的边缘创建一个平滑的过渡效果，而不是产生一个硬边。这使得颜色从内部的红色（colInner）平滑过渡到外部的白色（colOuter）。smoothstep的平滑过渡效果避免了颜色的突然变化，生成视觉上更为柔和和自然的边缘。通过这种方式，c的值平滑地从0变到1，从而使mix(colInner, colOuter, c)能够创建一个从红色到白色的平滑过渡，而不是一个硬边界。这种方法通常用于反走样，以减少边缘的锯齿效应。形状转变效果mix函数不仅可以混合颜色，还可以混合别的东西，比如说形状，只要是维度相同的两个值，都可以拿来混合。比方说我现在创建两个SDF图形，长方形和圆形，把其形状通过mix混合起来，混合程度参数就用iTime。外部包裹了两层函数：1.sin函数负责周期性的变化2.abs函数负责确保混合程度的值是正数的OK，我们得到了一个随着时间流逝，在圆和长方形之间反复变化的图形重复图案之前都是单个图形进行绘制，实际上在UV变化那部分的讲解时，我们已经尝试过使用fract函数来实现“重复”的操作了，利用这一点，我们可以创建出其他的常见的重复图案。这个函数的原理是取小数点。条纹我们可以先用step函数绘制出一根线条（其实就是靠右边的一块颜色块儿）再使用fract函数来重复UV，注意这一步要写在绘制线条之前。void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uv = fragCoord / iResolution.xy; uv = fract(uv * 16.0); vec3 c = vec3(step(.5, uv.x)); fragColor = vec4(c, 1.0); }这里要格外注意fract的使用规则，注意事项我已经更新到上一篇文档的重复部分了。使用居中且自适应画布的uv，不太推荐使用fract，因为它会导致坐标折叠回0到1的范围，产生不可预期的图案或重复图案。特别是当uv包含负数时，fract会将这些负数转化为正数的小数部分，从而导致图案变得复杂和混乱。波浪把刚刚的条纹改成y轴方向，然后对uv的y轴进行修改，加上x的sin值，这会导致什么情况呢？void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uvCenter = (2.0 * fragCoord - iResolution.xy) / iResolution.y; vec2 uv = fragCoord / iResolution.xy; uv.y += sin(uv.x); uv = fract(uv * 16.); vec3 c = vec3(step(.5, uv.y)); fragColor = vec4(c, 1.0); }视觉上已经有点弯曲了，但是不够明显，我们对sin函数做一下微调，比如对x乘以5，之后对sin的整体值微调一点比如.2void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uvCenter = (2.0 * fragCoord - iResolution.xy) / iResolution.y; vec2 uv = fragCoord / iResolution.xy; uv.y += sin(uv.x * 5.) * .2; uv = fract(uv * 16.); vec3 c = vec3(step(.5, uv.y)); fragColor = vec4(c, 1.0); }现在就非常的合适了：网格网格实际上就可以看成是2个互相垂直的条纹叠加形成的形状首先我们用fract来重复uv,然后再用我们之前学到的并集来处理x轴和y轴，将他们合并起来。另外，为了解决画布的长宽不相等的问题，我们还要处理一下比例。整体代码如下float opUion(float d1, float d2) { return min(d1, d2); } void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uvCenter = (2.0 * fragCoord - iResolution.xy) / iResolution.y; vec2 uv = fragCoord / iResolution.xy; uv.x*=iResolution.x/iResolution.y; uv = fract(uv * 16.); vec3 c = vec3(opUion( step(.25, uv.x), step(.25, uv.y)) ); fragColor = vec4(c, 1.0); }波纹要注意，除了fract函数，sin也是有重复特性的，初中数学吧，这样一来我们就不需要用fract函数了，用sin来重复SDF函数的距离float opUion(float d1, float d2) { return min(d1, d2); } float sdCircle (vec2 p, float r) { return length(p) - r; } void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec2 uvCenter = (2.0 * fragCoord - iResolution.xy) / iResolution.y; float circleVal = sdCircle(uvCenter, .5); circleVal = sin(circleVal * 40.); float circle = smoothstep(0., .02, circleVal); fragColor = vec4(vec3(circleVal), 1.0); }极坐标我们到目前为止，涉及到的Shader的默认坐标系是直接坐标系，除了这一类坐标系，还有一个坐标系，叫做极坐标系，这个坐标系可以画出一系列基于圆形的图案出来如果上面这个示意图，极坐标系是由两个维度构成的，极角φ和半径r。先将uv进行居中处理，图中的极角φ可以通过atan函数计算直角坐标系的反正切值就能算出来了。第二个维度半径r，则用length函数计算直角坐标系到原地的距离就可以轻松算出。所以，我们来整一个比较具现化的极坐标展示吧：vec2 cart2polar (in vec2 uv) { float phi = atan(uv.y, uv.x); float r = length(uv); return vec2(phi, r); } void mainImage (out vec4 fragColor, in vec2 fragCoord) { vec2 uv = (2.0*fragCoord-iResolution.xy)/iResolution.y; uv = cart2polar(uv); fragColor = vec4((uv), 0, 1.0); }这样出来的就是这张图：我们可以继续使用极坐标搭配其他的函数，比如sin来画出各种各样的形状放射形直接用sinvec2 cart2polar (in vec2 uv) { float phi = atan(uv.y, uv.x); float r = length(uv); return vec2(phi, r); } void mainImage (out vec4 fragColor, in vec2 fragCoord) { vec2 uv = (2.0*fragCoord-iResolution.xy)/iResolution.y; uv = cart2polar(uv); float c = step(.0,sin(uv.x * 12.)); fragColor = vec4(vec3(c), 1.0); }讲解一下原理吧：我们把uv转换为了极坐标，phi是我们从X轴正方向到点的连线之间的角度（极角），r是点到原点的距离.对极角 phi 进行了正弦函数计算，并乘以一个因子 12。由于 phi 是角度，sin(uv.x * 12.0) 将在每个极角周期内重复 12 次。这意味着每隔 π/6（30 度）就会重复一次正弦波的周期，这导致图案中每 30 度出现一次明暗变化。正弦函数 sin(x) 在 [0,2?] 范围内有一个完整的周期，并且在每个周期内从 -1 变化到 1 再回到 -1。这个周期性的波形导致了图像中明暗交替的效果。所以说，[0,1]这个范围是明亮，[-1,0]这个范围是暗淡。注意：在 GLSL 中，颜色分量的范围通常在 [0, 1] 之间。如果 c 是负值，那么 vec3(c) 的分量将被截断为 0（黑色）。如果 c 是正值但小于 1，那么颜色将是灰色到白色的渐变。所以我们为了避免这种渐变，就用到了阶梯函数来做螺旋形也是用sin函数，但是参数要更改，是半径乘以倍数 + 角度，乘以半径是因为要随着半径的增加，使其sin函数在半径方向上进行快速变化。当角度变化时，每个固定的 ?对应的角度值 ? 会在 [0,2?] 之间循环变化。这意味着正弦波的相位不仅随? 变化，还会随 ? 变化，形成一个螺旋状的图案。vec2 cart2polar (in vec2 uv) { float phi = atan(uv.y, uv.x); float r = length(uv); return vec2(phi, r); } void mainImage (out vec4 fragColor, in vec2 fragCoord) { vec2 uv = (2.0*fragCoord-iResolution.xy)/iResolution.y; uv = cart2polar(uv); float c = step(.0,sin(uv.y * 20. + uv.x)); fragColor = vec4(vec3(c), 1.0); }
UV变换基于上一篇文章我们画好的长方形，我们开始进行UV变换相关的实践。注意：UV变换的相关代码要写在SDF函数调用之前平移总之先尝试给uv的x和y加上一些值： uv.x += .5; uv.y += .5;为什么加上值会往左下角移动呢？我们把uv坐标分布的情况用fragColor画出来就知道了：之前位于中间的原点值是(0,0)，现在则变成了(0.5,0.5)，那么上一个(0,0)则是跑到了左下角的如图位置，而SDF函数输入的坐标值的原点值是(0,0)，正好对应左下方的那个点，因此图形才会整体往左下方移动。简单来说：要确认SDF图形位置的变化，要看目前(0,0)这个点的位置变化。相反，如果要往右上方平移的话，就用减法就行了，顺带一提可以简化为： uv -= vec2(.5);缩放给UV的x和y坐标做乘运算以及除运算，可以实现缩放效果：与上面的加减法差不多的，原理一致，不赘述了，贴一张图在这里：翻转要实现翻转我们需要找一个三角形的SDF函数，sdEquilateralTriangle float sdEquilateralTriangle( in vec2 p, in float r ) { const float k = sqrt(3.0); p.x = abs(p.x) - r; p.y = p.y + r/k; if( p.x+k*p.y>0.0 ) p = vec2(p.x-k*p.y,-k*p.x-p.y)/2.0; p.x -= clamp( p.x, -2.0*r, 0.0 ); return -length(p)*sign(p.y); } ... float d = sdEquilateralTriangle(uv, .5); float c = smoothstep(0., .02, d); fragColor = vec4(vec3(c), 1.); ...我们得到的是一个三角形，要实现翻转，我们只需将uv的y乘上-1就行了。简单的逻辑原理图是这样的：之前的虚线三角形的上顶点是(0.,0.5)，乘上-1 后就变成了(0.,-0.5)，也就变成了实际三角形的下顶点。旋转这个效果有点复杂，但是好在，有已经封装好的库，2D相关的旋转函数我们直接拿来用：mat2 rotation2d(float angle){ float s=sin(angle); float c=cos(angle); return mat2( c,-s, s,c ); } vec2 rotate(vec2 v,float angle){ return rotation2d(angle)*v; }我们可以简单地讲解一下这个函数的意义，有一点需要提前了解：在数学和计算机图形学中，二维平面上的旋转通常是默认逆时针方向的。这个约定基于右手坐标系的定义，在这种坐标系中，正角度表示逆时针旋转。这其实相当于简单的复习了一下学生时代的知识。所以上面的旋转函数的实现就相当于是：通过二维矩阵的方式对uv进行乘法，得到的就是旋转之后的新的坐标。另外我们在进行旋转操作时经常会用到PI这个常量，代表 180 度。 const float PI = 3.14159265359; uv = rotate(uv, PI / 2.);最后会变成：在之前的文章中我们提到了iTime的变量，表示Shader从开始到现在执行所经过的时间。我们可以以此来做一个简单的旋转的动画效果：uv = rotate(uv, iTime)重复我们来认识一个新的内置内涵fract，它的作用很简单，就是获取一个数的小数部分。比如我们fract(1145.14)，那我们得到的值就是.14。来自2024年7月1日的修正：因此，fract函数对于负数返回的是其小数部分作为正数，这是由floor函数的特性决定的。这个方法有什么用呢？我们来试试用上图的代码运行出来的结果也在右边显示出来了。为什么会这样？我稍微做一下标记：将坐标的值乘以2，我图中给出了几个象征意义的关键点，大于1的部分的点的整数部分都被去掉了，剩下的就是他们的小数点部分，这样其实就可以达成重复的效果。那我们重新回到三角形来咯，四个小三角。怎么实现的，不用再赘述了吧。注意一点：我是把uv先乘了2.之后，再进行的居中和适应画布。这样就可以得到四个均等的区域，并且大家都是居中对齐的啦。镜像关于uv的变换，我们最后再来认识一个函数吧，大家高中有可能听说过但是大学经常会用到的：abs。它的作用是获取一个数的绝对值。函数图像长这样：由此可以看出，两个函数都是基于对应的变量轴对称，由此可以得出结论：这个函数具有“对称性”那我们来举个例子：这是镜像之前的：这个是镜像之后的：非常明显的差距，原本UV坐标的左下角的部分是负数，现在全部镜像翻转为了正数，大小还和x轴对称的值一样。那我们获得一个菱形也很简单：三角形翻转一下不就完事儿了SDF图形变换除了UV之外，SDF本身的形状也是可以改变的，变形函数现阶段咱们可以直接从网上搜罗到，搜不到满足不了需求的，也可以使用AI嘛不是。咱们还是从之前的图形学大佬iq的博客里拿下来一些函数来用，比如：float onRound (in float d, in float r) { return d -r; } float opOnion (in float d, in float r) { return abs(d) - r; }onRound是“圆角”操作，能让图形的角变成圆角opOnion是“镂空”操作，能挖空图形中间的部分SDF布尔运算SDF函数本身能绘制出很多的形状，但是也是有限的，我们想画一个梯形，可以用sdTrapezoid ，但是如果我们想画一个类似于人的骨头关节的那种呢？没有对应的SDF函数是不是就不能画了？NoNoNo，我们可以采用大家都会的并，交，差运算同样的，如果自身数学基础不好，没法自己推导的话，可以从Iq大佬的博客copy一份，或者寻求AI的帮助。新时代了，要充分结合AI创造出自己的优势区间float opUnion(float d1,float d2) { return min(d1,d2); } float opIntersection(float d1,float d2) { return max(d1,d2); } float opSubtraction(float d1,float d2) { return max(-d1,d2); }虽然是copy过来的，但是还是简单的解释一下吧：opUnion：实现两个形状的并集，也就是结合两个图形，形成一个新的图形。原理：对于每个点，取两个图形中，距离最近的那个。如果一个点在形状A内部或形状B内部，它就属于新形状。取较小的距离 min(d1, d2) 就实现了这个效果。opIntersection：：实现两个形状的交集，即只保留两个形状共同的部分。原理：对于每个点，取两个形状中距离最大的那一个。如果一个点同时在形状A内部和形状B内部，它才属于新形状。取较大的距离 max(d1, d2) 就实现了这个效果。opSubtraction：：实现两个形状的差集，即从第二个形状中减去第一个形状。原理：对于每个点，取第一个形状的负距离和第二个形状的距离中较大的那个。如果一个点在形状A内部但不在形状B内部，它就属于 新形状。取 max(-d1, d2) 就实现了这个效果。没什么好说的，你就可以理解成把d1的内容取个反，比如一个正方形区域，由一个圆形和其余部分组成，一开始d1是那个圆，但是取负处理之后，就成为了剩余的部分，然后这个差集就是把剩余的部分和第二个参数取了一个交集，就是上面我们说到的那个max，应该豁然开朗了吧？这是并集运算示例：这是交集运算示例：这是差集运算示例：平滑效果布尔运算还有一种另外的版本：(smooth)，能够产生一种更加有机的结果，也即是“粘稠”的感觉。我们可以直接从iq大佬的博客上拿一些示例下来，之后了解的更深了，我们也可以自己写一些SDF函数：float opSmoothUnion( float d1, float d2, float k ) { float h = clamp( 0.5+0.5*(d2-d1)/k, 0.0, 1.0 ); return mix( d2, d1, h ) - k*h*(1.0-h); } float opSmoothSubtraction( float d1, float d2, float k ) { float h = clamp( 0.5-0.5*(d2+d1)/k, 0.0, 1.0 ); return mix( d2, -d1, h ) + k*h*(1.0-h); } float opSmoothIntersection( float d1, float d2, float k ) { float h = clamp( 0.5-0.5*(d2-d1)/k, 0.0, 1.0 ); return mix( d2, d1, h ) + k*h*(1.0-h); }可以看看这些函数实现的效果：并集：交集：差集：
UV坐标这是主体代码：void mainImage(out vec4 fragColor,in vec2 fragCoord){ }前面我们已经直到了，这个是主体函数mainImage，里面有两个参数，一个是四维变量 fragColor，代表输颜色、fragCoord代表输入坐标，并且我们还有一个内置的变量iResolution代表着画布的大小。在Shader中，一个变量的基本值都是分布在[0. , 1.]之间的，也就是：在图形处理中，归一化 UV 坐标将每个轴的坐标限制在 0 到 1 之间。这是大多数图形API和硬件期望的纹理坐标范围。而现在的输入坐标的值远远地超过了它，我们也就需要将它给“归一化”。UV坐标的解释U坐标：通常对应的是纹理的水平方向，对应的是传统的X轴V坐标：通常对应的是纹理的竖直方向，对应的是传统的Y轴在处理纹理时，归一化的坐标系统让你能够独立于纹理的实际像素尺寸，统一处理纹理坐标。例如，无论纹理的实际尺寸是 256x256 像素还是 1024x1024 像素，UV坐标 (0.5, 0.5) 总是指向纹理的中心点。用输入坐标fragCoord除以画布大小iResolution.xy，我们就能得到一个归一化的坐标，把它命名为uv。 vec2 uv = fragCood / iResolution.xy;来看一下x轴坐标分布情况：现在我们代码里第三个值是0，我们暂时只看前两个，可以看到x坐标从左边开始是黑色，值是(0, 0)，在最右边是红色，值是(1,0)，中间则是分布在(0, 1)之间的值。从整体上来看，我们得到的是一个从左到右渐变的图案。一样的，我们再来看一下y坐标的分布：可以看到y坐标从底下开始是黑色，值是(0,0)，到最上面是纯绿色，值是(0,1)，而中间则是分布在(0,1)之间的值。从整体上看，我们得到了一个纵向的渐变图案。接下来，我们同时输出x坐标和y坐标的分布：直接传递一个vec2作为vec4的第一个参数，剩下只需要两个参数位置了：左下角原点是黑色，值是(0,0)，右下角是红色，值是(1,0)，左上角是绿色，值是(0,1)，右上角是黄色，值是(1,1)，中间的所有值在(0,0)到(1,1)这 2 个区间分布。从整体上看，我们得到了一个有多种颜色的渐变图案。这就是所谓的UV坐标，它代表了图像（这里指画布）上所有像素的归一化后的坐标位置，其中U代表水平方向，V代表垂直方向。图形的绘制图形界最常见的东西，就是圆。说一下设计思路：先计算uv坐标上的点到原点的距离，这个可以使用GLSL自带的方法length来实现length 函数返回一个向量的长度（模），它是一个标量（即一个实数），始终为非负数。这个长度是向量从原点到指定点的距离，因此总是正数或零。会计算 uv 向量从原点 (0, 0) 到指定点 (uv.x, uv.y) 的距离，这个距离总是非负的。 float d = length(uv); fragColor = vec4(vec3(d), 1.0);实现效果是这样的：左下角是黑色的原点，值是(0,0)，从原点向右上方向辐射的径向渐变，上面每个点的值代表的就是该点到原点的距离，越靠近原点距离越小，越接近黑色，反之越远离原点距离越大，越接近白色。我们怎么把这个玩意儿挪到中间呢？前面说了，坐标是在(0,0)到(1,1)这个区间分布的，那么要居中的话，我们可以让区间扩大到(-1,-1)到(1,1)。这样会发生什么？那就是(0,0)这个原点会出现在屏幕的正中间（注意这一行代码要放在length函数代码的上面。）于是我们有了：怎么样，是不是有那种味儿了？原点黑色在中央位置，发散到四面八方，越远颜色越接近白色但是目前好像并不是完好的圆形，我们稍微把preview的窗口拉小一点：这就是一个椭圆了，这是什么问题？因为，uv坐标的值并不会去自动适应画布的大小，我们该怎么做呢？我们需要计算画布的比例，将画布长除以画布宽就能算出，再将UV的x坐标与比例相乘即可。就是这句关键的语句： uv.x *= iResolution.x / iResolution.y;调整 uv.x 可以确保纹理在不同分辨率的画布上保持正确的比例，而 uv.y 不需要调整，因为它已经在 [0.0, 1.0] 的范围内且不受画布宽高比变化的影响。那么我们整理一下：UV的最终合理的表达式是： vec2 uv = (fragCoord / iResolution.xy) * 2. - 1.; uv.x *= iResolution.x / iResolution.y;我们可以把它简化为： vec2 uv = (2.0*fragCoord-iResolution.xy)/iResolution.y;这是怎么计算出来的？首先，我们将 uv.x 调整宽高比：uv.x = ((fragCoord.x / iResolution.x) * 2.0 - 1.0) * (iResolution.x / iResolution.y);同时 uv.y保持不变：uv.y = (fragCoord.y / iResolution.y) * 2.0 - 1.0;再给uv.y合并一下：uv.y = (2.0 * fragCoord.y - iResoluation.y) / iResolution.y对于uv.x：我们合并之后得到uv.x = (2.0 * fragCoord.x / iResolution.x - 1.0) * (iResolution.x / iResolution.y);进一步化简：uv.x = (2.0 * fragCoord.x * (iResolution.x / iResolution.y) / iResolution.x - (iResolution.x / iResolution.y)); uv.x = (2.0 * fragCoord.x / iResolution.y - iResolution.x / iResolution.y); uv.x = (2.0 * fragCoord.x - iResolution.x) / iResolution.y;综合所得：uv.x 和 uv.y合并之后就是：vec2 uv = (2.0*fragCoord-iResolution.xy)/iResolution.y;OK，到目前为止，我们得到了一个完整的圆形径向渐变，但我们要的并不是渐变，而是实实在在的圆形。首先我们可以进行思考：这个朦胧的渐变圆的原理是 纯黑色的点在(0,0)，处于画布中间，其他部分的向周围辐射的点的值都是大于0的，都非纯黑色那么目标就自然而然的来到了把周边的点的值都变成0。这里就要补充一个小知识了：在Shader中，值的显示范围只会是[0,1]之间，也就是说，小于 0 的负数实际显示的值还是 0（黑色），大于 1 的数实际显示的值还是 1（白色）。我们可以利用这一点，给距离d减去一个值（这里我取了 0.5），制造出一片负数的区域，而这片区域不就是我们所要的黑色吗？（注意这一行代码要写在length函数的下面。）接下来我们只需要把周边模糊的部分去除，就是我们想要的圆形了，我们可以使用if判断逻辑来进行：我们先定义一个中间变量c，用if语句来判断距离d的大小，如果大于 0，代表的是除了中间纯黑区域外的渐变区域，将它们的值设为 1（白色）；反之，就代表的是中间的纯黑区域，将它们的值设为 0（黑色），最后将中间变量直接作为结果输出即可。因为d是在上面减去过0.5的，其实就是相当于判断，是否处于半径为0.5的圆的里面，因为画布全长为-1到1（上面UV居中之后的范围就是这样的），所以圆的半径占了四分之一个画布，也就是全长直径占了半个画布。然而，在Shader的编写中，我们应当尽量避免使用if语句，为什么呢？因为GPU是并行处理结果的，而if语句会让处理器进行分支切换这一操作，处理多个分支会降低并行处理的性能。那么如何优化掉if语句呢？我们可以用GLSL其中的一个内置函数来代替它。这个内置函数是 step函数，也被称作“阶梯函数”，是因为它的图像是阶梯的形状，如下图所示：它是一个矢量化的操作，所有的计算都可以在同一条指令下进行，避免了线程分歧和性能损失。相比之下，if-else 会引入分支切换，每个分支可能需要不同的指令路径，导致处理器无法高效利用并行计算的优势。代码的表现形式是这样的：step(edge,x) 它接受 2 个参数：边界值edge和目标值x，如果目标值x大于边界值edge，则返回 1，反之返回 0。用它来替换掉我们刚刚使用的if else：这一步，不仅简化了代码，还优化了shader的性能但是，如果仔细观察的话，我们生成的圆还是很有问题的，毕竟有锯齿，影响美观。那我们就再来认识个GLSL函数：smoothstep函数，它也被称作“平滑阶梯函数”，是因为它的函数图像是一个平滑过的阶梯的形状，如下图所示：代码的表现形式是：smoothstep(edge1,edge2,x) 比起step阶梯函数多了一个参数，我们可以将它的边界值定为edge1和edge2：如果目标值x小于边界值edge1，则返回 0；如果目标值x大于边界值edge2，则返回 1；如果目标值x在 2 个边界值之间，则返回从 0 到 1 平滑过渡的值。这样一来就圆滑很多了。图形效果虽然上面我们使用Shader画出一个圆的操作比传统的办法要繁琐一点，但是，重点是shader能够带来的可能性，比如实现一些特殊的效果模糊效果我们为了保持圆滑，使用了smoothstep方法来进行了绘制图形，第二个参数就是我们用到了很小的参数.01，现在我们把它放大一点：能够看到非常明显的边缘模糊效果。随着渐变区域的扩大，圆形的边缘变得模糊了起来，这是因为两个边界值的差变大了，渐变的区域也就随着变大了，这样就营造出了一种模糊的效果。这种效果实用性非常之高发光效果我们这次就不用smooth了，我们直接取倒数，然后乘以一个比较小的值，比如.25我们就得到了一个看起来很炫酷的发光小球一行简单的代码，但是它实际上是怎么形成的呢？这是的函数分布图，我们的值是(0., 1.)，所以在这段范围内，如果输入的值是(0., .25)那么，输出值都是大于1的，Shader里，输出比1大的都是白色，因此我们能看到中间的白色圆形部分; 当输入值处于(.25, 1.)的区间时，值都是逐渐减小的，比1小，因此会出现渐变的效果。现在这个图的光的辐射效果有点太强了，我们引入一个内置函数来让他缩小一些pow函数，它用来计算数字的指数幂，比如说pow(2.0, 3.0)算的就是2的三次方，也就是8，也就是说这个pow函数能让数值指数增长，小数点也是一样可以的。我们给刚刚的代码加一个pow，指数取1.6亮度低一点了。来看一下函数分布图：函数的走向要比之前往下“躺”一点点了。也就是说输出值整体会低一点，这样的话光的辐射也会低一点。这样的发光效果换成别的绘图方式可能会很难实现吧，而Shader只要借助数学计算，就能轻松实现知道Shader的厉害之处后，让我们依旧回到那个最基本的圆形，开始绘制其他图形SDF函数圆形，只不过是众多几何图形里的一种，我们同样可用其他的办法画出其他的几何图形。回顾我们画圆形的时候，所使用的方法 float d = length(uv); d -= .5;其实我们可以把它抽象为一个函数，名字叫做sdCirclefloat sdCicrle (vec2 p, float r) { return length(p) - r; } void mainImage (out vec4 fragColor, in vec2 fragCoord) { vec2 uv = (2. * fragCoord - iResolution.xy) / iResolution.y; float d = sdCicrle(uv, .5); float c = smoothstep(0., .05, d); ... }这其实最后的调用结果和我们之前写的代码是一模一样的，但是它有一个特殊的含义，那就是这是一个SDF函数SDF函数，中文译作符号距离函数，SDF 函数通常接受一个点作为输入，并返回该点到几何形状的最近距离。如果该点位于形状内部，距离是负数；如果在形状外部，距离是正数；如果在形状上，则距离是零。这种有符号的距离使得 SDF 函数可以非常灵活地描述各种复杂的几何形状，包括曲面、体积等。举个例子，这是圆形SDF的数据分布图图形学大咖 Inigo Quilez把基本上常用的2D图形的SDF函数都列了出来，我在这里贴出来，可以收藏以防备用。直达链接知道了SDF的概念之后，我们就可以轻松的画出来其他的图形，比如我想要一个长方形，我只需要调用长方形的SDF函数，获取距离之后，使用step或者smoothstep勾画出图形就行
这篇文章总结了一些GLSL语言的基础知识GLSL总体上的写法和C语言相似变量声明变量的格式是这样的：格式是变量类型 变量名=值;比如我声明一个foo：float foo=1.;这样我就给float浮点类型的数字变量foo赋值了一个1.0在写的时候是可以忽视0的，1.0可以写成1.，0.5可以写成.5浮点型变量必须要有小数点，不能省略！在写GLSL的时候一定要记得：语句结尾一定要加一个分号;。这是新手最容易会犯的错误。浮点型float可以说是GLSL里最常用的类型。除此之外，还有比较常用的是int整型，代表了整数，它无需加小数点int foo=1;还有一种bool布尔型，代表逻辑值，即真（true）或假（false），通常用于条件判断和控制流程。 bool foolBool = true; bool bar = fragCoord.x < iResolution.x * .25; if (bar) { fragColor = vec4(1., 0., 0., 1.); } else { fragColor = vec4(0., 1., 0., 1.); }上面提到的变量都是一维变量（标量），如果想要声明更高维度的变量的话，就要用到vec类型（也就是向量类型）。vec2 a=vec2(1.,0.); vec3 b=vec3(1.,.5,0.); vec4 c=vec4(1.,.5,0.,1.); vec类型支持 3 种维度：二维vec2、三维vec3和四维vec4，四个维度分别为x、y、z、w（也可以写为r、g、b、a）。如果想对多维度变量进行取值或赋值操作，就要用到.符号，并且四个维度可以任意组合（称为Swizzling）。vec2 d=b.xy;// d的值为vec2(1.,.5) d.y=2.;// d的值为vec2(1.,2.) vec3 e=c.yxy;// e的值为vec3(.5,1.,.5)，c.yxy可以理解为vec3(c.y,c.x,c.y) e.zx=vec2(1.);// e的值为vec3(1.,1.,1.) 除了vec类型有更高的维度支持外，mat类型（也就是矩阵类型）亦是如此。mat2类型代表了一个大小是2x2的矩阵，mat3类型则代表了一个3x3的矩阵、mat4类型是4x4的矩阵。矩阵是一个二维数组，它的每一行代表一个向量。矩阵的每一列代表一个向量的分量。矩阵的行数和列数决定了矩阵的维度。矩阵的维度是一个二维向量，它的x分量代表行数，y分量代表列数。 mat2 f = mat2(1., 0., 0., 1.); mat3 g = mat3(1., 0., 0., 0., 1., 0., 0., 0., 1.); mat4 h = mat4(1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.);而我们要调用它也是很简单的： // 渲染矩阵 fragColor = vec4(f[0].x, f[0].y, f[1].x, f[1].y);矩阵如何调用的？矩阵的调用方式是矩阵名行号。行号和列号都是从0开始的。矩阵的行号和列号都是整数，所以可以用整数变量来调用矩阵的元素。f[0]就是矩阵f的第一行，f[1]就是矩阵f的第二行。f[0].x就是矩阵f的第一行的第一列，f[0].y就是矩阵f的第一行的第二列。如果想把多个变量捆绑到一个变量上，可以使用结构体struct，它的使用方式如下所示 struct Foo { vec2 a; vec3 b; vec4 c; }; Foo i; i.a = a; i.b = b; i.c = c; fragColor = vec4(i.a, i.b.x, i.c.y);顺便提一嘴，vec4的变量是可以合起来的，比如上面的代码示例，vec4四维变量可以用vec2（x，y）+ z + w来组合起来，很灵活在创建变量时，我们也可以先声明变量，然后再对它进行赋值操作，并且赋值是从右到左进行的，因此，我们可以同时给多个变量赋予同一个值。 float one, two, three; one = two = three = 1.;运算符算数运算符执行基本的四则运算 float computA = 1.; float computB = 2.; float computC = computA + computB; // 3. float computD = computA - computB; // -1. float computE = computA * computB; // 2. float computF = computA / computB; // 0.5 float g = mod(computA/computB) // 取余，值为1.赋值运算符用于将值赋给变量，赋值时也可同时进行算数运算。（和一般编程语言差不多，比如a+=k这样的） float varA, varB, varC, varD; varA = varB = varC = varD = 3.; float varE = 2.; varA += varE; // varA = varA + varE; // 5. varB -= varE; // varB = varB - varE; // 1. varC *= varE; // varC = varC * varE; // 6. varD /= varE; // varD = varD / varE; // 1.5除此之外，还有比较运算符（>,<,==等)、逻辑运算符(&&,||,!等)，这些运算符在一般的编程语言里也是有的，不多说了唯一要注意的一点是：运算一定要保证维度的匹配，比如你不能将一个vec2的变量与一个vec3的变量相加，要加的话你得选取vec3变量的其中 2 个维度，转成vec2变量才能与另一个vec2变量相加。 vec3 color = vec3(1., 0., 0.); vec2 uv = fragCoord / iResolution.xy; vec2 res = color.xy + uv;当然，也有一种特殊的情况：当一个向量和一个标量进行运算时，GLSL会将标量广播（broadcast）到向量的每一个分量上。当一个向量与一个标量进行数学运算时，这个标量会被自动扩展（或广播）到向量的每一个分量上。这意味着，标量的值将被应用于向量的每一个分量，执行相应的运算。例如，考虑一个二维向量 vec2 和一个标量值。如果你进行减法运算，GLSL会将标量值分别减到向量的两个分量上：vec2 a=vec2(1.); a-=.5;// a的值为vec2(.5)函数当你想要复用一个代码片段时，只需要定义一个函数就行，比如说：float add(float a,float b){ return a+b; } float c=add(1.,2.);// c的值为3.函数要声明在main之外，并且从上到下调用，所以在main函数之前一个函数包括返回类型（无返回类型为void）、函数名、带类型的参数和返回值（可选）。调用函数时也要严格遵循定义好的类型。当然，GLSL本身也内置了相当多的函数，在以后我们会逐渐地碰到它们。控制流和常规的编程语言一样，GLSL有一些基本的控制流结构。条件语句if，可以根据条件来执行不同的代码块。if(condition){ // ... }else if(anotherCondition){ // ... }else{ // ... }这里要注意一点：Shader是针对整个屏幕的像素进行处理的，因此if的所有分支只要满足一定的条件，都会被执行，也就是说，某一个分支对于其中一个点无效，那对应除了这个点之外的其他点可能就有效了。循环语句for，可以用来重复执行一段代码。for(int i=0;i<8;i++){ // repeat ... }此外，还有while、do-while、switch这些常规的编程语言都支持的语句，这里就不再赘述了变量限定符变量限定符是用来描述变量的存储和使用方式的关键词。它的书写格式是变量限定符 变量类型 变量名;，语句结尾要加分号，例如：uniform vec3 uColor;以上代码声明了一个uniform类型的名为uColor的 3 维向量。在GLSL中，常用的变量限定符有以下几种：uniform、const、varying、attribute等。目前我们先要了解uniform和const，其他的我们以后会碰到。uniformuniform变量是一种全局的变量，一旦定义后会同时存在于顶点着色器与片元着色器中，并且它在每一个顶点和片元上的值都是相同的，是一个“统一”的值。在我们目前的Shader创作环境中，有一些内置的uniform变量可供我们直接使用，无需显式地去声明它们。要注意的一点是：以后在three.js环境下，这些变量就不是内置的了，需要手动地去注入和声明。iTimeiTime变量表示Shader从开始到现在执行所经过的时间。如果要创作动画效果，这个变量可以说是必备的。iResolutioniResolution变量表示Shader所在画布的大小，默认是占满整个屏幕。有时我们可能会根据屏幕的比例对Shader的坐标进行适当的调整。iMouseiMouse变量表示用户鼠标当前所在的位置。如果要创作可交互的效果，这个变量也是必备的。constconst定义的量是一种常量，它是无法被改变的一个值。比如我声明一个圆周率const float PI=3.14159265359;宏宏（macros）是一种预处理指令，用于在编译时进行文本的替换，常用于定义常量、函数、条件编译等。宏定义的格式是#define 宏的名称 宏的值，语句结尾没有分号#define PI 3.14159265359以上代码定义了一个名为PI的宏，Shader编译时会将所有的PI替换为3.14159265359这个值，可以类比为JS的String.replace这个函数。宏也可以带有参数，如下所示：#define add(a,b) a+b以上代码定义了一个名为add的宏，接受 2 个参数a和b，对它们应用相加的运算，并且无需指定明确的类型，调用这个宏时只要参数的类型相匹配，就能正确执行宏定义的运算。宏也可以条件编译，例如：#define IS_IN_SHADERTOY 1 #if IS_IN_SHADERTOY==1 #define iChannel0Cube iChannel0 #endif这个宏的#if部分会判断IS_IN_SHADERTOY这个宏的值是否为 1，是的话就会将下面的一行宏代码#define iChannel0Cube iChannel0包含到Shader代码中去，不是的话就忽略掉这行宏代码需要注意的是，宏定义是一种文本替换机制，并没有类型检查和作用域限制，因此要根据实际的情况来使用它。
在我们进行编码之前，我们应该弄一下准备工作编译语言的选择目前来说Shader的编程语言有四种GLSL：主要用于OpenGL平台的图形APIHLSL ：微软主导开发，主要用于DirectX 平台的图形APICg：Nvidia开发，可以编译为GLSL 和HLSLWGSL ：主要用于webGPU的图形API因为我的本业是前端开发，我们学习这个东西主要是在Web环境上进行的，而这个环境有两种图形API，分别是：WebGL和WebGPU。WebGL本身是基于OpenGL ES的一个JS图形API，然后OpenGL ES本身也属于OpenGL，所以我们可以在GLSL和WGSL中进行选择。（不考虑Cg，因为它主要被用于Unity平台。）WebGPU尽管较新，但浏览器的支持率并不是很高，这使得写出的效果只能在少数的浏览器下运行，用来初学不是很合适。而WebGL则是主流的浏览器几乎都支持，并且大部分网站的Shader效果也是基于这个图形平台实现的，因此我们将选择WebGL平台所对应的语言——GLSLWebGL 渲染管线流程由于GLSL是属于WebGL平台的，因此我们有必要了解下WebGL渲染管线的整个流程是什么样的。说的通俗点，就是一个物体是如何被渲染到屏幕上的这一过程。下图是我用无边记 APP 画出来的示意图：整个流程是这样的：我们在JS中提供顶点的数据（通常是 Float32Array 类型的数组，包含了顶点的位置等信息），将这些数据传递给顶点着色器，让它计算每个顶点的位置，然后WebGL将顶点装配成图元（如三角形），图元再被转换成屏幕上的空像素（光栅化），让片元着色器来计算每个像素的颜色并填充上去，最终将物体渲染到屏幕上。其中，图元装配和光栅化是WebGL自带的操作，我们无法进行额外的定制，但顶点着色器和片元着色器则是完全可通过编程来定制化的。我记录下这次的学习记录，其实也就就是要把对它们两者的定制化给做到极致。前面的内容，会专注于学习片元着色器，要渲染的物体仅仅是一个占满屏幕的平面。到后期，当接触到顶点着色器时，就可以来玩 3D 的物体了Shader 开发环境搭建Shader 的开发环境主要有 2 种：网站和编辑器。但是基于个人习惯，我选择的是 vscode 来编写在网站里开发 Shader但是我们还是不能跳过网站的编写，因为这个网站可以说是 Shader 学习者一定要了解的东西：shadertoy 是一个充满着大量优秀 Shader 作品的网站，当然，你也可以在上面直接创作你的 Shader，只需点击右上角的“新建”，就能从一个最基础的模板开始 Shader。在编辑器内开发 Shader以 VSCode 编辑器为例以下列举了一些助力 Shader 开发的插件，它们是 VSCode 编辑器专用的，如果你用的是别的编辑器，请自行寻找对应的替代品。Shader 语言支持（必须）首先，我们的 Shader 文件的代码需要有完整的高亮支持。在插件中搜索 Shader languages support for VS Code，安装即可。Shader 实时预览（必须）其次，我们希望能实时预览我们 Shader 渲染的效果。在插件中搜索 Shader Toy，安装即可。HTML 实时预览（必须）在后面的某几个章节里，我们将会把 Shader 代码直接作为字符串写入 html 文件，因此需要一个能直接预览 html 文件渲染结果的插件。在插件中搜索 Live Preview，安装即可。JS 中的 Shader 高亮（可选）在后面的某几个章节里，我们的 html 文件的 JS 部分会有 Shader 代码的字符串，它们也需要得到高亮提示。在插件中搜索 es6-string-html，安装即可。Shader 格式化（可选）有一个比较实用的插件，它为 Shader 代码提供了格式化功能，同时也提供了取色器，方便调整 3 维变量的颜色值。在插件中搜索 glsl-canvas，安装即可。第一个 Shader 程序GLSL语言的 Shader 文件后缀名就是.glsl，新建一个文件，命名为first-shader.glsl。首先我们定义 Shader 的主体：void mainImage(out vec4 fragColor,in vec2 fragCoord){ }我们定义了一个mainImage函数，它不返回任何值，故返回类型为void，它接受 2 个参数：一个是 4 维的fragColor，代表输出的像素颜色；另一个是 2 维的fragCoord，代表输入的像素坐标。直到顶点着色器之前，我们都是使用的 Shadertoy 环境，主函数 main 需要携程 mainImage，输出颜色 gl_FragColor 需写成 fragColor，输入坐标 gl_FragCoord 需写成 fragCoord暂时先不用管 fragCoord，直接向屏幕输出一个颜色吧，例如红色：void mainImage(out vec4 fragColor,in vec2 fragCoord){ vec3 color=vec3(1.,0.,0.); fragColor=vec4(color,1.); }我们定义了一个名为color的 3 维变量，要将它的值设置为红色，红色的 RGB 颜色值为(255,0,0)，在GLSL中，我们需要先将颜色原先的值进行归一化操作（除以 255）后才能将它正确地输出，因此将红色的值归一化后我们就得到了(1,0,0)这个值，将它转换为 3 维变量vec3(1.,0.,0.)赋给 color 变量。最后我们给输出颜色fragColor赋值一个 4 维变量，前 3 维就是 color 这个颜色变量，最后一维是透明度，由于纯红色并不透明，直接将其设为 1 即可。按下 Ctrl+Shift+P，输入Shader Toy: Show GLSL Preview，点击即可预览我们的结果，如果一切顺利的话，你应该能看到画面是一片红色。仅仅输出一种颜色也太单调了，让我们尝试连续输出四种颜色吧！// 定义了一个mainImage函数，这个函数接受两个参数，一个是输出的颜色，一个是当前像素的坐标 // vec4表示的是一个四维向量，分别表示红、绿、蓝、透明度 // vec2表示的是一个二维向量，分别表示x、y坐标 void mainImage(out vec4 fragColor, in vec2 fragCoord) { // vec3表示的是一个三维向量，分别表示红、绿、蓝 vec3 color1 = vec3(0.15, 0.13, 0.84); vec3 color2 = vec3(0.84, 0.13, 0.15); vec3 color3 = vec3(0.13, 0.84, 0.15); vec3 color4 = vec3(0.84, 0.84, 0.15); if (fragCoord.x < iResolution.x * .25) { fragColor = vec4(color1, 1.0); } else if (fragCoord.x < iResolution.x * .5 && fragCoord.x >= iResolution.x * .25) { fragColor = vec4(color2, 1.0); } else if (fragCoord.x >= iResolution.x * .5 && fragCoord.x < iResolution.x * .75) { fragColor = vec4(color3, 1.0); } else { fragColor = vec4(color4, 1.0); } }这里我们就要用到fragCoord变量了，它代表了输入的像素坐标，有 2 个维度 xy，它们的大小取决于画面本身的大小。假设我们画面当前的大小为3024 × 1964，那么每一个像素的fragCoord 的 x 坐标值将会分布在(0,3024)之间，y 坐标值则分布在(0,1964)之间。在当前的 Shader 开发环境内，还有个内置的变量iResolution，代表了画面整体的大小，使用它时一般会取它的 xy 维度。我们取fragCoord的 x 轴维度，判断如果它小于四分之一的画面长度iResolution.x*.25，就填充第一种颜色color1。按照这类方法以此内推，四种颜色就得到了下面的效果：这样，我们就得到了我们第一个 Shader 啦。尽管它很简单，但也很好地说明了 Shader 的核心要点：根据像素的坐标来计算出对应的颜色。总结一下就是：Shader 有一个主体函数mainImage，接受 2 个参数：输出颜色fragColor和输入坐标fragCoord。通过定义vec类型的变量赋值给fragColor，我们可以向屏幕输出颜色，颜色的值需要归一化。我们可以通过判断像素在屏幕上的哪个位置来填充不同的颜色，同时也是Shader的核心要点：根据像素的坐标来计算出对应的颜色。
Shader，也就是着色器，对于我们大部分人来讲，可以说是一个既熟悉又陌生的小伙伴。经常玩游戏的小伙伴应该熟悉，《着色器编译中》，经常很费时间。让我们来看看着色器shader会在哪些地方用到吧：目前市面上大部分的3D游戏，里面的一些视觉效果都用到了Shader：上图是游戏《原神》中一个波光粼粼的水面效果，除了水体的光泽、扰动外，还有物理光学的折射现象，它们都是由Shader渲染出来的。除了3D游戏外，Shader在图像处理领域也有一定的应用。上图是一款名叫 PhotoMosh 的图像处理应用，里面内置了非常多的图像效果，比如上图用的Wobble效果可以使图片发生扭曲变形，如果你查看了网站的源码，就会发现它背后用的不是Canvas2D，而是Shader来实现的。图像处理，既可以是处理静态的图片，也可以是处理动态的视频。上图是一款名叫剪映专业版的视频剪辑软件，同样地也内置了很多视频特效，这些特效背后也是用Shader实现的。在Web页面上，我们可以看到很多图像，用Shader可以给这些内容注入一种奇妙的“魔法”，例如上图中 taotajima 网站的鼠标悬浮图片特效，体验一下还是挺有意思的。还有下面这个：云朵效果这个也是完完全全用shader制作的。那么我们来到正式的内容：shader是什么？Shader，是运行于GPU显卡里的一段程序。GPU负责整个的图形渲染流程（也被称之为“渲染管线”），而Shader就是用于让我们能够使用编程的手段来控制渲染管线的一部分的工具。最主要的Shader有两种：顶点着色器Vertex Shader 和片元着色器Fragment Shader。顶点着色器负责确定图形的每一个顶点的位置，片元着色器负责渲染图形的每一个片元（像素）的颜色。shader和其他技术的关系对于我现在的职业，前端来说，复习一下知识点吧：前端常用的图形技术有：css、svg、Canvas2D 、webGLCSS创作的效果基本都是直线式的，上图就是用CSS的animation属性配合transform属性创作的一个轨道运动的动画效果。而SVG就是拿来创造折线式或曲线式的效果。SVG有一个path标签，它可以定义出任意形状的路径，再配合stroke-dasharray、stroke-dashoffset等属性，我们能很轻松地实现如上图所示的线条运动的动画但是对于创作欲望强烈的前端开发来说，svg是无法满足的，那么canvas应运而生。它能对画面进行像素级的操控。用Canvas2D配合一些数学运算和随机函数，就可以实现这个效果。但是canvas2D对3D的效果不是太好，亦或者说是不太适合，那么我们就要搬出来webGL了WebGL是一种能让我们在浏览器中高效地渲染3D图形的技术，它把图形渲染到屏幕上的方式被称为“渲染管线”，而用编程的方式来控制渲染管线其中的一部分，则正是Shader的职责所在。因此，Shader是WebGL的一个重要的组成部分。由于原生的WebGL非常难用，我们会更倾向于使用一些封装好的框架，如 three.js，这个框架封装了很多WebGL的API，同时也封装了很多Shader，用它我们就能避免很多冗余的代码，轻松地创建出一个3D场景。但是因为封装的太多，很多场景的重合率太高了，没有个性。舍弃了个性和死掉了没什么两样所以我们这个shader分类专题，就是为了学习如何自定义shader，创造我们自己的东西。Shader的本质是用各种数学计算来操控屏幕上的像素，这带给了我们极高的自由度：小到写各种创意的图形demo，大到实现3D游戏里的场景特效，对于我们前端来说，则可以实现各种新颖的网页特效。比如这些网站：kentatoshikurahomunculushsmkrt1996nightingaleunseenzero除此之外，shader在GPU显卡的并行计算的帮加持下，渲染像素的效率非常的高。在很久以前，英伟达发布GTX280的时候，现场通过简单易懂的演示介绍了CPU和GPU的效率对比。 .video-container { position: relative; padding-bottom: 56.25%; / 16:9 aspect ratio / height: 0; overflow: hidden; max-width: 100%; background: #000;}.video-container iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%;}这个视频很形象的介绍了GPU和CPU的差距，再拿我自己的电脑举例：我的电脑的显示器是这样的：帧率是120帧，那么我的电脑的GPU需要处理的数据是：显示器分辨率和刷新率：3024 x 1964，帧率为120Hz。每帧像素数：3024 x 1964 = 5,938,176 像素。每秒帧数：120 帧。每秒像素总数=每帧像素数×每秒帧数将数据代入公式5,938,176 像素/帧×120 帧/秒=712,581,120 像素/秒每秒需要处理的像素总数是 712,581,120 个像素。假设每个像素平均需要进行 10 次运算（这是一个相对保守的估计，实际运算量可能会更高），那么每秒需要进行的总运算次数为：712,581,120 像素/秒×10 次/像素=7,125,811,200 次/秒每秒需要进行的 GPU 运算次数约为 7,125,811,200 次。这足以说明在GPU并行计算能力的加持下，Shader的渲染效率是非常高的。平时写Shader时，我们可以放心大胆地用各种方式来进行创作，而不用担心GPU会来不及渲染这种问题。除非你的GPU实在是差劲，支持不了这种需求。这个时候就建议更换显卡啦！游戏里的降帧也是这些原因，GPU的重要程度不言而喻，高复杂程度的3A大作，当GPU无法在预定时间内完成所有的渲染任务时，就会无法按时生成新的一帧，导致帧率下降。如果在屏幕上显示的内容过于复杂，需要更多的计算资源，GPU可能无法按时完成渲染任务。