From 5fdc9dab01a359915c887c8b91868e202d18c9f8 Mon Sep 17 00:00:00 2001 From: Fff Date: Fri, 13 Nov 2026 07:22:25 -0800 Subject: [PATCH] =?UTF-8?q?perf(font):=20=E4=BC=98=E5=8C=96=E5=AD=97?= =?UTF-8?q?=E4=BD=93=E9=A6=96=E6=AC=A1=E5=8A=A0=E8=BD=BD=E4=B8=8E=E5=9B=BE?= =?UTF-8?q?=E9=9B=86=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分离字体度量和字形生成,提前预热常用样式并按需生成字形 - 使用有界后台任务队列处理字形,缩短图集发布锁并保持文字宽度稳定 - 复用字体纹理并只上传变化区域,避免整页转换和重复创建,及时释放临时图像 - 保留完整文本布局缓存,补充异步生成、并发请求及增量上传回归测试 --- module.font/build.gradle | 10 ++ .../anvilcraft/lib/v2/font/AnvilLibFont.java | 4 +- .../lib/v2/font/sdf/SdfAtlasTexture.java | 68 +++++----- .../lib/v2/font/sdf/SdfGlyphAtlas.java | 122 +++++++++-------- .../lib/v2/font/sdf/SdfGlyphPage.java | 87 ++++++++----- .../lib/v2/font/sdf/SdfTextLayout.java | 11 +- .../lib/v2/font/sdf/SdfTextRenderer.java | 5 +- .../lib/v2/font/sdf/FontStartupTest.java | 123 ++++++++++++++++++ 8 files changed, 291 insertions(+), 139 deletions(-) create mode 100644 module.font/src/test/java/dev/anvilcraft/lib/v2/font/sdf/FontStartupTest.java 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"); + } +}