惊人的瞎眼-如何实现简易摩尔纹特效

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

这是本文最终效果。

一些理论

本文不讨论摩尔纹的成因(作者也没这个水平讨论成因),总之我们可以认为摩尔纹是干涉产生的,把电脑屏幕想象成摄像头,被渲染出摩尔纹的部分看做屏幕,则二者正对时不会产生摩尔纹。因此必须将被渲染部分变换为一任意四边形(很适合世界坐标下的渲染,哈哈)。继续考虑被渲染部分看做屏幕的比喻,相信能注意到,它不需要是个比喻,将这部分当做屏幕渲染,即用真实屏幕的一个像素作为这个虚拟屏幕的一个子像素,即可产生摩尔纹。

作者无法解释为什么这个方案可行,但是显然这其实是对拍屏这个物理过程的模拟。虚拟屏幕按与真实屏幕类似的方式显示内容,真实屏幕的每个像素则类似相机上的每个像素,对拍摄的画面进行采样。

此方案非常简单且易于实现,并且开销只需要一次后处理。当然缺点也是存在的,这需要详述:

  • 等效于降低了这块虚拟屏幕的分辨率
  • 由于每个像素只显示一个颜色通道的部分,总体亮度降到原来的三分之一
  • 有时候摩尔纹带有子像素的颜色
  • 根据使用的子像素排列,甚至还得对这个虚拟屏幕调色

上述缺点会在实现时尽力补救,但是无法完全解决。

在正式开始实现前我们先解决两个技术问题。

如何变换屏幕

在屏幕上,我们相当于把一个矩形变换到一个任意四边形,且可知每个店变换前后的坐标。

显然这是线性变换,我们可以从这八个点中解出一个矩阵完成该投影。

设输入四个点为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文件描述如何组合顶点着色器和片段着色器和使用的samplersuniforms等。这个文件具体怎么写请参照原版资源文件或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);
//按文档要求, 应该保存注册回调里返回的shaderInstance而不是自己new的, 然而在实际上并没有区别, 考虑到ShaderInstance在更新的版本已经似了, 这个问题已经无关紧要
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);
}

}