From fed94f97d18b25aac076b4ade41927ac4e276f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 21 Jun 2026 16:15:57 +0200 Subject: [PATCH] add sign text rendering: support font loading, text rasterization, and color adjustments for signs --- .../eu/mhsl/minecraft/pixelpics/Main.java | 5 +- .../pixelpics/assets/AssetPaths.java | 5 + .../pixelpics/assets/font/BitmapFont.java | 56 ++++++ .../pixelpics/assets/font/FontLoader.java | 160 ++++++++++++++++++ .../pixelpics/assets/font/Glyph.java | 19 +++ .../render/entity/BlockEntityState.java | 11 +- .../render/entity/cem/BlockEntityBaker.java | 76 ++++++++- .../render/entity/cem/SignTextRasterizer.java | 109 ++++++++++++ .../snapshot/BlockEntitySnapshotBuilder.java | 42 ++++- .../pixelpics/render/util/ColorUtil.java | 45 +++++ tools/BlockEntityTestRender.java | 58 +++++-- 11 files changed, 564 insertions(+), 22 deletions(-) create mode 100644 src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/BitmapFont.java create mode 100644 src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/FontLoader.java create mode 100644 src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/Glyph.java create mode 100644 src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/SignTextRasterizer.java diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java index 4f84a64..ee2d15e 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java @@ -65,10 +65,13 @@ public final class Main extends JavaPlugin { getLogger().severe("Failed to load CEM entity models: " + e.getMessage()); } eu.mhsl.minecraft.pixelpics.assets.SkinCache skinCache = new eu.mhsl.minecraft.pixelpics.assets.SkinCache(); + eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont font = + eu.mhsl.minecraft.pixelpics.assets.font.FontLoader.load(resourcePack, textures, getLogger()); + getLogger().info("Loaded sign font (" + (font.isEmpty() ? "no glyphs — text disabled" : "ok") + ")."); eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker entityBaker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(cemLoader, textures, skinCache); eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker blockEntityBaker = - new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(cemLoader, textures, skinCache); + new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(cemLoader, textures, skinCache, font); this.screenRenderer = new DefaultScreenRenderer(registry, tintProvider, textures, entityBaker, blockEntityBaker, getLogger()); // Warm the map palette on the main thread so off-thread dithering never triggers its first init. diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/AssetPaths.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/AssetPaths.java index 9bcd9ba..7e9f8e7 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/AssetPaths.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/AssetPaths.java @@ -27,4 +27,9 @@ public final class AssetPaths { public static String textureMeta(ResourceLocation id) { return texture(id) + ".mcmeta"; } + + /** {@code assets//font/.json}, e.g. for {@code minecraft:default} or {@code minecraft:include/space}. */ + public static String font(ResourceLocation id) { + return String.format("assets/%s/font/%s.json", id.namespace(), id.path()); + } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/BitmapFont.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/BitmapFont.java new file mode 100644 index 0000000..bf62b56 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/BitmapFont.java @@ -0,0 +1,56 @@ +package eu.mhsl.minecraft.pixelpics.assets.font; + +import java.util.Map; + +/** + * A Minecraft bitmap font assembled from the resource pack's {@code font/default.json} providers + * (the {@code bitmap} and {@code space} providers; {@code unihex}/{@code legacy_unicode}/{@code ttf} + * are out of scope). Maps codepoints to {@link Glyph}s and supplies advance widths, enough to rasterize + * a line of text. Immutable after construction. + */ +public final class BitmapFont { + + private final Map glyphs; + private final Map spaceAdvances; + private final int maxAscent; + private final int maxDescent; + + public BitmapFont(Map glyphs, Map spaceAdvances, int maxAscent, int maxDescent) { + this.glyphs = glyphs; + this.spaceAdvances = spaceAdvances; + this.maxAscent = maxAscent; + this.maxDescent = maxDescent; + } + + /** The glyph for a codepoint, or null when no bitmap provider supplies it (e.g. CJK via unihex). */ + public Glyph glyph(int codepoint) { + return glyphs.get(codepoint); + } + + /** Horizontal advance for a codepoint (font px): a space-provider advance, a glyph advance, else 0. */ + public int advance(int codepoint) { + Integer sp = spaceAdvances.get(codepoint); + if (sp != null) return sp; + Glyph g = glyphs.get(codepoint); + return g != null ? g.advance() : 0; + } + + /** Largest baseline offset across all glyphs (font px) — the common baseline for a rendered line. */ + public int maxAscent() { + return maxAscent; + } + + /** Largest below-baseline extent across all glyphs (font px). */ + public int maxDescent() { + return maxDescent; + } + + /** Total vertical extent of one line (ascent + descent, font px). */ + public int lineHeight() { + return maxAscent + maxDescent; + } + + public boolean isEmpty() { + return glyphs.isEmpty(); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/FontLoader.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/FontLoader.java new file mode 100644 index 0000000..08349d9 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/FontLoader.java @@ -0,0 +1,160 @@ +package eu.mhsl.minecraft.pixelpics.assets.font; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import eu.mhsl.minecraft.pixelpics.assets.AssetPaths; +import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation; +import eu.mhsl.minecraft.pixelpics.assets.ResourcePack; +import eu.mhsl.minecraft.pixelpics.assets.TextureCache; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.logging.Logger; + +/** + * Builds a {@link BitmapFont} from a resource pack's {@code font/default.json}. Resolves {@code reference} + * providers recursively, parses {@code bitmap} providers into per-codepoint {@link Glyph}s (cell size and + * per-glyph pixel width derived from the PNG), and {@code space} providers into advance overrides. + * {@code unihex}/{@code legacy_unicode}/{@code ttf} providers are skipped (CJK etc. render as gaps). + * + *

Provider order is priority order (first match wins, matching vanilla): an earlier provider's glyph is + * never overwritten by a later one. + */ +public final class FontLoader { + + private FontLoader() {} + + /** Loads {@code minecraft:default}. Returns an empty font (no glyphs) when the pack has no font assets. */ + public static BitmapFont load(ResourcePack pack, TextureCache textures, Logger log) { + Builder b = new Builder(pack, textures, log); + b.loadFont(ResourceLocation.parse("minecraft:default"), new HashSet<>()); + return b.build(); + } + + private static final class Builder { + private final ResourcePack pack; + private final TextureCache textures; + private final Logger log; + private final Map glyphs = new HashMap<>(); + private final Map spaceAdvances = new HashMap<>(); + private int maxAscent = 0; + private int maxDescent = 0; + + Builder(ResourcePack pack, TextureCache textures, Logger log) { + this.pack = pack; + this.textures = textures; + this.log = log; + } + + BitmapFont build() { + return new BitmapFont(glyphs, spaceAdvances, maxAscent, maxDescent); + } + + void loadFont(ResourceLocation id, Set visited) { + String path = AssetPaths.font(id); + if (!visited.add(path)) return; // cycle guard + Optional bytes = pack.read(path); + if (bytes.isEmpty()) return; + JsonObject root; + try { + root = JsonParser.parseString(new String(bytes.get(), StandardCharsets.UTF_8)).getAsJsonObject(); + } catch (Exception e) { + if (log != null) log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage()); + return; + } + JsonArray providers = root.getAsJsonArray("providers"); + if (providers == null) return; + for (JsonElement el : providers) { + if (!el.isJsonObject()) continue; + provider(el.getAsJsonObject(), visited); + } + } + + private void provider(JsonObject p, Set visited) { + String type = p.has("type") ? p.get("type").getAsString() : ""; + switch (type) { + case "reference" -> { + if (p.has("id")) loadFont(ResourceLocation.parse(p.get("id").getAsString()), visited); + } + case "space" -> space(p); + case "bitmap" -> bitmap(p); + default -> { /* unihex, legacy_unicode, ttf, … — out of scope */ } + } + } + + private void space(JsonObject p) { + JsonObject advances = p.getAsJsonObject("advances"); + if (advances == null) return; + for (Map.Entry e : advances.entrySet()) { + String key = e.getKey(); + if (key.isEmpty()) continue; + int cp = key.codePointAt(0); + spaceAdvances.putIfAbsent(cp, e.getValue().getAsInt()); + } + } + + private void bitmap(JsonObject p) { + if (!p.has("file") || !p.has("chars")) return; + int height = p.has("height") ? p.get("height").getAsInt() : 8; + int ascent = p.has("ascent") ? p.get("ascent").getAsInt() : 7; + + String file = p.get("file").getAsString(); + if (file.endsWith(".png")) file = file.substring(0, file.length() - 4); + int[][] tex = textures.get(ResourceLocation.parse(file)).orElse(null); + if (tex == null || tex.length == 0) { + if (log != null) log.warning("PixelPics: font bitmap missing: " + file); + return; + } + int imgH = tex.length; + int imgW = tex[0].length; + + JsonArray rows = p.getAsJsonArray("chars"); + int nRows = rows.size(); + if (nRows == 0) return; + int nCols = rows.get(0).getAsString().codePointCount(0, rows.get(0).getAsString().length()); + if (nCols == 0) return; + int cellW = imgW / nCols; + int cellH = imgH / nRows; + if (cellW == 0 || cellH == 0) return; + double scale = height / (double) cellH; + + for (int r = 0; r < nRows; r++) { + String row = rows.get(r).getAsString(); + int col = 0; + for (int ci = 0; ci < row.length(); ) { + int cp = row.codePointAt(ci); + ci += Character.charCount(cp); + int thisCol = col++; + if (cp == 0 || thisCol >= nCols) continue; // 0x0000 = empty slot + if (glyphs.containsKey(cp)) continue; // earlier provider wins + int srcX = thisCol * cellW; + int srcY = r * cellH; + int glyphPx = rightmostOpaqueColumn(tex, srcX, srcY, cellW, cellH) + 1; + if (glyphPx <= 0) continue; // blank glyph (handled by space provider) + int advance = (int) (glyphPx * scale + 0.5) + 1; + glyphs.put(cp, new Glyph(tex, srcX, srcY, cellW, cellH, glyphPx, height, ascent, advance)); + maxAscent = Math.max(maxAscent, ascent); + maxDescent = Math.max(maxDescent, height - ascent); + } + } + } + + /** Rightmost column index (0-based, relative to the cell) containing an opaque pixel, or -1 if blank. */ + private static int rightmostOpaqueColumn(int[][] tex, int srcX, int srcY, int cellW, int cellH) { + int last = -1; + for (int x = 0; x < cellW; x++) { + for (int y = 0; y < cellH; y++) { + int argb = tex[srcY + y][srcX + x]; + if (((argb >>> 24) & 0xFF) != 0) { last = x; break; } + } + } + return last; + } + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/Glyph.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/Glyph.java new file mode 100644 index 0000000..c8a308a --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/font/Glyph.java @@ -0,0 +1,19 @@ +package eu.mhsl.minecraft.pixelpics.assets.font; + +/** + * A single bitmap-font glyph: a rectangular cell {@code [srcX, srcX+cellW) × [srcY, srcY+cellH)} inside + * its provider PNG ({@code tex}, ARGB top-left), the number of opaque pixel columns to actually draw + * ({@code glyphPx}, measured to the rightmost non-transparent column), the rendered {@code height} and + * {@code ascent} (baseline offset from the top, font px) and the horizontal {@code advance} (font px). + * + *

Glyphs from different providers may declare different cell sizes and ascents (e.g. ascii 8px/ascent + * 7 vs accented 12px/ascent 10); rendering aligns them on a common baseline so umlauts and Latin mix. + */ +public record Glyph(int[][] tex, int srcX, int srcY, int cellW, int cellH, + int glyphPx, int height, int ascent, int advance) { + + /** Vertical pixels below the baseline (font px). */ + public int descent() { + return height - ascent; + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityState.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityState.java index df70229..e2ac907 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityState.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityState.java @@ -24,7 +24,9 @@ public record BlockEntityState( String skinUrl, // player-head owner skin URL; null otherwise List patterns, // banner overlay patterns (may be empty) List sherds, // decorated-pot sherds: front/back/left/right item keys (may be empty) - BellAttach bellAttach // bell attachment; null when not a bell + BellAttach bellAttach, // bell attachment; null when not a bell + SignText frontText, // sign front-side text; null when not a sign or blank + SignText backText // sign back-side text; null when not a sign or blank ) { public enum Kind { CHEST, TRAPPED_CHEST, ENDER_CHEST, @@ -43,4 +45,11 @@ public record BlockEntityState( /** One banner overlay layer: a pattern key (e.g. "stripe_top") and the ARGB colour it is dyed with. */ public record BannerPattern(String patternKey, int colorArgb) {} + + /** + * One side of a sign: up to four plain-text lines plus the resolved colours. {@code fillArgb} is the + * glyph fill colour (already glow/darken-adjusted); {@code outlineArgb} is the 8-directional outline + * drawn only when {@code glowing}. + */ + public record SignText(List lines, int fillArgb, int outlineArgb, boolean glowing) {} } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java index 6a1f03a..bd18939 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java @@ -3,6 +3,9 @@ package eu.mhsl.minecraft.pixelpics.render.entity.cem; import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation; import eu.mhsl.minecraft.pixelpics.assets.SkinCache; import eu.mhsl.minecraft.pixelpics.assets.TextureCache; +import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont; +import eu.mhsl.minecraft.pixelpics.assets.model.Direction; +import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.render.entity.Affine; import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityModels; import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState; @@ -29,14 +32,27 @@ public final class BlockEntityBaker implements EntityBaker { private static final double SIGN_SCALE = 0.6666667; // vanilla SignRenderer model scale + // Sign-text placement (model px in the board's local frame; calibrated against the test harness). + // The text block is scaled to fill the board's writable area (W×H) so few-line signs are rendered as + // large as possible (legibility on low-res maps), capped so a single line isn't blown up absurdly. + private static final double SIGN_TEXT_W = 22.0, SIGN_TEXT_H = 11.0, SIGN_TEXT_CAP = 0.5; // board 24x12 + private static final double HANG_TEXT_W = 13.0, HANG_TEXT_H = 9.0, HANG_TEXT_CAP = 0.42; // board 14x10 + private static final double SIGN_TEXT_CY = 20.0; // board Y∈[14,26], centre 20 + private static final double HANGING_TEXT_CY = 5.0; // board Y∈[0,10], centre 5 + private static final double BOARD_FRONT_Z = 1.0; // both boards: front/back faces at Z=±1 + private static final double TEXT_Z_EPS = 0.05; // lift the text just off the board face + private static final double TEXT_THICK = 0.5; // slab thickness so the ray test never degenerates + private final CemModelLoader models; private final TextureCache textures; private final SkinCache skins; + private final BitmapFont font; - public BlockEntityBaker(CemModelLoader models, TextureCache textures, SkinCache skins) { + public BlockEntityBaker(CemModelLoader models, TextureCache textures, SkinCache skins, BitmapFont font) { this.models = models; this.textures = textures; this.skins = skins; + this.font = font; } /** Returns the baked block-entity, or null when it has no model/texture (then nothing renders). */ @@ -61,9 +77,67 @@ public final class BlockEntityBaker implements EntityBaker { cubes.add(new EntityCube(b.from(), b.to(), b.faces(), placement.mul(b.world()))); } } + addSignText(s, placement.mul(pre), cubes); return cubes.isEmpty() ? null : RenderedEntity.of(cubes); } + /** + * Appends flat text quads in front of (and behind) the sign board, rasterized from the captured + * {@link BlockEntityState.SignText}. Built in the board's model-px frame and transformed by the same + * {@code placement·pre} as the CEM board, so the text rides exactly on the board. + * + *

The CEM sign model faces north by default and is aimed with {@code rotY(180−facingDeg)} where + * {@code facingDeg} is the direction the sign's FRONT faces — so the model's −Z (NORTH) face ends up + * being the front the player reads, and +Z (SOUTH) is the back. Each face uses an unflipped UV: the + * intersector already mirrors NORTH relative to SOUTH ({@code s=1−fx} vs {@code s=fx}), so both read + * left-to-right when viewed from outside. + */ + private void addSignText(BlockEntityState s, Affine toWorld, List cubes) { + if (font == null || font.isEmpty()) return; + boolean hanging = s.kind() == BlockEntityState.Kind.HANGING_SIGN; + boolean sign = hanging || s.kind() == BlockEntityState.Kind.SIGN || s.kind() == BlockEntityState.Kind.WALL_SIGN; + if (!sign) return; + double cy = hanging ? HANGING_TEXT_CY : SIGN_TEXT_CY; + double tw = hanging ? HANG_TEXT_W : SIGN_TEXT_W; + double th = hanging ? HANG_TEXT_H : SIGN_TEXT_H; + double cap = hanging ? HANG_TEXT_CAP : SIGN_TEXT_CAP; + addSide(s.frontText(), Direction.NORTH, tw, th, cap, cy, toWorld, cubes); + addSide(s.backText(), Direction.SOUTH, tw, th, cap, cy, toWorld, cubes); + } + + private void addSide(BlockEntityState.SignText t, Direction faceDir, double tw, double th, double cap, + double cy, Affine toWorld, List cubes) { + if (t == null) return; + int[][] bmp = SignTextRasterizer.rasterize(trimBlankLines(t.lines()), font, t.fillArgb(), t.outlineArgb(), t.glowing()); + if (bmp == null) return; + // Scale so the text block fills the board's writable area, whichever dimension binds, capped. + double fpm = Math.min(cap, Math.min(tw / bmp[0].length, th / bmp.length)); + double blockW = bmp[0].length * fpm; + double blockH = bmp.length * fpm; + + double z0, z1; + if (faceDir == Direction.NORTH) { // front: −Z face, text sits just past −1 + z1 = -BOARD_FRONT_Z - TEXT_Z_EPS; + z0 = z1 - TEXT_THICK; + } else { // back: +Z face, text sits just past +1 + z0 = BOARD_FRONT_Z + TEXT_Z_EPS; + z1 = z0 + TEXT_THICK; + } + Face[] faces = new Face[6]; + faces[faceDir.ordinal()] = new Face(bmp, 0, 0, 1, 1, 0, -1); + double[] from = {-blockW / 2, cy - blockH / 2, z0}; + double[] to = {blockW / 2, cy + blockH / 2, z1}; + cubes.add(new EntityCube(from, to, faces, toWorld)); + } + + /** Drops leading/trailing blank lines so short text fills the board; keeps interior blanks. */ + private static List trimBlankLines(List lines) { + int lo = 0, hi = lines.size(); + while (lo < hi && lines.get(lo).isBlank()) lo++; + while (hi > lo && lines.get(hi - 1).isBlank()) hi--; + return lines.subList(lo, hi); + } + /** The CEM model name; for heads it depends on the texture's aspect (64x32 vs square 64x64). */ private String modelName(BlockEntityState s, int[][] tex) { if (s.kind() != BlockEntityState.Kind.HEAD && s.kind() != BlockEntityState.Kind.WALL_HEAD) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/SignTextRasterizer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/SignTextRasterizer.java new file mode 100644 index 0000000..3533152 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/SignTextRasterizer.java @@ -0,0 +1,109 @@ +package eu.mhsl.minecraft.pixelpics.render.entity.cem; + +import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont; +import eu.mhsl.minecraft.pixelpics.assets.font.Glyph; + +import java.util.List; + +/** + * Rasterizes a sign side's text lines into a transparent ARGB grid ({@code int[][]}, top-left origin) at + * the font's native pixel resolution. Lines are centred horizontally; the block of lines fills the canvas + * top-to-bottom (blank lines reserve their slot so vertical centring matches vanilla, which always lays + * out four lines). The baker maps the result onto a quad whose model size keeps the canvas aspect, so the + * text is never stretched. Glyphs from different providers (ascii/accented) are aligned on a common + * baseline. Glowing text gets an 8-directional outline. + */ +final class SignTextRasterizer { + + private SignTextRasterizer() {} + + /** Returns the rasterized panel, or null when there is nothing to draw (no font/glyphs). */ + static int[][] rasterize(List lines, BitmapFont font, int fillArgb, int outlineArgb, boolean glow) { + if (font.isEmpty() || lines.isEmpty()) return null; + + int ascent = font.maxAscent(); + int pitch = font.lineHeight(); // ascent + descent + int margin = glow ? 1 : 0; // room for the outline + + int contentW = 1; + for (String line : lines) contentW = Math.max(contentW, lineWidth(line, font)); + int contentH = Math.max(1, lines.size() * pitch); + + int w = contentW + 2 * margin; + int h = contentH + 2 * margin; + int[][] out = new int[h][w]; + boolean[][] covered = new boolean[h][w]; + + // Pass 1: stamp glyph coverage (fill colour) into `out` + `covered`. + boolean any = false; + for (int li = 0; li < lines.size(); li++) { + String line = lines.get(li); + int penX = margin + (contentW - lineWidth(line, font)) / 2; + int baseline = margin + li * pitch + ascent; + for (int ci = 0; ci < line.length(); ) { + int cp = line.codePointAt(ci); + ci += Character.charCount(cp); + Glyph g = font.glyph(cp); + if (g != null) { + any |= blit(g, penX, baseline, fillArgb, out, covered); + } + penX += font.advance(cp); + } + } + if (!any) return null; + + // Pass 2 (glow only): paint the outline into uncovered neighbours of covered pixels. + if (glow) { + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + if (!covered[y][x]) continue; + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) continue; + int nx = x + dx, ny = y + dy; + if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; + if (!covered[ny][nx]) out[ny][nx] = outlineArgb; + } + } + } + } + } + return out; + } + + /** Blits a glyph's opaque pixels as {@code fillArgb}; aligns the glyph's baseline to {@code baseline}. */ + private static boolean blit(Glyph g, int penX, int baseline, int fillArgb, int[][] out, boolean[][] covered) { + double scale = g.height() / (double) g.cellH(); + int rw = Math.max(1, (int) (g.glyphPx() * scale + 0.5)); + int rh = g.height(); + int top = baseline - g.ascent(); + boolean any = false; + for (int dy = 0; dy < rh; dy++) { + int oy = top + dy; + if (oy < 0 || oy >= out.length) continue; + int sy = g.srcY() + (int) (dy / scale); + for (int dx = 0; dx < rw; dx++) { + int ox = penX + dx; + if (ox < 0 || ox >= out[0].length) continue; + int sx = g.srcX() + (int) (dx / scale); + int argb = g.tex()[sy][sx]; + if (((argb >>> 24) & 0xFF) == 0) continue; + out[oy][ox] = fillArgb; + covered[oy][ox] = true; + any = true; + } + } + return any; + } + + /** Total advance width of a line in font px. */ + private static int lineWidth(String line, BitmapFont font) { + int w = 0; + for (int ci = 0; ci < line.length(); ) { + int cp = line.codePointAt(ci); + ci += Character.charCount(cp); + w += font.advance(cp); + } + return w; + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java index 248a99c..74a5df3 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java @@ -120,15 +120,22 @@ public final class BlockEntitySnapshotBuilder { // --- signs --- if (n.endsWith("_SIGN")) { String wood = signWood(n); + Kind kind; + float yaw; if (n.endsWith("_WALL_SIGN")) { - return base(Kind.WALL_SIGN, bx, by, bz, facingYaw(data)).wood(wood).build(); + kind = Kind.WALL_SIGN; yaw = facingYaw(data); + } else if (n.endsWith("_HANGING_SIGN")) { + kind = Kind.HANGING_SIGN; + yaw = n.endsWith("_WALL_HANGING_SIGN") ? facingYaw(data) : rotationYaw(data); + } else { + kind = Kind.SIGN; yaw = rotationYaw(data); } - if (n.endsWith("_HANGING_SIGN")) { - boolean wall = n.endsWith("_WALL_HANGING_SIGN"); - float yaw = wall ? facingYaw(data) : rotationYaw(data); - return base(Kind.HANGING_SIGN, bx, by, bz, yaw).wood(wood).build(); + Builder b = base(kind, bx, by, bz, yaw).wood(wood); + if (ts instanceof org.bukkit.block.Sign sign) { + b.frontText(signText(sign.getSide(org.bukkit.block.sign.Side.FRONT))); + b.backText(signText(sign.getSide(org.bukkit.block.sign.Side.BACK))); } - return base(Kind.SIGN, bx, by, bz, rotationYaw(data)).wood(wood).build(); + return b.build(); } // --- heads / skulls --- @@ -222,6 +229,23 @@ public final class BlockEntitySnapshotBuilder { return name.substring(0, name.length() - suffix.length()).toLowerCase(java.util.Locale.ROOT); } + /** One sign side → {@link BlockEntityState.SignText}, or null when all four lines are blank. */ + private static BlockEntityState.SignText signText(org.bukkit.block.sign.SignSide side) { + String[] raw = side.getLines(); + List lines = new ArrayList<>(raw.length); + boolean any = false; + for (String l : raw) { + String s = l == null ? "" : l.replaceAll("§.", ""); // strip legacy §-codes + if (!s.isEmpty()) any = true; + lines.add(s); + } + if (!any) return null; + DyeColor dye = side.getColor(); + boolean glow = side.isGlowingText(); + return new BlockEntityState.SignText(lines, ColorUtil.signFillArgb(dye, glow), + ColorUtil.signOutlineArgb(dye), glow); + } + private static String signWood(String name) { String s = name; for (String suf : new String[]{"_WALL_HANGING_SIGN", "_HANGING_SIGN", "_WALL_SIGN", "_SIGN"}) { @@ -293,6 +317,8 @@ public final class BlockEntitySnapshotBuilder { private List patterns = List.of(); private List sherds = List.of(); private BellAttach bellAttach; + private BlockEntityState.SignText frontText; + private BlockEntityState.SignText backText; Builder(Kind kind, int bx, int by, int bz, float yaw) { this.kind = kind; this.bx = bx; this.by = by; this.bz = bz; this.yaw = yaw; @@ -308,10 +334,12 @@ public final class BlockEntitySnapshotBuilder { Builder patterns(List v) { this.patterns = v; return this; } Builder sherds(List v) { this.sherds = v; return this; } Builder bellAttach(BellAttach v) { this.bellAttach = v; return this; } + Builder frontText(BlockEntityState.SignText v) { this.frontText = v; return this; } + Builder backText(BlockEntityState.SignText v) { this.backText = v; return this; } BlockEntityState build() { return new BlockEntityState(kind, bx, by, bz, yaw, chestKind, baseColorArgb, colorName, wood, - bedPart, headType, skinUrl, patterns, sherds, bellAttach); + bedPart, headType, skinUrl, patterns, sherds, bellAttach, frontText, backText); } } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/ColorUtil.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/ColorUtil.java index ca0c1bc..8bb9a32 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/ColorUtil.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/ColorUtil.java @@ -23,6 +23,51 @@ public final class ColorUtil { return argb(0xFF, c.getRed(), c.getGreen(), c.getBlue()); } + /** + * The vanilla sign-text colour for a dye (opaque ARGB), from Mojang's {@code DyeColor.getTextColor()} + * table (the firework/text colours, NOT the cloth colours). Null = black (the default sign ink). + */ + public static int signTextColor(org.bukkit.DyeColor dye) { + int rgb = switch (dye == null ? org.bukkit.DyeColor.BLACK : dye) { + case WHITE -> 0xF9FFFE; + case ORANGE -> 0xF9801D; + case MAGENTA -> 0xC74EBD; + case LIGHT_BLUE -> 0x3AB3DA; + case YELLOW -> 0xFED83D; + case LIME -> 0x80C71F; + case PINK -> 0xF38BAA; + case GRAY -> 0x474F52; + case LIGHT_GRAY -> 0x9D9D97; + case CYAN -> 0x169C9C; + case PURPLE -> 0x8932B8; + case BLUE -> 0x3C44AA; + case BROWN -> 0x835432; + case GREEN -> 0x5E7C16; + case RED -> 0xB02E26; + case BLACK -> 0x1D1D21; + }; + return 0xFF000000 | rgb; + } + + /** + * The fill colour for sign text: glowing text uses the full dye colour; non-glowing text is the dye + * colour darkened to 40% (matching vanilla {@code SignRenderer}, which is why normal ink looks dim). + */ + public static int signFillArgb(org.bukkit.DyeColor dye, boolean glowing) { + int base = signTextColor(dye); + return glowing ? base : (0xFF000000 | (shade(base, 0.4) & 0xFFFFFF)); + } + + /** + * The 8-directional outline colour drawn around glowing sign text (vanilla {@code getDarkColor}): + * the dye colour darkened to 40%, except glowing BLACK ink which gets a light cream outline so it + * stays readable. + */ + public static int signOutlineArgb(org.bukkit.DyeColor dye) { + if ((dye == null ? org.bukkit.DyeColor.BLACK : dye) == org.bukkit.DyeColor.BLACK) return 0xFFF0EBCC; + return 0xFF000000 | (shade(signTextColor(dye), 0.4) & 0xFFFFFF); + } + /** Multiplies the RGB channels of {@code base} by {@code tint} (per-channel, 0..255), keeping base alpha. */ public static int multiply(int base, int tint) { int a = alpha(base); diff --git a/tools/BlockEntityTestRender.java b/tools/BlockEntityTestRender.java index 216f88c..8f87a33 100644 --- a/tools/BlockEntityTestRender.java +++ b/tools/BlockEntityTestRender.java @@ -35,7 +35,18 @@ public class BlockEntityTestRender { static BlockEntityState be(Kind kind, int bx, int by, int bz, float yaw) { return new BlockEntityState(kind, bx, by, bz, yaw, ChestKind.SINGLE, 0, null, "oak", - null, null, null, List.of(), List.of(), null); + null, null, null, List.of(), List.of(), null, null, null); + } + + static BlockEntityState sign(Kind kind, float yaw, SignText front, SignText back) { + return new BlockEntityState(kind, 0, 0, 0, yaw, null, 0, null, "oak", + null, null, null, List.of(), List.of(), null, front, back); + } + + static SignText txt(org.bukkit.DyeColor dye, boolean glow, String... lines) { + return new SignText(List.of(lines), + eu.mhsl.minecraft.pixelpics.render.util.ColorUtil.signFillArgb(dye, glow), + eu.mhsl.minecraft.pixelpics.render.util.ColorUtil.signOutlineArgb(dye), glow); } public static void main(String[] args) throws Exception { @@ -48,8 +59,11 @@ public class BlockEntityTestRender { CemModelLoader geo = new CemModelLoader(); geo.load(new java.io.FileInputStream("/tmp/cem_models.json"), log); SkinCache skins = new SkinCache(); + eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont font = + eu.mhsl.minecraft.pixelpics.assets.font.FontLoader.load(pack, textures, log); + log.info("font glyphs loaded: " + (font.isEmpty() ? "NONE" : "ok")); CemBaker baker = new CemBaker(geo, textures, skins); - beBaker = new BlockEntityBaker(geo, textures, skins); + beBaker = new BlockEntityBaker(geo, textures, skins, font); decoBaker = new DecorationBaker(textures); DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log); @@ -76,23 +90,43 @@ public class BlockEntityTestRender { scenes.put("chest_E", List.of(be(Kind.CHEST, 0, 0, 0, 270))); scenes.put("chest_W", List.of(be(Kind.CHEST, 0, 0, 0, 90))); scenes.put("double_chest", List.of( - new BlockEntityState(Kind.CHEST, 0, 0, 0, 0, ChestKind.LEFT, 0, null, null, null, null, null, List.of(), List.of(), null), - new BlockEntityState(Kind.CHEST, 1, 0, 0, 0, ChestKind.RIGHT, 0, null, null, null, null, null, List.of(), List.of(), null))); + new BlockEntityState(Kind.CHEST, 0, 0, 0, 0, ChestKind.LEFT, 0, null, null, null, null, null, List.of(), List.of(), null, null, null), + new BlockEntityState(Kind.CHEST, 1, 0, 0, 0, ChestKind.RIGHT, 0, null, null, null, null, null, List.of(), List.of(), null, null, null))); scenes.put("sign", List.of(be(Kind.SIGN, 0, 0, 0, 0))); scenes.put("wall_sign", List.of(be(Kind.WALL_SIGN, 0, 0, 0, 0))); scenes.put("hanging_sign", List.of(be(Kind.HANGING_SIGN, 0, 0, 0, 0))); - scenes.put("banner", List.of(new BlockEntityState(Kind.BANNER, 0, 0, 0, 0, null, 0xFFCC2020, null, null, null, null, null, List.of(), List.of(), null))); + // --- sign text (calibration) --- facingDeg=180 → front (model −Z) faces the camera (north). + org.bukkit.DyeColor B = org.bukkit.DyeColor.BLACK; + scenes.put("sign_text", List.of(sign(Kind.SIGN, 180, + txt(B, false, "Hello World", "zweite Zeile", "Gruesse: AÖÜ", "ßäöü 12345"), null))); + scenes.put("wall_sign_text", List.of(sign(Kind.WALL_SIGN, 180, + txt(B, false, "Wall Sign", "Line 2", "Line 3", "Line 4"), null))); + scenes.put("hanging_sign_text", List.of(sign(Kind.HANGING_SIGN, 180, + txt(B, false, "Hanging", "sign text", "row 3", "row 4"), null))); + scenes.put("sign_text_red", List.of(sign(Kind.SIGN, 180, + txt(org.bukkit.DyeColor.RED, false, "RED INK", "colored", "sign text", ""), null))); + scenes.put("sign_text_glow", List.of(sign(Kind.SIGN, 180, + txt(org.bukkit.DyeColor.LIME, true, "GLOWING", "lime glow", "outline!", ""), null))); + scenes.put("sign_text_front", List.of(sign(Kind.SIGN, 180, + txt(B, false, "FRONT", "side", "", ""), txt(org.bukkit.DyeColor.BLUE, false, "BACK", "side", "", "")))); + // View the BACK side head-on (facingDeg=0 turns the back face toward the camera). + scenes.put("sign_view_back", List.of(sign(Kind.SIGN, 0, + txt(B, false, "FRONTXY", "f2", "", ""), txt(org.bukkit.DyeColor.BLUE, false, "BACKXY", "b2", "", "")))); + scenes.put("sign_1line", List.of(sign(Kind.SIGN, 180, txt(B, false, "", "SHOP", "", ""), null))); + scenes.put("sign_2line", List.of(sign(Kind.SIGN, 180, txt(B, false, "", "Welcome", "home!", ""), null))); + scenes.put("sign_long", List.of(sign(Kind.SIGN, 180, txt(B, false, "a very long line here", "", "", ""), null))); + scenes.put("banner", List.of(new BlockEntityState(Kind.BANNER, 0, 0, 0, 0, null, 0xFFCC2020, null, null, null, null, null, List.of(), List.of(), null, null, null))); scenes.put("banner_patterned", List.of(new BlockEntityState(Kind.BANNER, 0, 0, 0, 0, null, 0xFFFFFFFF, null, null, null, null, null, - List.of(new BannerPattern("stripe_bottom", 0xFFCC2020), new BannerPattern("cross", 0xFF2040CC), new BannerPattern("border", 0xFF20A020)), List.of(), null))); - scenes.put("wall_banner", List.of(new BlockEntityState(Kind.WALL_BANNER, 0, 0, 0, 0, null, 0xFF2040CC, null, null, null, null, null, List.of(), List.of(), null))); + List.of(new BannerPattern("stripe_bottom", 0xFFCC2020), new BannerPattern("cross", 0xFF2040CC), new BannerPattern("border", 0xFF20A020)), List.of(), null, null, null))); + scenes.put("wall_banner", List.of(new BlockEntityState(Kind.WALL_BANNER, 0, 0, 0, 0, null, 0xFF2040CC, null, null, null, null, null, List.of(), List.of(), null, null, null))); scenes.put("bed", List.of( - new BlockEntityState(Kind.BED, 0, 0, 0, 0, null, 0, "red", null, BedPart.FOOT, null, null, List.of(), List.of(), null), - new BlockEntityState(Kind.BED, 0, 0, 1, 0, null, 0, "red", null, BedPart.HEAD, null, null, List.of(), List.of(), null))); - scenes.put("shulker_box", List.of(new BlockEntityState(Kind.SHULKER_BOX, 0, 0, 0, 0, null, 0, null, null, null, null, null, List.of(), List.of(), null))); + new BlockEntityState(Kind.BED, 0, 0, 0, 0, null, 0, "red", null, BedPart.FOOT, null, null, List.of(), List.of(), null, null, null), + new BlockEntityState(Kind.BED, 0, 0, 1, 0, null, 0, "red", null, BedPart.HEAD, null, null, List.of(), List.of(), null, null, null))); + scenes.put("shulker_box", List.of(new BlockEntityState(Kind.SHULKER_BOX, 0, 0, 0, 0, null, 0, null, null, null, null, null, List.of(), List.of(), null, null, null))); scenes.put("conduit", List.of(be(Kind.CONDUIT, 0, 0, 0, 0))); scenes.put("decorated_pot", List.of(be(Kind.DECORATED_POT, 0, 0, 0, 0))); scenes.put("decorated_pot_sherds", List.of(new BlockEntityState(Kind.DECORATED_POT, 0, 0, 0, 0, null, 0, null, null, null, null, null, - List.of(), List.of("angler_pottery_sherd", "heart_pottery_sherd", "skull_pottery_sherd", "brick"), null))); + List.of(), List.of("angler_pottery_sherd", "heart_pottery_sherd", "skull_pottery_sherd", "brick"), null, null, null))); scenes.put("bell", List.of(be(Kind.BELL, 0, 0, 0, 0))); scenes.put("head_skeleton", List.of(head("skeleton"))); scenes.put("head_zombie", List.of(head("zombie"))); @@ -149,7 +183,7 @@ public class BlockEntityTestRender { static BlockEntityState head(String type) { return headKind(Kind.HEAD, type); } static BlockEntityState headKind(Kind kind, String type) { - return new BlockEntityState(kind, 0, 0, 0, 0, null, 0, null, null, null, type, null, List.of(), List.of(), null); + return new BlockEntityState(kind, 0, 0, 0, 0, null, 0, null, null, null, type, null, List.of(), List.of(), null, null, null); } static BufferedImage renderScene(DefaultScreenRenderer renderer, WorldSnapshot world, SkyContext sky,