本文基于:
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
控制,负责单纯复制。
注意动态模糊一共两次,一次是水平方向的,一次是竖直方向的,由下面 uniforms
中 BlurDir
对应的值确定。事实上 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 主类(已略去 package
和 import
):
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) { } private void onRenderWorldLast (RenderWorldLastEvent event) { } }
我们把 onModelRegistry
和 onRenderWorldLast
两个方法的方法引用作为事件监听器,稍后我们再完善这两个方法的实现。
加载着色器和帧缓冲 由于 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 ; } }
注意这里我们还没有调整着色器对应的帧缓冲的长宽,因此我们新建了两个名为 framebufferWidth
和 framebufferHeight
的字段,并且把它们都设成 -1
,稍后我们会在渲染的时候填入正确的值。
mainFramebuffer
是游戏的主帧缓冲,所有玩家能看得到的画面,对应的都是这一帧缓冲的渲染数据。
完成渲染 我们需要在世界渲染完成后在我们自己的帧缓冲上完成渲染,并叠加到游戏的主帧缓冲上,因此我们需要 Minecraft Forge 提供的名为 net.minecraftforge.client.event.RenderWorldLastEvent
的事件。
收集方块数据 首先我们检查 ShaderGroup
是否受支持:
1 2 if (this .shaders == null ) return ;
然后遍历客户端世界所有的 TileEntity
,从而确定所有工作中的熔炉和高炉:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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 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 );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, 1.0F , 1.0F , 1.0F , 0xFFFFFFFF , OverlayTexture.NO_OVERLAY, EmptyModelData.INSTANCE); matrixStack.pop(); } this .bufferBuilder.finishDrawing();
开始收集数据(begin
方法)需要两个参数。其中,第一个参数是 GL11.GL_QUADS
,因为是方块数据的默认形式,而第二个参数我们采用了 DefaultVertexFormats.POSITION
,因为我们根本不需要顶点位置之外的任何数据(通常情况下的渲染还需要颜色材质等其他数据)。
此外,注意 matrixStack
需要平移两次,一次针对玩家位置,一次针对方块位置。
渲染到我们的帧缓冲 首先需要绑定我们的帧缓冲。通过分析上面提到的 JSON,我们可以注意到,我们需要绑定的帧缓冲的名称是 examplelitfurnacehl:final
:
1 2 3 4 var framebuffer = this .shaders.getFramebufferRaw(ID + ":final" );framebuffer.framebufferClear(Minecraft.IS_RUNNING_ON_MAC); framebuffer.bindFramebuffer(false );
然后执行渲染,注意我们:
不需要和已有的渲染数据混合
不需要绑定任何材质
不需要透明度测试
不需要深度数据
重置颜色
1 2 3 4 5 6 7 RenderSystem.disableBlend(); RenderSystem.disableTexture(); RenderSystem.disableAlphaTest(); RenderSystem.depthMask(false ); RenderSystem.color4f(1.0F , 1.0F , 1.0F , 1.0F ); WorldVertexBufferUploader.draw(this .bufferBuilder);
上面有一些设置不是针对可编程图形管线的,但是由于 Minecraft 目前并没有采用纯粹的可编程图形管线(亦即 OpenGL Core Profile),因此还是需要设置一下。
使用着色器渲染 使用着色器渲染不需要绑定特定的帧缓冲。
1 2 this .shaders.render(event.getPartialTicks());
刚才的 JSON 告诉我们,我们最终仍然渲染到 examplelitfurnacehl:final
,稍后我们会重新用到这一帧缓冲。
渲染到主帧缓冲 渲染之前首先要绑定主帧缓冲:
1 2 mc.getFramebuffer().bindFramebuffer(false );
然后把混合打开,执行最终渲染。注意 Dst
是主帧缓冲,Src
是我们自己的帧缓冲:
1 2 3 4 5 6 7 RenderSystem.enableBlend(); RenderSystem.blendFuncSeparate( GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA, GlStateManager.SourceFactor.ZERO, GlStateManager.DestFactor.ONE); framebuffer.framebufferRenderExt(width, height, false );
收尾 记得把弄乱了的设置复原回去:
1 2 3 4 RenderSystem.disableBlend(); RenderSystem.enableTexture(); RenderSystem.depthMask(true );
最终效果
TeaConMC 旗下的开源项目 Slide Show 已经将上述特性写进相关代码中,并作为方便创造模式玩家寻找被埋藏的方块的一种解决方案。