查看: 3402|回复: 35

[讨论] 【记录向】Shader 学习日记

[复制链接]

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

发表于 2022-12-5 13:35:52 | 显示全部楼层 |阅读模式
最近打算比较系统地学习一下 Shader 基础,也打算顺便在此不定期地记录所学,我会尽可能写的相对通俗和连贯,也欢迎大家参与讨论和共同学习(


我使用的书籍是《Shader 开发实战》by Kyle Halladay

评分

参与人数 1经验 +3 硬币 +2 收起 理由
囿里有条小咸鱼 + 3 + 2 好,我有空也过来跟着学学

查看全部评分

Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-5 14:04:40 | 显示全部楼层
[1] GPU 渲染的基本流程

游戏画面的呈现工作由 GPU 完成,这个工作称为渲染。
与我们平常编写的给 CPU 跑的程序不同,GPU 专为渲染而生,因而 Shader 编写的方式也会有所不同。
因此,了解 GPU 渲染的流程是必要的,下面我给出一个简化后的流程(主要是列出与 Shader 相关的过程)
第一步是确定形状,这里 GPU 采用的基本单位是多边形(出于性能等方面考虑,可以全部分解为三角形),为了确定多边形需要以下步骤:
1.确定顶点(vertex),即多边形的顶点,应该为一系列有序的点集。
2.根据顶点确定多边形,即将点连成线的过程,顺序由上一步中顶点给出的顺序给定,最后一个点连到第一个点。
注意,在 3D 游戏中,多边形可能有正反两面,一些游戏引擎在默认设置下出于优化考虑不会绘制反面,因此我们需要了解正反面的规定方式:
(如果你学过多元微积分,这就是曲面定向)
顶点在连接过程中,若朝向我们的那一面是以逆时针方向连接的,则为正面,否则为反面。
接下来是一个技术步骤,因为屏幕的基本单位是像素,GPU 需要将刚刚确定的多边形“近似地”放到屏幕像素网格中,这一步称为光栅化。(这并不是很重要,因为跟 Shader 没什么关系,所以这里略过具体过程)
确定需要绘制的像素后,接下来就是确定每一个像素的颜色,我们将每一个像素的颜色信息称为 fragment(给定 fragment 的方式不需要特别关注,也略过)。
当然,最后的最后,再做一点简单处理,一方面可能有一些窗口外的不需要绘制的内容需要扔掉,另一方面如果 fragment 中的颜色信息带有透明度参数,那需要做一些混合。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-5 14:17:35 | 显示全部楼层
本帖最后由 dasasdhba 于 2022-12-5 16:17 编辑

[2]两类常见的 Shader 以及联系
Shader 可以简单理解为在上述过程[1]中某些环节里面运行的小程序。


1.Vertex Shader
顾名思义,是在 GPU 渲染过程中的第一步(确定顶点)运行的小程序,我们可以拿到某个单一顶点的位置信息(坐标),并对其作修改,以改变多边形的形状。

2.Fragment Shader
顾名思义,是在 GPU 对像素点填充颜色时运行的小程序,我们可以拿到某个像素点原先的 fragment,并对其作修改,以改变像素点的颜色。
3.两者之间的联系
Vertex Shader 的另一个重要作用是给 Fragment Shader 提供参数(因为在步骤上,顶点处理先于像素填充)。举例来说,你可以给一个三角形的三个顶点设置不同的颜色参数,并将它们以线性混合的方式传给 fragment(按照与顶点之间的距离线性混合,其实这甚至不需要自己写,因为 GPU 默认会这么做,专业术语叫 Fragment 插值),再经由 Fragment Shader 处理,就能得到一个三色渐变的三角形。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-5 14:22:10 | 显示全部楼层
本帖最后由 dasasdhba 于 2022-12-5 19:09 编辑

[3] 一些补充
Shader 是 GPU 渲染过程必不可少的一环,只是游戏引擎往往早已帮我们做好了“默认 Shader”。
而我们所谓的编写 Shader,其实是在覆盖这个默认 Shader。
我们最常用的 CTF 的 Effect 就是覆盖 Fragment Shader,CTF 不支持覆盖 Vertex Shader。
此外,若将来涉及一些 Shader 代码片段,本帖统一使用 GLSL(因为我看的书就是用的这个)

下面简要补充一下 GLSL 的内容,需要注意,部分内容偏向底层,编写一些游戏引擎的 Shader 可能根本不需要这些内容。(因为游戏引擎已经做了足够好的封装)

1. 基本风格
整体与 C/C++ 非常相似,语法细节几乎是一模一样的。
不过出于编写 Shader 方便的需要,GLSL 会自带大量的数据类型以及相关函数,这里说明一下向量类:
vec2 vec3 vec4
分量都是 float,可以用 xyzw 以及 rgba 访问其分量。
有趣的是构造函数足够方便,比如你可以这样构造 vec4:
vec4(vec2(x,y),a,b) vec4(c,vec3(a,vec2(x,y))
此外,向量类之间的乘法和除法运算都是逐点意义的,这会给我们后续做颜色混合等操作带来方便。(相反地,传统的向量点乘等操作可能在 Shader 编写中用处不大)

2. 重要的关键字与内置变量
(1) 使用 #version xxx 指定版本
(2) 使用 in 指定输入参数的变量名,举例:
在 Vertex Shader 中,使用:
in vec3 pos;
声明 pos 变量,那么该变量就存储了顶点坐标。
(3) 使用 out 指定输出值的变量名,举例:
在 Fragment Shader 中,使用:
out vec4 color;
声明 color 变量,那么该变量就会作为返回值。
(4) gl_Position 是一个 vec4 内置变量,在 Vertex Shader 中,应将位置修改结果写入该变量。
(5) uniform 关键字:声明“统一”变量:
可以简单理解为 Shader 之外的程序可以访问和修改的变量,这类变量是在 Shader 中全局统一的参数的存在,如 CTF Effect 的参数。


当然,这不是全部,我不希望在此一并列举,后续有需要会再提。

Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-5 19:06:10 | 显示全部楼层
[4] 使用 Texture
尽管我们可以通过逐个添加顶点以及为其附加颜色参数的方法,再配合 Fragment Shader 实现简单图形的渲染,但这显然不是大部分游戏的做法。(除了 MFO 以及 Nhelv 某些抽象关卡可以这么做)事实上我们常用的办法是使用 Texture,这个名词听起来也许既熟悉又陌生,下面我们讲解 GPU 使用 Texture 的技术细节。

在开始之前,先对 Texture 作一个简单的解释:Texture 是 fragment 数据集合——其实对于 2D 的情况,你直接把它当图片(不是矢量图)就行了。事实上,绝大部分情况我们都是用 2D 图片作为 Texture,因此接下来的讨论我们也只考虑这种情况。

那么以下是使用 Texture 的步骤:

1. 新建四个顶点以表示矩形,这是因为 2D 图片是矩形。

2. 导入图片作为 Texture,我们不妨定义一个 uniform 参数来接收数据,因为是 2D 图像,我们使用 sampler2D 类型:
uniform sampler2D tex;

3. sampler 为“样本”,而为了得到其中像素点的颜色信息(也就是fragment),我们需要“采样”:
问题是,如何获取样本中某个像素点?
一个自然的想法是坐标,但是一方面,图片长宽是不定的,不方便我们处理;另一方面,我们之前定义的四个顶点实际上已经给出了实际渲染长宽,我们应该将图像拉伸到这样的大小,为此我们引入 UV 坐标系:
UV 坐标系其实就是平面直角坐标系,之所以不用 XY,是因为顶点位置已经用了这个符号了,所以我们换一个。
但我们并不以像素点作为单位,而是仿照 RGB 的浮点存储模式,将 U V 的取值限制在 [0,1] 之中。
事实上,这是自带函数 texture(sampler2D, vec2) 的要求,我们将利用这个函数来“采样”。

4. 为顶点矩形建立 UV 坐标系,由于很多游戏引擎对这部分内容的封装做得相当完备,这里不详细阐述,你只需要知道最后每一个 fragment 都能拿到其在矩形下的 UV 坐标。

5.在 Fragment Shader 中采样并输出:
in vec2 UV;
out vec4 color;

void main() {
    color = texture(tex, UV);
}

这样我们就成功使用了 Texture,当然,说到现在这些都是我们熟知的游戏引擎早已帮我们做好的事情。换句话说,刚才提到的其实就是游戏引擎的“默认 Shader”,而我们平常所说的 CTF 的 Effect,其实是覆盖了这个默认 Shader,进而实现了相关视觉效果。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-5 19:32:54 | 显示全部楼层
[5]关于 UV 的补充
上一节提到了 U V 值被限制在[0,1]之间,这并不意味着你不能把 U V 值强行设置在这个范围外,但这会带来问题:
默认情况下,超过1的值按1处理,不足0的值按0处理,也就是如果你强行让某一段连续的 fragment 通过一个范围之外的 UV 作采样,最后的视觉效果就好像某一个像素点被拉伸了一样。
这种模式称为 clamp 模式。
当然,还有一种模式叫 wrap 模式:在这种模式下,1.2被当作0.2,-0.2被当作0.8,这就好比我们熟知的角度制下,361度就是1度,而-1度是359度。
这两种模式是否可以切换得看游戏引擎有没有提供,实在不行可以用 GLSL 实现,这并不困难。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-5 20:09:52 | 显示全部楼层
[6] 特别简单的 Fragment Shader
开始之前先作个补充:RGBA 值也遵循 clamp 模式,且 wrap 模式没用。

1. 调整亮度
为了调整一个颜色的亮度,我们需要给 RGB 值数乘一个常数。显然,这个常数大于 1 就可以使颜色更亮,小于 1 就可以使颜色更暗。(当然,乘 0 就全黑了)
这是因为一个值越接近 1,它所代表的颜色就越亮。

2. Add Sub Multiply
前两个我相信大家太熟悉了,但这里需要作一些说明:Add 和 Sub 操作并不能均匀地改变颜色的亮暗(正如 1 所述,均匀的效果应该是同乘一个常数),换句话说,这会产生新的颜色,在适当的使用下可以做出很多不错的效果。
而 Multiply 则是逐点乘法,可以说是调整亮度的推广,但就算单论调整亮度,也有较大的区别。具体来说,将颜色与图像相乘时,颜色较深(更接近0)的部分受到的影响明显会比颜色较亮(更接近1)的部分要小,而加减法则是一种均匀的调整。

3. 使用 mix 函数
这里用伪代码给出 mix 函数的定义:
Variant mix(Variant a, Variant b, float s) {
    return a*(1-s) + b*s;
}
注:Variant 表示可变参数类型,但是 a 和 b 应该为相同类型且有相关运算。(如 float,vec2 等)
注 2:在 HLSL 中,这个函数叫 lerp。

换句话说,mix 函数是在对两个值作线性插值,是一种“平滑的混合”,所以对于 mix 函数的执行结果,我们也很容易作出一些预估。

但是比较好玩的一种办法是,我们可以用 mix 函数混合两种颜色,但是采用其中一种颜色的某个通道的值作为混合系数:
举例来说:我们设 A 为黑白相间的网格,B 是任意一张图片,那么:
mix(colorA, colorB, colorA.r)
得到的结果是网格 A 的黑色部分和图片 B 的叠加。

当然,还有很多好玩的办法,可以自行发挥想象力。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-5 20:43:13 | 显示全部楼层
[7] 一些琐碎的语法补充
1. 使用 discard 丢弃 fragment
fragment 一旦被丢弃,相应的 Fragment Shader 也会立即停止,这块像素也一定不会被绘制,举例:丢弃 alpha 值小于 1 的部分:
out vec4 color;
void main() {
    color = ...;
    if (color.a < 1.0) discard;
}

2.max 与 min 函数对向量值的特殊操作
假设我有两个二维向量 vec2 A, B
那么 max(A,B) 也将返回二维向量 vec2
你可能会猜测,这会返回长度较长的那一个
但事实是返回 vec2(max(A.x,B.x), max(A.y, B.y))
min 函数也是类似的,对于 vec3 vec4 当然还是类似的。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-5 20:50:37 | 显示全部楼层
[8] 混合模式
这个比较熟悉 Gamemaker 的应该都知道,我就直接贴相关链接了:
https://gm8.nihil.cc/tutorials/blend/

需要注意,bm_normal 就是默认的 alpha 混合模式。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-5 21:12:37 | 显示全部楼层
[9] 在 Vertex Shader 中作简单缩放和旋转
这里我们讨论最简单直接的缩放和旋转。
1. 缩放
只要简单修改 gl_Position 即可。

unform vec3 scale = vec3(0.5, 0.75, 1.0);
in vec3 pos;

void main() {
    // 再次注意,向量乘法是逐点乘法
    gl_Position = vec4(pos * scale, 1.0);
}

当然,不难看出这样的缩放一定是基于原点(0,0)的,你当然可以加一个向量来平移。

2. 旋转
这需要一点矩阵的知识,我不作过多讲解。
下面给出的例子只旋转 xy 平面。

unform float radius;
in vec3 pos;

void main() {
    // 旋转矩阵,按照列向量构造
    mat2 r_mat = mat2(vec2(cos(radius),sin(radius)), vec2(-sin(radius),cos(radius));
    vec2 r_pos = r_mat * pos.xy;
    gl_Position = vec4(r_pos, pos.z, 1.0);
}

需要注意,书中没有提到矩阵,但我认为用矩阵更清晰。我不打算讲任何有关矩阵的数学内容,这里不是线性代数课。
除了按照上述方式构造矩阵,亦可传入四个 float(仍然按照列向量排序)。
此外,可以通过 mat2(c) 快速创建 c*E(单位矩阵)。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-6 18:01:25 | 显示全部楼层
本帖最后由 dasasdhba 于 2022-12-6 19:52 编辑

[插曲]
1. 我发现我好像搞错了一件事情,我想学的是图形算法,但这本书讲的是计算机绘制原理之类的东西。。。
2. 这本书有大量的篇幅花在 3D 光照上了,这我也不感兴趣(


那么这本书的学习就告一段落,通过这部分内容学习我对 Shader 的底层原理有了一个初步的认知,也对 GLSL 有了一个初步的了解


接下来我会转向新的内容,一方面,我打算阅读:https://thebookofshaders.com
不过这玩意还没写完,我打算先看看 Algorithmic drawing 和 Generative designs 两章,如果这玩意啥时候又更新了再说
另一方面,我会尝试开始解读一些开源 Shader 的源码,看看能否从中学到什么
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-7 17:19:24 来自手机 | 显示全部楼层
本帖最后由 dasasdhba 于 2022-12-7 18:16 编辑

[10] 绘制函数图像
1. step 与 smoothstep 函数
先给出伪代码定义
float step(float x, float a) {
    return float(a >= x); // bool 值,1为真 0为假
}

float smoothstep(float x, float y, float a) {
    // 假定总有 x ≠ y
    if (x > y)
        return 1 - smoothstep(y, x, a); //反转
   
    float t = clamp((a-x)/(y-x), 0, 1); // 参考 clamp 模式,即将值限制在 [0, 1] 之间
    return (3 - 2*t) * t * t;
}

可以看出 step 没什么用(不是,但据说用 step 比判断那个 bool 更快。
而 smoothstep 通常是将 [x, y] 利用三次函数“平滑”地映射到 [0, 1],特别地,若 x > y,得到的结果也会“反转”。

2. 绘制 y = x
之所以要花时间介绍 smoothstep,是因为我们想得到一个相对平滑的结果,下面我们来写相应的 Fragment Shader:
注意,你复制粘贴我的代码测试会报错,因为 GLSL 严格区分 1 和 1.0,且 int 和 float 之间的运算会报错;并不是我不想严格遵循规范,只是我学习时电脑不在身边,下面的代码都是我手机打的,可想而知。

uniform vec4 plot_color = vec4(1,0,0,1);
uniform vec4 back_color = vec4(1,1,1,1);
uniform float width = 0.02; // uv 单位
in vec2 uv;
out vec4 color;

void main() {
    // 获取 y = x 的点,利用 smoothstep 作平滑,只需要简单的计算 y 与 x 的差
    float plot = smoothstep(0, width, abs(uv.x - uv.y);
   
    color = plot*back_color + (1-plot)*plot_color;
}

这样我们就在白色的背景下绘制了一条红色的 y = x 直线(默认值)。

3.绘制任意函数
y = x 未免也太简单了,我们来处理一般的情况,先来画个幂函数吧,比如 y = x^5:

uniform vec4 plot_color = vec4(1,0,0,1);
uniform vec4 back_color = vec4(1,1,1,1);
uniform float width = 0.02; // uv 单位
in vec2 uv;
out vec4 color;

// 获取函数图像上点,利用 smoothstep 作平滑
float plot(float y) {
    // 不能简单地使用 abs 来获得对称的平滑,我们分成两段;注意,当 uv.y > y 时,首先第一部分会返回 1,所以我们减去第二部分,使得 uv.y 在 [y, y+width] 范围内平滑地由 1 变到 0
    return smoothstep(y-width, y, uv.y) - smoothstep(y, y+width, uv.y);
}

void main() {
    float y = pow(uv.x, 5); // 输入函数
   
    float p = plot(y);
    color = (1-p)*back_color + p*plot_color;
}

好了,那么任意的函数图像都可以绘制了。

注意,绘制函数图像只是一个简单练习,我们需要熟悉的是 smoothstep 的用法;另一方面,smoothstep 仅仅是众多函数的一种选择,我们完全可以自己构造别的函数来完成 [0, 1] 平滑的任务(比如使用三角函数),不过这是一个数学话题,在此不作展开。但是这里推荐一个比较方便的函数可视化工具,可以参考:https://graphtoy.com/

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-7 17:41:37 | 显示全部楼层
本帖最后由 dasasdhba 于 2022-12-8 09:08 编辑


[11] step 函数的作用
上一节中提到了 step 函数没用,其实不然,在编写 Shader 时,if 判断语句是更消耗性能的(我也不知道原因,可以当成事实),而自带的函数都被作了特殊处理,使得它们更快。


举例来说,如果你有一个比较语句用于判断某个变量应该被赋予什么值,你可能会这样写:


if (a < b)
    color = colorA;
else
    color = colorB;


在我们编写传统程序时,这似乎很自然;但在 Shader 编写中,这不好,好的写法应该是:


color = mix(colorA, colorB, step(b, a));


效果是完全一样的,但是这样在 Shader 角度是更快的。
下面举一个更复杂的例子,我们知道 RGB 转 HSV 有下面的公式:

                               
登录/注册后可看大图

现在我们用 mix 以及 step 对其作改写:
(我们把 H 限制在 [0, 1] 之间,而不是 360)


vec3 rgb_to_hsv(vec3 c) {
    // 将 gb 中的较大值放到 x,较小值放到 y
    /*我们后续计算 H 统一使用某两个分量作差,并对最终结果取绝对值
      为此我们需要记录可能需要添加的常数,将其放到 zw 分量备用。
      假定 R 最大, 此时我们统一使用的两个分量总是次大值减最小值
      而我们需要的值是 G-B
      则 G<B 时,常数为 -1,取绝对值后就得到正确的值:abs((B-G)/3-1) = (G-B)/3+1
      否则,常数为 0,我们将其放入 z
      若不然,则 GB 之一为最大值,我们将此时需要的值放入 w
      此时我们统一使用的两个分量总是 R 减去另一个
      若 G<B,则常数为 2/3,这是因为我们需要的值就是 R-G
      否则,常数为 -1/3,取绝对值后就得到正确的值:abs((R-B)/3 - 1/3)=(B-R)/3+1/3 */
    float p = mix(vec4(c.bg, -1.0, 2.0/3.0),
                            vec4(c.gb, 0.0, -1.0/3.0),
                            step(c.b, c.g));
   
    // 将 rgb 中的最大值放到 x,保持 y 不变,这一步比较中的较小值放到 w,需要添加的常数放到 z
    float q = mix(vec4(p.xyw, c.r),
                            vec4(c.r, p.yzx),
                            step(p.x, c.r));
   
    // 下面我们计算 S 的分子以及 H 的分母
    float d = q.x - min(q.y, q.w);
   
    // 于是我们就可以计算 HSV,为了排除分母为 0,我们加一个微小的扰动:
    float e = 1.0e-10;
    float h = abs(q.z + (q.w - q.y) / (6.0 * d + e);
    flost s = d / (q.x + e);
    float v = q.x;
    return vec3(h, s, v);
}


看起来可能会比较困难,关键是我们固定了计算 H 时分量作差的顺序,并利用绝对值修正了特定的情况,这实际上是事先固定,再分析得到的结果。

Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-7 18:46:40 | 显示全部楼层
本帖最后由 dasasdhba 于 2022-12-7 19:37 编辑

[插曲 2]
我发现 the book of shaders 这个网站上带有一个极为方便的 Shader 编辑器:
http://editor.thebookofshaders.com

为此我打算之后在这里测试代码,并且我重新修改了 [10] 中绘制任意函数图像的代码,使得它可以在这个在线编辑器上运行,
如果你想试试的话,将网站上的默认内容,从 uniform 变量之后开始,替换为以下内容:

vec4 plot_color = vec4(1,0,0,1);
vec4 back_color = vec4(1,1,1,1);
float width = 0.02;

float plot(float y, vec2 uv) {
    return smoothstep(y-width, y, uv.y) - smoothstep(y, y+width, uv.y);
}

void main() {
    vec2 uv = gl_FragCoord.xy/u_resolution.xy;
    float y = pow(uv.x, 5.0);
   
    float p = plot(y, uv);
    gl_FragColor = (1.0-p)*back_color + p*plot_color;
}

这里我不说明 uv 和 gl_FragColor 的作用,可以自行体会, 且不需要明白原理。
从这一节以后我尽可能保证我分享的代码可以在这个编辑器上运行。


Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-7 19:35:06 | 显示全部楼层
[12] 画圆
1. 使用距离


画圆的关键是求得两点之间的距离,为此我们可以使用以下办法:
(1). 使用 distance 函数 distance(vec2 x, vec2 y)
(2). 使用 length 函数 length(vec2 x)
(3). 使用原始的勾股定理办法,也就是sqrt(x^2+y^2)‘
这三种办法原理相同,都需要一个开方操作(这慢死了)

用这种办法,我们可以简单地使用 step 或者 smoothstep 函数来画圆


float radius = 0.4;
float smooth = 0.05;

void main() {
    vec2 uv = gl_FragCoord.xy/u_resolution.xy;
   
    float d = distance(uv, vec2(0.5));
    float r = smoothstep(radius + smooth, radius, d);
   
    gl_FragColor = vec4(vec3(r),1.0);
}



2. 不开方地使用“距离”
若我们不需要精确的半径(比如我只是想做个圈圈转场特效),注意到在 UV 坐标系下,[0, 1] 中的值平方之后仍然落在 [0, 1],而任意点到中心的距离显然也不超过 1。
因此,我们此时完全可以不使用开方,而是使用简单的数量积函数 dot 来画圆:


float radius = 0.15;
float smooth = 0.1;

void main() {
    vec2 uv = gl_FragCoord.xy/u_resolution.xy;
   
    vec2 v = uv - vec2(0.5);
    float d = dot(v,v);
    float r = smoothstep(radius + smooth, radius, d);
   
    gl_FragColor = vec4(vec3(r),1.0);
}


3. 圆形波纹
首先简单介绍 fract 函数,这个函数会返回 float 的小数部分,也就是取值范围是 [0,1)
利用这个函数处理距离,我们就可以得到类似波纹的图案,观察下面的代码:


void main() {
    vec2 uv = gl_FragCoord.xy/u_resolution.xy;
   
    vec2 v = uv - vec2(0.5);
    float d = dot(v,v);
    float r = fract(d*50.0);
   
    gl_FragColor = vec4(vec3(r),1.0);
}


因为我们使用的是平方后的距离,所以最终的结果也不是均匀分布的,你可以改用第一种模式再看看效果。

4. 缩放和对称
我们可以对 UV 坐标进行平移和缩放,从而平移结果甚至是创建多个对称的图案。


void main() {
    vec2 uv = gl_FragCoord.xy/u_resolution.xy;
   
    // 将 uv 坐标映射到 [-1, 1]
    uv = 2.0*uv - 1.0;
   
    vec2 v = abs(uv) - vec2(0.5); // 利用绝对值作对称
    float d = dot(v,v);
    float r = fract(d*50.0);
   
    gl_FragColor = vec4(vec3(r),1.0);
}


值得一提的是,由于我们将 UV 映到了 [-1, 1],在上述代码中,v 的分量可能会出现负值。
尽管在点乘过程中,负值被抵消了,但如果我们舍去负值,得到的结果就会完全不同——试用以下代码替换 v:
vec2 v = max(vec2(0.0), abs(uv) - vec2(0.5));

当然,我们也可以舍去正值(即使用 min),这也会得到比较有趣的图案。


Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-7 20:09:37 | 显示全部楼层
[13] 使用极坐标

这里不解释什么是极坐标。
将坐标映射到极坐标很简单,只需要使用:

vec2 pos = vec2(0.5)-uv; // 我们选中心为原点
float r = length(pos); // 长度
float a = atan(pos.y,pos.x); // 反三角

那么利用极坐标画上一节的圆简直太简单了:

float smooth = 0.01;

void main() {
        vec2 uv = gl_FragCoord.xy/u_resolution.xy;
   
        vec2 pos = vec2(0.5)-uv;
        float r = length(pos);
        float a = atan(pos.y,pos.x);
   
        float plot = 0.4*sin(a);

         gl_FragColor = vec4(vec3(smoothstep(plot+smooth,plot,r)),1.0);
}

当然这样圆心肯定不是中心点,不过这不重要。
重要的是极坐标对于画旋转对称图形非常有用,你只需要简单指定 plot 为其他的函数就可以得到很多有趣的图案,举例:

plot = 0.5*sin(a*n),n=1, 2, 3... // 花瓣探照灯(bushi
plot = 0.5*abs(sin(a*2.5)) // 5 花瓣
plot = 0.5*abs(sin(a*2.5)) + 0.2 // 更饱满的 5 花瓣
plot = 0.2*abs(cos(a*12.0)*sin(a*3.0)) + 0.2; // ……雪花?
plot = smoothstep(-0.5,0.5, sin(a*10.0))*0.05 + 0.2; // 齿轮

如果不了解极坐标作图,可以试着看看这些函数本身的图像跟这里的极坐标输出之间的联系。
Moonstruck Blossom
个人网站:dasasdhba.github.io

153

主题

1180

回帖

12

精华

管理员

脚滑王

经验
8104
硬币
662 枚

永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第十一届MW杯冠军欢乐演员对不起,小姐欢迎光临秘密合战!

发表于 2022-12-8 00:52:31 | 显示全部楼层
最近因为其他东西需要稍微补了一点GPU相关的知识,也因此对shader的原理一类的稍微懂了点吧,毕竟现代的shader就是怎样利用GPU去做高效率的渲染
目前帖子里讨论的似乎都是一些前置基础知识,我就说一些思路上的感想吧
我理解的GPU核心的特性应该是计算能力差、并行性极高且计算同构(换句话说,大量的计算单元根据不同的输入按相同方式计算),并且访问图像时访问相邻像素的性能较高、反之较低。而Shader说白了就是写一个函数,输入是画面中的某一像素的坐标,要求给出这一坐标的输出rgb值(有时加上alpha)。这个设计的原因也正是因为,同一时间所有的像素几乎是在并行执行Shader的函数。
虽然说shader看起来就是个简单的类C++函数,但纯粹按朴素的思想一顿写很容易导致画面爆卡。要追求效率的话,个人经验上是这么几个原则:
1. 尽可能简化计算(包括减小unroll展开的循环,如果不认识unroll可以无视这条),多用各种magic number、近似等等,因为实在话GPU的计算还是比较拉胯的……
2. 尽可能避免访问距离远的像素信息,能够就近解决的务必就近。例如,相邻的像素尽量访问材质里面相邻uv坐标的内容。
3. 慎用少用if和循环,因为GPU的计算是同构的,也就是所有像素需要按相同方式进行计算,if(cond){A}else{B}带来的实际结果就会是既算A又算B,至于那些终止条件不一的for也是如此,例如for(i=1;i<3;i++)比较高效但是for(i=1;i<end;i++)并且end值对各个像素不同的情况下就会低效很多。
不过这些话其实也都是很浅显的说法就是了。毕竟我主业不搞这个,也只是粗略了解了一下,实际情况应该会复杂很多……
但Shader这玩意着实是对“性能”要求比较高的,稍有不慎就会爆卡,属于很棘手的东西。
个人网站wsw233.com
新作 AUEV0.5.0 制作中!
解说/版聊视频随缘更新!

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-8 09:13:11 | 显示全部楼层
无视我233 发表于 2022-12-8 00:52
最近因为其他东西需要稍微补了一点GPU相关的知识,也因此对shader的原理一类的稍微懂了点吧,毕竟现代的sha ...

if 应该尽可能转为 mix 和 step(或 lerp)的组合,参见[11],这么看来我以前很多的特效都要大改((
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-8 09:37:41 | 显示全部楼层
本帖最后由 dasasdhba 于 2022-12-8 10:08 编辑

[14] 构造随机
1. 构造一维随机
之前已经介绍过 fract 函数,我们可以用其来构造 [0, 1] 之间的伪随机数。为此我们需要构造一个变化率足够大的函数,并利用 fract 将其打散到 [0, 1] 之中。
一个简单的例子是使用三角函数:

float  random(float x) {
    return fract(sin(x)*10000.0);
}

当然,这样构造的伪随机函数分布并不够均匀,事实上不难预见在 ±π/2 左右分布会非常集中。(极值点,变化较慢)

2. 构造二维随机
构造二维随机我们可以简单地使用数量积,为了方便我们仍然使用三角函数:

float random2(vec2 pos) {
    return fract(sin(dot(pos,vec2(23.333, 68.888))) * 10000.0);
}

3. 随机黑白马赛克
利用二维随机可以简单做出黑白马赛克效果,为此我们需要放大 UV,并取其整数部分作为随机函数的输入值,从而将输出网格化。

void main() {
    vec2 uv = gl_FragCoord.xy/u_resolution.xy;
   
    uv *= 10.0;
    vec2 i = floor(uv);
   
    gl_FragColor = vec4(vec3(random2(i)), 1.0);
}
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-8 10:55:19 | 显示全部楼层
本帖最后由 dasasdhba 于 2022-12-8 12:15 编辑

[15] 构造 Noise
首先我们谈谈 Value Noise,对其一个粗浅的理解是“平滑”后的 Random。
1. 构造一维 Value Noise
我们仍然先从一维的情况看起,为此我们继续沿用上一节提到的 random 函数。

float noise(float x) {
    float i = floor(x);
    float f = fract(x);
    return mix(random(i), random(i+1.0), smoothstep(0.0, 1.0, f));
}

使用 smoothstep 仅仅是其中一个选择,我们也可以使用我们自己构造的三次多项式作为插值。

2. 构造二维 Value Noise
二维 Noise 构造的关键在于我们需要对四个角的值作插值

float noise2(vec2 pos) {
    vec2 i = floor(pos);
    vec2 f = fract(pos);

    float a = random2(i);
    float b = random2(i + vec2(1.0, 0.0));
    float c = random2(i + vec2(0.0, 1.0));
    float d = random2(i + vec2(1.0, 1.0));
   
    /* 这是原始写法,不过没有必要,下面是一个化简的结果,效果相同
    return mix(mix(a, b, smoothstep(0.0, 1.0, f.x)),
                                mix(c, d, smoothstep(0.0, 1.0, f.x)),
                                smoothstep(0.0, 1.0, f.y));
    */
   
    vec2 u = smoothstep(vec2(0.0),vec2(1.0),f); // 注意,smoothstep 其实也是 Variant 函数,这里是分别对 xy 分量计算
   
    return mix(a, b, u.x) +
                (c - a)* u.y * (1.0 - u.x) +
                (d - b) * u.x * u.y;
}

3. Gradient Noise
二维 Value Noise 的问题就是看起来是块状的,我们可以使用点乘的办法消除,进而得到 Gradient Noise。
为此首先我们需要改写 random2,使其返回一个二维值:

vec2 rand2(vec2 pos) {
    pos = vec2( dot(pos,vec2(127.1,311.7)),
              dot(pos,vec2(269.5,183.3)) );
    return vec2(-1.0) + 2.0*fract(sin(pos)*43758.5453123);
}

然后我们使用类似二维 Value Noise 中被注释掉的原始办法:

float gradient_noise(vec2 pos) {
    vec2 i = floor(pos);
    vec2 f = fract(pos);

    vec2 u = smoothstep(0.0, 1.0, f);

    return mix( mix( dot(rand2(i), f),
                     dot(rand2(i + vec2(1.0,0.0) ), f - vec2(1.0,0.0)), u.x),
                mix( dot(rand2(i + vec2(0.0,1.0) ), f - vec2(0.0,1.0)),
                     dot(rand2(i + vec2(1.0) ), f - vec2(1.0)), u.x), u.y);
}

这样就得到了相对平滑的 Noise。

3.Simplex Noise
这个比较难,我直接贴链接了:https://thebookofshaders.com/edit.php#11/2d-snoise-clear.frag

虽然 Noise 生成的原理相对困难,但我们完全可以直接拿来应用(抄代码),下一节我们考虑几个例子。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-8 12:14:56 | 显示全部楼层
[16] 使用 Noise 的例子
我会贴上相关链接,并作简单解释。

1. 树干纹理:https://thebookofshaders.com/edit.php#11/wood.frag
注释掉 52 行,我们可以看到这个例子很简单,只是画了一些直线,然后利用 Noise 对这些直线的局部作旋转。

2. 水墨画???:https://thebookofshaders.com/edit.php#11/splatter.frag
很好理解的利用 Graident Noise 的例子,不过,这玩意有啥用……

3. 扭曲的圆环:https://thebookofshaders.com/edit.php#11/circleWave-noise.frag
利用极坐标画圆,并用 Gradient Noise 对半径大小作扰动,关键在于 46~50 行的部分。

4. 流体表面:https://thebookofshaders.com/edit.php#11/lava-lamp.frag
利用 Simplex Noise,以向右下方向平移的位置和旋转的位置(关于时间)作随机化。
选择怎样的位置作随机不能一概而论,可以自己尝试修改,看看怎样的效果能让自己满意。

5. 粗糙的表面:https://thebookofshaders.com/edit.php#11/iching-03.frag
这段代码有大量内容用于动画,这不是我们需要关注的重点。
重点是在 124 行,利用 Simplex Noise 对相关变量作了扰动,并最终在输出颜色时,利用 step 函数对颜色作控制。
可尝试将 step 改为 smoothstep,从而得到软化的效果。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-8 13:46:07 | 显示全部楼层
本帖最后由 dasasdhba 于 2022-12-8 15:12 编辑

[17] Celeste C6 极光效果

在进一步了解更深入的 Noise 理论之前,我们先来做一个简单的实践。
(这是我早在做 SMMWW5-4 的时候就想做的事情,可惜当时没有能力。)

                               
登录/注册后可看大图


首先我们做简单观察,极光的轮廓似乎只是一个简单的分段线性函数,我们可以用一维非平滑 Noise 来构造这样的函数:

float noise_line(float x) {
    float i = floor(x);
    float f = fract(x);
    return mix(random(i), random(i+1.0), f);
}

接下来,我们需要构造一个好看的波形,可以通过乘几个三角函数来获得:

float wave(float x) {
    return abs(cos(x*12.0)*sin(x*5.0)*sin(x*0.8)) + 0.25;
}

剩下的就不难了,一方面底部简单柔化处理,顺便可以使用 noise 做小小的扰动,另一方面混个色就可以了:

  1. vec3 light_color = vec3(0.4, 1.0, 0.9);
  2. float len = 0.3;

  3. float random(float x) {
  4.     return fract(sin(x)*23333.33);
  5. }

  6. float noise(float x) {
  7.     float i = floor(x);
  8.     float f = fract(x);
  9.     return mix(random(i), random(i+1.0), smoothstep(0.0, 1.0, f));
  10. }

  11. float noise_line(float x) {
  12.     float i = floor(x);
  13.     float f = fract(x);
  14.     return mix(random(i), random(i+1.0), f);
  15. }

  16. // 绘图系数
  17. float plot(vec2 st, float y, float width) {
  18.     return 1.0 - smoothstep(y, y+width, st.y);
  19. }

  20. // 三角波
  21. float wave(float x) {
  22.     return abs(cos(x*12.0)*sin(x*5.0)*sin(x*0.8)) + 0.25;
  23. }

  24. void main() {
  25.     vec2 uv = gl_FragCoord.xy/u_resolution.xy;
  26.    
  27.     uv.x *= 1.2; // 缩放
  28.    
  29.     float y = 0.08*noise_line(uv.x * 20.0 + u_time*0.1) + 0.25; // 折线函数
  30.    
  31.     float p = plot(uv, y, len * wave(uv.x*10.0 + u_time * 0.15)) - plot(uv, y - 0.02*noise(uv.x * 5.0), 0.02);
  32.    
  33.     vec3 color = p*mix(light_color+vec3(0.5), light_color, smoothstep(y - len, y + len, uv.y)); // 让底部更亮
  34.    
  35.     gl_FragColor = vec4(vec3(color), 1.0);
  36. }
复制代码



                               
登录/注册后可看大图

最后的效果大概如上,当然我这里构造的三角波有点太简单了,蔚蓝的应该更复杂一些。
此外,蔚蓝的极光应该还整体额外加了一个发光效果,这个更适合再用一个 Fragment Shader 通过处理 Texture  实现,以后再说。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-9 11:36:17 | 显示全部楼层
本帖最后由 dasasdhba 于 2022-12-9 11:40 编辑

[18] 使用 Cellular Noise
1. 特征点
在 [12] 在我们曾用 distance 方法配合 step/smoothstep 方法画圆,试想,若不用 step/smoothstep 作限制,而是直接将距离作为颜色输出,结果会将如何?
不难想象,这会得到径向的黑白渐变。

这里,“圆心”被放在了中心,我们称其为特征点。当然,只有一个特征点得到的结果规律且容易想象,我们要讨论的是 Noise:事实上,Cellular Noise 的基础就是使用多个特征点,并使用到各个特征点的最小值。(换成最大值,平均值也行,为了叙述方便,统一使用最小值)

2. 不使用 for 循环
当特征点较多时,使用 for 循环计算到特征点的最小距离是自然的,比如:

float min_d = 2.0;
for (i=0;i<100;i++)
    min_d = min(min_d, distance(uv, point[这里是 i,为了防止论坛转义为斜体 tag,我写了这段文字。]);

但 WSW 已经提到,在 Shader 编程中使用 for 循环非常糟糕(并不是说你真的写 100 次就好了),且 for 循环不支持动态的迭代次数。因此,我们需要寻找一个能够利用 GPU 并行架构的策略——网格化(Cellular):

WSW 已经提到,每一个 fragment 都在一个单独的线程运行,因此,我们可以将空间网格化,并使网格与特征点一一对应。这样,每个 fragment 要考虑的特征点仅仅是自身所在的网格以及其相邻部分。

下面是具体实现,我们已经实现过随机黑白马赛克,因此如何实现网格化并不陌生:

uv *= 5.0;

vec2 i_uv = floor(uv);
vec2 f_uv = fract(uv);

这样,我们就将空间分成了 5*5 的网格,i_uv 代表网格的位置,而 f_uv 则可以代表 fragment 在网格内的位置。
下面我们选取特征点,不妨使用随机数:

vec2 point = rand2(i_uv);

接下来,我们使用双重 for 循环,遍历每个网格的相邻位置(3*3=9,还是可以接受的):

float min_d = 2.0;
for (int x=-1;x<=1;x++)
    for (int y=-1;y<=1;y++) {
        vec2 n_uv = vec2(float(x), float(y));
        vec2 point = random2(i_uv + n_uv);
        vec2 diff = n_uv + point - f_uv;
        min_d = min(min_d, length(diff));
    }
   
3. Voronoi 方法
将距离直接用于 Noise 值仅仅是其中一种选择,这就像是一维的 Noise;我们还可以直接将最小距离对应的特征点的坐标用于 Noise(对应二维),这被称为 Voronoi 方法。
为了实现 Voronoi 方法,我们不仅需要最小值,我们还需要对应的特征点的位置,为此我们将更替最小值的语句使用 if 改写:

if (length(diff) < min_d) {
    min_d = length(diff);
    min_point = point;
}

由此我们可以尝试来画一类常见的手机壁纸,使用:

color.rg = min_point;

对红绿通道着色,当然,单色未免过于单调了,我们可以将两种方式结合,再加上:

color.b = 2.0 * min_d;

最终效果如何呢?可以自己试试看。
不过这有点偏题了,我们在谈论的是 Noise,为了得到基于 min_point 的 [0 ,1 ] Noise 值,我们再次利用点乘(二维的情况都是这样,我们已经用了很多次了):

dot(min_point, vec2(0.3, 0.6))

4. 现代的优化
下面是一些比较新的成果,由于难度较大,不作详解。
(1) 优化网格
Stefan Gustavson 在 2011 年提出了一种优化网格的办法,一个最大的优化点在于我们不用遍历 3*3 的相邻位置,而是 2*2 就足够了,详见:http://webstaff.itn.liu.se/~steg ... -cellular-notes.pdf

尽管这种方案会造成一些“人工痕迹”,但作为巨大的优化,还是值得了解。

(2) 描边 Voronoi
Inigo Quilez 在 2012 年提出了一种办法为 Voronoi 办法作精确描边,详见:http://www.iquilezles.org/www/ar ... es/voronoilines.htm

(3) Voronoi 方法与 Value Noise 的联系
Inigo 在 2014 年又提出,Voronoi 可以与 Value Noise 以一种方式相互转化,并提出是否可以找到一种更加一般方法,使得 Voronoi 与 Value Noise 只是其特殊情况?详见:http://www.iquilezles.org/www/articles/voronoise/voronoise.htm
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-9 20:18:27 | 显示全部楼层
[19] fBM 技术
1. 经典 fBM 的实现
回忆一道经典高中题目:

给定一等边三角形 (1),现将其每一条边三等分,并以中间的一段为基准再作一个等边三角形,由此得到 (2),这个过程可以一直做下去。

                               
登录/注册后可看大图

我们当然不是来计算第 (n) 个图有多少条边的,事实上,这个著名的雪花被称作 Koch 雪花,与数学理论——分形(fractal)相关。

这启发我们可以对 Noise 作类似的分形操作,具体来说,我们可以通过一个 for 循环(循环次数叫 Octaves)来叠加 Noise,并以一定的倍数(Lacunarity)升高频率(Frequency),同时以一定的比例(Gain)降低振幅(Amplitude),由此就可以制作类似 Koch 雪花的效果,这就是 fractal Brownian Motion 技术,简称 fBM。

#define OCTAVES 8
float fbm (vec2 uv) {
        float value = 0.0;
        float amplitude = 0.5;
        float frequency = 1.0;
        float lacunarity = 4.0;
        float gain = 0.6;

        for (int i = 0; i < OCTAVES; i++) {
                value += amplitude * noise2(frequency * uv);
                frequency *= lacunarity;
                amplitude *= gain;
        }
        return value;
}

直接将这个值以颜色输出,会得到我们熟悉的“大风”效果。

2. fBM 变种
除了上述经典的迭代方式,我们还有很多变种。

(1) Ridge:“反转”结果
这个方法的名字我也不知道怎么来的,但我也懒得自己起名字,不过这不重要。
在上例中,使用 Value Noise 仅仅是其中一种选择,如果我们使用 Simplex Noise,则会得到类似于水波纹的效果。但是,好像把水波纹和水搞反了。。。

                               
登录/注册后可看大图

有人可能会想反了的话拿 1 减一下不就行了,但实际情况不理想,因为这张图里面黑的部分不够黑,白的部分也不够白,反转了还是难看。。。
Ridge 方法在“反转”这个结果的同时极大的提高对比度,这关键是使用了:

float ridge(float h, float offset) {
    h = abs(h);     // 利用绝对值作对称,类比 sinx → abs(sinx)
    h = offset - h; // 反转
    h = h * h;      // offset 过高可能会使得全局过亮,这里类比 [0, 1] 区间上的 y=x 与 y=x^2,处理之后接近 1 的点会更少
    return h;
}

然后,一方面我们利用这个函数修改叠加过程,另一方面为了再次增大差异,我们修改 fBM 循环过程如下:

        float prev = 1.0;
        float offset = 0.9;
        for (int i = 0; i < OCTAVES; i++) {
                float n = ridge(snoise(frequency * uv), offset);
                sum += n * amp * (1 + prev);
                prev = n; // 利用上一次循环的值再次增大差异
                frequency *= lacunarity;
                amplitude *= gain;
        }

这样,我们就可以输出比较好看的水面效果。

(2) Domain Warping:fBM 迭代
对于一个函数 f(x),我们可以将其迭代:f(f(x)),结果往往难以预测,因此我们之前很少讨论迭代。
但是将 fBM 迭代的效果似乎很奇特,我们看图:

                               
登录/注册后可看大图

如果再以特定的方式赋予颜色,可以做出不少玄幻的效果,举一个迭代一次的例子:

void main() {
        vec2 uv = gl_FragCoord.xy/u_resolution.xy;
        uv *= 3.0

        vec3 color = vec3(0.0);

            vec2 q = vec2(fbm(uv), fbm(uv + vec2(1.0)));

            vec2 r = vec2(fbm(uv + 1.0*q + vec2(1.7, 9.2) + 0.15*u_time),
                               fbm(uv + 1.0*q + vec2(8.3, 2.8) + 0.126*u_time));

            float f = fbm(uv + r);

        // clamp 的函数不需要特别计较,f 与 f^2 效果差不多,length(q) 和 q.x/ q.y 效果也差不多
        // 第一步混色结果会比较均匀,总体可以预测会比较偏黄
            color = mix(vec3(0.101961, 0.619608, 0.666667),
                          vec3(0.666667, 0.666667, 0.498039),
                          clamp((f*f)*4.0 ,0.0, 1.0));

        // 第二步将整体亮度明显降低,并且比较突出蓝色
            color = mix(color,
                          vec3(0.0, 0.0, 0.164706),
                          clamp(length(q), 0.0, 1.0));

        // 第三步重新抬高亮度,但是红色通道只抬高了 2/3,结合上一步,整体结果仍然偏蓝
            color = mix(color,
                          vec3(0.666667, 1.0, 1.0),
                          clamp(length(r.x), 0.0, 1.0));

            gl_FragColor = vec4((f*f*f+0.6*f*f+0.5*f)*color, 1.0);
}

这会得到类似云雾的效果。

[插曲 3]
那么 The Book of Shaders 的学习就暂时告一段落了。(因为人家还没写完,就写到这里了)
由此我们对于如何利用 Fragment Shader 作图以及如何利用“随机”作了一个初步了解,也初步开始适应 Shader 的编写模式。
当然,目前我们对 Noise 以及 fBM 的介绍都非常浅,每一个要想深入了解都是一个大坑,这个我打算暂时不跳。
接下来,我将转而进入我们平常可能更为熟悉的领域——图像处理类 Fragment Shader 进行学习。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-10 20:44:21 来自手机 | 显示全部楼层
[暂停] 由于期末周临近,一方面需要复习,另一方面最近因为“能返尽返”政策的影响(这造成了很大的波动),我暂时停止 Shader 的学习,寒假继续。

87

主题

1713

回帖

11

精华

超级版主

经验
10251
硬币
1247 枚

赞助用户永吧十五周年倒计时海报勋章第三届MW杯冠军第十一届MW杯四强PK!MF3 冠军PK!MF5 季军PK!MF4 殿军欢乐演员人气之王

发表于 2022-12-12 20:02:46 | 显示全部楼层
话说这些东西适合什么需求的开发者
自己制作的游戏The Frontiers 点击进入

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-12 22:58:01 | 显示全部楼层
zqh——123 发表于 2022-12-12 20:02
话说这些东西适合什么需求的开发者

做视觉效果吧
Moonstruck Blossom
个人网站:dasasdhba.github.io

87

主题

1713

回帖

11

精华

超级版主

经验
10251
硬币
1247 枚

赞助用户永吧十五周年倒计时海报勋章第三届MW杯冠军第十一届MW杯四强PK!MF3 冠军PK!MF5 季军PK!MF4 殿军欢乐演员人气之王

发表于 2022-12-12 23:39:27 | 显示全部楼层

基本的效果吗,还是进阶的
自己制作的游戏The Frontiers 点击进入

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-19 12:38:04 | 显示全部楼层
本帖最后由 dasasdhba 于 2022-12-19 19:37 编辑

[20] 图像处理类 Shader 概况
狭义地说,图像处理类 Shader 就是 Fragment Shader 对 Texture 作操作。很可惜,这部分内容没有什么成体系的教程,因为各类算法繁杂且无相关性,为此我打算一方面直接学习相关算法源码,另一方面分具体效果的算法为专题进行学习,如模糊、光照、描边、锐化等等。

正式开始前先说一个很重要但容易被忽视的内容:pixel_size。
具体来说,之前已经介绍过 UV 坐标是限制在 [0, 1] 之间的,而为了作图像处理,我们迫切地需要知道一个像素点在 UV 坐标系下的大小,也就是 pixel_size。
原理层面上只需要传入 texture 的尺寸信息,然后取倒数即可。
方便起见,之后的内容中我们统一使用 vec2 unit 变量表示 pixel_size。

在进入相关专题之前,首先介绍后面需要用的数学工具——卷积(Convolution)
这里不扯卷积的数学定义,因为这不重要(就像之前没有扯分形的数学定义一样),我觉得最简单的理解办法是看一个例子:

首先我给定这样一个矩阵:

                               
登录/注册后可看大图

暂且将它称为卷积核(Convolution Kernel),等会解释原因。

现在,假定我们要对某个像素点的红色通道也就是 R 值作卷积处理,不妨设其 R 值为 0.7,为此有如下步骤:
  • 以该像素点为中心,用其周围像素点的 R 值构成一个与卷积核矩阵大小相同的矩阵,举例:

                                   
    登录/注册后可看大图

    (注:若该像素点为边界点,一种常见的办法是作镜像对称以补齐矩阵)
  • 将该矩阵与卷积核对应位置相乘后再作加和,也就是:0.2*0.5-0.3*0.5+0*0.5-0.5*0.5+0.7*1-0.6*0.5+0.1*0.5-0.2*0.5+0.4*0.5 = 0.25
  • 将运算结果也就是 0.25 作为该点 R 值的处理结果。
    (注:严格来说,还需要作 clamp 操作将结果限制在 0~1)


形式上来看,这就是以像素点为中心的矩阵与我们的卷积核作卷积的结果。显然,采用不同的卷积核会有不同的效果,在后续的各类算法中我们会时常采用这种办法。

下面举几个简单例子来展现卷积的威力:
1. 简单边缘检测效果,使用卷积核:

                               
登录/注册后可看大图

原理参考:https://zhuanlan.zhihu.com/p/59640437
2. 简单锐化效果,使用卷积核:

                               
登录/注册后可看大图

原理参考:https://zhuanlan.zhihu.com/p/162275458
3. 简单浮雕效果,使用卷积核:

                               
登录/注册后可看大图

这个暂时没有找到原理参考。
Moonstruck Blossom
个人网站:dasasdhba.github.io

36

主题

720

回帖

13

精华

版主

经验
7350
硬币
1157 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2022-12-19 15:35:59 | 显示全部楼层
本帖最后由 dasasdhba 于 2022-12-19 19:53 编辑

[21] 模糊(Blur)算法简介
1. 方框模糊(Box Blur)
这是非常简单的一种模糊办法,具体来说是求像素点的临近点的平均值,为此我们使用这样的卷积核:
设 E(n) 是 n×n 的元素全为 1 的矩阵,我们采用 (1/n^2)*E(n) 作为卷积核。举例(n = 3):

                               
登录/注册后可看大图


对 RGB 值同时作卷积即可。

2. 高斯模糊(Gaussian Blur)
事实上,方框模糊简单得有点过分了,甚至根本不需要什么“卷积”也能讲清楚。
但我使用卷积的语言并非故弄玄虚,而是因为高斯模糊从卷积的角度来看跟方框模糊有着重要的联系:
与方框模糊不同,高斯模糊使用了正态分布(也称高斯分布,故称高斯模糊)的办法。
回忆高中学习的正态分布方程:

                               
登录/注册后可看大图


不考虑偏移,我们将 μ 取 0,而 σ 即为“模糊半径”,下面我们将其改写为二维的情形:

                               
登录/注册后可看大图


但是这样不便于计算,在具体实现上,我们一般直接从一维情形出发,下面我们构造一个 5×5 的高斯模糊卷积核:
  • 首先在正态分布曲线上取五个点,为此我们使用上图的 f(x),方便起见,取 μ = 0,由此得到行向量:
    α = (f(-2), f(-1), f(0), f(1), f(2))
    (注:因为取 μ = 0,实际上已经有 f(x) = f(-x),因此不必算两次)
  • 为了得到卷积核,只需要作矩阵乘法:G = α^T * α,容易验证这恰好符合二维情形的结果。
    (注:直接使用二维的方法亦可,也就是:G =
    G(-2,-2), G(-1,-2), G(0,-2), G(1, -2), G(2, -2)
    G(-2,-1), G(-1,-1), G(0,-1), G(1, -1), G(2, -1)
    ...)
  • 最后,对 G 作归一化(否则会有亮度改变),为此只需要除以 G 的各个元素的加和。
    不妨设 G = (g(i,j)),我们的卷积核就可以表示为:(1/(∑g(i,j))) * G


在实际应用中,注意到 G = α^T * α,我们完全可以分别在两个一维的方向上作卷积,这样效率更高。
具体来说,记我们需要处理的点为 P(0, 0),首先分别将 P(-2,0), P(-1, 0), P(0, 0), P(1, 0), P(2, 0) 与 α^T 作卷积,由此得到行向量:
β = (P'(-2,0), P'(-1, 0), P'(0, 0), P'(1, 0), P'(2, 0)),其中 P'(i, 0) 为 P(i, 0) 及其临近像素点与 α^T 作卷积的结果。
再将 β 与 α 作卷积即可。
(最后别忘了归一化)

因此,高斯模糊跟方框模糊的唯一不同之处,仅仅在于使用了正态分布的办法进行了“加权平均”,本质上还是取平均数。

3. 径向模糊(Radial Blur)
径向模糊非常简单,我们只需要指定一个方向向量,然后采这个方向上的样并求均值即可。

#define ITERATION 8
vec4 radial_blur(vec2 uv, vec2 dir) {
        vec4 s = vec4(0.0);
        for (int i=0; i<ITERATION; i++) {
                s += texture(tex, uv + i * unit * dir);
        }
        return s / ITERATION;
}

4. 方向模糊(Direction Blur)
双向的径向模糊罢了:

#define ITERATION 8
vec4 direction_blur(vec2 uv, vec2 dir) {
        vec4 s = vec4(0.0);
        for (int i=-ITERATION; i<ITERATION; i++) {
                s += texture(tex, uv + i * unit * dir);
        }
        return s / (2.0 * ITERATION);
}

5. Kawase 模糊(Kawase Blur)
受径向模糊以及方向模糊的启发,我们可以同时采用多个方向,由此又得到一种相对均衡的模糊效果。
这是 CTF 做不到的一种办法,因为需要反复迭代 Shader,我们只作简单介绍,Kawase 模糊关键是使用了:
vec4 kawase_blur(vec2 uv, vec2 offset) {
        return 0.25* (texture(tex, uv + unit * vec2(offset + 0.5, offset + 0.5))
                                 + texture(tex, uv + unit * vec2(-offset - 0.5, offset + 0.5))
                                 + texture(tex, uv + unit * vec2(-offset - 0.5, -offset - 0.5))
                                 + texture(tex, uv + unit * vec2(offset + 0.5, -offset - 0.5))
                          );
}
不妨取 offset 为 (0, 0),可见这将会对像素点的临近四个角求平均值。
而 Kawase 模糊的关键在于反复迭代这种办法(一般四次以上),并以此将 offset 设为 迭代次数 * (1, 1),下面这两张图很好地展现了原理:

                               
登录/注册后可看大图


                               
登录/注册后可看大图


注 1:采用 Shader 迭代是因为这样的效果出奇地接近高斯模糊,且速度要快很多。
感兴趣可以试试在 Shader 内直接迭代的效果,这会得到类似方向模糊的效果。

注 2:在 2015 年,一个更快的 Kawase 模糊办法被提出:Dual Kawase Blur,简称 Dual Blur。
这种办法对于较大的 Texture 非常快,其实是使用了一个非常简单的思想:反正都模糊了,我为什么不缩小之后再模糊然后放大回来??
(当然,技术细节没那么简单,缩小和放大操作需要 Vertex Shader 协助,CTF 就更不用想了)

6. 散景模糊(Bokeh Blur)
这好像是摄影里面会出现的一种模糊现象,反正就是会出现一些光圈之类的东西,原理我不懂也不想懂。
为了模拟圆形光圈,可以借用黄金角 φ = 2π/((1+√5)/2)^2,大约为 137.51°,通过反复旋转进行采样。
Wikipedia 上有一个很直观的动图演示了这个过程:

                               
登录/注册后可看大图


下面是代码实现:
const mat2 rot = mat2(cos(φ), sin(φ), -sin(φ), cos(φ)) // 伪代码,黄金角 φ 的旋转矩阵

#define ITERATION 8
vec4 bokeh_blur(vec2 uv) {
        float r = 1.0;
        vec2 angle = vec2(1.0, 0.0);
        vec4 a = vec4(0.0);
        vec4 d = vec4(0.0);
        for (int i=0; i<ITERATION; i++) {
                r += 1.0 / r; // 半径向外沿伸
                angle = rot*angle; // 旋转
                vec4 bokeh = texture(tex, uv + unit * (r - 1.0) * angle);
                a += bokeh * bokeh;
                d += bokeh;
        }
        return a / d;
}

7. 粒状模糊(Grainy Blur)
这个算是我们之前学习的 Noise 的应用,也就是求特定像素点周围的若干个由二维 Noise 扰动得到的像素点的均值。
如果我们采用的是 Value Noise,就会产生颗粒感,模糊半径越大,颗粒感也会越明显。
当模糊半径足够大的时候,不难想象,这会非常接近我们之前得到的二维 Noise 对应的视觉效果。
(想想二维 Value Noise 的直观效果,也就是“雪花屏”)
如果使用 Simplex Noise 或者 Cellular Noise,效果会如何呢?感兴趣可以自己试试。

8. 镜头模糊(Lens Blur)与光圈模糊(Iris Blur)
这个也算是一个综合应用,本质上就是一部分模糊一部分不模糊的效果。
为此我们只需要利用我们之前所学的办法构造遮罩,比如使用 smoothstep 构造一个 白-黑-白 渐变(这对应镜头模糊),
或者构造一个椭圆形的径向渐变(这对应光圈模糊),然后再采用我们上面提到的各种模糊办法即可。

9. 马赛克模糊
这个太简单了,而且跟上面提到的内容也格格不入,所以单独放在这里了。
具体来说,将 Texture 用网格分割然后每个网格取一个固定的颜色就行了。
Moonstruck Blossom
个人网站:dasasdhba.github.io
您需要登录后才可以回帖 登录 | 创建账户

本版积分规则