延迟渲染初体验
第一个着色器
还记得 OptiFine 管线输出的最末端是哪个程序吗?没错就是延迟处理程序 final
。它在管线的最末端,并且只有一个输出,从这里开始了解延迟处理阶段再合适不过了。那么现在让我们在工作区先新建 final.vsh
和 final.fsh
来上手试试吧。
铺好画布
还记得我们在 延迟渲染 提到的概念(如果你没看过最好去瞅两眼)吗?延迟处理传入的几何是一个铺屏四边形 ,在 OptiFine 中它覆盖二维平面
看起来很多,实际上真正重要的内容只有 vaPosition.xy * 2.0 - 1.0
,它将顶点从
[1] 在几何缓冲章节我们将详细介绍,在这里你只需要知道 OpenGL 期望所有的顶点坐标都落在
现在,我们就可以在这张空白画布上大展拳脚了。
泼上一些颜色
来到我们的片段着色器,把输出变量随意设置一个颜色看看效果:
并按下 F3R 重载光影。然后,你就已经可以在游戏窗口中看到光影的效果了:

哇哦!真的是好……呃,纯正的红啊!当然,只靠这种固定纯色是没法画出场景的,我们需要更多数据。具体点说,会基于像素坐标变化的数据……比如,图像。
采样纹理
在 GLSL 中,我们通过采样 (Sampling)来获取图像颜色数据,每次采样可以获取图像对应坐标上的颜色,或者说至多四个通道(RGBA)的数据。
缓冲区
几何缓冲输出了内容的缓冲区就充当了我们采样要用的纹理,即缓冲区是几何缓冲向延迟处理传递信息的媒介:
OptiFine 提供了至多 16 个(通常我们只用一半)二维缓冲区供我们使用,由于我们没有自己编写几何缓冲,内置管线默认使用向前渲染法将所有场景输出到了 0 号缓冲区 colortex0
。
著名哲学家 维吉尔 曾经说过:“ If you want it, then you'll have to take it. ”,如果我们需要采样它,那我们就得先声明它。在 GLSL 中,我们使用采样器 (sampler)类型来声明缓冲区:
这行代码声明了从 GL 上下文传入的采样器类型统一变量 colortex0
,对应 0 号缓冲区,有了它,我们就能进行接下来的工作了。
纹理坐标
还记得片段着色器是如何执行的吗?它在每个图元覆盖的每个像素上运行一次,然后将处理结果写入指定缓冲区或屏幕的对应坐标。那么,我们要怎样才能知道当前像素的坐标以采样正确的位置呢?
幸运的是,我们有办法,而且还不止一种。第一种办法,我们直接利用 OptiFine 提供的顶点属性 vaUV0
,它提供了当前顶点对应的纹理坐标。我们只需要在顶点着色器中传入这个变量,然后赋给传出到片段着色器的变量即可:
接着,我们在片段着色器中传入同名变量:
这样,我们就获取到了采样纹理所需要的归一化坐标。
现在来回想一下,铺屏四边形完全覆盖了屏幕,也就意味着它的纹理坐标和屏幕坐标是一一对应的,因此这也引出了另一种办法,而且可以直接在片段着色器中完成。
首先我们需要 GLSL 的另一个内建变量 gl_FragCoord
,它指定了当前像素在窗口内的空间坐标。由于我们只关注像素在屏幕上的二维位置,因此我们只关心它的 gl_FragCoord.xy
表示了当前像素在横轴和纵轴上的序号(我们可以将其称之为整数坐标),因此它会随着分辨率的变化而变化。
那么我们应该如何求得其归一化坐标呢?这里我们需要 OptiFine 提供的两个统一变量: viewWidth
和 viewHeight
,它们以像素为单位表示了窗口的宽度和高度。
于是我们的归一化纹理坐标 uv
就可以求得了:
把它们画出来
有了采样器和采样坐标,我们就可以很方便地在 GLSL 中采样纹理了,隆重介绍我们的劳模函数: texture()
。 texture()
接受两个参数:采样器和与采样器同维的归一化坐标,它会返回纹理对应坐标上的颜色值。
现在,让我们修改片段着色器,试着向屏幕上输出场景:

What Amazing!我们成功向屏幕输出了 0 号缓冲区的内容!
然而,我们其实不止有一种采样函数。如果你在之前我们用整数坐标求归一化坐标时也想过:有没有更简单的办法,直接用整数坐标进行采样呢?
那么这里就将向你介绍另一个采样函数: texelFetch()
。 texelFetch()
和 texture()
很相似(更准确地说是和它的兄弟 textureLod()
相似),前者接受三个参数:采样器、同维采样整数坐标 ,以及一项额外的 Mipmap 等级。
第三个参数我们在这里不过多展开,你只需要知道它接受一个 int
值用于指定纹理细节等级,从级别 0 开始,每高一级纹理的精度就会减半。
值得注意的是, texelFetch()
的第二个参数要求传入整数向量类型(ivec
),而 gl_FragCoord
实际上是浮点向量类型(vec4
,和它的
和 texture()
不同的是,使用 texture()
进行采样时,由于其使用浮点坐标,如果它的采样坐标在纹理的像素之间就会自动进行插值;而 texelFetch()
由于使用了整型坐标,它的采样点不会产生偏移,也就不会自动插值了。你可以将它们的坐标参数同时除以一个数(相当于放大纹理)来验证具体区别:
texture()
- fragColor = texture(colortex0, uv / 8.0);
texelFetch()
- fragColor = texelFetch(colortex0, ivec2(gl_FragCoord.xy) / 8, 0);
可以很明显地看到, texture()
采样出来的画面比较模糊,而 texelFetch()
则棱角分明 ,像素颗颗饱满 。大多数时候,我们还是期望采样纹理时进行自动插值的,因此在本教程中,如无必要,我们都将使用 texture()
。
处理颜色
现在我们已经将颜色从纹理上采样了出来,可是这和原版的画面没什么区别,于是你自然而然就会想:何不可以对颜色做一些处理,比如……转换为灰度?要想将画面转化为灰度,我们需要考虑像素的每个色彩分量,并按一定比例将它们混合起来。亮度的权重公式为
我们可以将权重和颜色向量点乘:
如果你不记得(或者没学过)向量点乘公式的话,它是这样的:
于是我们的代码就出来了:
然后回到我们的游戏重载一次:

画面就已经成功变成灰色了!
场景深度
单纯的灰色似乎有些单调,我们还有没有其他信息可以用,让这个世界变得更有层次呢?幸运的是,在没接触几何缓冲之前,我们还有一个数据可以使用: 场景深度 。如果你仔细阅读了前文可能会想:难道是 gl_FragCoord.z
?
实则不然。还记得吗, gl_FragCoord
表示的是当前场景的坐标,但我们现在只不过在一个铺屏四边形上采样罢了。要想获得深度,我们需要深度图。
OptiFine 为我们提供了多达 3 张的屏幕深度,以 depthtex
前缀表示。它们依次表示完整深度、不包含半透明几何的深度和不包含半透明几何与手的深度。
当下这个情况,我们用 depthtex0
就能很好地应付了。要记住,深度图只有一个通道,所以在采样时候应该只采样红色通道:
如果你将它输出到屏幕上,看起来会是这样:

看起来白茫茫的一片,五米之外人畜不分对吧?这是因为由于进行了透视除法的场景深度是非线性的,我们会在几何缓冲章节详细介绍。现在让我们使用一个 神奇 的函数,先把场景转换到线性深度:
把它写成数学公式形式就是:
你只需要先记住它就行了。
两个统一变量 near
和 far
分别指定了场景的近裁切平面和远裁切平面,函数会把场景深度从非线性转换为方块对齐的线性深度。然后,我们将线性深度映射到
这样,我们就获得了场景的归一化线性深度了:

在这里,我们需要用到另一个 GLSL 内建函数: mix()
。它用法为 mix(值1, 值2, 混合比例)
。其内部实现为
我们试着将反转的深度用作 Mix()
函数的混合比例,将灰度和原始颜色混合起来:
回到游戏看看效果

看起来就像世界中的色彩随着距离增加而逐渐出现了一样!
通过深度图,我们还能干很多事情。然而这一小节也为我们留下了许多谜团:深度图为什么是非线性的?透视除法到底干了什么? near
和 far
又是什么?
它们将在之后我们进入几何缓冲时得到解答。
纹理边界
既然我们能采样纹理,你们会肯定很好奇:要是采样坐标不在像素所在位置会怎么样呢?
其实很好想象,因为所有像素的偏移坐标都一样,整个画面都会往一个方向移动……等会?那本来就在纹理边缘的像素呢?
texture()
期望接受归一化的纹理坐标,因为纹理的尺寸是已知的,总是可以用
GL_Repeat
(GL 默认行为)重复纹理内容
GL_MIRRORED_REPEAT
重复纹理内容,但是每次都会在对应轴上镜像
GL_CLAMP_TO_EDGE
纹理坐标被约束在
之间,超出范围的坐标会重复边缘像素GL_CLAMP_TO_BORDER
纹理坐标被约束在
之间,超出范围的坐标会设置为用户指定的颜色
缓冲区的默认行为是 GL_CLAMP_TO_EDGE
。然而,在 OptiFine 管线中,我们无法控制每个纹理的边缘行为,因此约束坐标就显得很重要了。我们可以手动实现上述的四种边缘行为,同时附上将画面缩小到纵横各 1/4 后(即提前将 uv
扩大 4 倍,用于演示大于 1.0
的坐标处置方式)的处理结果:
GL_Repeat
将纹理坐标约束在
上重复即可:uv = mod(uv, 1.0);mod()
函数用于取第一个参数除以第二个参数的余数,相比%
,它支持取浮点余数。GL_MIRRORED_REPEAT
根据重复次数翻转坐标:
ivec2 times = ivec2(abs(floor(uv))); // 取得对应坐标上的循环次数 ivec2 isFlip = times % 2; // 当重复次数为奇数 = 1,偶数 = 0 ivec2 flipMul = 1 - 2 * isFlip; // 用来避免使用 if 的奇怪乘数,isFlip = 1 时翻转 uv 值 uv = vec2(isFlip) + mod(uv, 1.0) * vec2(flipMul);abs()
函数用于取绝对值,floor()
用于取整数部分。GL_CLAMP_TO_EDGE
(OptiFine 缓冲区默认行为)将坐标直接约束在
:uv = clamp(uv, 0.0, 1.0);clamp()
的用法为clamp(变量, 下限, 上限)
。GL_CLAMP_TO_BORDER
将大于 1 的坐标替换为常量颜色
const vec4 bgColor = vec4(0.5, 0.5, 0.8, 1.0); float isOutBound = min(abs(floor(max(uv.s, uv.t))), 1.0); // 取坐标中的大值作为混合比例,在纹理范围内取得的整数部分永远是 0,其他的取到后约束最大值到 1。 fragColor = texture(colortex0, uv); fragColor = mix(fragColor, bgColor, isOutBound);
最后,我们可以将它们封装成函数,以供我们随时调用。
你可能注意到了,在 uv_clampToColor()
中我们在声明函数参数时使用了 out
关键字,GLSL 允许我们这样做。当函参声明 out
时,表示函数中更改的值会原路返还给用作这个参数的变量,并且这个参数在函数内赋值前无法参与计算,即只写参数 。我们可以利用这个特性来进行多个值的初始化:
结果为:
同样的,我们还可以使用 in out
或者 inout
来指定可读可写的参数,而单独的 in
就和默认行为一样,可以在函数内被更改,但不会写入传参,即只读参数。
结果为:
修饰符 | 读写性 |
---|---|
| 只读,即使函数内为其赋值也不会传出(默认行为) |
| 只写,即使传参带有值也不可读,除非在函数中为其赋值 |
| 既可以读取传参值,也可以向传参赋值 |
多次采样
另一个你可能会思考的问题是,我们能否采样同一个纹理多次,每次偏移一小段距离,从而绘制出更多效果?
如果你这样想了,那么恭喜你,你已经悟出了着色器中非常重要,也是很多现代特效极其依赖的采样手法:多次采样。一提到它,我们最容易想到的就是模糊 ,因为它的核心思想就是将像素的颜色扩散到周围,而在片段着色器中,我们可以逆向操作,将周围的像素扩散过来。
要想进行模糊,我们最先要做的就是获取周围像素的信息。回想一下坐标,我们不能通过简单的加减整数来获取临近像素的归一化坐标,那么我们要怎样精确地取到它们呢?
答案就藏在之前的统一变量中:我们知道屏幕的尺寸!于是我们变相知道了要步进一个像素,需要在归一化坐标上移动的距离:屏幕尺寸的倒数,即是像素尺寸。
接下来就很简单了,GLSL 支持 for
循环,我们只需要设定最大偏移量就可以采样到周围的信息:
但是值得注意的是,我们这里每次循环采样都把结果给替换了,这样我们永远只能获取到最后一次采样的值,显然违背了初衷。
一个简单的方法是把所有的值累加起来,然后在循环外除以循环次数,这样就求到了平均颜色:
现在让把这个值赋给我们的颜色试试:

和预期一样,画面被轻微模糊了!
还有一个值得注意的点是,边缘的像素偏移采样时由于超出了纹理边界,我们更期望它直接终止循环而不是继续采样。因此我们可以进行一些优化,当纹理坐标在范围外时直接跳过当前循环:
这种边界检测我们会经常使用,因此我们也可以将它封装成函数:
你可能注意到我们这里重载了一个同名函数,这是因为 GL 支持三维纹理。由于 OptiFine 默认没有一维纹理,我们这里就不额外重载了(当然,你可以手动导入一维纹理,不过这离我们还太远)。
由于采样数量不一,我们得动态地求平均值,因此还需要增加一个用于计数的变量,于是采样循环就变成了:
最终效果应该是没什么差异的,但是节约了很多无效采样。
宏命令
不知道你有没有发现一个很烦人的事。假如由于模糊效果太轻微,我们现在试图扩大采样数,需要怎么做?
比如把每个方向从 5 次采样改成 7 次,我们需要把这两行改!四!次!
万一一不留神改漏一个,效果出现差错,这种合法的编程疏忽可没有编译器给报错。但你应该已经注意到了,这几个值其实是相同的,那么我们有没有什么办法把它们一起修改呢?
答案就是宏命令 。和 C 一样,GLSL 也支持很多宏命令,其中用得最多、和 OptiFine 设置页面密切相关的就是 #define
。我们只需要在文件开头的版本宏下再添加一行:
然后将采样循环中的相关量全部替换:
就可以轻松更改采样数了。现在让我们取每个方向 11 次模糊的较极端的值试试:

和预期一样,模糊的半径更大了!
除了 #define
和之前介绍过的 #version
外,GLSL 还支持下列 C 中的常见宏
以及用以装载 GL 扩展的 #extension
和包含文件的 #include
。
宏定义可以进行嵌套,可以在 #if
中使用逻辑运算符。 #define
除了可以替换值以外,还可以仅定义而不赋值,用作开关。
习题
尝试调整场景模糊的采样步进,让每次采样跨过多个像素。
尝试利用深度信息,随着深度变化动态地增大模糊半径。记得固定采样次数(固定采样、动态步进)或钳制最大采样次数(固定步进、动态采样)!
好了,在这一节中,你已经学会了延迟处理最重要的纹理采样操作,并且封装了几个好用的函数,还认识了一下宏定义。在下一节中,我们将会整理文件结构并引入光影配置,以备迎接几何缓冲。