diff --git a/module.font/build.gradle b/module.font/build.gradle
index 14301213d..1086cd966 100644
--- a/module.font/build.gradle
+++ b/module.font/build.gradle
@@ -5,3 +5,13 @@ dependencies {
slimJarJar(api project(":anvillib-rendering-neoforge-1.21.8"))
}
}
+
+
+dependencies {
+ testImplementation platform('org.junit:junit-bom:5.10.2')
+ testImplementation 'org.junit.jupiter:junit-jupiter'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+neoForge { addModdingDependenciesTo(sourceSets.test) }
+test { useJUnitPlatform() }
diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/AnvilLibFont.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/AnvilLibFont.java
index 96a5c8f0d..bab7ce353 100644
--- a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/AnvilLibFont.java
+++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/AnvilLibFont.java
@@ -28,9 +28,9 @@ public AnvilLibFont(ModContainer container) {
// variants on background threads at mod init time. This avoids render-
// thread blocking (visible lag) when bold/italic text is first drawn.
Font base = getSelectFont();
- SdfGlyphAtlas.getOrCreate(base);
+ SdfGlyphAtlas.getOrCreate(base).thenAccept(SdfGlyphAtlas::preWarmAscii);
for (int style : List.of(Font.BOLD, Font.ITALIC, Font.BOLD | Font.ITALIC)) {
- SdfGlyphAtlas.getOrCreate(base.deriveFont(style));
+ SdfGlyphAtlas.getOrCreate(base.deriveFont(style)).thenAccept(SdfGlyphAtlas::preWarmAscii);
}
}
diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfAtlasTexture.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfAtlasTexture.java
index 73a5a63d4..8f6bea07d 100644
--- a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfAtlasTexture.java
+++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfAtlasTexture.java
@@ -9,8 +9,8 @@
import net.minecraft.client.renderer.texture.AbstractTexture;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.ApiStatus;
+import org.lwjgl.system.MemoryUtil;
-import java.awt.image.BufferedImage;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -31,28 +31,24 @@ public static ResourceLocation uploadPage(SdfGlyphAtlas atlas, int pageIndex) {
SdfGlyphPage page = atlas.page(pageIndex);
String key = atlas.key() + ".p" + pageIndex;
ResourceLocation id = ResourceLocation.fromNamespaceAndPath("anvillib_font", "dynamic/sdf_atlas/" + sanitize(key));
- // Synchronize on page to read the version and image data atomically
- // with respect to the glyph creation thread, which also synchronizes
- // on page for placeGlyph / fillPaddingForCell.
- NativeImage nativeImage;
- int version;
- synchronized (page) {
- version = page.version.get();
- PageEntry cached = CACHE.get(key);
- if (cached != null && cached.version == version) return cached.id;
- nativeImage = toNativeImage(page.image);
- page.dirty = false;
- }
-
PageEntry entry = CACHE.get(key);
- if (entry != null && entry.version == version) return entry.id;
-
- SdfTexture texture = new SdfTexture(nativeImage);
- Minecraft.getInstance().getTextureManager().register(id, texture);
-
- if (entry != null) entry.texture.close();
- CACHE.put(key, new PageEntry(id, texture, version));
+ boolean create = entry == null || entry.texture.closed;
+ if (!create && entry.version == page.version.get() && !page.dirty) return entry.id;
+ SdfGlyphPage.Upload upload = page.snapshot(create);
+ if (upload == null) return id;
+ try (NativeImage image = toNativeImage(upload)) {
+ if (create) {
+ SdfTexture texture = new SdfTexture(image);
+ Minecraft.getInstance().getTextureManager().register(id, texture);
+ entry = new PageEntry(id, texture, upload.version());
+ CACHE.put(key, entry);
+ } else {
+ entry.texture.upload(image, upload.x(), upload.y());
+ entry.version = upload.version();
+ }
+ }
page.textureId = id;
+ page.uploaded(upload);
return id;
}
@@ -66,6 +62,7 @@ public static void ensureUploaded(SdfGlyphAtlas atlas) {
PageEntry entry = CACHE.get(key);
if (page.textureId == null
|| entry == null
+ || entry.texture.closed
|| entry.version != page.version.get()
|| page.dirty) {
uploadPage(atlas, i);
@@ -77,6 +74,7 @@ public static void ensureUploaded(SdfGlyphAtlas atlas) {
* A minimal single-channel texture with LINEAR+CLAMP filtering for SDF sampling.
*/
static final class SdfTexture extends AbstractTexture {
+ private boolean closed;
SdfTexture(NativeImage image) {
GpuDevice device = RenderSystem.getDevice();
this.texture = device.createTexture(
@@ -87,19 +85,25 @@ static final class SdfTexture extends AbstractTexture {
this.textureView = device.createTextureView(this.texture);
this.texture.setTextureFilter(FilterMode.LINEAR, false);
this.texture.setAddressMode(com.mojang.blaze3d.textures.AddressMode.CLAMP_TO_EDGE);
- image.close();
}
- }
- static NativeImage toNativeImage(BufferedImage image) {
- NativeImage ni = new NativeImage(NativeImage.Format.RGBA, image.getWidth(), image.getHeight(), false);
- for (int y = 0; y < image.getHeight(); y++) {
- for (int x = 0; x < image.getWidth(); x++) {
- int gray = image.getRGB(x, y) & 0xFF;
- ni.setPixel(x, y, (0xFF << 24) | (gray << 16) | (gray << 8) | gray);
- }
+ void upload(NativeImage image, int x, int y) {
+ RenderSystem.getDevice().createCommandEncoder().writeToTexture(
+ this.texture, image, 0, 0, x, y, image.getWidth(), image.getHeight(), 0, 0
+ );
+ }
+
+ @Override
+ public void close() {
+ this.closed = true;
+ super.close();
}
- return ni;
+ }
+
+ static NativeImage toNativeImage(SdfGlyphPage.Upload upload) {
+ NativeImage image = new NativeImage(NativeImage.Format.LUMINANCE, upload.width(), upload.height(), false);
+ MemoryUtil.memByteBuffer(image.getPointer(), upload.pixels().length).put(upload.pixels());
+ return image;
}
private static String sanitize(String key) {
@@ -122,7 +126,7 @@ private static String sanitize(String key) {
private static final class PageEntry {
final ResourceLocation id;
final SdfTexture texture;
- final int version;
+ int version;
PageEntry(ResourceLocation id, SdfTexture texture, int version) {
this.id = id;
diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphAtlas.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphAtlas.java
index 4cce9e988..16908464c 100644
--- a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphAtlas.java
+++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphAtlas.java
@@ -10,14 +10,15 @@
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
-import java.util.ArrayList;
-import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
@@ -25,8 +26,8 @@
/**
* Multi-page on-demand SDF glyph atlas.
*
- * Glyphs are packed into fixed-size 1024×1024 pages. ASCII 32-126 is
- * pre-warmed; all other codepoints are rendered lazily on first use.
+ * Metrics become available independently of rasterization. Glyphs are generated
+ * on demand and packed into fixed-size 1024×1024 pages on background workers.
*/
@ApiStatus.Internal
@Slf4j
@@ -41,14 +42,13 @@ public final class SdfGlyphAtlas {
private static final Map> CACHE = new ConcurrentHashMap<>();
- /**
- * Per-task virtual-thread executor for background glyph creation.
- * Each glyph (or batch) gets its own virtual thread; the existing
- * {@code synchronized} blocks on the atlas and page objects already
- * provide the necessary mutual exclusion.
- */
- private static final ExecutorService GLYPH_EXECUTOR = Executors.newThreadPerTaskExecutor(
- Thread.ofVirtual().name("AnvilLib-SDF-Glyph-", 0).factory()
+ // Metrics must never queue behind CPU-heavy glyph jobs: callers may join their future.
+ private static final ExecutorService METRICS_EXECUTOR = Executors.newThreadPerTaskExecutor(
+ Thread.ofVirtual().name("AnvilLib-SDF-Metrics-", 0).factory()
+ );
+ private static final ExecutorService GLYPH_EXECUTOR = Executors.newFixedThreadPool(
+ Math.max(1, Math.min(2, Runtime.getRuntime().availableProcessors() / 2)),
+ Thread.ofPlatform().daemon().name("AnvilLib-SDF-Glyph-", 0).factory()
);
private final String key;
@@ -61,9 +61,12 @@ public final class SdfGlyphAtlas {
private final int awtHeight;
private final FontMetrics fontMetrics;
- private final List pages = new ArrayList<>();
+ private final List pages = new CopyOnWriteArrayList<>();
private final Map glyphMap = new ConcurrentHashMap<>();
private final Set pendingGlyphs = ConcurrentHashMap.newKeySet();
+ private final Map advances = new ConcurrentHashMap<>();
+ private final ConcurrentLinkedQueue glyphQueue = new ConcurrentLinkedQueue<>();
+ private final AtomicBoolean workerScheduled = new AtomicBoolean();
private SdfGlyphAtlas(String key, Font font) {
this.key = key;
@@ -92,26 +95,26 @@ private SdfGlyphAtlas(String key, Font font) {
this.paddedCellSize = this.cellSize + 2 * this.padding;
log.info("SdfGlyphAtlas cellSize={} ascent={} descent={} key={}", this.cellSize, this.awtAscent, awtDescent, key);
- preWarmAscii();
- log.info("SdfGlyphAtlas ready for key={}, pages={}", key, this.pages.size());
+ log.info("SdfGlyphAtlas metrics ready for key={}, pages={}", key, this.pages.size());
}
// ── Public API ──────────────────────────────────────────────
/**
- * Start building the atlas on a background thread; returns the future.
+ * Prepare font metrics on a background thread. Completion does not wait for
+ * any glyph rasterization or GPU upload.
*/
public static CompletableFuture getOrCreate(@Nullable Font font) {
Font resolved = resolveFont(font);
String key = resolved.getFontName(Locale.ENGLISH) + "." + resolved.getStyle() + "." + resolved.getSize();
return CACHE.computeIfAbsent(key, ignored -> {
log.debug("Starting SDF atlas build for key={}", key);
- return CompletableFuture.supplyAsync(() -> new SdfGlyphAtlas(key, resolved), GLYPH_EXECUTOR);
+ return CompletableFuture.supplyAsync(() -> new SdfGlyphAtlas(key, resolved), METRICS_EXECUTOR);
});
}
/**
- * Return the atlas if fully built, or {@code null} if still constructing.
+ * Return the atlas once its metrics are ready, or {@code null} while preparing.
*
* If the previous build failed (future completed exceptionally), the
* failed future is removed and a fresh build is started so that a
@@ -179,29 +182,33 @@ public SdfGlyphPage page(int index) {
if (entry != null) return entry;
// Trigger async creation on background thread
if (this.pendingGlyphs.add(codepoint)) {
- GLYPH_EXECUTOR.submit(() -> createGlyphAsync(codepoint));
+ this.glyphQueue.add(codepoint);
+ scheduleWorker();
}
return null;
}
- /**
- * Background-thread entry point for glyph creation.
- * Synchronized to ensure only one glyph is created at a time per atlas,
- * avoiding races on page state (nextCol, nextRow, image pixels).
- */
- private void createGlyphAsync(int codepoint) {
+ private void scheduleWorker() {
+ if (this.workerScheduled.compareAndSet(false, true)) GLYPH_EXECUTOR.execute(this::drainGlyphs);
+ }
+
+ // One queued job per atlas, not one thread per character. No render-thread lock
+ // is held during rasterization; only the completed pixels are published to a page.
+ private void drainGlyphs() {
try {
- synchronized (this) {
- // Double-check: may have been created since we were enqueued
- if (this.glyphMap.containsKey(codepoint)) return;
- createGlyph(codepoint, SdfGlyphPage::updateHash);
+ Integer codepoint;
+ while ((codepoint = this.glyphQueue.poll()) != null) {
+ try {
+ if (!this.glyphMap.containsKey(codepoint)) createGlyph(codepoint);
+ } catch (Exception e) {
+ log.error("Failed to create SDF glyph for codepoint {}", codepoint, e);
+ } finally {
+ this.pendingGlyphs.remove(codepoint);
+ }
}
- // Invalidate cached layouts so they pick up the new glyph
- SdfTextLayout.invalidateAtlas(this.key);
- } catch (Exception e) {
- log.error("Failed to create SDF glyph for codepoint {} (U+{})", codepoint, Integer.toHexString(codepoint).toUpperCase(), e);
} finally {
- this.pendingGlyphs.remove(codepoint);
+ this.workerScheduled.set(false);
+ if (!this.glyphQueue.isEmpty()) scheduleWorker();
}
}
@@ -214,14 +221,11 @@ public int measureText(String text) {
*/
public int measureText(String text, int start, int end) {
int width = 0;
- Set glyphPages = new HashSet<>();
for (int i = start; i < end; ) {
int cp = text.codePointAt(i);
- GlyphEntry g = glyph(cp, glyphPages::add);
- width += g == null ? this.cellSize / 2 : g.advance;
+ width += measureCodepoint(cp);
i += Character.charCount(cp);
}
- glyphPages.forEach(SdfGlyphPage::updateHash);
return width;
}
@@ -229,39 +233,33 @@ public int measureText(String text, int start, int end) {
* Measure a single codepoint's advance in atlas pixels.
*/
public int measureCodepoint(int codepoint) {
- GlyphEntry g = glyph(codepoint, SdfGlyphPage::updateHash);
- return g == null ? this.cellSize / 2 : g.advance;
+ return this.advances.computeIfAbsent(codepoint, this.fontMetrics::charWidth);
+ }
+
+ /** Stable GUI advance, including while the glyph is being rasterized. */
+ public int scaledAdvance(int codepoint, float scale) {
+ return Math.max(1, Math.round(measureCodepoint(codepoint) * scale));
}
// ── Glyph creation ──────────────────────────────────────────
- private GlyphEntry createGlyph(int codepoint, Consumer pageConsumer) {
+ private void createGlyph(int codepoint) {
BufferedImage mask = renderMask(codepoint);
+ BufferedImage cell = new BufferedImage(this.paddedCellSize, this.paddedCellSize, BufferedImage.TYPE_BYTE_GRAY);
+ blitSdfGlyph(mask, cell, this.padding, this.padding, this.cellSize, this.sdfRadius);
+ fillPadding(cell, 0, 0, this.paddedCellSize, this.cellSize, this.padding);
int pageIdx = findOrCreatePageIndex();
- SdfGlyphPage page = this.pages.get(pageIdx);
- GlyphEntry entry = page.placeGlyph(this, mask);
- entry = new GlyphEntry(
- pageIdx,
- entry.atlasX(),
- entry.atlasY(),
- entry.width(),
- entry.height(),
- this.fontMetrics.charWidth(codepoint)
- );
- this.glyphMap.put(codepoint, entry);
- page.fillPaddingForCell(this, entry);
- page.dirty = true;
- pageConsumer.accept(page);
- return entry;
+ GlyphEntry placed = this.pages.get(pageIdx).placeGlyph(this, cell);
+ // Publish only after the complete cell (including padding and upload revision) is visible.
+ this.glyphMap.put(codepoint, new GlyphEntry(pageIdx, placed.atlasX(), placed.atlasY(),
+ placed.width(), placed.height(), measureCodepoint(codepoint)));
+ // Complete layouts contain only immutable glyph positions, so new glyphs do not invalidate them.
}
- private void preWarmAscii() {
- Set glyphPages = new HashSet<>();
- for (int code = FIRST_CHAR; code <= LAST_CHAR; code++) {
- createGlyph(code, glyphPages::add);
- }
- glyphPages.forEach(SdfGlyphPage::updateHash);
+ /** Queue common UI glyphs without delaying callers that only need metrics. */
+ public void preWarmAscii() {
+ for (int code = FIRST_CHAR; code <= LAST_CHAR; code++) glyph(code, ignored -> {});
}
private int findOrCreatePageIndex() {
diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphPage.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphPage.java
index 3b6aaa193..77ee9f45a 100644
--- a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphPage.java
+++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfGlyphPage.java
@@ -2,63 +2,82 @@
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.ApiStatus;
+import org.jspecify.annotations.Nullable;
import java.awt.image.BufferedImage;
import java.util.concurrent.atomic.AtomicInteger;
-/**
- * A single 1024×1024 atlas page holding packed glyphs.
- */
+/** A page of immutable glyph cells, with short synchronized publication/snapshot operations. */
@ApiStatus.Internal
public final class SdfGlyphPage {
private static final int SIZE = SdfGlyphAtlas.PAGE_SIZE;
- final BufferedImage image;
+ final BufferedImage image = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_BYTE_GRAY);
+ private final byte[] pixels = new byte[SIZE * SIZE];
final AtomicInteger version = new AtomicInteger();
final int cols, rows;
int nextCol, nextRow;
ResourceLocation textureId;
volatile boolean dirty = true;
+ private int minX = SIZE, minY = SIZE, maxX, maxY;
SdfGlyphPage(int paddedCellSize) {
this.cols = SIZE / paddedCellSize;
this.rows = SIZE / paddedCellSize;
- this.image = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_BYTE_GRAY);
}
- boolean hasSpace() {
- return nextRow < rows;
- }
+ boolean hasSpace() { return this.nextRow < this.rows; }
- synchronized SdfGlyphAtlas.GlyphEntry placeGlyph(SdfGlyphAtlas atlas, BufferedImage mask) {
- int col = nextCol, row = nextRow;
- int padX = col * atlas.paddedCellSize;
- int padY = row * atlas.paddedCellSize;
- int innerX = padX + atlas.padding;
- int innerY = padY + atlas.padding;
- SdfGlyphAtlas.blitSdfGlyph(mask, this.image, innerX, innerY, atlas.cellSize, atlas.sdfRadius);
- nextCol++;
- if (nextCol >= cols) {
- nextCol = 0;
- nextRow++;
+ SdfGlyphAtlas.GlyphEntry placeGlyph(SdfGlyphAtlas atlas, BufferedImage cell) {
+ int size = atlas.paddedCellSize;
+ // Preserve the gray color model's conversion, but perform it once on the worker.
+ int[] colors = cell.getRGB(0, 0, size, size, null, 0, size);
+ byte[] gray = new byte[colors.length];
+ for (int i = 0; i < colors.length; i++) gray[i] = (byte) colors[i];
+ synchronized (this) {
+ int x = this.nextCol * size, y = this.nextRow * size;
+ this.image.getRaster().setRect(x, y, cell.getRaster());
+ for (int row = 0; row < size; row++) {
+ System.arraycopy(gray, row * size, this.pixels, (y + row) * SIZE + x, size);
+ }
+ if (++this.nextCol >= this.cols) { this.nextCol = 0; this.nextRow++; }
+ this.minX = Math.min(this.minX, x);
+ this.minY = Math.min(this.minY, y);
+ this.maxX = Math.max(this.maxX, x + size);
+ this.maxY = Math.max(this.maxY, y + size);
+ this.version.incrementAndGet();
+ this.dirty = true;
+ return new SdfGlyphAtlas.GlyphEntry(0, x + atlas.padding, y + atlas.padding,
+ atlas.cellSize, atlas.cellSize, 0);
}
- dirty = true;
- return new SdfGlyphAtlas.GlyphEntry(0, innerX, innerY, atlas.cellSize, atlas.cellSize, 0);
}
- synchronized void fillPaddingForCell(SdfGlyphAtlas atlas, SdfGlyphAtlas.GlyphEntry e) {
- int col = (e.atlasX() - atlas.padding) / atlas.paddedCellSize;
- int row = (e.atlasY() - atlas.padding) / atlas.paddedCellSize;
- SdfGlyphAtlas.fillPadding(
- this.image,
- col * atlas.paddedCellSize,
- row * atlas.paddedCellSize,
- atlas.paddedCellSize,
- atlas.cellSize,
- atlas.padding
- );
+ /** Compatibility hook for callers that explicitly invalidate a page. */
+ public synchronized void updateHash() {
+ this.minX = this.minY = 0;
+ this.maxX = this.maxY = SIZE;
+ this.version.incrementAndGet();
+ this.dirty = true;
}
- public void updateHash() {
- this.version.incrementAndGet();
+ /** Copy only changed pixels; neither SDF computation nor native allocation holds this lock. */
+ synchronized @Nullable Upload snapshot(boolean full) {
+ if (!full && (!this.dirty || this.maxX <= this.minX || this.maxY <= this.minY)) return null;
+ int x = full ? 0 : this.minX, y = full ? 0 : this.minY;
+ int width = full ? SIZE : this.maxX - x, height = full ? SIZE : this.maxY - y;
+ byte[] copy = new byte[width * height];
+ for (int row = 0; row < height; row++) {
+ System.arraycopy(this.pixels, (y + row) * SIZE + x, copy, row * width, width);
+ }
+ return new Upload(this.version.get(), x, y, width, height, copy);
}
+
+ synchronized void uploaded(Upload upload) {
+ // A worker may have published another cell during the GPU upload. Keep it dirty.
+ if (this.version.get() != upload.version()) return;
+ this.minX = this.minY = SIZE;
+ this.maxX = this.maxY = 0;
+ this.dirty = false;
+ }
+
+ record Upload(int version, int x, int y, int width, int height, byte[] pixels) {}
}
diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextLayout.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextLayout.java
index 4bdff3bdc..4cd30a334 100644
--- a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextLayout.java
+++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextLayout.java
@@ -6,11 +6,9 @@
import java.util.ArrayList;
import java.util.Collections;
-import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
-import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
@@ -90,15 +88,14 @@ private static SdfTextLayout computeLayout(SdfGlyphAtlas atlas, String text, flo
Map> buckets = new LinkedHashMap<>();
boolean allAvailable = true;
- Set glyphPages = new HashSet<>();
for (int ci = 0; ci < text.length(); ) {
int cp = text.codePointAt(ci);
ci += Character.charCount(cp);
- SdfGlyphAtlas.GlyphEntry glyph = atlas.glyph(cp, glyphPages::add);
+ SdfGlyphAtlas.GlyphEntry glyph = atlas.glyph(cp, ignored -> {});
if (glyph == null) {
allAvailable = false;
- penX += Math.round(Math.max(6, atlas.font().getSize() / 2) * scale);
+ penX += atlas.scaledAdvance(cp, scale);
continue;
}
if (glyph.width() <= 0) {
@@ -121,13 +118,15 @@ private static SdfTextLayout computeLayout(SdfGlyphAtlas atlas, String text, flo
penX += Math.max(1, Math.round(glyph.advance() * scale));
maxHeight = Math.max(maxHeight, h);
}
- glyphPages.forEach(SdfGlyphPage::updateHash);
List pages = new ArrayList<>();
for (var entry : buckets.entrySet()) {
int pi = entry.getKey();
SdfGlyphPage page = atlas.page(pi);
ResourceLocation tex = page.textureId;
+ // A worker can publish a new page between ensureUploaded and layout.
+ // Do not permanently cache a page that has no texture yet.
+ if (tex == null) allAvailable = false;
pages.add(new PageQuads(pi, tex, page.image.getWidth(), page.image.getHeight(), entry.getValue()));
}
diff --git a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextRenderer.java b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextRenderer.java
index bcfa38c0d..3c3148db9 100644
--- a/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextRenderer.java
+++ b/module.font/src/main/java/dev/anvilcraft/lib/v2/font/sdf/SdfTextRenderer.java
@@ -31,9 +31,8 @@ public SdfTextRenderer() {
}
/**
- * Get the SDF atlas for a font, blocking until it is ready on first access.
- * The atlas is cached forever, so only the very first call for a particular
- * font blocks (typically ~200-400 ms while the glyph atlas is built).
+ * Wait only for font metrics, never for glyph rasterization or texture uploads.
+ * The configured font and its style variants are prepared during client initialization.
*/
private static SdfGlyphAtlas getAtlas(@Nullable Font font) {
return SdfGlyphAtlas.getOrCreate(font).join();
diff --git a/module.font/src/test/java/dev/anvilcraft/lib/v2/font/sdf/FontStartupTest.java b/module.font/src/test/java/dev/anvilcraft/lib/v2/font/sdf/FontStartupTest.java
new file mode 100644
index 000000000..7ade94a8c
--- /dev/null
+++ b/module.font/src/test/java/dev/anvilcraft/lib/v2/font/sdf/FontStartupTest.java
@@ -0,0 +1,123 @@
+package dev.anvilcraft.lib.v2.font.sdf;
+
+import net.minecraft.resources.ResourceLocation;
+import org.junit.jupiter.api.Test;
+
+import java.awt.Font;
+import java.awt.image.BufferedImage;
+import java.util.HashSet;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.IntStream;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class FontStartupTest {
+ private static SdfGlyphAtlas atlas(int size) throws Exception {
+ return SdfGlyphAtlas.getOrCreate(new Font("Dialog", Font.PLAIN, size)).get(30, TimeUnit.SECONDS);
+ }
+
+ private static SdfGlyphAtlas.GlyphEntry awaitGlyph(SdfGlyphAtlas atlas, int cp) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
+ SdfGlyphAtlas.GlyphEntry glyph;
+ while ((glyph = atlas.glyph(cp, ignored -> {})) == null) {
+ assertTrue(System.nanoTime() < deadline, "glyph never became ready: " + cp);
+ Thread.sleep(1);
+ }
+ return glyph;
+ }
+
+ @Test
+ void measurementDoesNotRasterizeAndStaysStable() throws Exception {
+ SdfGlyphAtlas atlas = atlas(63);
+ assertEquals(0, atlas.pageCount(), "metrics must not wait for ASCII rasterization");
+ String text = "Handbook 铁砧工艺 𝄞";
+ int width = atlas.measureText(text);
+ assertTrue(width > 0);
+ assertEquals(0, atlas.pageCount(), "measuring a document must not generate its glyphs");
+ BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
+ var graphics = image.createGraphics();
+ try {
+ var metrics = graphics.getFontMetrics(atlas.font());
+ for (int cp : text.codePoints().toArray()) {
+ assertEquals(metrics.charWidth(cp), atlas.measureCodepoint(cp));
+ assertEquals(atlas.measureCodepoint(cp), awaitGlyph(atlas, cp).advance());
+ }
+ } finally { graphics.dispose(); }
+ assertEquals(width, atlas.measureText(text));
+ }
+
+ @Test
+ void layoutHasStableAdvanceAndDoesNotCacheAnUnuploadedPage() throws Exception {
+ SdfGlyphAtlas atlas = atlas(62);
+ String text = "Wi中文";
+ float scale = 9f / atlas.awtHeight();
+ var pending = SdfTextLayout.fromAtlas(atlas, text, 0, 0, scale);
+ for (int cp : text.codePoints().toArray()) awaitGlyph(atlas, cp);
+ var notUploaded = SdfTextLayout.fromAtlas(atlas, text, 0, 0, scale);
+ assertEquals(pending.width(), notUploaded.width());
+ atlas.page(0).textureId = ResourceLocation.fromNamespaceAndPath("test", "font");
+ var uploaded = SdfTextLayout.fromAtlas(atlas, text, 0, 0, scale);
+ assertNotSame(notUploaded, uploaded);
+ assertNotNull(uploaded.pages().getFirst().atlasTexture());
+ assertSame(uploaded, SdfTextLayout.fromAtlas(atlas, text, 0, 0, scale));
+ awaitGlyph(atlas, '新');
+ assertSame(uploaded, SdfTextLayout.fromAtlas(atlas, text, 0, 0, scale),
+ "new immutable glyphs must not evict already complete text layouts");
+ }
+
+ @Test
+ void partialUploadsPreservePixelsAndAConcurrentPublication() throws Exception {
+ SdfGlyphAtlas atlas = atlas(61);
+ awaitGlyph(atlas, 'A');
+ SdfGlyphPage page = atlas.page(0);
+ var first = page.snapshot(true);
+ assertNotNull(first);
+ assertEquals(1024 * 1024, first.pixels().length);
+ page.uploaded(first);
+ assertNull(page.snapshot(false));
+
+ awaitGlyph(atlas, '中');
+ var patch = page.snapshot(false);
+ assertNotNull(patch);
+ assertEquals(atlas.paddedCellSize * atlas.paddedCellSize, patch.pixels().length);
+ assertTrue(patch.pixels().length < first.pixels().length / 16);
+ for (int y = 0; y < patch.height(); y++) {
+ for (int x = 0; x < patch.width(); x++) {
+ assertEquals(page.image.getRGB(x + patch.x(), y + patch.y()) & 255,
+ patch.pixels()[y * patch.width() + x] & 255);
+ }
+ }
+ byte[] saved = patch.pixels().clone();
+ awaitGlyph(atlas, 'B');
+ page.uploaded(patch);
+ assertTrue(page.dirty, "upload acknowledgement must not lose a newer glyph");
+ assertArrayEquals(saved, patch.pixels(), "upload snapshot must be immutable");
+ var newer = page.snapshot(false);
+ assertTrue(newer.version() > patch.version());
+ page.uploaded(newer);
+ assertFalse(page.dirty);
+ assertNull(page.snapshot(false));
+ assertNotNull(page.snapshot(true), "a recreated texture needs a full snapshot even when clean");
+ }
+
+ @Test
+ void concurrentRequestsPublishEachCellOnceAcrossPages() throws Exception {
+ SdfGlyphAtlas atlas = atlas(60);
+ int[] codepoints = IntStream.range(32, 260).toArray();
+ CompletableFuture>[] requests = IntStream.range(0, 8).mapToObj(ignored ->
+ CompletableFuture.runAsync(() -> {
+ for (int cp : codepoints) atlas.glyph(cp, page -> fail("render caller must not mutate pages"));
+ })).toArray(CompletableFuture[]::new);
+ CompletableFuture.allOf(requests).get(30, TimeUnit.SECONDS);
+ var cells = new HashSet();
+ for (int cp : codepoints) {
+ var glyph = awaitGlyph(atlas, cp);
+ assertTrue(cells.add(glyph.pageIndex() + ":" + glyph.atlasX() + ":" + glyph.atlasY()));
+ }
+ assertTrue(atlas.pageCount() > 1);
+ int revisions = 0;
+ for (int i = 0; i < atlas.pageCount(); i++) revisions += atlas.page(i).version.get();
+ assertEquals(codepoints.length, revisions, "duplicate requests must not consume extra cells");
+ }
+}