|
| 1 | +/* |
| 2 | + * Copyright 2024 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.renderer.gui.font |
| 19 | + |
| 20 | +import com.google.common.math.IntMath.pow |
| 21 | +import com.lambda.event.events.ConnectionEvent |
| 22 | +import com.lambda.event.listener.UnsafeListener.Companion.unsafeListenOnce |
| 23 | +import com.lambda.graphics.texture.MipmapTexture |
| 24 | +import com.lambda.http.Method |
| 25 | +import com.lambda.http.request |
| 26 | +import com.lambda.module.modules.client.RenderSettings |
| 27 | +import com.lambda.threading.runGameScheduled |
| 28 | +import com.lambda.util.LambdaResource |
| 29 | +import com.lambda.util.math.Vec2d |
| 30 | +import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap |
| 31 | +import it.unimi.dsi.fastutil.objects.Object2DoubleArrayMap |
| 32 | +import it.unimi.dsi.fastutil.objects.Object2IntArrayMap |
| 33 | +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap |
| 34 | +import java.awt.Color |
| 35 | +import java.awt.Font |
| 36 | +import java.awt.FontMetrics |
| 37 | +import java.awt.Graphics2D |
| 38 | +import java.awt.RenderingHints |
| 39 | +import java.awt.image.BufferedImage |
| 40 | +import java.util.function.ToIntFunction |
| 41 | +import java.util.zip.ZipFile |
| 42 | +import javax.imageio.ImageIO |
| 43 | +import kotlin.math.ceil |
| 44 | +import kotlin.math.log2 |
| 45 | +import kotlin.math.max |
| 46 | +import kotlin.math.sqrt |
| 47 | +import kotlin.time.Duration.Companion.days |
| 48 | + |
| 49 | +/** |
| 50 | + * The [LambdaAtlas] manages the creation and binding of texture atlases for fonts, emojis and user defined atlases |
| 51 | + * It stores glyph information, manages texture uploads, and provides functionality to build texture buffers for fonts and emoji sets |
| 52 | + * |
| 53 | + * It caches font information and emoji data for efficient rendering and includes mechanisms for uploading and binding texture atlases |
| 54 | + */ |
| 55 | +object LambdaAtlas { |
| 56 | + private val fontMap = Object2ObjectOpenHashMap<Any, Int2ObjectArrayMap<GlyphInfo>>() |
| 57 | + private val emojiMap = Object2ObjectOpenHashMap<Any, Object2ObjectOpenHashMap<String, GlyphInfo>>() |
| 58 | + private val textureMap = Object2ObjectOpenHashMap<Any, MipmapTexture>() |
| 59 | + private val slotReservation = Object2IntArrayMap<Any>() |
| 60 | + |
| 61 | + private val bufferPool = |
| 62 | + mutableMapOf<Any, BufferedImage>() // This array is nuked once the data is dispatched to OpenGL |
| 63 | + |
| 64 | + private val fontCache = mutableMapOf<Any, Font>() |
| 65 | + private val metricCache = mutableMapOf<Font, FontMetrics>() |
| 66 | + private val heightCache = Object2DoubleArrayMap<Font>() |
| 67 | + |
| 68 | + operator fun LambdaFont.get(char: Char): GlyphInfo? = fontMap.getValue(this)[char.code] |
| 69 | + operator fun LambdaEmoji.get(string: String): GlyphInfo? = emojiMap.getValue(this)[string] |
| 70 | + |
| 71 | + /** |
| 72 | + * Upload additional atlas that can be used with the owner to bind textures to shaders |
| 73 | + */ |
| 74 | + fun Any.uploadAtlas(data: BufferedImage) = textureMap.set(this, MipmapTexture(data)) |
| 75 | + |
| 76 | + // Allow binding any valid font definition enums |
| 77 | + fun <T : Enum<T>> T.bind() = with(textureMap.getValue(this)) |
| 78 | + { |
| 79 | + bind(slot = slotReservation.computeIfAbsent(this, ToIntFunction { slotReservation.size })) |
| 80 | + setLOD(RenderSettings.lodBias.toFloat()) |
| 81 | + } |
| 82 | + |
| 83 | + val LambdaFont.height: Double |
| 84 | + get() = heightCache.getDouble(fontCache[this]) |
| 85 | + |
| 86 | + val LambdaEmoji.keys |
| 87 | + get() = emojiMap.getValue(this) |
| 88 | + |
| 89 | + /** |
| 90 | + * Builds the buffer for an emoji set by reading a ZIP file containing emoji images. |
| 91 | + * The images are arranged into a texture atlas, and their UV coordinates are computed for later rendering. |
| 92 | + * |
| 93 | + * @throws IllegalStateException If the texture size is too small to fit the emojis. |
| 94 | + */ |
| 95 | + fun LambdaEmoji.buildBuffer() { |
| 96 | + val file = request(url) { |
| 97 | + method(Method.GET) |
| 98 | + }.maybeDownload("emojis.zip", maxAge = 30.days) |
| 99 | + |
| 100 | + var image: BufferedImage |
| 101 | + |
| 102 | + ZipFile(file).use { zip -> |
| 103 | + val firstImage = ImageIO.read(zip.getInputStream(zip.entries().nextElement())) |
| 104 | + val length = zip.size().toDouble() |
| 105 | + |
| 106 | + val textureDimensionLength: (Int) -> Int = { dimLength -> |
| 107 | + pow(2, ceil(log2((dimLength + 2) * sqrt(length))).toInt()) |
| 108 | + } |
| 109 | + |
| 110 | + val width = textureDimensionLength(firstImage.width) |
| 111 | + val height = textureDimensionLength(firstImage.height) |
| 112 | + val texelSize = Vec2d.ONE / Vec2d(width, height) |
| 113 | + |
| 114 | + image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) |
| 115 | + val graphics = image.graphics as Graphics2D |
| 116 | + graphics.color = Color(0, 0, 0, 0) |
| 117 | + |
| 118 | + var x = 0 |
| 119 | + var y = 0 |
| 120 | + |
| 121 | + val constructed = Object2ObjectOpenHashMap<String, GlyphInfo>() |
| 122 | + for (entry in zip.entries()) { |
| 123 | + val name = entry.name.substringAfterLast("/").substringBeforeLast(".") |
| 124 | + val emoji = ImageIO.read(zip.getInputStream(entry)) |
| 125 | + |
| 126 | + if (x + emoji.width >= image.width) { |
| 127 | + y += emoji.height + 2 |
| 128 | + x = 0 |
| 129 | + } |
| 130 | + |
| 131 | + check(y + emoji.height < image.height) { "Can't load emoji glyphs. Texture size is too small" } |
| 132 | + |
| 133 | + graphics.drawImage(emoji, x, y, null) |
| 134 | + |
| 135 | + val size = Vec2d(emoji.width, emoji.height) |
| 136 | + val uv1 = Vec2d(x, y) * texelSize |
| 137 | + val uv2 = Vec2d(x, y).plus(size) * texelSize |
| 138 | + |
| 139 | + constructed[name] = GlyphInfo(size, -uv1, -uv2) |
| 140 | + |
| 141 | + x += emoji.width + 2 |
| 142 | + } |
| 143 | + |
| 144 | + emojiMap[this] = constructed |
| 145 | + } |
| 146 | + |
| 147 | + bufferPool[this] = image |
| 148 | + } |
| 149 | + |
| 150 | + fun LambdaFont.buildBuffer( |
| 151 | + characters: Int = 2048 // How many characters from that font should be used for the generation |
| 152 | + ) { |
| 153 | + val font = fontCache.computeIfAbsent(this) { |
| 154 | + val resource = LambdaResource("fonts/$fontName.ttf") |
| 155 | + |
| 156 | + Font.createFont(Font.TRUETYPE_FONT, resource.stream).deriveFont(64.0f) |
| 157 | + } |
| 158 | + |
| 159 | + val textureSize = characters * 2 |
| 160 | + val oneTexelSize = 1.0 / textureSize |
| 161 | + |
| 162 | + val image = BufferedImage(textureSize, textureSize, BufferedImage.TYPE_INT_ARGB) |
| 163 | + |
| 164 | + val graphics = image.graphics as Graphics2D |
| 165 | + graphics.background = Color(0, 0, 0, 0) |
| 166 | + |
| 167 | + var x = 0 |
| 168 | + var y = 0 |
| 169 | + var rowHeight = 0 |
| 170 | + |
| 171 | + val constructed = Int2ObjectArrayMap<GlyphInfo>() |
| 172 | + (Char.MIN_VALUE..<characters.toChar()).forEach { char -> |
| 173 | + val charImage = getCharImage(font, char) ?: return@forEach |
| 174 | + |
| 175 | + rowHeight = max(rowHeight, charImage.height + 2) |
| 176 | + |
| 177 | + if (x + charImage.width >= textureSize) { |
| 178 | + y += rowHeight |
| 179 | + x = 0 |
| 180 | + rowHeight = 0 |
| 181 | + } |
| 182 | + |
| 183 | + check(y + charImage.height <= textureSize) { "Can't load font glyphs. Texture size is too small" } |
| 184 | + |
| 185 | + graphics.drawImage(charImage, x, y, null) |
| 186 | + |
| 187 | + val size = Vec2d(charImage.width, charImage.height) |
| 188 | + val uv1 = Vec2d(x, y) * oneTexelSize |
| 189 | + val uv2 = Vec2d(x, y).plus(size) * oneTexelSize |
| 190 | + |
| 191 | + constructed[char.code] = GlyphInfo(size, uv1, uv2) |
| 192 | + heightCache[font] = max(heightCache.getDouble(font), size.y) // No compare set unfortunately |
| 193 | + |
| 194 | + x += charImage.width + 2 |
| 195 | + } |
| 196 | + |
| 197 | + fontMap[this] = constructed |
| 198 | + bufferPool[this] = image |
| 199 | + } |
| 200 | + |
| 201 | + init { |
| 202 | + // TODO: Change this when we've refactored the loadables |
| 203 | + unsafeListenOnce<ConnectionEvent.Connect.Pre, ConnectionEvent.Connect.Pre> { |
| 204 | + runGameScheduled { |
| 205 | + bufferPool.forEach { (owner, image) -> |
| 206 | + textureMap[owner] = MipmapTexture(image) |
| 207 | + } |
| 208 | + |
| 209 | + bufferPool.clear() |
| 210 | + } |
| 211 | + |
| 212 | + true |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + private fun getCharImage(font: Font, codePoint: Char): BufferedImage? { |
| 217 | + if (!font.canDisplay(codePoint)) return null |
| 218 | + |
| 219 | + val fontMetrics = metricCache.getOrPut(font) { |
| 220 | + val image = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB) |
| 221 | + val graphics2D = image.createGraphics() |
| 222 | + |
| 223 | + graphics2D.font = font |
| 224 | + graphics2D.dispose() |
| 225 | + |
| 226 | + image.graphics.getFontMetrics(font) |
| 227 | + } |
| 228 | + |
| 229 | + val charWidth = if (fontMetrics.charWidth(codePoint) > 0) fontMetrics.charWidth(codePoint) else 8 |
| 230 | + val charHeight = if (fontMetrics.height > 0) fontMetrics.height else font.size |
| 231 | + |
| 232 | + val charImage = BufferedImage(charWidth, charHeight, BufferedImage.TYPE_INT_ARGB) |
| 233 | + val graphics2D = charImage.createGraphics() |
| 234 | + |
| 235 | + graphics2D.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) |
| 236 | + graphics2D.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_DEFAULT) |
| 237 | + |
| 238 | + graphics2D.font = font |
| 239 | + graphics2D.color = Color.WHITE |
| 240 | + graphics2D.drawString(codePoint.toString(), 0, fontMetrics.ascent) |
| 241 | + graphics2D.dispose() |
| 242 | + |
| 243 | + return charImage |
| 244 | + } |
| 245 | +} |
0 commit comments