From cd0e21abc8273138f71145cab2c9d23d3a5f6fa1 Mon Sep 17 00:00:00 2001 From: MiniDay <372403923@qq.com> Date: Sun, 8 Feb 2026 02:44:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=A4=A7=E5=B0=8F=E4=BC=9A=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=B4=A9=E6=BA=83=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/ui/client/render/ComposeRender.kt | 100 ++++++++++++++ .../compose/ui/client/render/SkiaRender.kt | 128 ++++++++++++++++++ .../compose/ui/client/screen/ComposeScreen.kt | 10 +- 3 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 src/client/kotlin/net/airgame/compose/ui/client/render/ComposeRender.kt create mode 100644 src/client/kotlin/net/airgame/compose/ui/client/render/SkiaRender.kt diff --git a/src/client/kotlin/net/airgame/compose/ui/client/render/ComposeRender.kt b/src/client/kotlin/net/airgame/compose/ui/client/render/ComposeRender.kt new file mode 100644 index 0000000..e35ba82 --- /dev/null +++ b/src/client/kotlin/net/airgame/compose/ui/client/render/ComposeRender.kt @@ -0,0 +1,100 @@ +package net.airgame.compose.ui.client.render + +import androidx.compose.runtime.Composable +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.asComposeCanvas +import androidx.compose.ui.scene.ComposeScene +import androidx.compose.ui.scene.MultiLayerComposeScene +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.* +import net.minecraft.client.render.RenderLayer +import org.jetbrains.skiko.FrameDispatcher +import java.util.function.Consumer + +@OptIn(InternalComposeUiApi::class) +class ComposeRender { + // private val threadPool = Executors.newSingleThreadExecutor() + // private val composeScope = CoroutineScope(SupervisorJob() + threadPool.asCoroutineDispatcher()) + private val composeScope = MainScope() + private val frameDispatcher = FrameDispatcher(composeScope) { render() } + + private val skiaRender = SkiaRender() + private val composeScene: ComposeScene = MultiLayerComposeScene( + coroutineContext = composeScope.coroutineContext, + invalidate = { frameDispatcher.scheduleFrame() } + ) + lateinit var composeCanvas: Canvas; private set + + val output: RenderLayer get() = skiaRender.output + + data class ResizeMessage(val width: Int, val height: Int, val uiScale: Float, val fontScale: Float) + + // resize防抖:消息的容量为1,后续多余的resize请求会覆盖旧的resize请求 + // compose 渲染线程定期消费 resize 消息 + @Volatile + var resizeMessage: ResizeMessage? = null + + fun init(width: Int, height: Int, uiScale: Float, fontScale: Float, content: @Composable () -> Unit) { + skiaRender.init(width, height) + val task = composeScope.async { + // 确保线程中的OpenGL状态机已经切换到目标窗口 + skiaRender.switchWindow() + composeCanvas = skiaRender.skiaSurface.canvas.asComposeCanvas() + } + runBlocking { + // 确保 SkiaRender 完全初始化之后,才允许 Minecraft UI 线程继续执行 + task.await() + } + composeScene.density = if (uiScale <= 0) { + Density(1f, fontScale) + } else { + Density(uiScale / 2, fontScale) + } + composeScene.size = IntSize(width, height) + composeScene.setContent(content) + } + + fun render() { + val resizeData = resizeMessage + resizeMessage = null + if (resizeData != null) { + val (width, height, uiScale, fontScale) = resizeData + skiaRender.resize(width, height) + composeCanvas = skiaRender.skiaSurface.canvas.asComposeCanvas() + composeScene.density = if (uiScale <= 0) { + Density(1f, fontScale) + } else { + Density(uiScale / 2, fontScale) + } + composeScene.size = IntSize(width, height) + } + skiaRender.render { + composeScene.render(composeCanvas, System.nanoTime()) + } + } + + fun post(task: Consumer) { + composeScope.launch { + task.accept(composeScene) + } + } + + fun resize(width: Int, height: Int, uiScale: Float, fontScale: Float) { + resizeMessage = ResizeMessage(width, height, uiScale, fontScale) + frameDispatcher.scheduleFrame() + } + + fun destroy() { + val task = composeScope.async { + skiaRender.destroy() + composeScene.close() + } + runBlocking { + task.await() + composeScope.cancel() +// threadPool.close() + } + } +} \ No newline at end of file diff --git a/src/client/kotlin/net/airgame/compose/ui/client/render/SkiaRender.kt b/src/client/kotlin/net/airgame/compose/ui/client/render/SkiaRender.kt new file mode 100644 index 0000000..2f79ca6 --- /dev/null +++ b/src/client/kotlin/net/airgame/compose/ui/client/render/SkiaRender.kt @@ -0,0 +1,128 @@ +package net.airgame.compose.ui.client.render + +import com.mojang.blaze3d.platform.GlStateManager +import com.mojang.blaze3d.platform.TextureUtil +import com.mojang.blaze3d.systems.RenderSystem +import net.minecraft.client.render.RenderLayer +import net.minecraft.client.render.RenderPhase +import net.minecraft.client.render.VertexFormat +import net.minecraft.client.render.VertexFormats +import org.jetbrains.skia.* +import org.lwjgl.glfw.GLFW.* +import org.lwjgl.opengl.GL +import org.lwjgl.opengl.GL11.glTexImage2D +import org.lwjgl.opengl.GL40.* +import org.lwjgl.system.MemoryUtil.NULL + +/** + * Skia 离屏渲染 + * + * 这个类不负责线程安全,除了 init 方法需要在 Minecraft UI 线程中调用以外 + * + * 所有绘制方法(render、resize、destroy)需要确保在同一线程进行,调用者应自行保证线程安全 + */ +class SkiaRender { + private var windowId: Long = -1 + private var frameBufferId = -1 + private var textureId = -1 + + // 所有渲染结果都会绘制到这个纹理中 + // Minecraft 直接将这个纹理渲染到窗口(或者世界内)即可 + lateinit var output: RenderLayer; private set + + lateinit var skiaContext: DirectContext; private set + lateinit var skiaSurface: Surface; private set + + fun init(width: Int, height: Int) { + textureId = TextureUtil.generateTextureId() + output = RenderLayer.of( + "cpu_render_compose", + VertexFormats.POSITION_TEXTURE, + VertexFormat.DrawMode.QUADS, + // 顶点数据缓存大小,单位:字节 + 768 * 1024, + RenderLayer.MultiPhaseParameters.builder() + .texture(RenderPhase.TextureBase({ + RenderSystem.setShaderTexture(GL_TEXTURE0, textureId) + }, {})) + .program(RenderPhase.POSITION_TEXTURE_PROGRAM) + .transparency(RenderPhase.TRANSLUCENT_TRANSPARENCY) + .depthTest(RenderPhase.LEQUAL_DEPTH_TEST) + .build(false) + ) + + val oldWindow = glfwGetCurrentContext() + // 在 OpenGL 里,一个上下文(Context)在同一时间只能被一个线程拥有。 + // 在某些显卡驱动上,如果基于一个已经被占用的上下文去创建它的共享上下文,可能会导致 glfwCreateWindow 返回失败或死锁。 + glfwMakeContextCurrent(0) + // 告诉 GLFW,接下来创建的窗口不要显示在任务栏和桌面上 + glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE) + // 创建一个 1x1 的隐形窗口,并指定它和 oldWindow 共享仓库(纹理、VBO 全部通用)。 + windowId = glfwCreateWindow(1, 1, "", NULL, oldWindow) + glfwMakeContextCurrent(windowId) + GL.createCapabilities() + + frameBufferId = glGenFramebuffers() + glBindFramebuffer(GL_FRAMEBUFFER, frameBufferId) + glBindTexture(GL_TEXTURE_2D, textureId) + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textureId, 0) + skiaContext = DirectContext.makeGL() + val renderTarget = BackendRenderTarget.makeGL( + width, height, 0, 8, frameBufferId, GL_RGBA8 + ) + skiaSurface = Surface.makeFromBackendRenderTarget( + skiaContext, renderTarget, SurfaceOrigin.TOP_LEFT, SurfaceColorFormat.RGBA_8888, ColorSpace.sRGB + ) ?: throw IllegalStateException("Surface could not be created") + glfwMakeContextCurrent(oldWindow) + } + + fun switchWindow() { + glfwMakeContextCurrent(windowId) + } + + fun render(renderTask: Runnable) { + // 由于在其他线程中渲染,而 OpenGL 的状态机会根据线程储存 + // 所以无需对 windowId 做保存与恢复 + val oldWindow = glfwGetCurrentContext() + glfwMakeContextCurrent(windowId) + skiaSurface.canvas.clear(Color.TRANSPARENT) + renderTask.run() + skiaContext.flush() + glfwMakeContextCurrent(oldWindow) + } + + fun resize(width: Int, height: Int) { + val oldWindow = glfwGetCurrentContext() + glfwMakeContextCurrent(windowId) + glBindTexture(GL_TEXTURE_2D, textureId) + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL) + + skiaSurface.close() + val renderTarget = BackendRenderTarget.makeGL( + width, height, 0, 8, frameBufferId, GL_RGBA8 + ) + skiaSurface = Surface.makeFromBackendRenderTarget( + skiaContext, renderTarget, SurfaceOrigin.TOP_LEFT, SurfaceColorFormat.RGBA_8888, ColorSpace.sRGB + ) ?: throw IllegalStateException("Failed to recreate Surface") + + glfwMakeContextCurrent(oldWindow) + } + + fun destroy() { + if (::skiaSurface.isInitialized) { + skiaSurface.close() + } + if (::skiaContext.isInitialized) { + skiaContext.close() + } + if (textureId != -1) { + GlStateManager._deleteTexture(textureId) + } + if (windowId != -1L) { + glfwDestroyWindow(windowId) + } + } +} \ No newline at end of file diff --git a/src/client/kotlin/net/airgame/compose/ui/client/screen/ComposeScreen.kt b/src/client/kotlin/net/airgame/compose/ui/client/screen/ComposeScreen.kt index 5dc45f5..72c3b30 100644 --- a/src/client/kotlin/net/airgame/compose/ui/client/screen/ComposeScreen.kt +++ b/src/client/kotlin/net/airgame/compose/ui/client/screen/ComposeScreen.kt @@ -26,9 +26,7 @@ import net.minecraft.text.Text import org.jetbrains.skia.* import org.jetbrains.skiko.FrameDispatcher import org.lwjgl.glfw.GLFW.* -import org.lwjgl.opengl.ARBSync.glClientWaitSync -import org.lwjgl.opengl.ARBSync.glDeleteSync -import org.lwjgl.opengl.ARBSync.glFenceSync +import org.lwjgl.opengl.ARBSync.* import org.lwjgl.opengl.GL import org.lwjgl.opengl.GL30.* import org.lwjgl.opengl.GL32.GL_SYNC_FLUSH_COMMANDS_BIT @@ -161,7 +159,11 @@ class ComposeScreen( surface = Surface.makeRasterN32Premul(windowWidth, windowHeight) canvas = surface.canvas.asComposeCanvas() old.close() - scene.density = Density(client.options.guiScale.value.toFloat() / 2) + scene.density = if (mcGuiScale <= 0) { + Density(1f, fontScaleValue) + } else { + Density(mcGuiScale / 2, fontScaleValue) + } scene.size = IntSize(windowWidth, windowHeight) }