发光实体再处理 · 认识 Image 类型
Image Load Store 扩展
还记得原版的发光实体吗?在生存模式中,当实体被光灵箭命中就会附加上发光效果。此时在其他玩家眼中,只要实体在渲染距离之内, 无论是否被其他物体遮挡 ,它们都会有一层厚厚的描边。

在之前的片元着色器中,我们一直使用常规的输出方式,在全局空间声明 out 变量,然后绘制到每个像素。不幸的是,在 OptiFine 的设置中,这些片元着色器都会主动进行深度测试,一旦被其他物体遮挡,几何体就不会被绘制,而且这个功能无法取消。幸运的是,GL 还有一种读写方案,而这个方案最初由英伟达作为扩展添加,并在 GLSL 4.20 转正为内置特性。
声明扩展
由于我们所声明的 GLSL 版本较低,因此需要使用 #extension 指令来调用扩展:
宏命令 #extension 我们都知道了,它紧接是扩展名。扩展名的命名规则为:
以现代规范要求的
GL前缀开头;扩展的 类型,
ARB表示获得了 OpenGL ARB 协会批准的正式扩展,类似的还有EXT表示泛用的扩展,但是可能部分硬件厂商未予以实现;扩展的名称。
如果扩展名使用 all ,则会启用所有支持的扩展。
声明了扩展之后,我们还需要指定扩展的行为,在扩展名称后接 : 即可进行定义。扩展的行为可以为 require、 enable、 warn 和 disable ,它们的具体行为由下表决定:
行为 | 声明扩展 | 硬件不支持且使用扩展语法时 |
|---|---|---|
| 强制启用,不支持即报错 | |
| 通过编译 | 报错并中止(失败) |
| 通过编译 | 警告并继续(通过 1) |
| 强制禁用,使用即报错 | |
除了 require ,如果着色器的实际代码中没有使用任何扩展的内容或语法,则不会报错。
[1] 如果在不支持的硬件上使用了 warn 行为的扩展,就算通过了编译,最后的程序也会出现未定义行为。由于我们有插件进行报错,这个行为基本不会用到。
使用图像类型
和纹理类似,在着色器程序的全局空间使用 uniform 关键字即可声明图像类型 gimage ,我们可以在任何着色器中访问 0 ~ 5 号颜色缓冲区和两个阴影颜色缓冲区,只需要把它们的后缀从 tex 改为 img (shadowcolor 同样加上 img 后缀)。有一点不同的是,我们需要使用 layout() 指定图像的格式。
上一节,我们将几何 ID 保存在了单通道8位无符号整数的 3 号缓冲区中,并且已经为发光实体保存了独特的 ID,但是这个 ID 实际只能用于为显露的发光实体处理额外特效。由于发光实体的描边特效仅描边外轮廓,而几何 ID 则覆盖了几何的内部,当发光实体被其他几何遮挡而强行写入时,延迟处理中的几何 ID 就会产生误判。
因此,我们将会启用 4 号缓冲区,用以保存发光实体需要绘制的轮廓依据。在原版中虽然在写入原始数据时只有 1 (发光区域) 和 0 (非发光区域),但是后续进行模糊扩散要求插值过渡,还要独立处理描边的不透明度,不过实际上也只需要一个数据。为此,我们将 4 号缓冲区设置为单通道 16 位浮点 1 来避免过渡断层,并依照上文的方法调用:
当然,你也可以将 3 号缓冲区的格式改为双通道,并将发光数据存入其中,但同时也需要处理先前的整型几何 ID,将它们转换为浮点值(习题 1)。
[1] 描边的 Alpha 差值计算会在边界处产生大于 1 的值,因此不能使用归一化浮点。
我们可以使用函数 imageLoad(gimage image, ivec coord) 来读取图像区域内任意坐标的内容,和 texelFetch() 类似,它使用索引坐标,唯一的区别是图像不可以使用 Mipmap。此外,也可以使用 imageSize() 函数来取到图像的尺寸,它直接返回等维的整型尺寸。
同样的,使用 imageStore(gimage image, ivec coord, gvec4 data) 来写入任意位置。无论几通道,第三项 data 均为四元数,由 GLSL 自动裁切。
在实际写入之前,还需要注意的是,它不会进行任何深度测试和 Alpha 测试(或者说自动深度测试已经是程序结束之后了),所以如果你想要描边完美贴合实体,应当在 Alpha 测试的 discard 之后才调用写入函数。由于没有上下文级别的深度测试,在同一个着色器中对多个重叠三角的同二维位置片元写入会导致写入竞争,从而产生块状闪烁,但是我们这里写入的都是与位置无关的常量,因此就无所谓了。
discard 指令在图像写入与片元着色器的普通输出之间有些许差别。如果触发了 discard 指令,像素着色器的普通输出会停止并丢弃当前像素的任何东西;而图像写入则只会在当前位置停下,而不影响先前写入的内容。
要想用普通输出达到图像写入的功能,可以使用反转条件:
要想用图像写入达到普通输出功能,可以延后图像写入到条件判定之后:
最后,我们的 gbuffers_entities_glowing.glsl 就长这样了:
如果直接读取发光实体缓冲,看起来就像这样:

描边与原版实现
现在我们已经获得了发光实体的遮罩数据,如果单纯地想给覆盖区域外围加上一圈描边,我们只需要在延迟处理中简单地检查非发光像素邻近的四个(或加上对角线共八个)像素即可是否与之状态有别即可。当搜索到任何一个邻近像素是发光区域时,就给当前像素上色,类似这样:
至此,我们就已经初步成功给发光实体描上边了,值得注意的是,上一章中,我们将发光实体暂时设定为了无光照类:

当然,这个发光描边效果仍然很粗糙,只有 1 像素,分辨率高一些就会变得极不明显,所以让我们来拆解原版的发光描边着色器。
原版的描边使用了两个延迟处理 Pass,第一个 Pass entity_outline_box_blur 使用方框模糊处理了发光区域的数据,并保留累积的 Alpha 值(而不是除以采样次数来取均值)以在模糊边缘形成明显断层,从而形成渐变过渡(RGB)并间接确定了描边宽度(Alpha):
这个着色器利用 texture() 的插值性在纹理的四个像素交界处进行两次采样,然后再单独在采样方向上的最外侧进行一次采样,从而快速获得半径内的平均色,最后再除以采样半径 1 。由于原版的发光效果是纯白色,RGB 通道和 Alpha 通道的数据只有是否除以半径的区别。
[1] 因为之前的采样都是两两像素的平均色,最后一次采样又手动减半,因此采样出来的颜色总和实际上只有一半,所以最后除数就不必为像素数量总和了。
第二个 Pass entity_outline 2 则用于再次混合邻近的四个像素并比较 Alpha 的总差异来确认颜色和混合比例:
事实上,这个着色器中只有作为 Alpha 的 total 会起作用, outColor 虽然进行了写入,却并没有影响和修改最终的颜色。由于第一个 Pass 进行了数据模糊,所以发光和非发光相接的区域就会产生 Alpha 值过渡。当我们使用与邻近像素的 Alpha 差值和作为绘制依据时,远离交界区域的 Alpha 值始终为 0 或均匀累积值(数值上与模糊半径相等),Alpha 差值也就为 0,而交界区域与附近的 Alpha 值一直在变化,自然就会产生 Alpha 差值条带。
[2] 原版允许使用 JSON 文件自定义使用的顶点着色器和片元着色器,因此会出现 Pass 名称和着色器名称对不上的情况。比如描边模糊 Pass 的顶点着色器 blur.vsh 也被用于了打开菜单后的背景模糊。
多程序处理
之前的编程中,我们的延迟处理程序都集中在管线的最末端,即 final 中。而描边着色器要求将场景完全模糊之后再来检查,也就是说,如果我们想在同一个着色器中完成这些事情,需要将四周邻近的像素都进行模糊处理然后再来比较。然而普通的片元着色器中,这些数据是无法共享的,也就是说周围的像素在它们各自的片元着色器中也会这样干,最终就会造成四倍的模糊开销,这是极其不划算的。自然而然的,我们就会做出像原版那样的事情:先在一个 Pass 中进行图像模糊,再在下一个 Pass 中处理描边。
OptiFine 为我们提供了高度可自定义的延迟处理程序数量。回顾一下,我们可以用的延迟处理主要集中在两个阶段:固体几何缓冲之后的 Deferred 和余下几何缓冲之后的 Composite 。由于模糊着色器只处理发光遮罩缓冲区,因此程序的选择没什么所谓,这里我们就选择更靠近 Final 的 Composite 了。
原版的模糊着色器看起来会很奇怪,它似乎只在某个方向(sampleStep )上处理了模糊,当然,实际上是它在不同方向上运行了两次。为了平衡性能和质量,我们也会效仿原版,将模糊也分为两个 Pass,先将其进行水平模糊,再进行垂直模糊。最终,我们的模糊就在 composite 和 composite1 中进行,而描边则接在 final 中场景绘制完毕之后。
没有特别的要求的话,所有延迟处理的顶点着色器都一样,因此你可以直接复制 final.vsh 并更名。
参考原版着色器,它使用了方框模糊将周围的数据进行平均,我们也将仿照它的方法和技巧,进行 5x5 的模糊处理,并将半径设计为可调整。
其他的常规声明与 Final 一样,在这个着色器中,我们只需要向发光数据中写入内容,因此渲染目标只有 4 号缓冲区:
main 函数的内容和原版着色器很相似,只不过我们可以把模糊半径塞入 Settings.glsl 中,以便间接控制发光描边宽度。水平模糊的着色器看起来就像这样:
竖直方向的模糊只需要将 sampleDir 的 vec2(pixelSize.x, 0.0) 替换为 vec2(0.0, pixelSize.y) 即可。
你可能注意到了,我们这里没有除以模糊半径,因为原版描边着色器中 Alpha 通道用得比较多。如果我们提早进行了除法,那么在描边着色器中需要使用 min(c * (GLOWING_BLUR_RADIUS + 0.5), 1.0) 来进行手动复原和截断。由于涉及到原版 Alpha 通道的部分都会包含不可应用分配律的 min() 函数,因此会有很多次 min(a * x,b) ,那我们何不延后原版 RGB 通道的除法变成 min(x,b) 减少乘法次数呢?
回到 Final 中,将原版的方法封装成函数原样搬入,只是不再关心 RGB 的值,然后将其他统一变量对齐即可:
就像之前我们说的那样,原版的描边着色器仅用来判定描边边界,因此我们略去了所有与 RGB 相关的计算。最后,将 Final 中之前的颜色与之相混合,就可以绘制出发光描边了:
当然,你也可以自定义发光描边的颜色,比如基于深度值或生物纹理本身。最后来看看大模糊半径下,将混合颜色使用三角函数和 frameTimeCounter 处理以呈现的动态彩虹发光描边!

习题
(与习题 2 二选一)将 4 号缓冲区的内容合并入 3 号缓冲区中。之后,你可以先使用
imageLoad(colorimg3, COORD).r取出已经写入的几何 ID,然后手动进行深度测试来决定保留几何 ID 的源内容还是覆写新内容(当前片元深度小于深度图上已有的深度时,说明发光实体本身也在前景,因此要覆写几何 ID),最后使用imageStore(colorimg3, COORD, vec4(geometryID, 1.0, vec2(0.0)))覆写图像内容并确保 Green 通道为 1.0 即可。随后的模糊 Pass 只需要将 Red 通道中的内容原样输出即可。(与习题 1 二选一)将 4 号缓冲区改为四通道,并在几何缓冲存入数据时写入纹理颜色,这在一定程度上可以根据实体的纹理颜色产生描边颜色。虽然会因为写入竞争而产生块状闪烁,但是描边区域是基本上稳定的。你可以将写入的 Alpha 值固定为 1 ,并仅写入正面片元(
if(gl_FrontFacing) { imageStore(...); })来基本消除边缘闪烁。尝试编写一个本节末尾处的彩虹描边效果。可以直接定义一个三维向量,每个通道都利用三角函数将
glowingColor加上frameTimeCounter作为参数来周期性地改变颜色;然后配置文件中按统一变量的方法定义常量,将其添加入 Uniforms.glsl,加入三角函数中用以给每个颜色分量不同的相位偏移(、 )。为了防止颜色溢出到负值,你可以将结果进行平方;如果你想要更加平缓的边缘,可以给颜色额外乘上 glowingColor或将其乘入混合参考。如果你同时完成了习题 2 和 3,可以把宏开关合并成
#define GLOWING_EFFECT 0 // [0 1 2],每个数字代表一个描边上色模式。在混合比例中乘入
float(geoID != geoID_enum.glowing_entities)可以避免发光实体本身被描边遮挡,将其设置为宏开关是个不错的选择。如果你觉得描边变得太淡,那是因为模糊总是从边框开始双向延伸,因此最终向外蔓延的值总是小于 0.5,因此你可以将描边颜色乘 2。
利用自定义统一变量,将 摄像机参数
is_glowing传入uniform.bool.cam_isGlowing = is_glowing然后在
gbuffers_hand.glsl中使用if(cam_isGlowing)来向发光缓冲区进行图像写入,这样就可以在玩家发光时让手部和持有物也发光了。