自定义发光效果——浅谈着色器和帧缓冲在 Minecraft 的运用
本篇文章首发于 TeaCon Blog:https://blog.teacon.org/shaders-and-framebuffers.html
本文基于:
- 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 的全部内容:
{
"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
控制,负责单纯复制。
注意动态模糊一共两次,一次是水平方向的,一次是竖直方向的,由下面 uniforms
中 BlurDir
对应的值确定。事实上 uniforms
将会作为 GLSL 的 uniform
输入传递给着色器。
每一组着色器的控制文件位于资源包中特定 Mod ID 所处资源路径下的 shaders/program
目录,比如 assets/minecraft/shaders/program
目录下的 blur.json
。该文件由 Minecraft 本身提供,对应 minecraft:blur
,其中定义了每一次渲染是如何进行的。以下是该文件的大致内容:
{
"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 主类(已略去 package
和 import
):
@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
}
}
我们把 onModelRegistry
和 onRenderWorldLast
两个方法的方法引用作为事件监听器,稍后我们再完善这两个方法的实现。
加载着色器和帧缓冲
由于 ShaderGroup
的相关定义位于资源包中,因此我们需要在资源包重新加载(如按下 F3 + T
)时生成新的 ShaderGroup
,因此我们需要寻找每次重新加载时都触发的事件。在 Minecraft Forge 中,我们可以监听 net.minecraftforge.client.event.ModelRegistryEvent
。
以下是 onModelRegistry
的实现:
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;
}
}
注意这里我们还没有调整着色器对应的帧缓冲的长宽,因此我们新建了两个名为 framebufferWidth
和 framebufferHeight
的字段,并且把它们都设成 -1
,稍后我们会在渲染的时候填入正确的值。
mainFramebuffer
是游戏的主帧缓冲,所有玩家能看得到的画面,对应的都是这一帧缓冲的渲染数据。
完成渲染
我们需要在世界渲染完成后在我们自己的帧缓冲上完成渲染,并叠加到游戏的主帧缓冲上,因此我们需要 Minecraft Forge 提供的名为 net.minecraftforge.client.event.RenderWorldLastEvent
的事件。
收集方块数据
首先我们检查 ShaderGroup
是否受支持:
// step 0: check if shaders are supported
if (this.shaders == null) return;
然后遍历客户端世界所有的 TileEntity
,从而确定所有工作中的熔炉和高炉:
// 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
,那么也没有进行下一步渲染的必要了。
设置帧缓冲的长宽
我们还没设置帧缓冲的长宽,我们把长宽缓存到两个字段中,如果发现不一样(比如说玩家调整了窗口的大小等)则重新设置一次。
// 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
用于收集顶点数据。
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
:
// step 4: bind our framebuffer
var framebuffer = this.shaders.getFramebufferRaw(ID + ":final");
framebuffer.framebufferClear(Minecraft.IS_RUNNING_ON_MAC);
framebuffer.bindFramebuffer(/*set viewport*/false);
然后执行渲染,注意我们:
- 不需要和已有的渲染数据混合
- 不需要绑定任何材质
- 不需要透明度测试
- 不需要深度数据
- 重置颜色
// 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),因此还是需要设置一下。
使用着色器渲染
使用着色器渲染不需要绑定特定的帧缓冲。
// step 6: apply shaders
this.shaders.render(event.getPartialTicks());
刚才的 JSON 告诉我们,我们最终仍然渲染到 examplelitfurnacehl:final
,稍后我们会重新用到这一帧缓冲。
渲染到主帧缓冲
渲染之前首先要绑定主帧缓冲:
// step 7: bind main framebuffer
mc.getFramebuffer().bindFramebuffer(/*set viewport*/false);
然后把混合打开,执行最终渲染。注意 Dst
是主帧缓冲,Src
是我们自己的帧缓冲:
// 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);
收尾
记得把弄乱了的设置复原回去:
// step 9: clean up
RenderSystem.disableBlend();
RenderSystem.enableTexture();
RenderSystem.depthMask(/*flag*/true);
最终效果
TeaConMC 旗下的开源项目 Slide Show 已经将上述特性写进相关代码中,并作为方便创造模式玩家寻找被埋藏的方块的一种解决方案。