Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion module.font/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,13 @@ dependencies {
} else {
slimJarJar(implementation project(":anvillib-rendering-neoforge-26.2"))
}
}
}

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() }
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import net.minecraft.resources.Identifier;
import org.jetbrains.annotations.ApiStatus;

import java.awt.image.BufferedImage;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

Expand All @@ -31,28 +30,24 @@ public static Identifier uploadPage(SdfGlyphAtlas atlas, int pageIndex) {
SdfGlyphPage page = atlas.page(pageIndex);
String key = atlas.key() + ".p" + pageIndex;
Identifier id = Identifier.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;
PageEntry entry = CACHE.get(key);
int version;
synchronized (page) {
version = page.version.get();
if (entry != null && entry.version == version) return entry.id;
nativeImage = toNativeImage(page.image);
page.dirty = false;
}

SdfTexture texture;
try (nativeImage) {
texture = new SdfTexture(nativeImage);
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();
}
}
Minecraft.getInstance().getTextureManager().register(id, texture);

if (entry != null) entry.texture.close();
CACHE.put(key, new PageEntry(id, texture, version));
page.textureId = id;
page.uploaded(upload);
return id;
}

Expand All @@ -66,6 +61,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);
Expand All @@ -77,6 +73,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(
Expand All @@ -87,18 +84,24 @@ static final class SdfTexture extends AbstractTexture {
this.textureView = device.createTextureView(this.texture);
this.sampler = RenderSystem.getSamplerCache().getClampToEdge(FilterMode.LINEAR);
}
}

static NativeImage toNativeImage(BufferedImage image) {
NativeImage ni = new NativeImage(NativeImage.Format.LUMINANCE, image.getWidth(), image.getHeight(), false);
var pixels = ni.getPixelBytes();
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
int gray = image.getRGB(x, y) & 0xFF;
pixels.put(x + y * image.getWidth(), (byte) gray);
}
void upload(NativeImage image, int x, int y) {
RenderSystem.getDevice().createCommandEncoder().writeToTexture(
this.texture, image, 0, 0, x, y
);
}

@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);
image.getPixelBytes().put(upload.pixels());
return image;
}

private static String sanitize(String key) {
Expand All @@ -121,7 +124,7 @@ private static String sanitize(String key) {
private static final class PageEntry {
final Identifier id;
final SdfTexture texture;
final int version;
int version;

PageEntry(Identifier id, SdfTexture texture, int version) {
this.id = id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,24 @@
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;

/**
* Multi-page on-demand SDF glyph atlas.
* <p>
* 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
Expand All @@ -41,14 +42,13 @@ public final class SdfGlyphAtlas {

private static final Map<String, CompletableFuture<SdfGlyphAtlas>> 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;
Expand All @@ -61,9 +61,12 @@ public final class SdfGlyphAtlas {
private final int awtHeight;
private final FontMetrics fontMetrics;

private final List<SdfGlyphPage> pages = new ArrayList<>();
private final List<SdfGlyphPage> pages = new CopyOnWriteArrayList<>();
private final Map<Integer, GlyphEntry> glyphMap = new ConcurrentHashMap<>();
private final Set<Integer> pendingGlyphs = ConcurrentHashMap.newKeySet();
private final Map<Integer, Integer> advances = new ConcurrentHashMap<>();
private final ConcurrentLinkedQueue<Integer> glyphQueue = new ConcurrentLinkedQueue<>();
private final AtomicBoolean workerScheduled = new AtomicBoolean();

private SdfGlyphAtlas(String key, Font font) {
this.key = key;
Expand Down Expand Up @@ -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<SdfGlyphAtlas> getOrCreate(@Nullable Font font) {
Font resolved = resolveFont(font);
String key = resolved.getFontName(Locale.ENGLISH) + "." + resolved.getStyle() + "." + resolved.getSize();
return CACHE.computeIfAbsent(key, _ -> {
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.
* <p>
* If the previous build failed (future completed exceptionally), the
* failed future is removed and a fresh build is started so that a
Expand Down Expand Up @@ -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();
}
}

Expand All @@ -214,54 +221,45 @@ public int measureText(String text) {
*/
public int measureText(String text, int start, int end) {
int width = 0;
Set<SdfGlyphPage> 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;
}

/**
* 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<SdfGlyphPage> 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<SdfGlyphPage> 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() {
Expand Down
Loading
Loading