正常人都不会想看见大片大片的摩尔纹,但是在无论是发神金还是风格化渲染,总有时候有人会想渲染摩尔纹。因此本文讨论如何渲染简易的摩尔纹效果。文中仅做原理介绍,完整代码请看这里。

这是本文最终效果。
一些理论
本文不讨论摩尔纹的成因(作者也没这个水平讨论成因),总之我们可以认为摩尔纹是干涉产生的,把电脑屏幕想象成摄像头,被渲染出摩尔纹的部分看做屏幕,则二者正对时不会产生摩尔纹。因此必须将被渲染部分变换为一任意四边形(很适合世界坐标下的渲染,哈哈)。继续考虑被渲染部分看做屏幕的比喻,相信能注意到,它不需要是个比喻,将这部分当做屏幕渲染,即用真实屏幕的一个像素作为这个虚拟屏幕的一个子像素,即可产生摩尔纹。
作者无法解释为什么这个方案可行,但是显然这其实是对拍屏这个物理过程的模拟。虚拟屏幕按与真实屏幕类似的方式显示内容,真实屏幕的每个像素则类似相机上的每个像素,对拍摄的画面进行采样。
此方案非常简单且易于实现,并且开销只需要一次后处理。当然缺点也是存在的,这需要详述:
- 等效于降低了这块虚拟屏幕的分辨率
- 由于每个像素只显示一个颜色通道的部分,总体亮度降到原来的三分之一
- 有时候摩尔纹带有子像素的颜色
- 根据使用的子像素排列,甚至还得对这个虚拟屏幕调色
上述缺点会在实现时尽力补救,但是无法完全解决。
在正式开始实现前我们先解决两个技术问题。
如何变换屏幕
在屏幕上,我们相当于把一个矩形变换到一个任意四边形,且可知每个店变换前后的坐标。
显然这是线性变换,我们可以从这八个点中解出一个矩阵完成该投影。
设输入四个点为a, 输出四个点为b

可知存在一矩阵H满足:

最后得到完整线性方程组, 求解即可知道H

这个矩阵被称之为单应性矩阵。对鼠标位置做反变换可以获得它真正生效的坐标,让gui正常工作。
点这里查看如何使用高斯消元法解线性方程组。
选择子像素排列
现今多数显示屏每个像素均是由显示红绿蓝的子像素组成的,但是存在若干种排列形式。

AI生成图片, 未做事实性核查, 明白意思就行
对于我们的虚拟屏幕,选择任意一种皆可。这里我们选择拜耳排列,因为后处理时只需要对屏幕坐标对2取模就能获得应当输出的颜色通道。
拜耳排列每个像素有两个绿色子像素,这是因为人眼对绿色更敏感,但是真实显示屏都是已经经过校正的了(真的吗),所以再在虚拟显示屏上超级加倍绿色分量会导致画面严重偏绿,需要削减绿色分量的输出。
实现
后处理…是什么…
以防你不知道后处理是什么,这里最简单地介绍一下。
输入整个屏幕画面,输出到整个屏幕,期间可以对屏幕上已有的内容进行任意操作。
屏幕变换
我们使用uniform来向着色器传递单应性矩阵和屏幕大小(屏幕大小暂时用不到)。
对于顶点着色器,什么都不需要干。
对于片段着色器,我们计算当前屏幕坐标变换前在哪里,在屏幕内则采样,不在就discard。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| #version 150
uniform sampler2D DiffuseSampler; uniform vec2 OutSize; uniform mat3 Homography;
in vec2 texCoord; out vec4 fragColor;
void main() { vec3 remappedCoord = Homography * vec3(texCoord, 1.0); vec2 uv = remappedCoord.xy / remappedCoord.z; if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { discard; } fragColor = texture(DiffuseSampler, uv); }
|

这是完成这一步的效果。
渲染虚拟屏幕
依旧不关顶点着色器什么事。
我们从uv获得的坐标是归一化的,将其乘以屏幕大小再转换为整数才是我们需要的坐标。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| #version 150
uniform sampler2D DiffuseSampler; uniform vec2 OutSize; uniform mat3 Homography;
in vec2 texCoord; out vec4 fragColor;
const mat2 bayer = mat2( 1,0, 2,1 );
const mat4 mask = mat4( 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 );
void main() { vec3 remappedCoord = Homography * vec3(texCoord, 1.0); vec2 uv = remappedCoord.xy / remappedCoord.z; if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { discard; } vec4 color = texture(DiffuseSampler, uv); int index = int(bayer[int(uv.y*OutSize.y)%2][int(uv.x*OutSize.x)%2]); fragColor = vec4(color*mask[index].rgb,color.a); }
|

该步效果, 可以看见摩尔纹了, 但是画面基本无法使用, 存在前文已述的问题。
微调与改进
有许多处微调, 下面直接列出:
- 减小绿色分量, 改动颜色mask即可
- 根据原来像素亮度控制输出像素强度
- 调整伽马值来弥补亮度损失
- 为什么不和原画面混合呢 (其实这一条效果最明显)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| #version 150
uniform sampler2D DiffuseSampler; uniform vec2 OutSize; uniform mat3 Homography;
in vec2 texCoord; out vec4 fragColor;
const vec3 gamma_ = vec3(1.1,1.2,1.1); const float moorishStrength = 0.2; const mat2 bayer = mat2( 1,0, 2,1 );
const mat4 mask = mat4( 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 );
vec3 getByChannel(int index, vec4 color) { float light = dot(color.rgb, vec3(0.299, 0.587, 0.114)); vec4 temp = vec4(color.rgb,light)*mask[index]; return (temp.rgb + vec3(temp.a))*1; }
vec3 gamma(vec3 color) { return pow(color, 1.0 / gamma_); }
void main() { vec3 remappedCoord = Homography * vec3(texCoord, 1.0); vec2 uv = remappedCoord.xy / remappedCoord.z; if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { discard; } vec4 color = texture(DiffuseSampler, uv); vec3 c = getByChannel(int(bayer[int(uv.y*OutSize.y)%2][int(uv.x*OutSize.x)%2]),color); fragColor = vec4(vec4(gamma(c)*moorishStrength+color.rgb*(1-moorishStrength), color.a) }
|

现在的画面基本就可以使用了。
仍然存在有色带的问题, 这大概就不是这个方案能解决的了。猜想使用抗锯齿能进一步提升画面质量。
集成进Minecraft
前面只给出着色器代码, 现在简单介绍怎么集成进Minecraft, 环境为 1.21.1 Neoforge。
加载shader
在VibrantVisual更新前, 加载自定义shader还比较麻烦。
首先将顶点着色器和片段着色器放入资源包下{namespace}/shaders/core文件夹下, 并添加一个json文件描述如何组合顶点着色器和片段着色器和使用的samplers和uniforms等。这个文件具体怎么写请参照原版资源文件或mcwiki上的历史版本。
然后向游戏注册shader, 我们使用nf提供的RegisterShadersEvent, 记得保存注册的ShaderInstance. 填入的rl路径应该匹配之前写的json文件(所以ShaderInstance是更类似渲染管线的存在, 在后来的VibrantVisual也的确删除了该类型改为重新抽象了一层渲染管线出来)。
1 2 3 4 5 6 7 8 9
| public static ShaderInstance EXAMPLE_SHADER; @SubscribeEvent public static void onRegShader(RegisterShadersEvent event) { var res = event.getResourceProvider(); var testShader = new ShaderInstance(res, ResourceLocation.fromNamespaceAndPath("examplemod","example"), DefaultVertexFormat.POSITION); event.registerShader(testShader,s -> EXAMPLE_SHADER = s);
}
|
如何后处理
原版存在自己的后处理链, 但是其不便于修改, 我们要影响gui也无法使用。
所以我们自己进行后处理, 原理是画一个跟屏幕一样大的quad传给着色器。
渲染的前置知识太多, 这里只给出部分关键代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| var shader = EXAMPLE_SHADER; var inputTexture = Minecraft.getInstance().getMainRenderTarget().getColorTextureId(); RenderSystem.setShader(() -> shader); shader.setSampler("DiffuseSampler", inputTexture); shader.safeGetUniform("ProjMat").set(this.projectionMatrix); shader.safeGetUniform("OutSize").set((float) xSize, (float) ySize); shader.safeGetUniform("Homography").set(homography); RenderSystem.disableDepthTest();
BufferBuilder bufferBuilder = Tesselator.getInstance().begin(VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION); bufferBuilder.addVertex(0.0f, 0.0f, 500.0f); bufferBuilder.addVertex(xSize, 0.0f, 500.0f); bufferBuilder.addVertex(xSize, ySize, 500.0f); bufferBuilder.addVertex(0.0f, ySize, 500.0f);
shader.apply(); writeFramebuffer.bindWrite(true); BufferUploader.draw(bufferBuilder.buildOrThrow()); writeFramebuffer.unbindWrite(); RenderSystem.depthFunc(GL11.GL_LEQUAL); RenderSystem.enableDepthTest(); RenderSystem.setShader(() -> null); shader.clear();
|
其中writeFramebuffer应该是你自己创建的跟屏幕一样大的RenderTarget, 因为opengl并不能输入输出都是相同的帧缓冲, 所以我们还需要把这个帧缓冲的内容复制回主帧缓冲。
1 2 3 4 5 6 7 8 9 10 11 12 13
| public static void copyFrameBufferColorTo(RenderTarget from, RenderTarget to){ copyFrameBufferColorTo(from,to.getColorTextureId()); }
public static void copyFrameBufferColorTo(RenderTarget from, int to) { GL30.glBindFramebuffer(GL30.GL_READ_FRAMEBUFFER, from.frameBufferId); GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, to); GL30.glBlitFramebuffer(0,0,from.width,from.height, 0,0,from.width,from.height, GL30.GL_COLOR_BUFFER_BIT, GL30.GL_NEAREST); GL30.glBindFramebuffer(GL30.GL_READ_FRAMEBUFFER, 0); GL30.glBindFramebuffer(GL30.GL_DRAW_FRAMEBUFFER, 0); }
|
接下来就是寻找时机调用我们自己的后处理代码, 我们要在gui绘制后进行后处理, 因此使用一个mixin到gui绘制后执行我们的代码.
1 2 3 4 5 6 7 8 9 10 11
| @Mixin(GameRenderer.class) public class MixinGameRenderer { @Inject(method = "render",at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/GuiGraphics;flush()V", shift = At.Shift.AFTER)) public void afterRender(DeltaTracker deltaTracker, boolean renderLevel, CallbackInfo ci){ Minecraft.getInstance().getMainRenderTarget().bindWrite(true); } }
|