设计渲染旁路让MC支持GIF图片渲染

前言

参考以下文章

参考以下项目

MoriyaShiine的项目主要面向Fabric,使用Dyhanb的Open-Imaging gif解码器(2014),比较过时了,所以在这篇文章里我们讨论使用ribasco gif解码器的forge端实现

环境

  • 客户端:1.20.1 Forge 47.4.8, Parchment, 使用Kotlin2.0.0(KotlinForForge)
  • 服务端:1.20.1 Paper,使用Kotlin2.1.0(Taboolib)

由于是跨平台开发,通讯方案使用笔者这篇文章里的modlink方法

目标

  • 在RenderSystem中创建GIF渲染旁路
  • 接管物品贴图的渲染,将GIF渲染在物品贴图上
  • 发送跨平台的信息,在服务端发送配置文件到客户端,动态决定该渲染什么(在笔者这篇文章已经有方案,不再提到)

实战!

创建渲染旁路

原版MC的材质渲染只原生支持PNG图片,在1.14以后麻将将老版本的OpenGL状态机GlStateManager进行了进一步封装,衍生出了Blaze3D渲染库,对GlStateManager的相关操作被进一步封装到了com.mojang.blaze3d.systems.RenderSystem

所有绑定纹理的操作均会经过RenderSystem中绑定纹理的核心方法RenderSystem#setShaderTexture

java 复制代码
   public static void setShaderTexture(int pShaderTexture, ResourceLocation pTextureId) {
      if (!isOnRenderThread()) {
         recordRenderCall(() -> {
            _setShaderTexture(pShaderTexture, pTextureId);
         });
      } else {
         _setShaderTexture(pShaderTexture, pTextureId);
      }

   }

   public static void _setShaderTexture(int pShaderTexture, ResourceLocation pTextureId) {
      if (pShaderTexture >= 0 && pShaderTexture < shaderTextures.length) {
         TextureManager texturemanager = Minecraft.getInstance().getTextureManager();
         AbstractTexture abstracttexture = texturemanager.getTexture(pTextureId);
         shaderTextures[pShaderTexture] = abstracttexture.getId();
      }

   }

阅读其源码,发现这个方法传入了一个资源路径ResourceLocation,在经过MC的材质管理器TextureManager获取纹理实例的Id(纹理Id是MC渲染管线将图片上传到GPU后生成的),之后绑定到绘图上下文之中,纹理的类型是继承了抽象类AbstractTexture后的一些个类,只支持PNG图片

所以我们的思路就是:
::: align-center
setShaderTexture尝试获取纹理之前“截胡”它,加载我们自己的GIF图片,之后绑定到上下文

:::
这是一种“旁路加载”的方案,另一种方案是直接继承AbstractTexture,覆写里面所有的方法,但是这样做的话我们的GIF就无法复用了,因为每一个新的GIF纹理实例都会将GIF图片上传到GPU,即使我们使用的是同一张GIF,虽然游戏里其他PNG材质的加载也是这样做的,但是对于GIF却并不太好,详情见下

选择了GIF该如何被“引用”的问题后,我们开始思考怎么把GIF加载进MC里面去,在PNG图片的加载过程中担当这个任务的是Blaze3D的NativeImage(在老版本中是java的BufferedImage),所以我们的大致想法是:
::: align-center
将GIF图片分割为序列帧,对于每一个帧将其读为NativeImage,然后包装为DynamicTexture注册到MC材质管理器,之后在旁路设计我们自己的逻辑决定读取GIF的哪一帧

:::
因为要将GIF分割为序列帧,一个GIF可能会生成数十个DynamicTexture,所以此时如果再将其设计为与原版纹理一样不加复用的加载形式,在GIF(尤其是同一张GIF)实例较多时势必会引发性能问题,必须要想办法尽可能的复用GIF实例,此时旁路的方案就胜出了

接下来我们来考虑如何读取GIF
ribasco gif解码器提供了一个GifImageReader可以依次的读取GIF的每一帧

java 复制代码
    /**
     * Reads a single GIF frame from the data stream
     *
     * @return A decoded {@link GifFrame} instance.
     *
     * @throws IOException
     *         When an I/O error occurs
     */
    @API(status = API.Status.STABLE)
    public GifFrame read() throws IOException {
        checkInit();
        final var filter = this.filter == null ? DO_NOT_SKIP : this.filter;
        Block block;
        //scan the next image descriptor block
        while (!Block.TRAILER.equals(block = readBlock(is, metadata, filter))) {
            //image frame usually starts with a descriptor
            if (Block.IMAGE_DESCRIPTOR.equals(block)) {
                var lastFrame = getLastFrame();
                if (lastFrame != null) {
                    return lastFrame;
                }
            }
        }
        if (!closed)
            reset();
        return null;
    }

GifFrame下有方法

java 复制代码
    /**
     * @return The decoded image data (In ARGB Integer format)
     */
    @API(status = API.Status.EXPERIMENTAL)
    public int[] getData() {
        return data;
    }

所以我们可以获取每一帧GIF的ARGB数据,查看NativeImage的定义,发现其有

java 复制代码
   public void setPixelRGBA(int pX, int pY, int pAbgrColor) {
      if (this.format != NativeImage.Format.RGBA) {
         throw new IllegalArgumentException(String.format(Locale.ROOT, "setPixelRGBA only works on RGBA images; have %s", this.format));
      } else if (this.isOutsideBounds(pX, pY)) {
         throw new IllegalArgumentException(String.format(Locale.ROOT, "(%s, %s) outside of image bounds (%s, %s)", pX, pY, this.width, this.height));
      } else {
         this.checkAllocated();
         long i = ((long)pX + (long)pY * (long)this.width) * 4L;
         MemoryUtil.memPutInt(this.pixels + i, pAbgrColor);
      }
   }

所以到这里我们的思路就确定了

  1. 开一个空的NativeImage
  2. 读取GIF的每一帧,将其ARGB数据使用NativeImage#setPixelRGBA手动写入
  3. NativeImage实例包装成DynamicTexture,注册进材质管理器

故有

kotlin 复制代码
    /**
     * 从ARGB生成NativeImage
     * @param unsafe 忽略是否允许渲染,仅在内部加载材质时设为true
     *
     * 此方法用于创建Gif,不会读取缓存
     */
    @Suppress("SameParameterValue")
    private fun getNativeImageFromARGB(pixels: IntArray, width: Int, height: Int, unsafe: Boolean): NativeImage? {
        // 是否就绪
        if (!isResourcesReady.get() && !unsafe) return null
        require(!(width <= 0 || height <= 0 || pixels.size != width * height)) { "无效的图片信息" }

        val nativeImage = NativeImage(width, height, true) // 使用透明度
        for (y in 0..<height) {
            for (x in 0..<width) {
                val argb = pixels[y * width + x]
                val a = (argb ushr 24) and 0xFF // Alpha
                val r = (argb ushr 16) and 0xFF // Red
                val g = (argb ushr 8) and 0xFF // Green
                val b = (argb) and 0xFF // Blue
                nativeImage.setPixelRGBA(x, y, (a shl 24) or (b shl 16) or (g shl 8) or r)
            }
        }

        return nativeImage
    }

之后我们设计

kotlin 复制代码
    @Suppress("SameParameterValue")
    private fun registerTextureGIF(location: ResourceLocation, unsafe: Boolean) {
        // 是否是GIF图片
        if (!location.path.endsWith(".gif")) return
        // 是否已经注册
        if (gifCache.containsKey(location)) return
        // 是否就绪
        if (!isResourcesReady.get() && !unsafe) return

        try {
            val stream = getInputStream(location.path)
            val reader = GifImageReader(stream)
            val frames: Array<Pair<ResourceLocation, Int>?> = arrayOfNulls(reader.totalFrames)

            var totalDuration = 0
            while (reader.hasRemaining()) {
                val frame = reader.read()
                val frameLocation = createGifFrameTexture(location, frame, frame.getIndex(), reader.totalFrames)
                if (frameLocation != null) {
                    frames[frame.getIndex()] = Pair(frameLocation, frame.getDelay())
                }
                totalDuration += frame.getDelay()
            }

            @Suppress("UNCHECKED_CAST")
            gifCache.put(location, GifData(frames as Array<Pair<ResourceLocation, Int>>, totalDuration))
        } catch (_: java.lang.Exception) {
            SaintIconEngineMod.LOGGER.error("创建Gif时发生错误: ${location.path}")
            cleanupPartialGifLoad(location)
        }
    }

    private fun createGifFrameTexture(
        location: ResourceLocation,
        frame: GifFrame,
        frameIndex: Int,
        totalFrames: Int
    ): ResourceLocation? {
        val width = frame.getWidth()
        val height = frame.getHeight()
        val pixelData = frame.getData()

        if (pixelData == null || width <= 0 || height <= 0) {
            LOGGER.warn("无效的Gif帧: ${location.path}, 帧: $frameIndex")
            return null
        }

        try {
            getNativeImageFromARGB(pixelData, width, height, true).use { nativeImage ->
                if (nativeImage == null) return null
                val frameName = String.format("%s_frame_%d_of_%d", location.path.replace(".gif", ""), frameIndex, totalFrames)
                val frameLocation: ResourceLocation = buildResourceLocation(frameName)
                registerDynamicTextureOnRenderThread(frameLocation, nativeImage)
                // 加入缓存
                imageCache[frameLocation] = nativeImage
                return frameLocation
            }
        } catch (e: Exception) {
            LOGGER.error("生成帧时发生错误: ${location.path} 帧: $frameIndex, $e")
            return null
        }
    }

    private fun cleanupPartialGifLoad(location: ResourceLocation) {
        // 清理可能已加载的帧
        gifCache.filterKeys {
            it.path.startsWith(
                location.path.replace(".gif", "") + "_frame_"
            )
        }.forEach { (k, _) ->
            // 取出并注销
            imageCache.remove(k)?.let { Minecraft.getInstance().textureManager.release(k) }
        }
    }

其中注册材质是在MC渲染线程完成的

kotlin 复制代码
    private fun registerDynamicTextureOnRenderThread(location: ResourceLocation, nativeImage: NativeImage) {
        if (!RenderSystem.isOnRenderThread()) {
            RenderSystem.recordRenderCall {
                registerDynamicTextureInternal(location, nativeImage)
            }
        } else {
            registerDynamicTextureInternal(location, nativeImage)
        }
    }

    private fun registerDynamicTextureInternal(location: ResourceLocation, nativeImage: NativeImage) {
        try {
            val textureManager = Minecraft.getInstance().getTextureManager()
            val dynamicTexture = DynamicTexture(nativeImage)
            textureManager.register(location, dynamicTexture)
        } catch (_: Exception) {
            throw RuntimeException()
        }
    }

使用一个简单的ConcurrentHashMap来缓存我们的GifData实例(其实没有线程安全问题,使用FastUtil的Object2ObjectOpenHashMap也行)

kotlin 复制代码
class GifData(val frames: Array<Pair<ResourceLocation, Int>>, val totalDuration: Int)

其中frames是我们的帧,totalDuration是这个GIF的总时长

之后我们设计一个方法,让MC渲染循环每次调用时决定我们该绑定哪个纹理

kotlin 复制代码
    private fun getAnimatedTexture(location: ResourceLocation): ResourceLocation {
        val data: GifData? = gifCache[location]
        if (data == null) {
            return location
        }
        if (Minecraft.getInstance().isPaused) {
            val startTime: Long? = startTimes[location]
            if (startTime == null) {
                return data.getImage(0)
            }

            val pausedTime = System.currentTimeMillis() - startTime
            return data.getFrame(pausedTime)
        }

        var startTime: Long? = startTimes[location]
        if (startTime == null) {
            startTime = System.currentTimeMillis()
            startTimes.put(location, startTime)
        }

        val elapsedTime = System.currentTimeMillis() - startTime
        return data.getFrame(elapsedTime)
    }

在我们的GifData中设计

kotlin 复制代码
    fun getFrame(elapsedTime: Long): ResourceLocation {
        val gifTimeUnits = (elapsedTime / 10).toInt()
        val timeInCycle = gifTimeUnits % this.totalDuration
        var accumulated = 0
        var index = 0

        for (frame in frames) {
            accumulated += frame.second
            if (timeInCycle < accumulated) {
                break
            }
            index++
        }

        index = min(index, this.frames.size - 1)
        return getImage(index)
    }

    fun getImage(frame: Int): ResourceLocation {
        return frames[frame].first
    }

我们在有东西尝试引用GIF纹理时记下此时的时间戳并将其放入了startTimes这个缓存里,此后每引用一次我们便取当前的时间戳与其相减获得经过的时间,然后根据这个时间在GifData#getFrame方法里与GIF的总时长GifData#totalDuration取模,得到当前在这个GIF里我们从开始播放起经过的时间timeInCycle,之后与每一帧的的时长frame.second(也就是在读取时放入的delay)累和比较,决定当前播放到了哪一帧,获得frames数组的索引index,之后从Pair的左侧获取这一帧的材质路径frames[frame].first,因为这些GIF帧在之前已经被包装为了DynamicTexture注册进了材质管理器,所以此时直接绑定这个ResourceLocation即可在上下文中引用到GIF这一帧的材质

在调用时,可以使用上面的getAnimatedTexture方法,如果想要让原版也支持GIF渲染,则需要修改RenderSystem#_setShaderTexture方法的形参,使其接受我们的修饰,它的形参有一个int一个ResourceLocation,返回void,故JVM签名为_setShaderTexture(ILnet/minecraft/resources/ResourceLocation;)V

java 复制代码
@Mixin(RenderSystem.class)
public class MixinRenderSystem {

    @ModifyVariable(
	method = "_setShaderTexture(ILnet/minecraft/resources/ResourceLocation;)V",
         at = @At("HEAD"),
         argsOnly = true
    )
    private static ResourceLocation on_SetShaderTexture(ResourceLocation location) {
        return CustomTextureManager.getResourceLocation(location);
    }
}

然后判断以下是否是GIF就可以接入我们的GIF材质获取逻辑

kotlin 复制代码
    internal fun getResourceLocation(location: ResourceLocation): ResourceLocation {
        if (location.path.lowercase().endsWith(".gif")) {
            return getAnimatedTexture(location)
        }
        return location
    }

接管物品贴图渲染

MC中负责物品贴图渲染的是net.minecraft.client.renderer.entity#ItemRenderer,查看其源码,注意到其中的render方法

java 复制代码
   public void render(ItemStack pItemStack, ItemDisplayContext pDisplayContext, boolean pLeftHand, PoseStack pPoseStack, MultiBufferSource pBuffer, int pCombinedLight, int pCombinedOverlay, BakedModel pModel)

所以只要将我们的代码Mixin插入到此方法的头部即可,如果当前ItemStack是我们想要的,则使用取消原版逻辑并启动我们的逻辑

java 复制代码
@Mixin(ItemRenderer.class)
public class ItemRendererMixin {
    @Inject(
            method = "render",
            at = @At("HEAD"),
            cancellable = true
    )
    public void onRenderItem(ItemStack itemStack,
                             ItemDisplayContext displayContext,
                             boolean leftHand,
                             PoseStack poseStack,
                             MultiBufferSource bufferSource,
                             int combinedLight,
                             int combinedOverlay,
                             BakedModel model,
                             CallbackInfo ci
    ) {
        // 如果是方块物品
        if (itemStack.getItem() instanceof BlockItem) return;

        if (ItemIconRenderer.INSTANCE.renderItemIcon(itemStack, displayContext, leftHand, poseStack, bufferSource, combinedLight, combinedOverlay, model))
            ci.cancel();
    }

}

renderItemIcon方法中我们可以设计自己的逻辑渲染物品贴图,包括调用前面的getAnimatedTexture绑定GIF图片

游客

全部评论 (0)

暂无评论,快来抢沙发吧~