自定义发光效果——浅谈着色器和帧缓冲在 Minecraft 的运用

本文基于:

  • Java 11.0.8
  • Minecraft 1.15.2
  • Minecraft Forge 31.2.0
  • MCP Mapping 20200514-1.15.1

读者可以在这里下载到本文的源代码:source.zip(56.6 KiB)。


本篇文章由 TeaConMC 采用知识共享-署名-相同方式共享 4.0 国际许可协议进行许可。

引言

发光效果于 Minecraft 1.9 正式引入。发光效果的引入是划时代的:它使得基于着色器的可编程图形管线(Programmable Graphics Pipeline)正式作为不可或缺的游戏特性被引入,而非仅仅通过点击 Super Secret Settings 这一若有若无的按钮,或是当玩家在旁观模式观察生物时才会引起玩家的注意。

发光效果的实际渲染方式需要首先计算特定边缘,然后在计算得到的边缘处绘制外框。这一操作固然可以使用 CPU 完成,但是交给 GPU 计算显然是更好的选择,着色器(Shader)便是用于交给 GPU 计算的小程序,与之有关的编程语言被称为 OpenGL Shader Language,简称 GLSL。

因为计算边缘这一特定需求,因此发光效果必须单独渲染,不能和已有的世界渲染等直接混合(否则世界中其他的「边缘」便会一并囊括进来),这也是我们需要在渲染过程中引入额外帧缓冲(Framebuffer)的必要性所在。

本篇文章将以使工作中的熔炉(Furnace)和高炉(Blast Furnace)发光为目标,演示整个渲染过程。以下是大致的渲染流程:

本文中的示例 Mod ID 为 examplelitfurnacehl

Minecraft 中的着色器和帧缓冲

在 Minecraft 1.15.2 中,控制着色器的类为 net.minecraft.client.shader.ShaderGroup,我们会用到它的以下几个方法:

  • createBindFramebuffers:用于调整着色器对应的帧缓冲的长宽。
  • getFramebufferRaw:用于获取着色器相关联的帧缓冲。
  • render:为特定的帧缓冲应用着色器。
  • close:清理内存。

帧缓冲相关的类为 net.minecraft.client.shader.Framebuffer,我们会用到:

  • framebufferRenderExt:把一个帧缓冲中的渲染数据全部渲染到另一个帧缓冲上。
  • bindFramebuffer:绑定该帧缓冲(亦即接下来的渲染操作全部针对该帧缓冲)。
  • framebufferClear:清空帧缓冲中的渲染数据。

每个 ShaderGroup 的实例都对应到一个 JSON 文件。通常该 JSON 文件位于资源包中特定 Mod ID 所处资源路径下的 shaders/post 目录中,本文为 assets/examplelitfurnacehl/shaders/post 目录下的 furnace_outline.json。以下是该 JSON 的全部内容:

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
{
"targets": [
"examplelitfurnacehl:swap",
"examplelitfurnacehl:final"
],
"passes": [{
"name": "minecraft:entity_outline",
"intarget": "examplelitfurnacehl:final",
"outtarget": "examplelitfurnacehl:swap"
}, {
"name": "minecraft:blur",
"intarget": "examplelitfurnacehl:swap",
"outtarget": "examplelitfurnacehl:final",
"uniforms": [{
"name": "BlurDir",
"values": [1.0, 0.0]
}, {
"name": "Radius",
"values": [2.0]
}]
}, {
"name": "minecraft:blur",
"intarget": "examplelitfurnacehl:final",
"outtarget": "examplelitfurnacehl:swap",
"uniforms": [{
"name": "BlurDir",
"values": [0.0, 1.0]
}, {
"name": "Radius",
"values": [2.0]
}]
}, {
"name": "minecraft:blit",
"intarget": "examplelitfurnacehl:swap",
"outtarget": "examplelitfurnacehl:final"
}]
}

targets 代表创建多少相关联的帧缓冲,这里创建了两个:

  • 第一个帧缓冲名为 examplelitfurnacehl:swap
  • 第二个帧缓冲名为 examplelitfurnacehl:final

passes 代表应用着色器的渲染次数,这里一共四次,由三组着色器控制:

  • 第一次由 minecraft:entity_outline 控制,负责边缘探测。
  • 第二次和第三次由 minecraft:blur 控制,负责动态模糊。
  • 最后一次由 minecraft:blit 控制,负责单纯复制。

注意动态模糊一共两次,一次是水平方向的,一次是竖直方向的,由下面 uniformsBlurDir 对应的值确定。事实上 uniforms 将会作为 GLSL 的 uniform 输入传递给着色器。

每一组着色器的控制文件位于资源包中特定 Mod ID 所处资源路径下的 shaders/program 目录,比如 assets/minecraft/shaders/program 目录下的 blur.json。该文件由 Minecraft 本身提供,对应 minecraft:blur,其中定义了每一次渲染是如何进行的。以下是该文件的大致内容:

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
{
"blend": {
"func": "add",
"srcrgb": "one",
"dstrgb": "zero"
},
"vertex": "sobel",
"fragment": "blur",
"attributes": ["Position"],
"samplers": [{
"name": "DiffuseSampler"
}],
"uniforms": [{
"name": "ProjMat",
"type": "matrix4x4",
"count": 16,
"values": [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]
}, {
"name": "InSize",
"type": "float",
"count": 2,
"values": [1.0, 1.0]
}, {
"name": "OutSize",
"type": "float",
"count": 2,
"values": [1.0, 1.0]
}, {
"name": "BlurDir",
"type": "float",
"count": 2,
"values": [1.0, 1.0]
}, {
"name": "Radius",
"type": "float",
"count": 1,
"values": [5.0]
}]
}
  • blend 代表混合模式。
  • vertex 代表顶点着色器的位置。
  • fragment 代表片元着色器的位置。
  • attributes 代表着色器的 attribute 输入,通常只用得到 Position
  • samplers 代表着色器的 sampler2D 输入,通常只用得到 DiffuseSampler
  • uniforms 代表着色器的 uniform 输入和默认值,通常而言它们是固定的。

ShaderGroup 中的每一次渲染,本质上都是将一个帧缓冲中的渲染数据提取出来,重新绘制到另一个帧缓冲上,这使得顶点着色器虽然不是完全没有用处,但一定程度上也有一点鸡肋——只有固定的 1 个面和 4 个顶点,因此不同的 ShaderGroup 复用同一个顶点着色器是很常发生的事情,不过片元着色器相对而言要有用得多。

可能有读者对边缘探测的算法感兴趣,其实就是相当于对整个渲染数据做了一次差分计算,感兴趣的可以进一步了解 Sobel Filter 相关的资料。

Mod 主类

以下是最初的 Mod 主类(已略去 packageimport):

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
@Mod(ExampleLitFurnaceHighlighting.ID)
public final class ExampleLitFurnaceHighlighting {
public static final String ID = "examplelitfurnacehl";
public static final Logger LOGGER = LogManager.getLogger(ExampleLitFurnaceHighlighting.class);

public ExampleLitFurnaceHighlighting() {
FMLJavaModLoadingContext.get().getModEventBus().addListener(this::onModelRegistry);
MinecraftForge.EVENT_BUS.addListener(this::onRenderWorldLast);
}

private void onModelRegistry(ModelRegistryEvent event) {
// TODO
}

private void onRenderWorldLast(RenderWorldLastEvent event) {
// TODO: step 0
// TODO: step 1
// TODO: step 2
// TODO: step 3
// TODO: step 4
// TODO: step 5
// TODO: step 6
// TODO: step 7
// TODO: step 8
// TODO: step 9
}
}

我们把 onModelRegistryonRenderWorldLast 两个方法的方法引用作为事件监听器,稍后我们再完善这两个方法的实现。

加载着色器和帧缓冲

由于 ShaderGroup 的相关定义位于资源包中,因此我们需要在资源包重新加载(如按下 F3 + T)时生成新的 ShaderGroup,因此我们需要寻找每次重新加载时都触发的事件。在 Minecraft Forge 中,我们可以监听 net.minecraftforge.client.event.ModelRegistryEvent

以下是 onModelRegistry 的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private int framebufferWidth = -1;
private int framebufferHeight = -1;

private ShaderGroup shaders = null;

private void onModelRegistry(ModelRegistryEvent event) {
if (this.shaders != null) this.shaders.close();

this.framebufferWidth = this.framebufferHeight = -1;

var resourceLocation = new ResourceLocation(ID, "shaders/post/furnace_outline.json");

try {
var mc = Minecraft.getInstance();
var mainFramebuffer = mc.getFramebuffer();
var textureManager = mc.getTextureManager();
var resourceManager = mc.getResourceManager();
this.shaders = new ShaderGroup(textureManager, resourceManager, mainFramebuffer, resourceLocation);
} catch (IOException | JsonSyntaxException e) {
LOGGER.warn("Failed to load shader: {}", resourceLocation, e);
this.shaders = null;
}
}

注意这里我们还没有调整着色器对应的帧缓冲的长宽,因此我们新建了两个名为 framebufferWidthframebufferHeight 的字段,并且把它们都设成 -1,稍后我们会在渲染的时候填入正确的值。

mainFramebuffer 是游戏的主帧缓冲,所有玩家能看得到的画面,对应的都是这一帧缓冲的渲染数据。

完成渲染

我们需要在世界渲染完成后在我们自己的帧缓冲上完成渲染,并叠加到游戏的主帧缓冲上,因此我们需要 Minecraft Forge 提供的名为 net.minecraftforge.client.event.RenderWorldLastEvent 的事件。

收集方块数据

首先我们检查 ShaderGroup 是否受支持:

1
2
// step 0: check if shaders are supported
if (this.shaders == null) return;

然后遍历客户端世界所有的 TileEntity,从而确定所有工作中的熔炉和高炉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// step 1: collect furnaces
var mc = Minecraft.getInstance();
var world = Objects.requireNonNull(mc.world);
var furnaceCollection = new HashMap<BlockPos, BlockState>();
for (var tileEntity : world.loadedTileEntityList) {
var blockState = tileEntity.getBlockState();
if (Blocks.FURNACE.equals(blockState.getBlock()) && blockState.get(BlockStateProperties.LIT)) {
furnaceCollection.put(tileEntity.getPos(), blockState);
}
if (Blocks.BLAST_FURNACE.equals(blockState.getBlock()) && blockState.get(BlockStateProperties.LIT)) {
furnaceCollection.put(tileEntity.getPos(), blockState);
}
}
if (furnaceCollection.isEmpty()) return;

如果不存在这样的 TileEntity,那么也没有进行下一步渲染的必要了。

设置帧缓冲的长宽

我们还没设置帧缓冲的长宽,我们把长宽缓存到两个字段中,如果发现不一样(比如说玩家调整了窗口的大小等)则重新设置一次。

1
2
3
4
5
6
7
8
9
// step 2: resize our framebuffer
var mainWindow = mc.getMainWindow();
var width = mainWindow.getFramebufferWidth();
var height = mainWindow.getFramebufferHeight();
if (width != this.framebufferWidth || height != this.framebufferHeight) {
this.framebufferWidth = width;
this.framebufferHeight = height;
this.shaders.createBindFramebuffers(width, height);
}

收集顶点数据

Minecraft 自身提供了 net.minecraft.client.renderer.BufferBuilder 用于收集顶点数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private final BufferBuilder bufferBuilder = new BufferBuilder(256);

// step 3: prepare block faces
var matrixStack = event.getMatrixStack();
var dispatcher = mc.getBlockRendererDispatcher();
var view = mc.gameRenderer.getActiveRenderInfo().getProjectedView();
this.bufferBuilder.begin(GL11.GL_QUADS, DefaultVertexFormats.POSITION);
for (var entry : furnaceCollection.entrySet()) {
var blockPos = entry.getKey();
var blockState = entry.getValue();
var model = dispatcher.getModelForState(blockState);

matrixStack.push();
matrixStack.translate(-view.getX(), -view.getY(), -view.getZ());
matrixStack.translate(blockPos.getX(), blockPos.getY(), blockPos.getZ());

dispatcher.getBlockModelRenderer().renderModel(
matrixStack.getLast(), this.bufferBuilder, blockState, model,
/*red*/1.0F, /*green*/1.0F, /*blue*/1.0F, /*light*/0xFFFFFFFF,
/*overlay*/OverlayTexture.NO_OVERLAY, /*model data*/EmptyModelData.INSTANCE);

matrixStack.pop();
}
this.bufferBuilder.finishDrawing();

开始收集数据(begin 方法)需要两个参数。其中,第一个参数是 GL11.GL_QUADS,因为是方块数据的默认形式,而第二个参数我们采用了 DefaultVertexFormats.POSITION,因为我们根本不需要顶点位置之外的任何数据(通常情况下的渲染还需要颜色材质等其他数据)。

此外,注意 matrixStack 需要平移两次,一次针对玩家位置,一次针对方块位置。

渲染到我们的帧缓冲

首先需要绑定我们的帧缓冲。通过分析上面提到的 JSON,我们可以注意到,我们需要绑定的帧缓冲的名称是 examplelitfurnacehl:final

1
2
3
4
// step 4: bind our framebuffer
var framebuffer = this.shaders.getFramebufferRaw(ID + ":final");
framebuffer.framebufferClear(Minecraft.IS_RUNNING_ON_MAC);
framebuffer.bindFramebuffer(/*set viewport*/false);

然后执行渲染,注意我们:

  • 不需要和已有的渲染数据混合
  • 不需要绑定任何材质
  • 不需要透明度测试
  • 不需要深度数据
  • 重置颜色
1
2
3
4
5
6
7
// step 5: render block faces to our framebuffer
RenderSystem.disableBlend();
RenderSystem.disableTexture();
RenderSystem.disableAlphaTest();
RenderSystem.depthMask(/*flag*/false);
RenderSystem.color4f(1.0F, 1.0F, 1.0F, 1.0F);
WorldVertexBufferUploader.draw(this.bufferBuilder);

上面有一些设置不是针对可编程图形管线的,但是由于 Minecraft 目前并没有采用纯粹的可编程图形管线(亦即 OpenGL Core Profile),因此还是需要设置一下。

使用着色器渲染

使用着色器渲染不需要绑定特定的帧缓冲。

1
2
// step 6: apply shaders
this.shaders.render(event.getPartialTicks());

刚才的 JSON 告诉我们,我们最终仍然渲染到 examplelitfurnacehl:final,稍后我们会重新用到这一帧缓冲。

渲染到主帧缓冲

渲染之前首先要绑定主帧缓冲:

1
2
// step 7: bind main framebuffer
mc.getFramebuffer().bindFramebuffer(/*set viewport*/false);

然后把混合打开,执行最终渲染。注意 Dst 是主帧缓冲,Src 是我们自己的帧缓冲:

1
2
3
4
5
6
7
// step 8: render our framebuffer to main framebuffer
RenderSystem.enableBlend();
RenderSystem.blendFuncSeparate(
GlStateManager.SourceFactor.SRC_ALPHA,
GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA,
GlStateManager.SourceFactor.ZERO, GlStateManager.DestFactor.ONE);
framebuffer.framebufferRenderExt(width, height, /*replacement*/false);

收尾

记得把弄乱了的设置复原回去:

1
2
3
4
// step 9: clean up
RenderSystem.disableBlend();
RenderSystem.enableTexture();
RenderSystem.depthMask(/*flag*/true);

最终效果

TeaConMC 旗下的开源项目 Slide Show 已经将上述特性写进相关代码中,并作为方便创造模式玩家寻找被埋藏的方块的一种解决方案。