add sign text rendering: support font loading, text rasterization, and color adjustments for signs

This commit is contained in:
2026-06-21 16:15:57 +02:00
parent 5330948dbd
commit fed94f97d1
11 changed files with 564 additions and 22 deletions
@@ -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.
@@ -27,4 +27,9 @@ public final class AssetPaths {
public static String textureMeta(ResourceLocation id) {
return texture(id) + ".mcmeta";
}
/** {@code assets/<ns>/font/<path>.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());
}
}
@@ -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<Integer, Glyph> glyphs;
private final Map<Integer, Integer> spaceAdvances;
private final int maxAscent;
private final int maxDescent;
public BitmapFont(Map<Integer, Glyph> glyphs, Map<Integer, Integer> 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();
}
}
@@ -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).
*
* <p>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<Integer, Glyph> glyphs = new HashMap<>();
private final Map<Integer, Integer> 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<String> visited) {
String path = AssetPaths.font(id);
if (!visited.add(path)) return; // cycle guard
Optional<byte[]> 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<String> 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<String, JsonElement> 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;
}
}
}
@@ -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).
*
* <p>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;
}
}
@@ -24,7 +24,9 @@ public record BlockEntityState(
String skinUrl, // player-head owner skin URL; null otherwise
List<BannerPattern> patterns, // banner overlay patterns (may be empty)
List<String> 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<String> lines, int fillArgb, int outlineArgb, boolean glowing) {}
}
@@ -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<BlockEntityState> {
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<BlockEntityState> {
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.
*
* <p>The CEM sign model faces north by default and is aimed with {@code rotY(180facingDeg)} 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=1fx} vs {@code s=fx}), so both read
* left-to-right when viewed from outside.
*/
private void addSignText(BlockEntityState s, Affine toWorld, List<EntityCube> 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<EntityCube> 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<String> trimBlankLines(List<String> 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) {
@@ -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<String> 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;
}
}
@@ -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<String> 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<BannerPattern> patterns = List.of();
private List<String> 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<BannerPattern> v) { this.patterns = v; return this; }
Builder sherds(List<String> 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);
}
}
}
@@ -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);
+46 -12
View File
@@ -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,