Skip to content

Commit 2d0e9b7

Browse files
committed
refactor: global texture atlas
1 parent ae7bd1f commit 2d0e9b7

File tree

10 files changed

+266
-314
lines changed

10 files changed

+266
-314
lines changed

common/src/main/java/com/lambda/mixin/render/ChatInputSuggestorMixin.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import com.google.common.base.Strings;
2121
import com.lambda.command.CommandManager;
22+
import com.lambda.graphics.renderer.gui.font.LambdaAtlas;
2223
import com.lambda.module.modules.client.LambdaMoji;
2324
import com.lambda.module.modules.client.RenderSettings;
2425
import com.mojang.brigadier.CommandDispatcher;
@@ -88,8 +89,8 @@ private void refreshEmojiSuggestion(CallbackInfo ci) {
8889

8990
String emojiString = typing.substring(start + 1);
9091

91-
Stream<String> results = RenderSettings.INSTANCE.getEmojiFont().glyphs.getKeys()
92-
.stream()
92+
Stream<String> results = LambdaAtlas.INSTANCE.getKeys(RenderSettings.INSTANCE.getEmojiFont())
93+
.keySet().stream()
9394
.filter(s -> s.startsWith(emojiString))
9495
.map(s -> s + ":");
9596

common/src/main/java/com/lambda/mixin/render/TextRendererMixin.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@
1818
package com.lambda.mixin.render;
1919

2020
import com.lambda.Lambda;
21-
import com.lambda.graphics.renderer.gui.font.FontRenderer;
21+
import com.lambda.graphics.renderer.gui.font.LambdaAtlas;
2222
import com.lambda.graphics.renderer.gui.font.LambdaEmoji;
2323
import com.lambda.module.modules.client.LambdaMoji;
24+
import com.lambda.module.modules.client.RenderSettings;
2425
import com.lambda.util.math.Vec2d;
2526
import net.minecraft.client.font.TextRenderer;
2627
import net.minecraft.client.render.VertexConsumerProvider;
@@ -104,7 +105,7 @@ public int draw(
104105
String constructed = ":" + emoji + ":";
105106
int index = raw.indexOf(constructed);
106107

107-
if (LambdaEmoji.Twemoji.get(emoji) == null ||
108+
if (LambdaAtlas.INSTANCE.get(RenderSettings.INSTANCE.getEmojiFont(), emoji) == null ||
108109
index == -1) continue;
109110

110111
int height = Lambda.getMc().textRenderer.fontHeight;

common/src/main/kotlin/com/lambda/graphics/renderer/gui/font/FontRenderer.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ package com.lambda.graphics.renderer.gui.font
2020
import com.lambda.graphics.buffer.VertexPipeline
2121
import com.lambda.graphics.buffer.vertex.attributes.VertexAttrib
2222
import com.lambda.graphics.buffer.vertex.attributes.VertexMode
23-
import com.lambda.graphics.renderer.gui.font.glyph.GlyphInfo
23+
import com.lambda.graphics.renderer.gui.font.LambdaAtlas.bind
24+
import com.lambda.graphics.renderer.gui.font.LambdaAtlas.get
25+
import com.lambda.graphics.renderer.gui.font.LambdaAtlas.height
2426
import com.lambda.graphics.shader.Shader
2527
import com.lambda.module.modules.client.ClickGui
2628
import com.lambda.module.modules.client.LambdaMoji
@@ -115,7 +117,7 @@ class FontRenderer {
115117
* @param scale The scale factor for the height calculation.
116118
* @return The height of the text at the specified scale.
117119
*/
118-
fun getHeight(scale: Double = 1.0) = chars.glyphs.fontHeight * getScaleFactor(scale) * 0.7
120+
fun getHeight(scale: Double = 1.0) = chars.height * getScaleFactor(scale) * 0.7
119121

120122
/**
121123
* Iterates over each character and emoji in the text and applies a block operation.
@@ -221,8 +223,8 @@ class FontRenderer {
221223
shader.use()
222224
shader["u_EmojiTexture"] = 1
223225

224-
chars.glyphs.bind()
225-
emojis.glyphs.bind()
226+
chars.bind()
227+
emojis.bind()
226228

227229
pipeline.upload()
228230
pipeline.render()

common/src/main/kotlin/com/lambda/graphics/renderer/gui/font/glyph/GlyphInfo.kt renamed to common/src/main/kotlin/com/lambda/graphics/renderer/gui/font/GlyphInfo.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1616
*/
1717

18-
package com.lambda.graphics.renderer.gui.font.glyph
18+
package com.lambda.graphics.renderer.gui.font
1919

2020
import com.lambda.util.math.Vec2d
2121

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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+
}

common/src/main/kotlin/com/lambda/graphics/renderer/gui/font/LambdaEmoji.kt

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,11 @@
1818
package com.lambda.graphics.renderer.gui.font
1919

2020
import com.lambda.core.Loadable
21-
import com.lambda.graphics.renderer.gui.font.glyph.EmojiGlyphs
21+
import com.lambda.graphics.renderer.gui.font.LambdaAtlas.buildBuffer
2222

23-
enum class LambdaEmoji(private val zipUrl: String) {
23+
enum class LambdaEmoji(val url: String) {
2424
Twemoji("https://github.com/Edouard127/emoji-generator/releases/latest/download/emojis.zip");
2525

26-
lateinit var glyphs: EmojiGlyphs
27-
28-
operator fun get(emoji: String) = glyphs.emojiFromString(emoji)
29-
30-
fun loadGlyphs() {
31-
glyphs = EmojiGlyphs(zipUrl)
32-
}
33-
3426
private val emojiRegex = Regex(":[a-zA-Z0-9_]+:")
3527

3628
/**
@@ -45,8 +37,9 @@ enum class LambdaEmoji(private val zipUrl: String) {
4537

4638
object Loader : Loadable {
4739
override fun load(): String {
48-
entries.forEach(LambdaEmoji::loadGlyphs)
49-
return "Loaded ${entries.size} emoji sets with a total of ${entries.sumOf { it.glyphs.count }} emojis"
40+
entries.forEach { it.buildBuffer() }
41+
42+
return "Loaded ${entries.size} emoji sets"
5043
}
5144
}
5245
}

common/src/main/kotlin/com/lambda/graphics/renderer/gui/font/LambdaFont.kt

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,28 +18,15 @@
1818
package com.lambda.graphics.renderer.gui.font
1919

2020
import com.lambda.core.Loadable
21-
import com.lambda.graphics.renderer.gui.font.glyph.FontGlyphs
22-
import com.lambda.util.LambdaResource
23-
import java.awt.Font
21+
import com.lambda.graphics.renderer.gui.font.LambdaAtlas.buildBuffer
2422

25-
enum class LambdaFont(private val fontName: String) {
23+
enum class LambdaFont(val fontName: String) {
2624
FiraSansRegular("FiraSans-Regular"),
2725
FiraSansBold("FiraSans-Bold");
2826

29-
lateinit var glyphs: FontGlyphs
30-
31-
operator fun get(char: Char) = glyphs.getChar(char)
32-
33-
fun loadGlyphs() {
34-
val resource = LambdaResource("fonts/$fontName.ttf")
35-
val stream = resource.stream ?: throw IllegalStateException("Failed to locate font $fontName")
36-
val font = Font.createFont(Font.TRUETYPE_FONT, stream).deriveFont(64.0f)
37-
glyphs = FontGlyphs(font)
38-
}
39-
4027
object Loader : Loadable {
4128
override fun load(): String {
42-
entries.forEach(LambdaFont::loadGlyphs)
29+
entries.forEach { it.buildBuffer() }
4330
return "Loaded ${entries.size} fonts"
4431
}
4532
}

0 commit comments

Comments
 (0)