10

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

 3 years ago
source link: https://blog.ustc-zzzz.net/minecraft-shaders-and-framebuffers/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

本篇文章首发于 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

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

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

引言

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

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

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

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

faUjYbV.png!mobile

本文中的示例 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

注意动态模糊一共两次,一次是水平方向的,一次是竖直方向的,由下面 uniformsBlurDir 对应的值确定。事实上 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 主类(已略去 packageimport ):

@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 的实现:

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 是否受支持:

// 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);

最终效果

UBfqM3.png!mobile

baEnuiE.png!mobile

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK