|
| 1 | +/* |
| 2 | + * Copyright 2026 Lambda |
| 3 | + * |
| 4 | + * This program is free software: you can redistribute it and/or modify |
| 5 | + * it under the terms of the GNU General Public License as published by |
| 6 | + * the Free Software Foundation, either version 3 of the License, or |
| 7 | + * (at your option) any later version. |
| 8 | + * |
| 9 | + * This program is distributed in the hope that it will be useful, |
| 10 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 11 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 12 | + * GNU General Public License for more details. |
| 13 | + * |
| 14 | + * You should have received a copy of the GNU General Public License |
| 15 | + * along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 16 | + */ |
| 17 | + |
| 18 | +package com.lambda.graphics.text |
| 19 | + |
| 20 | +import com.lambda.util.stream |
| 21 | +import com.mojang.blaze3d.systems.RenderSystem |
| 22 | +import com.mojang.blaze3d.textures.FilterMode |
| 23 | +import com.mojang.blaze3d.textures.GpuTexture |
| 24 | +import com.mojang.blaze3d.textures.GpuTextureView |
| 25 | +import com.mojang.blaze3d.textures.TextureFormat |
| 26 | +import net.minecraft.client.gl.GpuSampler |
| 27 | +import net.minecraft.client.texture.NativeImage |
| 28 | +import org.lwjgl.stb.STBTTFontinfo |
| 29 | +import org.lwjgl.stb.STBTruetype.stbtt_FindGlyphIndex |
| 30 | +import org.lwjgl.stb.STBTruetype.stbtt_GetFontVMetrics |
| 31 | +import org.lwjgl.stb.STBTruetype.stbtt_GetGlyphBitmapBox |
| 32 | +import org.lwjgl.stb.STBTruetype.stbtt_GetGlyphHMetrics |
| 33 | +import org.lwjgl.stb.STBTruetype.stbtt_InitFont |
| 34 | +import org.lwjgl.stb.STBTruetype.stbtt_MakeGlyphBitmap |
| 35 | +import org.lwjgl.stb.STBTruetype.stbtt_ScaleForPixelHeight |
| 36 | +import org.lwjgl.system.MemoryStack |
| 37 | +import org.lwjgl.system.MemoryUtil |
| 38 | +import java.nio.ByteBuffer |
| 39 | + |
| 40 | +/** |
| 41 | + * Font atlas that uses MC 1.21's GPU texture APIs for proper rendering. |
| 42 | + * |
| 43 | + * Uses STB TrueType for glyph rasterization and MC's GpuTexture/GpuTextureView/GpuSampler |
| 44 | + * for texture management, enabling correct texture binding via RenderPass.bindTexture(). |
| 45 | + * |
| 46 | + * @param fontPath Resource path to TTF/OTF file |
| 47 | + * @param fontSize Font size in pixels |
| 48 | + * @param atlasWidth Atlas texture width (must be power of 2) |
| 49 | + * @param atlasHeight Atlas texture height (must be power of 2) |
| 50 | + */ |
| 51 | +class FontAtlas( |
| 52 | + fontPath: String, |
| 53 | + val fontSize: Float = 64f, |
| 54 | + val atlasWidth: Int = 2048, |
| 55 | + val atlasHeight: Int = 2048 |
| 56 | +) : AutoCloseable { |
| 57 | + |
| 58 | + data class Glyph( |
| 59 | + val codepoint: Int, |
| 60 | + val x0: Int, val y0: Int, |
| 61 | + val x1: Int, val y1: Int, |
| 62 | + val xOffset: Float, val yOffset: Float, |
| 63 | + val xAdvance: Float, |
| 64 | + val u0: Float, val v0: Float, |
| 65 | + val u1: Float, val v1: Float |
| 66 | + ) |
| 67 | + |
| 68 | + private val fontBuffer: ByteBuffer |
| 69 | + private val fontInfo: STBTTFontinfo |
| 70 | + private val glyphs = mutableMapOf<Int, Glyph>() |
| 71 | + |
| 72 | + // MC 1.21 GPU texture objects |
| 73 | + private var glTexture: GpuTexture? = null |
| 74 | + private var glTextureView: GpuTextureView? = null |
| 75 | + private var gpuSampler: GpuSampler? = null |
| 76 | + |
| 77 | + // Temporary storage for atlas during construction |
| 78 | + private var atlasData: ByteArray? = null |
| 79 | + |
| 80 | + val lineHeight: Float |
| 81 | + val ascent: Float |
| 82 | + val descent: Float |
| 83 | + |
| 84 | + /** Get the texture view for binding in render pass */ |
| 85 | + val textureView: GpuTextureView? |
| 86 | + get() = glTextureView |
| 87 | + |
| 88 | + /** Get the sampler for binding in render pass */ |
| 89 | + val sampler: GpuSampler? |
| 90 | + get() = gpuSampler |
| 91 | + |
| 92 | + /** Check if texture is uploaded and ready */ |
| 93 | + val isUploaded: Boolean |
| 94 | + get() = glTexture != null |
| 95 | + |
| 96 | + init { |
| 97 | + // Load font file |
| 98 | + val fontBytes = fontPath.stream.readAllBytes() |
| 99 | + fontBuffer = MemoryUtil.memAlloc(fontBytes.size).put(fontBytes).flip() |
| 100 | + |
| 101 | + fontInfo = STBTTFontinfo.create() |
| 102 | + if (!stbtt_InitFont(fontInfo, fontBuffer)) { |
| 103 | + MemoryUtil.memFree(fontBuffer) |
| 104 | + throw RuntimeException("Failed to initialize font: $fontPath") |
| 105 | + } |
| 106 | + |
| 107 | + // Calculate scale and metrics |
| 108 | + val scale = stbtt_ScaleForPixelHeight(fontInfo, fontSize) |
| 109 | + |
| 110 | + MemoryStack.stackPush().use { stack -> |
| 111 | + val ascentBuf = stack.mallocInt(1) |
| 112 | + val descentBuf = stack.mallocInt(1) |
| 113 | + val lineGapBuf = stack.mallocInt(1) |
| 114 | + stbtt_GetFontVMetrics(fontInfo, ascentBuf, descentBuf, lineGapBuf) |
| 115 | + |
| 116 | + ascent = ascentBuf[0] * scale |
| 117 | + descent = descentBuf[0] * scale |
| 118 | + lineHeight = (ascentBuf[0] - descentBuf[0] + lineGapBuf[0]) * scale |
| 119 | + } |
| 120 | + |
| 121 | + // Build atlas data |
| 122 | + atlasData = ByteArray(atlasWidth * atlasHeight * 4) // RGBA |
| 123 | + buildAtlas(scale) |
| 124 | + } |
| 125 | + |
| 126 | + private fun buildAtlas(scale: Float) { |
| 127 | + val data = atlasData ?: return |
| 128 | + var penX = 1 |
| 129 | + var penY = 1 |
| 130 | + var rowHeight = 0 |
| 131 | + |
| 132 | + // Rasterize printable ASCII + extended Latin |
| 133 | + val codepoints = (32..126) + (160..255) |
| 134 | + |
| 135 | + MemoryStack.stackPush().use { stack -> |
| 136 | + val x0 = stack.mallocInt(1) |
| 137 | + val y0 = stack.mallocInt(1) |
| 138 | + val x1 = stack.mallocInt(1) |
| 139 | + val y1 = stack.mallocInt(1) |
| 140 | + val advanceWidth = stack.mallocInt(1) |
| 141 | + val leftSideBearing = stack.mallocInt(1) |
| 142 | + |
| 143 | + for (cp in codepoints) { |
| 144 | + val glyphIndex = stbtt_FindGlyphIndex(fontInfo, cp) |
| 145 | + if (glyphIndex == 0 && cp != 32) continue |
| 146 | + |
| 147 | + stbtt_GetGlyphHMetrics(fontInfo, glyphIndex, advanceWidth, leftSideBearing) |
| 148 | + stbtt_GetGlyphBitmapBox(fontInfo, glyphIndex, scale, scale, x0, y0, x1, y1) |
| 149 | + |
| 150 | + val glyphW = x1[0] - x0[0] |
| 151 | + val glyphH = y1[0] - y0[0] |
| 152 | + |
| 153 | + // Check if we need to wrap to next row |
| 154 | + if (penX + glyphW + 1 >= atlasWidth) { |
| 155 | + penX = 1 |
| 156 | + penY += rowHeight + 1 |
| 157 | + rowHeight = 0 |
| 158 | + } |
| 159 | + |
| 160 | + // Check atlas overflow |
| 161 | + if (penY + glyphH + 1 >= atlasHeight) break |
| 162 | + |
| 163 | + // Rasterize glyph |
| 164 | + if (glyphW > 0 && glyphH > 0) { |
| 165 | + val tempBuffer = MemoryUtil.memAlloc(glyphW * glyphH) |
| 166 | + try { |
| 167 | + stbtt_MakeGlyphBitmap( |
| 168 | + fontInfo, tempBuffer, |
| 169 | + glyphW, glyphH, glyphW, scale, scale, glyphIndex |
| 170 | + ) |
| 171 | + // Copy to atlas as RGBA (white with grayscale as alpha) |
| 172 | + for (row in 0 until glyphH) { |
| 173 | + for (col in 0 until glyphW) { |
| 174 | + val srcIndex = row * glyphW + col |
| 175 | + val alpha = tempBuffer.get(srcIndex).toInt() and 0xFF |
| 176 | + val dstIndex = ((penY + row) * atlasWidth + penX + col) * 4 |
| 177 | + data[dstIndex + 0] = 0xFF.toByte() // R |
| 178 | + data[dstIndex + 1] = 0xFF.toByte() // G |
| 179 | + data[dstIndex + 2] = 0xFF.toByte() // B |
| 180 | + data[dstIndex + 3] = alpha.toByte() // A |
| 181 | + } |
| 182 | + } |
| 183 | + } finally { |
| 184 | + MemoryUtil.memFree(tempBuffer) |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + // Store glyph info |
| 189 | + glyphs[cp] = Glyph( |
| 190 | + codepoint = cp, |
| 191 | + x0 = penX, y0 = penY, |
| 192 | + x1 = penX + glyphW, y1 = penY + glyphH, |
| 193 | + xOffset = x0[0].toFloat(), |
| 194 | + yOffset = y0[0].toFloat(), |
| 195 | + xAdvance = advanceWidth[0] * scale, |
| 196 | + u0 = penX.toFloat() / atlasWidth, |
| 197 | + v0 = penY.toFloat() / atlasHeight, |
| 198 | + u1 = (penX + glyphW).toFloat() / atlasWidth, |
| 199 | + v1 = (penY + glyphH).toFloat() / atlasHeight |
| 200 | + ) |
| 201 | + |
| 202 | + penX += glyphW + 1 |
| 203 | + rowHeight = maxOf(rowHeight, glyphH) |
| 204 | + } |
| 205 | + } |
| 206 | + } |
| 207 | + |
| 208 | + /** |
| 209 | + * Upload atlas to GPU using MC 1.21 APIs. |
| 210 | + * Must be called on the render thread. |
| 211 | + */ |
| 212 | + fun upload() { |
| 213 | + if (glTexture != null) return // Already uploaded |
| 214 | + val data = atlasData ?: return |
| 215 | + |
| 216 | + RenderSystem.assertOnRenderThread() |
| 217 | + |
| 218 | + val gpuDevice = RenderSystem.getDevice() |
| 219 | + |
| 220 | + // Create GPU texture (usage flags: 5 = COPY_DST | TEXTURE_BINDING) |
| 221 | + glTexture = gpuDevice.createTexture( |
| 222 | + "Lambda FontAtlas", |
| 223 | + 5, // COPY_DST (1) | TEXTURE_BINDING (4) |
| 224 | + TextureFormat.RGBA8, |
| 225 | + atlasWidth, atlasHeight, |
| 226 | + 1, // layers |
| 227 | + 1 // mip levels |
| 228 | + ) |
| 229 | + |
| 230 | + // Create texture view |
| 231 | + glTextureView = gpuDevice.createTextureView(glTexture) |
| 232 | + |
| 233 | + // Get sampler with linear filtering |
| 234 | + gpuSampler = RenderSystem.getSamplerCache().get(FilterMode.LINEAR) |
| 235 | + |
| 236 | + // Create NativeImage and copy data |
| 237 | + val nativeImage = NativeImage(atlasWidth, atlasHeight, false) |
| 238 | + for (y in 0 until atlasHeight) { |
| 239 | + for (x in 0 until atlasWidth) { |
| 240 | + val srcIndex = (y * atlasWidth + x) * 4 |
| 241 | + val r = data[srcIndex + 0].toInt() and 0xFF |
| 242 | + val g = data[srcIndex + 1].toInt() and 0xFF |
| 243 | + val b = data[srcIndex + 2].toInt() and 0xFF |
| 244 | + val a = data[srcIndex + 3].toInt() and 0xFF |
| 245 | + // NativeImage uses ABGR format |
| 246 | + val abgr = (a shl 24) or (b shl 16) or (g shl 8) or r |
| 247 | + nativeImage.setColor(x, y, abgr) |
| 248 | + } |
| 249 | + } |
| 250 | + |
| 251 | + // Upload to GPU |
| 252 | + RenderSystem.getDevice().createCommandEncoder().writeToTexture(glTexture, nativeImage) |
| 253 | + nativeImage.close() |
| 254 | + |
| 255 | + // Free atlas data after upload |
| 256 | + atlasData = null |
| 257 | + } |
| 258 | + |
| 259 | + fun getGlyph(codepoint: Int): Glyph? = glyphs[codepoint] |
| 260 | + |
| 261 | + /** Calculate the width of a string in pixels. */ |
| 262 | + fun getStringWidth(text: String): Float { |
| 263 | + var width = 0f |
| 264 | + for (char in text) { |
| 265 | + val glyph = glyphs[char.code] ?: glyphs[' '.code] ?: continue |
| 266 | + width += glyph.xAdvance |
| 267 | + } |
| 268 | + return width |
| 269 | + } |
| 270 | + |
| 271 | + override fun close() { |
| 272 | + glTextureView?.close() |
| 273 | + glTextureView = null |
| 274 | + glTexture?.close() |
| 275 | + glTexture = null |
| 276 | + gpuSampler = null // Sampler is managed by cache, don't close |
| 277 | + atlasData = null |
| 278 | + MemoryUtil.memFree(fontBuffer) |
| 279 | + } |
| 280 | +} |
0 commit comments