Compare commits

...

6 Commits

78 changed files with 3063 additions and 826 deletions
+9
View File
@@ -1,5 +1,6 @@
plugins {
id 'java'
id 'com.diffplug.spotless' version '7.0.2'
}
group = 'eu.mhsl.minecraft.pixelpics'
@@ -48,6 +49,14 @@ processResources {
}
}
spotless {
java {
target 'src/main/java/**/*.java'
palantirJavaFormat()
removeUnusedImports()
}
}
if (file("local.gradle").exists()) {
apply from: "local.gradle"
}
@@ -4,38 +4,81 @@ import eu.mhsl.minecraft.pixelpics.assets.AssetReader;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.ResourcePack;
import eu.mhsl.minecraft.pixelpics.assets.ResourcePackLoader;
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.font.FontLoader;
import eu.mhsl.minecraft.pixelpics.commands.PixelPicsCommand;
import eu.mhsl.minecraft.pixelpics.listeners.OnMapInitialize;
import eu.mhsl.minecraft.pixelpics.render.RenderManager;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.survival.CameraListener;
import eu.mhsl.minecraft.pixelpics.survival.CraftingListener;
import eu.mhsl.minecraft.pixelpics.survival.JoinListener;
import eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes;
import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette;
import java.io.File;
import java.io.InputStream;
import java.util.Objects;
import java.util.Optional;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.util.Objects;
import java.util.Optional;
public final class Main extends JavaPlugin {
private static Main instance;
private ResourcePack resourcePack;
private DefaultScreenRenderer screenRenderer;
private RenderManager renderManager;
public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid");
/** Marks a {@code PLAYER_HEAD} as a camera (BYTE 1). */
public final NamespacedKey cameraMarker = new NamespacedKey(this, "camera");
/** Loaded film count stored on a camera (INTEGER, 0..{@code CameraItems.MAX_FILM}). */
public final NamespacedKey filmCountKey = new NamespacedKey(this, "filmcount");
/** Marks a {@code PLAYER_HEAD} as a film roll (BYTE 1). */
public final NamespacedKey filmMarker = new NamespacedKey(this, "film");
@Override
public void onEnable() {
instance = this;
saveDefaultConfig();
initRenderManager();
Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this);
Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")).setExecutor(new PixelPicsCommand());
Bukkit.getPluginManager().registerEvents(new CameraListener(), this);
Bukkit.getPluginManager().registerEvents(new CraftingListener(), this);
Bukkit.getPluginManager().registerEvents(new JoinListener(), this);
SurvivalRecipes.register();
initRenderer();
}
private void initRenderManager() {
int cores = Runtime.getRuntime().availableProcessors();
int threads = getConfig().getInt("render.threads", 0);
if (threads <= 0) threads = Math.max(1, cores - 2);
threads = Math.min(threads, cores);
int maxConcurrent = Math.max(1, getConfig().getInt("render.max-concurrent", 2));
int queueSize = Math.max(0, getConfig().getInt("render.queue-size", 8));
int timeoutSeconds = Math.max(1, getConfig().getInt("render.timeout-seconds", 30));
this.renderManager = new RenderManager(this, threads, maxConcurrent, queueSize, timeoutSeconds);
getLogger()
.info("Render pool: " + threads + " core(s), max " + maxConcurrent + " concurrent, queue " + queueSize
+ ", timeout " + timeoutSeconds + "s.");
}
private void initRenderer() {
File resourcePackDir = new File(getDataFolder(), "resourcepack");
if (!resourcePackDir.exists() && !resourcePackDir.mkdirs()) {
@@ -44,7 +87,8 @@ public final class Main extends JavaPlugin {
Optional<ResourcePack> pack = ResourcePackLoader.load(resourcePackDir, getLogger());
if (pack.isEmpty()) {
getLogger().severe("No resource pack found in " + resourcePackDir.getPath()
getLogger()
.severe("No resource pack found in " + resourcePackDir.getPath()
+ " — place a vanilla resource pack (directory with assets/minecraft/... or a .zip) there. "
+ "/pixelPic is disabled until a pack is available.");
return;
@@ -56,28 +100,39 @@ public final class Main extends JavaPlugin {
BlockModelRegistry registry = new BlockModelRegistry(reader, textures);
BiomeTintProvider tintProvider = new BiomeTintProvider(textures);
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader cemLoader =
new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader();
try (java.io.InputStream in = getResource("cem/cem_template_models.json")) {
CemModelLoader cemLoader = new CemModelLoader();
try (InputStream in = getResource("cem/cem_template_models.json")) {
int n = in == null ? 0 : cemLoader.load(in, getLogger());
getLogger().info("Loaded " + n + " CEM entity models.");
} catch (Exception e) {
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.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);
SkinCache skinCache = new SkinCache();
BitmapFont font = FontLoader.load(resourcePack, textures, getLogger());
getLogger().info("Loaded sign font (" + (font.isEmpty() ? "no glyphs — text disabled" : "ok") + ").");
CemBaker entityBaker = new CemBaker(cemLoader, textures, skinCache);
BlockEntityBaker blockEntityBaker = new BlockEntityBaker(cemLoader, textures, skinCache, font);
this.screenRenderer = new DefaultScreenRenderer(registry, tintProvider, textures, entityBaker, blockEntityBaker, getLogger());
this.screenRenderer = new DefaultScreenRenderer(
registry,
tintProvider,
textures,
entityBaker,
blockEntityBaker,
getLogger(),
renderManager.tracePool());
// Warm the map palette on the main thread so off-thread dithering never triggers its first init.
eu.mhsl.minecraft.pixelpics.utils.MapColorPalette.size();
MapColorPalette.size();
getLogger().info("PixelPics renderer initialized with resource pack assets.");
}
@Override
public void onDisable() {
SurvivalRecipes.unregister();
if (renderManager != null) {
renderManager.shutdown();
renderManager = null;
}
if (resourcePack != null) {
resourcePack.close();
resourcePack = null;
@@ -89,6 +144,11 @@ public final class Main extends JavaPlugin {
return this.screenRenderer;
}
/** The render queue/limiter; always available after {@code onEnable}. */
public RenderManager getRenderManager() {
return this.renderManager;
}
public static Main getInstance() {
return instance;
}
@@ -10,7 +10,8 @@ public final class AssetPaths {
/** {@code assets/<ns>/blockstates/<name>.json} for a plain block name (no {@code block/} prefix). */
public static String blockState(String blockName) {
return String.format("assets/%s/blockstates/%s.json", ResourceLocation.DEFAULT_NAMESPACE, blockName.toLowerCase());
return String.format(
"assets/%s/blockstates/%s.json", ResourceLocation.DEFAULT_NAMESPACE, blockName.toLowerCase());
}
/** {@code assets/<ns>/models/<path>.json}. The id path already contains e.g. {@code block/stone}. */
@@ -27,4 +28,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());
}
}
@@ -3,7 +3,6 @@ package eu.mhsl.minecraft.pixelpics.assets;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
@@ -32,7 +31,8 @@ public final class AssetReader {
public Optional<JsonObject> readJsonObject(String path) {
return pack.read(path).flatMap(bytes -> {
try {
return Optional.of(JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject());
return Optional.of(JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8))
.getAsJsonObject());
} catch (Exception e) {
return Optional.empty();
}
@@ -5,13 +5,13 @@ import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
/**
* Top-level entry point that turns a {@link BlockData} into a baked {@link ResolvedModel}, combining
@@ -51,7 +51,7 @@ public final class BlockModelRegistry {
List<Variant> variants = blockStateResolver.resolve(data);
List<Element> elements = new java.util.ArrayList<>();
List<Element> elements = new ArrayList<>();
AverageColor.Accumulator avgColor = new AverageColor.Accumulator();
FlatModel lastFlat = null;
@@ -88,7 +88,11 @@ public final class BlockModelRegistry {
private static EnumSet<Material> buildInvisibleMaterials() {
EnumSet<Material> set = EnumSet.noneOf(Material.class);
for (String n : new String[] {"BARRIER", "LIGHT", "STRUCTURE_VOID"}) {
try { set.add(Material.valueOf(n)); } catch (IllegalArgumentException ignored) { /* older/newer server */ }
try {
set.add(Material.valueOf(n));
} catch (IllegalArgumentException ignored) {
/* older/newer server */
}
}
return set;
}
@@ -106,14 +110,17 @@ public final class BlockModelRegistry {
EnumSet<Material> set = EnumSet.noneOf(Material.class);
for (Material m : Material.values()) {
String n = m.name();
boolean match =
n.endsWith("_SIGN") // signs: standing/wall/(wall_)hanging
boolean match = n.endsWith("_SIGN") // signs: standing/wall/(wall_)hanging
|| n.endsWith("_BANNER") // banners: standing/wall
|| n.endsWith("_BED")
|| n.endsWith("SHULKER_BOX") // SHULKER_BOX + <color>_SHULKER_BOX
|| ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD)
|| m == Material.CHEST || m == Material.TRAPPED_CHEST || m == Material.ENDER_CHEST
|| m == Material.CONDUIT || m == Material.DECORATED_POT || m == Material.BELL;
|| m == Material.CHEST
|| m == Material.TRAPPED_CHEST
|| m == Material.ENDER_CHEST
|| m == Material.CONDUIT
|| m == Material.DECORATED_POT
|| m == Material.BELL;
if (match) set.add(m);
}
return set;
@@ -1,9 +1,8 @@
package eu.mhsl.minecraft.pixelpics.assets;
import org.bukkit.block.data.BlockData;
import java.util.LinkedHashMap;
import java.util.Map;
import org.bukkit.block.data.BlockData;
/**
* Parses the property map and plain block name out of a {@link BlockData} string such as
@@ -3,11 +3,10 @@ package eu.mhsl.minecraft.pixelpics.assets;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import org.bukkit.block.data.BlockData;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.bukkit.block.data.BlockData;
/**
* Resolves a {@link BlockData} to the list of model variants vanilla would render, by reading the
@@ -1,7 +1,6 @@
package eu.mhsl.minecraft.pixelpics.assets;
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
import java.util.List;
import java.util.Map;
@@ -5,7 +5,6 @@ import eu.mhsl.minecraft.pixelpics.assets.model.AverageColor;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -62,7 +61,8 @@ public final class ModelBaker {
boolean rescale = false;
if (dto.rotation != null && dto.rotation.angle != 0 && dto.rotation.origin != null) {
rotOrigin = new double[] {
dto.rotation.origin[0] / 16.0, dto.rotation.origin[1] / 16.0, dto.rotation.origin[2] / 16.0};
dto.rotation.origin[0] / 16.0, dto.rotation.origin[1] / 16.0, dto.rotation.origin[2] / 16.0
};
rotAxis = axisIndex(dto.rotation.axis);
rotAngle = Math.toRadians(dto.rotation.angle);
rescale = dto.rotation.rescale;
@@ -106,8 +106,8 @@ public final class ModelBaker {
return new BakedGeometry(baked, avgColor.average(0xFF7F7F7F), !baked.isEmpty());
}
private Face buildFace(Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to,
Map<String, String> textureVars) {
private Face buildFace(
Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to, Map<String, String> textureVars) {
int[][] tex = resolveTexture(dto.texture, textureVars);
if (tex == null) return null;
@@ -1,8 +1,8 @@
package eu.mhsl.minecraft.pixelpics.assets;
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -30,13 +30,14 @@ public final class ModelResolver {
}
private FlatModel resolve(ResourceLocation modelId, int depth) {
ModelFileDto dto = reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null);
ModelFileDto dto =
reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null);
if (dto == null) {
return new FlatModel(new HashMap<>(), null);
}
Map<String, String> textures = new HashMap<>();
java.util.List<ModelFileDto.ElementDto> elements = dto.elements;
List<ModelFileDto.ElementDto> elements = dto.elements;
if (dto.parent != null && depth < MAX_DEPTH && !dto.parent.startsWith("builtin/")) {
FlatModel parent = resolve(ResourceLocation.parse(dto.parent), depth + 1);
@@ -45,15 +45,13 @@ public final class ResourcePackLoader {
// Zip packs anywhere under the resourcepack folder.
try (Stream<Path> walk = Files.walk(resourcePackDir.toPath())) {
List<Path> zips = walk
.filter(Files::isRegularFile)
List<Path> zips = walk.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
.toList();
for (Path zip : zips) {
try {
ZipResourcePack pack = new ZipResourcePack(zip);
if (pack.exists(MARKER + "/blockstates") || pack.exists("pack.mcmeta")
|| hasAnyBlockstate(pack)) {
if (pack.exists(MARKER + "/blockstates") || pack.exists("pack.mcmeta") || hasAnyBlockstate(pack)) {
packs.add(pack);
logger.info("Loaded resource pack zip: " + zip);
} else {
@@ -1,12 +1,12 @@
package eu.mhsl.minecraft.pixelpics.assets;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.net.URLConnection;
import java.util.Optional;
import javax.imageio.ImageIO;
/**
* Downloads and caches player skin textures (by URL) as ARGB pixel grids. Legacy 64x32 skins are
@@ -1,8 +1,8 @@
package eu.mhsl.minecraft.pixelpics.assets;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import javax.imageio.ImageIO;
/**
* Loads and caches block textures as raw ARGB pixel grids. Textures are stored unflipped (vanilla
@@ -4,5 +4,4 @@ package eu.mhsl.minecraft.pixelpics.assets;
* A resolved blockstate variant: which model to use plus its {@code x}/{@code y} rotation (in
* degrees, multiples of 90) and {@code uvlock}.
*/
public record Variant(ResourceLocation model, int x, int y, boolean uvlock) {
}
public record Variant(ResourceLocation model, int x, int y, boolean uvlock) {}
@@ -22,9 +22,7 @@ public final class ZipResourcePack implements ResourcePack {
public ZipResourcePack(Path zipPath) throws IOException {
this.zipFile = new ZipFile(zipPath.toFile());
zipFile.stream()
.filter(entry -> !entry.isDirectory())
.forEach(entry -> entries.put(entry.getName(), entry));
zipFile.stream().filter(entry -> !entry.isDirectory()).forEach(entry -> entries.put(entry.getName(), entry));
}
@Override
@@ -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,167 @@
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;
}
}
@@ -11,7 +11,6 @@ public enum Direction {
SOUTH(0, 0, 1),
WEST(-1, 0, 0),
EAST(1, 0, 0);
public final int nx, ny, nz;
Direction(int nx, int ny, int nz) {
@@ -42,8 +41,8 @@ public enum Direction {
case EAST -> SOUTH;
case SOUTH -> WEST;
case WEST -> NORTH;
default -> d; // up/down unchanged
};
// up/down unchanged
default -> d;};
}
return d;
}
@@ -58,8 +57,8 @@ public enum Direction {
case NORTH -> DOWN;
case DOWN -> SOUTH;
case SOUTH -> UP;
default -> d; // east/west unchanged
};
// east/west unchanged
default -> d;};
}
return d;
}
@@ -19,8 +19,14 @@ public final class Element {
public final double rotAngleRad; // radians
public final boolean rescale;
public Element(double[] from, double[] to, Face[] faces,
double[] rotOrigin, int rotAxis, double rotAngleRad, boolean rescale) {
public Element(
double[] from,
double[] to,
Face[] faces,
double[] rotOrigin,
int rotAxis,
double rotAngleRad,
boolean rescale) {
this.from = from;
this.to = to;
this.faces = faces;
@@ -29,10 +29,21 @@ public final class Face {
// Apply face rotation by rotating the (s,t) lookup.
double rs = s, rt = t;
switch (((rotation % 360) + 360) % 360) {
case 90 -> { rs = t; rt = 1.0 - s; }
case 180 -> { rs = 1.0 - s; rt = 1.0 - t; }
case 270 -> { rs = 1.0 - t; rt = s; }
default -> { /* 0 */ }
case 90 -> {
rs = t;
rt = 1.0 - s;
}
case 180 -> {
rs = 1.0 - s;
rt = 1.0 - t;
}
case 270 -> {
rs = 1.0 - t;
rt = s;
}
default -> {
/* 0 */
}
}
double u = u1 + (u2 - u1) * rs;
@@ -44,8 +55,8 @@ public final class Face {
int px = (int) Math.floor(u * w);
int py = (int) Math.floor(v * h);
px = Math.max(0, Math.min(w - 1, px));
py = Math.max(0, Math.min(h - 1, py));
px = Math.clamp(px, 0, w - 1);
py = Math.clamp(py, 0, h - 1);
return texture[py][px];
}
}
@@ -19,8 +19,13 @@ public final class ResolvedModel {
public final boolean occluding;
public final boolean hasGeometry;
public ResolvedModel(List<Element> elements, int averageColor,
double transparency, double reflection, boolean occluding, boolean hasGeometry) {
public ResolvedModel(
List<Element> elements,
int averageColor,
double transparency,
double reflection,
boolean occluding,
boolean hasGeometry) {
this.elements = elements;
this.averageColor = averageColor;
this.transparency = transparency;
@@ -1,107 +1,54 @@
package eu.mhsl.minecraft.pixelpics.commands;
import eu.mhsl.minecraft.pixelpics.Main;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.render.RenderJob;
import eu.mhsl.minecraft.pixelpics.render.render.Resolution;
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
import eu.mhsl.minecraft.pixelpics.survival.PhotoService;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import java.util.List;
import java.util.Set;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.MapView;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import java.awt.image.BufferedImage;
import java.util.List;
import java.util.Set;
import java.util.UUID;
public class PixelPicsCommand implements CommandExecutor {
private static final int DEFAULT_CLEANUP_DAYS = 30;
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label,
public boolean onCommand(
@NotNull CommandSender sender,
@NotNull Command command,
@NotNull String label,
@NotNull String @NotNull [] args) {
if (args.length >= 1 && args[0].equalsIgnoreCase("cleanup")) {
return cleanup(sender, args);
}
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("Dieser Command kann nur von einem Spieler ausgeführt werden!",
NamedTextColor.RED));
sender.sendMessage(
Component.text("Dieser Command kann nur von einem Spieler ausgeführt werden!", NamedTextColor.RED));
return true;
}
if (args.length > 0) return false;
DefaultScreenRenderer renderer = Main.getInstance().getScreenRenderer();
if (renderer == null) {
player.sendMessage(Component.text("PixelPics ist nicht einsatzbereit: es wurde kein Resource-Pack geladen.",
NamedTextColor.RED));
// Debug shortcut: render a photo from the player's view without needing a camera or film.
if (!player.hasPermission("pixelpic.admin")) {
player.sendActionBar(
Component.text("Dafür fehlt dir die Berechtigung — nutze eine Kamera.", NamedTextColor.RED));
return true;
}
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
// Capture the world snapshot on the main thread.
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, player.getUniqueId());
// Hand the map over immediately, showing blank "film"; it develops once the render is ready.
ImageMapRenderer mapRenderer = new ImageMapRenderer();
MapView mapView = Bukkit.createMap(player.getWorld());
int id = mapView.getId();
MapManager.attachView(mapView, mapRenderer);
ItemStack map = new ItemStack(Material.FILLED_MAP, 1);
MapMeta meta = (MapMeta) map.getItemMeta();
meta.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag,
PersistentDataType.STRING, UUID.randomUUID().toString());
meta.setMapView(mapView);
map.setItemMeta(meta);
player.getInventory().addItem(map);
player.sendMessage(Component.text("📸 Aufnahme wird entwickelt …", NamedTextColor.GRAY));
// Trace + dither off-thread, then start the developing animation on the main thread.
Main plugin = Main.getInstance();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
BufferedImage image;
byte[] indices;
try {
image = renderer.execute(job);
indices = MapImageDither.dither(image);
} catch (Exception e) {
plugin.getLogger().warning("Render failed: " + e.getMessage());
Bukkit.getScheduler().runTask(plugin, () ->
player.sendMessage(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
return;
}
BufferedImage finalImage = image;
byte[] finalIndices = indices;
Bukkit.getScheduler().runTask(plugin, () -> {
MapManager.saveImage(finalImage, id);
MapManager.saveIndices(finalIndices, id);
mapRenderer.develop(finalIndices);
player.sendMessage(Component.text("✅ Aufnahme erstellt!", NamedTextColor.GREEN));
});
});
PhotoService.takePhoto(player);
return true;
}
/** {@code /pixelPic cleanup [days]} dry-run; {@code /pixelPic cleanup confirm [days]} deletes orphans. */
private boolean cleanup(CommandSender sender, String[] args) {
if (!sender.hasPermission("pixelpic.admin") && !sender.isOp()) {
sender.sendMessage(Component.text("Dafür fehlt dir die Berechtigung (pixelpic.admin).", NamedTextColor.RED));
sender.sendMessage(
Component.text("Dafür fehlt dir die Berechtigung (pixelpic.admin).", NamedTextColor.RED));
return true;
}
@@ -124,17 +71,21 @@ public class PixelPicsCommand implements CommandExecutor {
.toList();
if (candidates.isEmpty()) {
sender.sendMessage(Component.text("Keine aufräumbaren Aufnahmen gefunden (älter als " + days
+ " Tage und nicht in Benutzung).", NamedTextColor.YELLOW));
sender.sendMessage(Component.text(
"Keine aufräumbaren Aufnahmen gefunden (älter als " + days + " Tage und nicht in Benutzung).",
NamedTextColor.YELLOW));
return true;
}
if (!confirm) {
sender.sendMessage(Component.text(candidates.size() + " Aufnahme(n) könnten gelöscht werden "
+ "(älter als " + days + " Tage, nicht in geladenen Itemframes/Online-Inventaren).",
sender.sendMessage(Component.text(
candidates.size() + " Aufnahme(n) könnten gelöscht werden " + "(älter als " + days
+ " Tage, nicht in geladenen Itemframes/Online-Inventaren).",
NamedTextColor.YELLOW));
sender.sendMessage(Component.text("Achtung: Maps in lange ungeladenen Bereichen werden hierbei nicht "
+ "erkannt. Zum Löschen: /pixelPic cleanup confirm " + days, NamedTextColor.GRAY));
sender.sendMessage(Component.text(
"Achtung: Maps in lange ungeladenen Bereichen werden hierbei nicht "
+ "erkannt. Zum Löschen: /pixelPic cleanup confirm " + days,
NamedTextColor.GRAY));
return true;
}
@@ -3,12 +3,11 @@ package eu.mhsl.minecraft.pixelpics.listeners;
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import java.awt.image.BufferedImage;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.server.MapInitializeEvent;
import java.awt.image.BufferedImage;
public class OnMapInitialize implements Listener {
@EventHandler
@@ -0,0 +1,159 @@
package eu.mhsl.minecraft.pixelpics.render;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinWorkerThread;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
/**
* Server-friendly scheduling for photo renders. Bounds CPU use and protects the server tick by:
* <ul>
* <li><b>Per player:</b> at most one photo in the system at a time (running or queued).</li>
* <li><b>Globally:</b> at most {@code maxConcurrent} photos render at once, with up to
* {@code queueSize} more waiting; further requests are rejected.</li>
* <li><b>CPU:</b> the ray tracing runs on a dedicated {@link ForkJoinPool} of {@code threads}
* low-priority worker threads ({@link Thread#MIN_PRIORITY}), so it tends to use spare CPU and
* never fans out across every core like the default common pool would.</li>
* </ul>
*
* <p>Reserve a slot with {@link #tryReserve(UUID)}; if accepted, run the heavy work via
* {@link #dispatch} (which releases the slot on completion) — or call {@link #release(UUID)} if you
* bail out before dispatching.
*/
public final class RenderManager {
public enum Outcome {
ACCEPTED,
USER_BUSY,
QUEUE_FULL
}
private final Plugin plugin;
private final ForkJoinPool tracePool;
private final ThreadPoolExecutor dispatcher;
private final ScheduledExecutorService watchdog;
private final Set<UUID> activeUsers = ConcurrentHashMap.newKeySet();
private final AtomicInteger inFlight = new AtomicInteger();
private final int capacity;
private final long timeoutMillis;
public RenderManager(Plugin plugin, int threads, int maxConcurrent, int queueSize, int timeoutSeconds) {
this.plugin = plugin;
this.capacity = maxConcurrent + queueSize;
this.timeoutMillis = Math.max(1L, timeoutSeconds) * 1000L;
this.tracePool = new ForkJoinPool(threads, lowPriorityForkJoinFactory(), null, false);
this.dispatcher = new ThreadPoolExecutor(
maxConcurrent,
maxConcurrent,
30,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), // capacity is enforced by inFlight, not this queue
lowPriorityThreadFactory());
this.dispatcher.allowCoreThreadTimeOut(true);
this.watchdog = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "PixelPics-render-watchdog");
thread.setDaemon(true);
return thread;
});
}
/** The bounded, low-priority pool the ray tracer must run its parallel work on. */
public ForkJoinPool tracePool() {
return tracePool;
}
/** Atomically reserves a slot for {@code user}. On {@code ACCEPTED}, you must later release it. */
public Outcome tryReserve(UUID user) {
if (!activeUsers.add(user)) return Outcome.USER_BUSY;
if (inFlight.incrementAndGet() > capacity) {
inFlight.decrementAndGet();
activeUsers.remove(user);
return Outcome.QUEUE_FULL;
}
return Outcome.ACCEPTED;
}
/** Releases a reservation made by {@link #tryReserve}. Safe to call once per accepted reserve. */
public void release(UUID user) {
if (activeUsers.remove(user)) {
inFlight.decrementAndGet();
}
}
/**
* Runs {@code work} off the main thread (honoring the global concurrency limit), then delivers the
* result back on the main thread via {@code onSuccess}, or {@code onFailure} if it fails, returns
* null, or exceeds the configured timeout. The {@link AtomicBoolean} handed to {@code work} is set
* once the deadline passes — {@code work} should poll it and bail out cooperatively. Releases the
* caller's reservation when done. Requires a prior {@link #tryReserve} success.
*/
public <T> void dispatch(UUID user, Function<AtomicBoolean, T> work, Consumer<T> onSuccess, Runnable onFailure) {
dispatcher.execute(() -> {
AtomicBoolean cancelled = new AtomicBoolean(false);
ScheduledFuture<?> deadline =
watchdog.schedule(() -> cancelled.set(true), timeoutMillis, TimeUnit.MILLISECONDS);
T result = null;
boolean ok = false;
try {
result = work.apply(cancelled);
ok = result != null && !cancelled.get();
} catch (Throwable t) {
plugin.getLogger().warning("Render job failed: " + t);
} finally {
deadline.cancel(false);
if (cancelled.get()) {
plugin.getLogger()
.warning(
"Render for " + user + " aborted after " + (timeoutMillis / 1000) + "s (timeout).");
}
release(user);
}
T finalResult = result;
boolean finalOk = ok;
Bukkit.getScheduler().runTask(plugin, () -> {
if (finalOk) onSuccess.accept(finalResult);
else onFailure.run();
});
});
}
public void shutdown() {
watchdog.shutdownNow();
dispatcher.shutdownNow();
tracePool.shutdownNow();
}
private static ForkJoinPool.ForkJoinWorkerThreadFactory lowPriorityForkJoinFactory() {
return pool -> {
ForkJoinWorkerThread thread = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(pool);
thread.setName("PixelPics-trace-" + thread.getPoolIndex());
thread.setPriority(Thread.MIN_PRIORITY);
thread.setDaemon(true);
return thread;
};
}
private static ThreadFactory lowPriorityThreadFactory() {
AtomicInteger counter = new AtomicInteger();
return runnable -> {
Thread thread = new Thread(runnable, "PixelPics-render-" + counter.incrementAndGet());
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
};
}
}
@@ -72,9 +72,7 @@ public final class Affine {
/** Linear part only (for directions). */
public double[] applyLinear(double x, double y, double z) {
return new double[] {
r[0] * x + r[1] * y + r[2] * z,
r[3] * x + r[4] * y + r[5] * z,
r[6] * x + r[7] * y + r[8] * z
r[0] * x + r[1] * y + r[2] * z, r[3] * x + r[4] * y + r[5] * z, r[6] * x + r[7] * y + r[8] * z
};
}
@@ -1,7 +1,6 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import java.util.ArrayList;
import java.util.List;
@@ -17,7 +16,9 @@ public final class BlockEntityModels {
/** The CEM ({@code .jem}) model name for a block-entity. */
public static String cemModel(BlockEntityState s) {
return switch (s.kind()) {
case CHEST, TRAPPED_CHEST, ENDER_CHEST -> switch (s.chestKind() == null ? BlockEntityState.ChestKind.SINGLE : s.chestKind()) {
case CHEST, TRAPPED_CHEST, ENDER_CHEST -> switch (s.chestKind() == null
? BlockEntityState.ChestKind.SINGLE
: s.chestKind()) {
case LEFT -> "chest_left";
case RIGHT -> "chest_right";
case SINGLE -> "chest";
@@ -92,9 +93,11 @@ public final class BlockEntityModels {
}
private static void headTextures(List<String> paths, String headType) {
if (headType == null) { paths.add("entity/skeleton/skeleton"); return; }
if (headType == null) {
paths.add("entity/skeleton/skeleton");
return;
}
switch (headType) {
case "skeleton" -> paths.add("entity/skeleton/skeleton");
case "wither_skeleton" -> paths.add("entity/skeleton/wither_skeleton");
case "zombie" -> paths.add("entity/zombie/zombie");
case "creeper" -> paths.add("entity/creeper/creeper");
@@ -13,7 +13,9 @@ import java.util.List;
*/
public record BlockEntityState(
Kind kind,
int bx, int by, int bz,
int bx,
int by,
int bz,
float facingDeg,
ChestKind chestKind, // double-chest half (CHEST/TRAPPED_CHEST/ENDER_CHEST)
int baseColorArgb, // banner base tint (white texture); 0 = none
@@ -24,23 +26,53 @@ 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,
SIGN, WALL_SIGN, HANGING_SIGN,
BANNER, WALL_BANNER,
BED, SHULKER_BOX,
HEAD, WALL_HEAD,
CONDUIT, DECORATED_POT, BELL
CHEST,
TRAPPED_CHEST,
ENDER_CHEST,
SIGN,
WALL_SIGN,
HANGING_SIGN,
BANNER,
WALL_BANNER,
BED,
SHULKER_BOX,
HEAD,
WALL_HEAD,
CONDUIT,
DECORATED_POT,
BELL
}
public enum ChestKind { SINGLE, LEFT, RIGHT }
public enum ChestKind {
SINGLE,
LEFT,
RIGHT
}
public enum BedPart { HEAD, FOOT }
public enum BedPart {
HEAD,
FOOT
}
public enum BellAttach { FLOOR, CEILING, SINGLE_WALL, DOUBLE_WALL }
public enum BellAttach {
FLOOR,
CEILING,
SINGLE_WALL,
DOUBLE_WALL
}
/** 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) {}
}
@@ -46,8 +46,13 @@ public final class BoxUv {
double[] down = {u + dz + 2 * dx, v, -dx, dz};
if (cube.mirror) {
for (double[] f : new double[][]{east, west, up, down, south, north}) { f[0] += f[2]; f[2] = -f[2]; }
double[] tmp = east; east = west; west = tmp; // mirror swaps the left/right faces
for (double[] f : new double[][] {east, west, up, down, south, north}) {
f[0] += f[2];
f[2] = -f[2];
}
double[] tmp = east;
east = west;
west = tmp; // mirror swaps the left/right faces
}
Face[] faces = new Face[6];
@@ -4,7 +4,6 @@ import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import java.util.ArrayList;
import java.util.List;
@@ -29,7 +28,8 @@ public final class DecorationBaker implements EntityBaker<DecorationState> {
}
private RenderedEntity bakePainting(DecorationState s) {
int[][] art = textures.get(ResourceLocation.parse("painting/" + s.paintingArt())).orElse(null);
int[][] art = textures.get(ResourceLocation.parse("painting/" + s.paintingArt()))
.orElse(null);
if (art == null) return null;
int[][] back = textures.get(ResourceLocation.parse("painting/back")).orElse(art);
@@ -53,19 +53,20 @@ public final class DecorationBaker implements EntityBaker<DecorationState> {
private RenderedEntity bakeItemFrame(DecorationState s) {
String frameTex = s.glow() ? "block/glow_item_frame" : "block/item_frame";
int[][] leather = textures.get(ResourceLocation.parse(frameTex)).orElse(null);
int[][] wood = textures.get(ResourceLocation.parse("block/birch_planks")).orElse(leather);
int[][] wood =
textures.get(ResourceLocation.parse("block/birch_planks")).orElse(leather);
if (leather == null) return null;
Affine toWorld = Affine.translation(faceCenterX(s), faceCenterY(s), faceCenterZ(s)).mul(facingRotation(s.facing()));
Affine toWorld = Affine.translation(faceCenterX(s), faceCenterY(s), faceCenterZ(s))
.mul(facingRotation(s.facing()));
int si = Direction.SOUTH.ordinal();
List<EntityCube> cubes = new ArrayList<>(3);
// Wood border 12x12 (behind), leather back 10x10 (1px proud), item 8x8 (in front). Front = local +Z.
if (wood != null) {
Face[] f = new Face[6];
f[si] = new Face(wood, 1, 0, 0, 1, 0, -1); // tileable; flip matches the others
cubes.add(new EntityCube(px(-6, -6, 0), px(6, 6, 1), f, toWorld));
}
// `wood` is never null here (orElse(leather) and leather is non-null past the guard above).
Face[] woodFace = new Face[6];
woodFace[si] = new Face(wood, 1, 0, 0, 1, 0, -1); // tileable; flip matches the others
cubes.add(new EntityCube(px(-6, -6, 0), px(6, 6, 1), woodFace, toWorld));
Face[] back = new Face[6];
back[si] = new Face(leather, 13.0 / 16, 3.0 / 16, 3.0 / 16, 13.0 / 16, 0, -1); // centre 10x10, flipped
cubes.add(new EntityCube(px(-5, -5, 1), px(5, 5, 2), back, toWorld));
@@ -8,18 +8,30 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
*/
public record DecorationState(
Kind kind,
double minX, double minY, double minZ,
double maxX, double maxY, double maxZ,
double minX,
double minY,
double minZ,
double maxX,
double maxY,
double maxZ,
Facing facing, // direction the front faces (away from the wall)
String paintingArt, // painting art asset key (texture painting/<art>); null for frames
String itemId, // item-frame contents material key (e.g. "diamond"); null if empty
int itemRotationDeg, // item-frame content rotation (0/45/…/315)
boolean glow // glow item frame
) {
public enum Kind { PAINTING, ITEM_FRAME }
public enum Kind {
PAINTING,
ITEM_FRAME
}
public enum Facing {
NORTH, SOUTH, EAST, WEST, UP, DOWN;
NORTH,
SOUTH,
EAST,
WEST,
UP,
DOWN;
/** The axis this face's normal lies on: 0=x, 1=y, 2=z. */
public int axis() {
@@ -18,8 +18,7 @@ public final class EntityIntersector {
private EntityIntersector() {}
public static FaceHit intersect(EntityCube cube, double ox, double oy, double oz,
double dx, double dy, double dz) {
public static FaceHit intersect(EntityCube cube, double ox, double oy, double oz, double dx, double dy, double dz) {
double[] o = cube.toLocal.apply(ox, oy, oz);
double[] d = cube.toLocal.applyLinear(dx, dy, dz);
@@ -36,19 +35,29 @@ public final class EntityIntersector {
double t2 = (cube.to[a] - o[a]) * inv;
boolean n = true;
if (t1 > t2) {
double tmp = t1; t1 = t2; t2 = tmp;
double tmp = t1;
t1 = t2;
t2 = tmp;
n = false;
}
if (t1 > tmin) { tmin = t1; axis = a; neg = n; }
if (t1 > tmin) {
tmin = t1;
axis = a;
neg = n;
}
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return null;
}
if (axis < 0) return null;
double t = tmin;
if (t < EPS) { t = tmax; if (t < EPS) return null; }
if (t < EPS) {
t = tmax;
if (t < EPS) return null;
}
double px = o[0] + d[0] * t, py = o[1] + d[1] * t, pz = o[2] + d[2] * t;
Direction dir = switch (axis) {
Direction dir =
switch (axis) {
case 0 -> neg ? Direction.WEST : Direction.EAST;
case 1 -> neg ? Direction.DOWN : Direction.UP;
default -> neg ? Direction.NORTH : Direction.SOUTH;
@@ -63,12 +72,30 @@ public final class EntityIntersector {
// run their horizontal axis opposite to back/left (they're viewed from the other side).
double s, tt;
switch (dir) {
case UP -> { s = fx; tt = fz; }
case DOWN -> { s = fx; tt = 1 - fz; }
case NORTH -> { s = 1 - fx; tt = 1 - fy; }
case SOUTH -> { s = fx; tt = 1 - fy; }
case EAST -> { s = 1 - fz; tt = 1 - fy; }
default -> { s = fz; tt = 1 - fy; } // WEST
case UP -> {
s = fx;
tt = fz;
}
case DOWN -> {
s = fx;
tt = 1 - fz;
}
case NORTH -> {
s = 1 - fx;
tt = 1 - fy;
}
case SOUTH -> {
s = fx;
tt = 1 - fy;
}
case EAST -> {
s = 1 - fz;
tt = 1 - fy;
}
default -> {
s = fz;
tt = 1 - fy;
} // WEST
}
int color = face.sample(s, tt);
if (ColorUtil.alpha(color) <= ALPHA_THRESHOLD) return null;
@@ -1,7 +1,6 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -43,8 +42,7 @@ public final class EntityModels {
Map.entry("camel_husk", "camel"),
Map.entry("rabbit", "rabbit_21.11"),
Map.entry("pufferfish", "puffer_fish_big"),
Map.entry("tropical_fish", "tropical_fish_a")
);
Map.entry("tropical_fish", "tropical_fish_a"));
/** The CEM model name for an entity type (boats/rafts share the boat hull). */
public static String cemModel(String typeKey) {
@@ -53,7 +51,7 @@ public final class EntityModels {
}
// Type key -> texture path override (where the first derived candidate is wrong).
private static final Map<String, String> TEX_OVERRIDE = Map.ofEntries(
private static final Map<String, String> TEX_OVERRIDE = Map.<String, String>ofEntries(
Map.entry("cow", "entity/cow/cow_temperate"),
Map.entry("mooshroom", "entity/cow/mooshroom_red"),
Map.entry("zombie", "entity/zombie/zombie"),
@@ -109,8 +107,7 @@ public final class EntityModels {
Map.entry("happy_ghast", "entity/ghast/happy_ghast"),
Map.entry("parched", "entity/skeleton/parched"), // husk-style skeleton, texture in skeleton/
Map.entry("zombie_nautilus_coral", "entity/nautilus/zombie_nautilus_coral"),
Map.entry("mannequin", "entity/player/wide/steve")
);
Map.entry("mannequin", "entity/player/wide/steve"));
/** Ordered texture-path candidates; the baker uses the first that loads. */
public static List<ResourceLocation> textureCandidates(String typeKey, String variant) {
@@ -138,31 +135,31 @@ public final class EntityModels {
* fallbacks, so an unknown variant still degrades to the base texture.
*/
private static List<String> variantPaths(String typeKey, String v) {
switch (typeKey) {
case "cat": return List.of("entity/cat/cat_" + v);
case "axolotl": return List.of("entity/axolotl/axolotl_" + v);
case "wolf": return List.of("entity/wolf/wolf_" + v, "entity/wolf/wolf");
case "horse": return List.of("entity/horse/horse_" + HORSE_COLOR.getOrDefault(v, v));
case "llama": return List.of("entity/llama/llama_" + v);
case "cow": return List.of("entity/cow/cow_" + v);
case "pig": return List.of("entity/pig/pig_" + v);
case "chicken": return List.of("entity/chicken/chicken_" + v);
case "frog": return List.of("entity/frog/frog_" + v);
case "panda": return List.of(v.equals("normal") ? "entity/panda/panda" : "entity/panda/panda_" + v);
case "fox": return List.of(v.equals("snow") ? "entity/fox/fox_snow" : "entity/fox/fox");
case "parrot": return List.of("entity/parrot/parrot_" + PARROT_COLOR.getOrDefault(v, v));
case "rabbit": return List.of("entity/rabbit/rabbit_" + RABBIT_TYPE.getOrDefault(v, v));
case "mooshroom": return List.of("entity/cow/mooshroom_" + v);
case "shulker": return List.of("entity/shulker/shulker_" + v);
return switch (typeKey) {
case "cat" -> List.of("entity/cat/cat_" + v);
case "axolotl" -> List.of("entity/axolotl/axolotl_" + v);
case "wolf" -> List.of("entity/wolf/wolf_" + v, "entity/wolf/wolf");
case "horse" -> List.of("entity/horse/horse_" + HORSE_COLOR.getOrDefault(v, v));
case "llama" -> List.of("entity/llama/llama_" + v);
case "cow" -> List.of("entity/cow/cow_" + v);
case "pig" -> List.of("entity/pig/pig_" + v);
case "chicken" -> List.of("entity/chicken/chicken_" + v);
case "frog" -> List.of("entity/frog/frog_" + v);
case "panda" -> List.of(v.equals("normal") ? "entity/panda/panda" : "entity/panda/panda_" + v);
case "fox" -> List.of(v.equals("snow") ? "entity/fox/fox_snow" : "entity/fox/fox");
case "parrot" -> List.of("entity/parrot/parrot_" + PARROT_COLOR.getOrDefault(v, v));
case "rabbit" -> List.of("entity/rabbit/rabbit_" + RABBIT_TYPE.getOrDefault(v, v));
case "mooshroom" -> List.of("entity/cow/mooshroom_" + v);
case "shulker" -> List.of("entity/shulker/shulker_" + v);
// villager/zombie_villager: type/<biome> and profession are transparent OVERLAYS (clothing
// only); the opaque base body is entity/<folder>/<folder> — handled by the generic candidates.
default: return List.of();
}
default -> List.of();
};
}
private static final Map<String, String> HORSE_COLOR = Map.of("dark_brown", "darkbrown");
private static final Map<String, String> PARROT_COLOR = Map.of(
"red", "red_blue", "cyan", "yellow_blue", "gray", "grey");
private static final Map<String, String> RABBIT_TYPE = Map.of(
"black_and_white", "white_splotched", "salt_and_pepper", "salt", "the_killer_bunny", "caerbannog");
private static final Map<String, String> PARROT_COLOR =
Map.of("red", "red_blue", "cyan", "yellow_blue", "gray", "grey");
private static final Map<String, String> RABBIT_TYPE =
Map.of("black_and_white", "white_splotched", "salt_and_pepper", "salt", "the_killer_bunny", "caerbannog");
}
@@ -3,7 +3,6 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
import java.util.ArrayList;
import java.util.List;
@@ -18,9 +17,13 @@ public final class EntityScene {
private static final double EPS = 1e-7;
private final List<RenderedEntity> entities;
public EntityScene(List<EntityState> states, CemBaker baker,
List<BlockEntityState> blockEntities, BlockEntityBaker beBaker,
List<DecorationState> decorations, DecorationBaker decoBaker) {
public EntityScene(
List<EntityState> states,
CemBaker baker,
List<BlockEntityState> blockEntities,
BlockEntityBaker beBaker,
List<DecorationState> decorations,
DecorationBaker decoBaker) {
this.entities = new ArrayList<>(states.size() + blockEntities.size() + decorations.size());
addAll(states, baker);
addAll(blockEntities, beBaker);
@@ -58,8 +61,8 @@ public final class EntityScene {
return best;
}
private static boolean rayAabb(double[] min, double[] max, double ox, double oy, double oz,
double dx, double dy, double dz, double maxT) {
private static boolean rayAabb(
double[] min, double[] max, double ox, double oy, double oz, double dx, double dy, double dz, double maxT) {
double tmin = 0, tmax = maxT;
double[] o = {ox, oy, oz}, d = {dx, dy, dz};
for (int a = 0; a < 3; a++) {
@@ -69,7 +72,11 @@ public final class EntityScene {
double inv = 1.0 / d[a];
double t1 = (min[a] - o[a]) * inv;
double t2 = (max[a] - o[a]) * inv;
if (t1 > t2) { double tmp = t1; t1 = t2; t2 = tmp; }
if (t1 > t2) {
double tmp = t1;
t1 = t2;
t2 = tmp;
}
if (t1 > tmin) tmin = t1;
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return false;
@@ -6,12 +6,18 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
*/
public record EntityState(
String typeKey, // e.g. "cow", "zombie", "player"
double x, double y, double z,
double x,
double y,
double z,
float bodyYaw,
boolean baby,
double width, double height,
boolean player, String skinUrl, boolean slim,
String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"); for villagers the biome type, or null
double width,
double height,
boolean player,
String skinUrl,
boolean slim,
String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"); for villagers the biome type,
// or null
int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none
double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default
String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null
@@ -19,5 +25,25 @@ public record EntityState(
String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null
boolean saddle, // horse/donkey/mule is saddled
boolean chest, // donkey/mule/llama is carrying a chest
String bodyEquip // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama"; null = none
String bodyEquip, // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama";
// null = none
Equipment equipment, // worn humanoid armor (players, armor stands, zombies, …); null = none/not a wearer
boolean invisible // entity is invisible -> render only its equipment (like vanilla), not the body
) {
/** Worn armor (4 slots) of a humanoid wearer; any field may be null. */
public record Equipment(EquipPiece head, EquipPiece chest, EquipPiece legs, EquipPiece feet) {
public boolean isEmpty() {
return head == null && chest == null && legs == null && feet == null;
}
}
/** One worn armor piece. */
public record EquipPiece(
String asset, // equipment asset id, e.g. "diamond", "leather", "elytra", "turtle_scute"
int dyeColor, // ARGB tint for dyeable (leather) armor; 0 = undyed/not dyeable
String trimMaterial, // armor-trim material key (e.g. "diamond"); null = no trim
String trimPattern, // armor-trim pattern key (e.g. "coast"); null = no trim
boolean glint // item is enchanted -> render the enchantment glint
) {}
}
@@ -10,7 +10,7 @@ public final class ModelCube {
public final double inflate; // px, expands the box on all sides (overlay layers)
public final double[] uv; // 2, box-UV offset (texels)
public final boolean mirror;
/** Optional modern per-face UV, indexed by {@link Direction#ordinal()}: {u, v, w, h} texels (h/w may be negative for flips). Null = use box-UV. */
/** Optional modern per-face UV, indexed by {@code Direction.ordinal()}: {u, v, w, h} texels (h/w may be negative for flips). Null = use box-UV. */
public final double[][] faceUv;
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror) {
@@ -39,7 +39,10 @@ public final class TextureOps {
int sp = src[y][x];
int sa = (sp >>> 24) & 0xFF;
if (sa == 0) continue;
if (sa == 255) { dst[y][x] = sp; continue; }
if (sa == 255) {
dst[y][x] = sp;
continue;
}
int dp = dst[y][x];
int da = (dp >>> 24) & 0xFF;
int outA = sa + da * (255 - sa) / 255;
@@ -52,4 +55,66 @@ public final class TextureOps {
}
}
}
/**
* Palette-swap recolouring used for armor trims: each (non-transparent) texel of {@code tex} is matched
* to its nearest entry in the grayscale source palette {@code from} (8-colour key, RGB distance) and
* replaced by the same-index colour from the material palette {@code to}, preserving alpha. The vanilla
* trim patterns are authored in those 8 key shades, so this reproduces the runtime palette swap.
*/
public static void paletteSwap(int[][] tex, int[] from, int[] to) {
int n = Math.min(from.length, to.length);
if (n == 0) return;
for (int[] row : tex) {
for (int x = 0; x < row.length; x++) {
int p = row[x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue;
int pr = (p >> 16) & 0xFF, pg = (p >> 8) & 0xFF, pb = p & 0xFF;
int best = 0, bestD = Integer.MAX_VALUE;
for (int i = 0; i < n; i++) {
int c = from[i];
int dr = pr - ((c >> 16) & 0xFF), dg = pg - ((c >> 8) & 0xFF), db = pb - (c & 0xFF);
int d = dr * dr + dg * dg + db * db;
if (d < bestD) {
bestD = d;
best = i;
}
}
row[x] = (a << 24) | (to[best] & 0xFFFFFF);
}
}
}
/**
* Static approximation of the (animated) enchantment glint: a single glint frame is tiled over the
* texture and additively blended — tinted by {@code glintColor} and scaled by {@code strength} and the
* frame's luminance — onto the texture's opaque texels only (so it sheens the armor, not the
* transparent background). Not animated; just a frozen purple shimmer.
*/
public static void addGlint(int[][] tex, int[][] glint, int glintColor, double strength) {
if (glint == null || glint.length == 0 || glint[0].length == 0) return;
int gh = glint.length, gw = glint[0].length;
int cr = (glintColor >> 16) & 0xFF, cg = (glintColor >> 8) & 0xFF, cb = glintColor & 0xFF;
for (int y = 0; y < tex.length; y++) {
for (int x = 0; x < tex[y].length; x++) {
int p = tex[y][x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue; // only sheen actual armor pixels
int gp = glint[y % gh][x % gw];
int ga = (gp >>> 24) & 0xFF;
int gl = Math.max((gp >> 16) & 0xFF, Math.max((gp >> 8) & 0xFF, gp & 0xFF));
double inten = strength * (ga / 255.0) * (gl / 255.0);
if (inten <= 0) continue;
int r = clamp(((p >> 16) & 0xFF) + (int) (cr * inten));
int g = clamp(((p >> 8) & 0xFF) + (int) (cg * inten));
int b = clamp((p & 0xFF) + (int) (cb * inten));
tex[y][x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
}
private static int clamp(int v) {
return v < 0 ? 0 : (Math.min(v, 255));
}
}
@@ -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;
@@ -10,8 +13,8 @@ import eu.mhsl.minecraft.pixelpics.render.entity.EntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -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). */
@@ -46,7 +62,7 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
if (layers.isEmpty()) return null;
// Head model depends on the texture aspect (skeleton 64x32 vs zombie/player 64x64), so resolve it
// from the chosen texture rather than statically.
CemModelLoader.CemModel model = models.get(modelName(s, layers.get(0).tex));
CemModelLoader.CemModel model = models.get(modelName(s, layers.getFirst().tex));
if (model == null) return null;
Place p = place(s);
@@ -57,13 +73,80 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
List<EntityCube> cubes = new ArrayList<>();
for (Layer layer : layers) {
for (CemGeometry.Baked b : CemGeometry.bakeModel(model, layer.tex, pre, layer.hidden, layer.texW, layer.texH, layer.boxUv)) {
for (CemGeometry.Baked b :
CemGeometry.bakeModel(model, layer.tex, pre, layer.hidden, layer.texW, layer.texH, layer.boxUv)) {
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) {
@@ -81,7 +164,9 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
/** A single bake pass: one texture applied to the parts not in {@code hidden}, with optional UV size
* and a flag forcing box-UV (for standalone part textures not matching the model's per-face UV). */
private record Layer(int[][] tex, Set<String> hidden, int texW, int texH, boolean boxUv) {
Layer(int[][] tex, Set<String> hidden) { this(tex, hidden, 0, 0, false); }
Layer(int[][] tex, Set<String> hidden) {
this(tex, hidden, 0, 0, false);
}
}
/** Some types paint different parts with different textures (pot sherds, conduit cage/heart). */
@@ -100,19 +185,23 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
/** The conduit's cage and inner heart use separate textures; the eye/wind (active state) are skipped. */
private List<Layer> conduitLayers() {
int[][] cage = textures.get(ResourceLocation.parse("entity/conduit/cage")).orElse(null);
int[][] base = textures.get(ResourceLocation.parse("entity/conduit/base")).orElse(null);
int[][] cage =
textures.get(ResourceLocation.parse("entity/conduit/cage")).orElse(null);
int[][] base =
textures.get(ResourceLocation.parse("entity/conduit/base")).orElse(null);
// Each part's texture is authored at its own native size (32x16) with a box-UV layout, so force
// box-UV and normalise by the texture's own size (the model's per-face UV assumes a combined sheet).
List<Layer> layers = new ArrayList<>(2);
if (cage != null) layers.add(new Layer(cage, onlyPart("cage", CONDUIT_PARTS), cage[0].length, cage.length, true));
if (base != null) layers.add(new Layer(base, onlyPart("base", CONDUIT_PARTS), base[0].length, base.length, true));
if (cage != null)
layers.add(new Layer(cage, onlyPart("cage", CONDUIT_PARTS), cage[0].length, cage.length, true));
if (base != null)
layers.add(new Layer(base, onlyPart("base", CONDUIT_PARTS), base[0].length, base.length, true));
return layers;
}
/** Hidden set that leaves only {@code keep} visible out of {@code all}. */
private static Set<String> onlyPart(String keep, Set<String> all) {
Set<String> hidden = new java.util.HashSet<>(all);
Set<String> hidden = new HashSet<>(all);
hidden.remove(keep);
return hidden;
}
@@ -121,12 +210,14 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
private static final String[] POT_FACES = {"front", "left", "right", "back"}; // matches sherd capture order
private List<Layer> potLayers(BlockEntityState s) {
int[][] base = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_base")).orElse(null);
int[][] side = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_side")).orElse(null);
int[][] base = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_base"))
.orElse(null);
int[][] side = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_side"))
.orElse(null);
if (base == null) return List.of();
List<Layer> layers = new ArrayList<>();
// Structure (rim/neck/foot) comes from the combined base texture; the four sides are NOT in it.
layers.add(new Layer(base, new java.util.HashSet<>(java.util.List.of("front", "back", "left", "right"))));
layers.add(new Layer(base, new HashSet<>(List.of("front", "back", "left", "right"))));
// Each side: its sherd pattern if set, else the plain brick side. The model's per-face UV maps the
// centre of the 16x16 texture onto the face (centred, edges intact).
for (int i = 0; i < POT_FACES.length; i++) {
@@ -135,7 +226,8 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
String sherd = s.sherds().get(i);
if (sherd != null && sherd.endsWith("_pottery_sherd")) {
int[][] pat = textures.get(ResourceLocation.parse(
"entity/decorated_pot/" + sherd.replace("_pottery_sherd", "_pottery_pattern"))).orElse(null);
"entity/decorated_pot/" + sherd.replace("_pottery_sherd", "_pottery_pattern")))
.orElse(null);
if (pat != null) tex = pat;
}
}
@@ -155,7 +247,6 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
return switch (s.kind()) {
case SIGN -> new Place(yaw, SIGN_SCALE, 0, 0, 0);
case WALL_SIGN -> new Place(yaw, SIGN_SCALE, 0, -0.3125, 0.4375); // drop to mid-block, push to wall
case HANGING_SIGN -> new Place(yaw, 1.0, 0, 0, 0);
case WALL_HEAD -> new Place(yaw, 1.0, 0, 0.25, 0.25); // mid-height, against the wall
case WALL_BANNER -> new Place(yaw, 1.0, 0, -0.16, 0.4375);
default -> new Place(yaw, 1.0, 0, 0, 0);
@@ -177,7 +268,8 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
private int[][] resolveTexture(BlockEntityState s) {
// Player heads use the owner's skin when available.
if (s.skinUrl() != null && (s.kind() == BlockEntityState.Kind.HEAD || s.kind() == BlockEntityState.Kind.WALL_HEAD)) {
if (s.skinUrl() != null
&& (s.kind() == BlockEntityState.Kind.HEAD || s.kind() == BlockEntityState.Kind.WALL_HEAD)) {
int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin;
}
@@ -199,7 +291,8 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
int[][] out = TextureOps.deepCopy(base);
if (s.baseColorArgb() != 0) TextureOps.tint(out, s.baseColorArgb());
for (BlockEntityState.BannerPattern pat : s.patterns()) {
int[][] mask = textures.get(ResourceLocation.parse("entity/banner/" + pat.patternKey())).orElse(null);
int[][] mask = textures.get(ResourceLocation.parse("entity/banner/" + pat.patternKey()))
.orElse(null);
if (mask == null) continue;
int[][] dyed = TextureOps.deepCopy(mask);
TextureOps.tint(dyed, pat.colorArgb());
@@ -13,9 +13,11 @@ import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Bakes an {@link EntityState} into world-space cubes using a vanilla Java {@link CemModelLoader.CemModel}
@@ -27,10 +29,9 @@ import java.util.List;
public final class CemBaker implements EntityBaker<EntityState> {
// Parts representing an alternate state (rolled-up, sleeping, …) that must not render in the idle pose.
private static final java.util.Map<String, java.util.Set<String>> HIDDEN_PARTS = java.util.Map.of(
"armadillo", java.util.Set.of("cube"), // the rolled-up ball
"illager", java.util.Set.of("left_arm", "right_arm")
);
private static final Map<String, Set<String>> HIDDEN_PARTS = Map.of(
"armadillo", Set.of("cube"), // the rolled-up ball
"illager", Set.of("left_arm", "right_arm"));
private final CemModelLoader models;
private final TextureCache textures;
@@ -53,26 +54,44 @@ public final class CemBaker implements EntityBaker<EntityState> {
tex = compositeHorse(s, tex); // coat markings + horse armor
} else if (cem.equals("llama")) {
tex = compositeLlama(s, tex); // dyed/trader carpet decor
} else if (cem.equals("nautilus")) {
tex = compositeNautilus(s, tex); // body armor + saddle (same-UV overlays)
}
CemModelLoader.CemModel model = models.get(cem);
if (model == null || tex == null) return fallbackBox(s, tex);
// A visible entity needs its body model+texture; an invisible one renders only its equipment
// (vanilla hides the body but still draws worn armor).
boolean invisible = s.invisible();
if (!invisible && (model == null || tex == null)) return fallbackBox(s, tex);
double sc = (s.baby() ? 0.5 : 1.0) * s.sizeScale();
// CEM model px -> entity-local blocks. Identity orientation (no axis flip) preserves ALL part
// rotations and handedness; only px->block scaling is applied.
Affine pre = Affine.scale(sc / 16.0);
java.util.Set<String> hidden = new java.util.HashSet<>(HIDDEN_PARTS.getOrDefault(cem, java.util.Set.of()));
Set<String> hidden = new HashSet<>(HIDDEN_PARTS.getOrDefault(cem, Set.of()));
// Donkeys/llamas carry the chest boxes inside the base model; hide them unless a chest is equipped.
if (!s.chest()) {
if (cem.equals("donkey")) { hidden.add("left_chest"); hidden.add("right_chest"); }
else if (cem.equals("llama")) { hidden.add("chest_left"); hidden.add("chest_right"); }
if (cem.equals("donkey")) {
hidden.add("left_chest");
hidden.add("right_chest");
} else if (cem.equals("llama")) {
hidden.add("chest_left");
hidden.add("chest_right");
}
List<CemGeometry.Baked> baked = new ArrayList<>(CemGeometry.bakeModel(model, tex, pre, hidden));
}
// The body model is baked even when invisible — not drawn, but used as the ground-snap reference
// so equipment stays at body height (e.g. a lone helmet sits at the head, not on the floor).
List<CemGeometry.Baked> body =
(model != null && tex != null) ? CemGeometry.bakeModel(model, tex, pre, hidden) : List.of();
List<CemGeometry.Baked> baked = new ArrayList<>();
if (!invisible) {
baked.addAll(body);
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
if (s.typeKey().equals("sheep")) {
CemModelLoader.CemModel wool = models.get("sheep_wool");
int[][] woolTex = textures.get(ResourceLocation.parse("entity/sheep/sheep_wool")).orElse(null);
int[][] woolTex = textures.get(ResourceLocation.parse("entity/sheep/sheep_wool"))
.orElse(null);
if (wool != null && woolTex != null) {
int[][] t = TextureOps.deepCopy(woolTex);
if (s.tint() != 0) TextureOps.tint(t, s.tint());
@@ -85,14 +104,21 @@ public final class CemBaker implements EntityBaker<EntityState> {
double[] org = {-8, 2, -6}, size = {2, 12, 12};
ModelCube mc = new ModelCube(org, size, 0, new double[] {0, 28}, true);
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
baked.add(new CemGeometry.Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre));
baked.add(new CemGeometry.Baked(
org, new double[] {org[0] + size[0], org[1] + size[1], org[2] + size[2]}, faces, pre));
}
// Saddle: an extra inflated layer from the *_saddle CEM model, showing only its saddle-specific parts.
if (s.saddle()) addSaddleLayer(s, cem, model, pre, baked);
if (baked.isEmpty()) return fallbackBox(s, tex);
}
// Humanoid armor (players, armor stands, zombies, …): extra inflated armor_layer models. Rendered
// even when invisible, so an invisible armored entity shows floating armor like in vanilla.
if (s.equipment() != null) addArmorLayers(s, pre, baked);
if (baked.isEmpty()) return invisible ? null : fallbackBox(s, tex);
// Ground-snap from the body extent (preserves visible behaviour); fall back to the drawn geometry.
List<CemGeometry.Baked> ref = (invisible && !body.isEmpty()) ? body : baked;
double minY = Double.MAX_VALUE;
for (CemGeometry.Baked b : baked) minY = Math.min(minY, b.minWorldY());
for (CemGeometry.Baked b : ref) minY = Math.min(minY, b.minWorldY());
Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
@@ -108,8 +134,9 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (s.player()) {
int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin;
int[][] def = textures.get(ResourceLocation.parse(
s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve")).orElse(null);
int[][] def = textures.get(
ResourceLocation.parse(s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve"))
.orElse(null);
if (def != null) return def;
}
for (ResourceLocation rl : EntityModels.textureCandidates(s.typeKey(), s.variant())) {
@@ -164,27 +191,144 @@ public final class CemBaker implements EntityBaker<EntityState> {
return out;
}
/** Nautilus body armor + saddle: same 128² UV as the nautilus texture, so composited as overlays. */
private int[][] compositeNautilus(EntityState s, int[][] base) {
if (base == null || (s.bodyEquip() == null && !s.saddle())) return base;
int[][] out = TextureOps.deepCopy(base);
if (s.bodyEquip() != null) overlayIfPresent(out, "entity/equipment/nautilus_body/" + s.bodyEquip());
if (s.saddle()) overlayIfPresent(out, "entity/equipment/nautilus_saddle/saddle");
return out;
}
/** Bake the saddle as a separate inflated layer; only the saddle-specific parts (those not in the base model). */
private void addSaddleLayer(EntityState s, String cem, CemModelLoader.CemModel base, Affine pre, List<CemGeometry.Baked> baked) {
private void addSaddleLayer(
EntityState s, String cem, CemModelLoader.CemModel base, Affine pre, List<CemGeometry.Baked> baked) {
String saddleModel = cem.equals("donkey") ? "donkey_saddle" : (cem.equals("horse") ? "horse_saddle" : null);
if (saddleModel == null) return; // llamas and other mounts have no saddle model
CemModelLoader.CemModel sm = models.get(saddleModel);
if (sm == null) return;
int[][] saddleTex = textures.get(ResourceLocation.parse("entity/equipment/" + s.typeKey() + "_saddle/saddle")).orElse(null);
int[][] saddleTex = textures.get(ResourceLocation.parse("entity/equipment/" + s.typeKey() + "_saddle/saddle"))
.orElse(null);
if (saddleTex == null) return;
// Show only the saddle-specific parts: hide every part the base body model also defines.
java.util.Set<String> hideBase = new java.util.HashSet<>();
Set<String> hideBase = new HashSet<>();
for (CemModelLoader.CemPart p : base.parts()) hideBase.add(p.name());
baked.addAll(CemGeometry.bakeModel(sm, saddleTex, pre, hideBase));
}
// --- humanoid armor ---------------------------------------------------------------------------
// Vanilla splits worn armor across two inflated layer models that overlay the standard humanoid body:
// armor_layer_1 (texture entity/equipment/humanoid/<mat>): head=helmet, body+arms=chestplate,
// shoes=boots;
// armor_layer_2 (texture entity/equipment/humanoid_leggings/<mat>): waist+legs=leggings.
// Each slot may use a different material, so each is baked separately, showing only its parts.
private static final Set<String> ARMOR1_HEAD = Set.of("head");
private static final Set<String> ARMOR1_CHEST = Set.of("body", "left_arm", "right_arm");
private static final Set<String> ARMOR1_FEET = Set.of("left_shoe", "right_shoe");
private static final Set<String> ARMOR2_LEGS = Set.of("waist", "left_leg", "right_leg");
private static final int GLINT_COLOR = 0xFF8040CC; // approximated enchantment-glint purple
private void addArmorLayers(EntityState s, Affine pre, List<CemGeometry.Baked> baked) {
EntityState.Equipment eq = s.equipment();
bakeArmorPiece(eq.head(), "armor_layer_1", ARMOR1_HEAD, "humanoid", pre, baked);
// Chest slot: an elytra renders its own wing model instead of the chestplate layer.
if (eq.chest() != null && eq.chest().asset().equals("elytra")) {
bakeElytra(eq.chest(), pre, baked);
} else {
bakeArmorPiece(eq.chest(), "armor_layer_1", ARMOR1_CHEST, "humanoid", pre, baked);
}
bakeArmorPiece(eq.legs(), "armor_layer_2", ARMOR2_LEGS, "humanoid_leggings", pre, baked);
bakeArmorPiece(eq.feet(), "armor_layer_1", ARMOR1_FEET, "humanoid", pre, baked);
}
/** Bake one armor slot: its layer model with only {@code show} parts, textured for the material. */
private void bakeArmorPiece(
EntityState.EquipPiece piece,
String modelName,
Set<String> show,
String layerFolder,
Affine pre,
List<CemGeometry.Baked> baked) {
if (piece == null) return;
CemModelLoader.CemModel model = models.get(modelName);
if (model == null) return;
int[][] tex = buildArmorTexture(piece, layerFolder);
if (tex == null) return;
Set<String> hidden = new HashSet<>();
for (CemModelLoader.CemPart p : model.parts()) if (!show.contains(p.name())) hidden.add(p.name());
baked.addAll(CemGeometry.bakeModel(model, tex, pre, hidden));
}
/** Resolve + composite a single armor slot's texture: material base (+leather dye/overlay), trim, glint. */
private int[][] buildArmorTexture(EntityState.EquipPiece piece, String layerFolder) {
String asset = piece.asset();
int[][] base = textures.get(ResourceLocation.parse("entity/equipment/" + layerFolder + "/" + asset))
.orElse(null);
if (base == null) return null;
int[][] out = TextureOps.deepCopy(base);
// Leather is dyeable: tint the base layer, then composite the (undyed) overlay layer on top.
if (asset.equals("leather")) {
if (piece.dyeColor() != 0) TextureOps.tint(out, piece.dyeColor());
overlayIfPresent(out, "entity/equipment/" + layerFolder + "/leather_overlay");
}
if (piece.trimMaterial() != null && piece.trimPattern() != null) {
applyTrim(out, layerFolder, piece.trimPattern(), piece.trimMaterial(), asset);
}
if (piece.glint()) applyGlint(out);
return out;
}
/** Armor trim: recolour the grayscale pattern via the material palette (same UV) and overlay it. */
private void applyTrim(int[][] armorTex, String layerFolder, String pattern, String material, String armorAsset) {
int[][] mask = textures.get(ResourceLocation.parse("trims/entity/" + layerFolder + "/" + pattern))
.orElse(null);
if (mask == null || mask.length != armorTex.length || mask[0].length != armorTex[0].length) return;
int[] from = palette8("trims/color_palettes/trim_palette");
// Vanilla uses the darker palette variant when the trim material matches the armor material.
String palette = material;
if (material.equals(armorAsset)
&& textures.get(ResourceLocation.parse("trims/color_palettes/" + material + "_darker"))
.isPresent()) {
palette = material + "_darker";
}
int[] to = palette8("trims/color_palettes/" + palette);
if (from == null || to == null) return;
int[][] colored = TextureOps.deepCopy(mask);
TextureOps.paletteSwap(colored, from, to);
TextureOps.overlay(armorTex, colored);
}
/** First row of an 8×1 trim colour-palette texture; null if missing. */
private int[] palette8(String path) {
int[][] p = textures.get(ResourceLocation.parse(path)).orElse(null);
return (p == null || p.length == 0) ? null : p[0];
}
/** Static enchantment-glint approximation over an armor/elytra/item texture (in place). */
private void applyGlint(int[][] tex) {
textures.get(ResourceLocation.parse("misc/enchanted_glint_armor"))
.ifPresent(glint -> TextureOps.addGlint(tex, glint, GLINT_COLOR, 0.6));
}
/** Elytra in the chest slot: bake the wing model with the default elytra texture (no chestplate). */
private void bakeElytra(EntityState.EquipPiece piece, Affine pre, List<CemGeometry.Baked> baked) {
CemModelLoader.CemModel model = models.get("elytra");
if (model == null) return;
int[][] tex = textures.get(ResourceLocation.parse("entity/equipment/wings/elytra"))
.orElse(null);
if (tex == null) return;
int[][] out = TextureOps.deepCopy(tex);
if (piece.glint()) applyGlint(out);
baked.addAll(CemGeometry.bakeModel(model, out, pre, Set.of()));
}
private RenderedEntity fallbackBox(EntityState s, int[][] tex) {
double w = Math.max(0.3, s.width()) * 16 * s.sizeScale(), h = Math.max(0.3, s.height()) * 16 * s.sizeScale();
double[] from = {-w / 2, 0, -w / 2};
double[] to = {w / 2, h, w / 2};
int[][] t = tex != null ? tex : flat(0xFF8C8C8C);
ModelCube box = new ModelCube(new double[]{-w/2, 0, -w/2}, new double[]{w, h, w}, 0,
new double[]{0, 0}, false);
ModelCube box =
new ModelCube(new double[] {-w / 2, 0, -w / 2}, new double[] {w, h, w}, 0, new double[] {0, 0}, false);
Face[] faces = BoxUv.build(box, t, Math.max(64, (int) (2 * (w + w))), Math.max(64, (int) (2 * (w + h))));
Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
@@ -4,7 +4,6 @@ import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@@ -45,8 +44,14 @@ final class CemGeometry {
* {@code ignoreFaceUv} is set, box-UV is forced even if the model declares per-face UV — used for a
* standalone texture (e.g. the conduit cage) whose layout is box-UV, not the combined-sheet layout.
*/
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden,
int texW, int texH, boolean ignoreFaceUv) {
static List<Baked> bakeModel(
CemModelLoader.CemModel model,
int[][] tex,
Affine pre,
Set<String> hidden,
int texW,
int texH,
boolean ignoreFaceUv) {
int nw = texW > 0 ? texW : model.texW();
int nh = texH > 0 ? texH : model.texH();
List<Baked> out = new ArrayList<>();
@@ -63,8 +68,17 @@ final class CemGeometry {
* parent origin from the 2nd nesting level on). Top-level boxes are absolute; nested boxes are offset
* by their group origin. The group transform is {@code parent · T(O) · R · T(-O)}.
*/
private static void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth,
Set<String> hidden, int texW, int texH, int[][] tex, boolean ignoreFaceUv, List<Baked> out) {
private static void bakePart(
CemModelLoader.CemPart part,
Affine parentWorld,
double[] o,
int depth,
Set<String> hidden,
int texW,
int texH,
int[][] tex,
boolean ignoreFaceUv,
List<Baked> out) {
if (hidden.contains(part.name())) return;
Affine world = parentWorld
.mul(Affine.translation(o[0], o[1], o[2]))
@@ -87,9 +101,9 @@ final class CemGeometry {
for (CemModelLoader.CemPart child : part.children()) {
double[] t = child.translate();
// submodel origin = its translate, accumulated with this group's origin from the 2nd level on.
double[] co = depth >= 1 ? new double[]{t[0] + o[0], t[1] + o[1], t[2] + o[2]} : new double[]{t[0], t[1], t[2]};
double[] co =
depth >= 1 ? new double[] {t[0] + o[0], t[1] + o[1], t[2] + o[2]} : new double[] {t[0], t[1], t[2]};
bakePart(child, world, co, depth + 1, hidden, texW, texH, tex, ignoreFaceUv, out);
}
}
}
@@ -4,7 +4,6 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
@@ -29,11 +28,13 @@ public final class CemModelLoader {
* and optional per-face UV ({@code faceUv}, indexed by {@link eu.mhsl.minecraft.pixelpics.assets.model.Direction}
* ordinal, each {@code {x, y, w, h}} texels; null = use box-UV).
*/
public record CemBox(double[] origin, double[] size, double inflate, double[] uv, boolean mirror, double[][] faceUv) {}
public record CemBox(
double[] origin, double[] size, double inflate, double[] uv, boolean mirror, double[][] faceUv) {}
/** A model part: its (raw) translate, rotation (deg), boxes and nested submodels. The rotation pivot
* is {@code -(sum of translates from the root to this part)} — accumulated by the baker. */
public record CemPart(String name, double[] translate, double[] rotate, List<CemBox> boxes, List<CemPart> children) {}
public record CemPart(
String name, double[] translate, double[] rotate, List<CemBox> boxes, List<CemPart> children) {}
/** A whole model: declared texture size and its top-level parts. */
public record CemModel(int texW, int texH, List<CemPart> parts) {}
@@ -50,13 +51,15 @@ public final class CemModelLoader {
/** Parse the CEM template-models JSON stream. Returns the number of models loaded. */
public int load(InputStream in, Logger logger) {
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)).getAsJsonObject();
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
.getAsJsonObject();
JsonObject modelsObj = root.getAsJsonObject("models");
for (Map.Entry<String, JsonElement> e : modelsObj.entrySet()) {
try {
JsonObject entry = e.getValue().getAsJsonObject();
if (!entry.has("model")) continue;
JsonObject model = JsonParser.parseString(entry.get("model").getAsString()).getAsJsonObject();
JsonObject model =
JsonParser.parseString(entry.get("model").getAsString()).getAsJsonObject();
int tw = model.getAsJsonArray("textureSize").get(0).getAsInt();
int th = model.getAsJsonArray("textureSize").get(1).getAsInt();
List<CemPart> parts = new ArrayList<>();
@@ -79,19 +82,29 @@ public final class CemModelLoader {
JsonObject b = be.getAsJsonObject();
if (!b.has("coordinates")) continue;
JsonArray c = b.getAsJsonArray("coordinates");
double[] origin = {c.get(0).getAsDouble(), c.get(1).getAsDouble(), c.get(2).getAsDouble()};
double[] size = {c.get(3).getAsDouble(), c.get(4).getAsDouble(), c.get(5).getAsDouble()};
double[] origin = {
c.get(0).getAsDouble(), c.get(1).getAsDouble(), c.get(2).getAsDouble()
};
double[] size = {
c.get(3).getAsDouble(), c.get(4).getAsDouble(), c.get(5).getAsDouble()
};
double inflate = b.has("sizeAdd") ? b.get("sizeAdd").getAsDouble() : 0;
double[] uv = b.has("textureOffset")
? new double[]{b.getAsJsonArray("textureOffset").get(0).getAsDouble(), b.getAsJsonArray("textureOffset").get(1).getAsDouble()}
? new double[] {
b.getAsJsonArray("textureOffset").get(0).getAsDouble(),
b.getAsJsonArray("textureOffset").get(1).getAsDouble()
}
: new double[] {0, 0};
boxes.add(new CemBox(origin, size, inflate, uv, partMirror || mirrorsU(b), parseFaceUv(b)));
}
}
List<CemPart> children = new ArrayList<>();
if (p.has("submodels")) for (JsonElement se : p.getAsJsonArray("submodels")) children.add(parsePart(se.getAsJsonObject()));
if (p.has("submodels"))
for (JsonElement se : p.getAsJsonArray("submodels")) children.add(parsePart(se.getAsJsonObject()));
if (p.has("submodel")) children.add(parsePart(p.getAsJsonObject("submodel")));
String name = p.has("part") ? p.get("part").getAsString() : (p.has("id") ? p.get("id").getAsString() : "");
String name = p.has("part")
? p.get("part").getAsString()
: (p.has("id") ? p.get("id").getAsString() : "");
return new CemPart(name, translate, rotate, boxes, children);
}
@@ -101,7 +114,11 @@ public final class CemModelLoader {
/** Parses per-face UV ({@code uvNorth} etc., each {@code [u1,v1,u2,v2]}) into {@code {x,y,w,h}}, or null. */
private static double[][] parseFaceUv(JsonObject b) {
boolean any = false;
for (String k : FACE_UV_KEYS) if (b.has(k)) { any = true; break; }
for (String k : FACE_UV_KEYS)
if (b.has(k)) {
any = true;
break;
}
if (!any) return null;
double[][] faces = new double[6][];
for (int i = 0; i < FACE_UV_KEYS.length; i++) {
@@ -121,6 +138,8 @@ public final class CemModelLoader {
private static double[] arr3(JsonObject o, String key) {
if (!o.has(key) || !o.get(key).isJsonArray()) return new double[] {0, 0, 0};
JsonArray a = o.getAsJsonArray(key);
return new double[]{a.get(0).getAsDouble(), a.get(1).getAsDouble(), a.get(2).getAsDouble()};
return new double[] {
a.get(0).getAsDouble(), a.get(1).getAsDouble(), a.get(2).getAsDouble()
};
}
}
@@ -0,0 +1,108 @@
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;
}
}
@@ -5,10 +5,9 @@ import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.util.Vector;
/**
* Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns
@@ -28,10 +27,17 @@ public final class ElementIntersector {
* @param dx,dy,dz ray direction (need not be normalized)
* @param bx,by,bz block min corner in world coordinates (for reconstructing the world hit point)
*/
public static FaceHit intersect(ResolvedModel model,
double ox, double oy, double oz,
double dx, double dy, double dz,
int bx, int by, int bz) {
public static FaceHit intersect(
ResolvedModel model,
double ox,
double oy,
double oz,
double dx,
double dy,
double dz,
int bx,
int by,
int bz) {
List<Candidate> candidates = new ArrayList<>(model.elements.size());
for (int i = 0; i < model.elements.size(); i++) {
Element element = model.elements.get(i);
@@ -61,16 +67,16 @@ public final class ElementIntersector {
return null;
}
private record Candidate(Element element, double t, Direction dir, double s, double t2, double[] normal, int order) {}
private record Candidate(
Element element, double t, Direction dir, double s, double t2, double[] normal, int order) {}
private static Candidate intersectAabb(Element e, double ox, double oy, double oz,
double dx, double dy, double dz) {
private static Candidate intersectAabb(
Element e, double ox, double oy, double oz, double dx, double dy, double dz) {
return slab(e, ox, oy, oz, dx, dy, dz, e.from, e.to, null);
}
/** Rotated element: transform the ray into the element's local (unrotated) frame, then slab-test. */
private static Candidate intersectObb(Element e, double ox, double oy, double oz,
double dx, double dy, double dz) {
private static Candidate intersectObb(Element e, double ox, double oy, double oz, double dx, double dy, double dz) {
double[] o = rotate(ox - e.rotOrigin[0], oy - e.rotOrigin[1], oz - e.rotOrigin[2], e.rotAxis, -e.rotAngleRad);
o[0] += e.rotOrigin[0];
o[1] += e.rotOrigin[1];
@@ -79,9 +85,17 @@ public final class ElementIntersector {
return slab(e, o[0], o[1], o[2], d[0], d[1], d[2], e.from, e.to, e);
}
private static Candidate slab(Element e, double ox, double oy, double oz,
double dx, double dy, double dz,
double[] from, double[] to, Element obb) {
private static Candidate slab(
Element e,
double ox,
double oy,
double oz,
double dx,
double dy,
double dz,
double[] from,
double[] to,
Element obb) {
double tmin = Double.NEGATIVE_INFINITY;
double tmax = Double.POSITIVE_INFINITY;
int axis = -1;
@@ -99,7 +113,9 @@ public final class ElementIntersector {
double t2 = (to[a] - o[a]) * inv;
boolean neg = true;
if (t1 > t2) {
double tmp = t1; t1 = t2; t2 = tmp;
double tmp = t1;
t1 = t2;
t2 = tmp;
neg = false;
}
if (t1 > tmin) {
@@ -138,9 +154,18 @@ public final class ElementIntersector {
switch (dir) {
// Texture V is top-down (0 = texture top). For side faces the texture top is the block
// top (high Y), so t = 1 - fracY.
case UP, DOWN -> { s = fracX; t = fracZ; }
case NORTH, SOUTH -> { s = fracX; t = 1 - fracY; }
default -> { s = fracZ; t = 1 - fracY; } // WEST, EAST
case UP, DOWN -> {
s = fracX;
t = fracZ;
}
case NORTH, SOUTH -> {
s = fracX;
t = 1 - fracY;
}
default -> {
s = fracZ;
t = 1 - fracY;
} // WEST, EAST
}
return new Candidate(e, tEntry, dir, s, t, normal, 0);
}
@@ -6,5 +6,4 @@ import org.bukkit.util.Vector;
* The result of intersecting a ray with a block's geometry: the world-space hit point and normal,
* the sampled ARGB color (before shading/tinting) and the face's tint index ({@code -1} = none).
*/
public record FaceHit(double t, Vector point, Vector normal, int color, int tintIndex) {
}
public record FaceHit(double t, Vector point, Vector normal, int color, int tintIndex) {}
@@ -11,6 +11,8 @@ import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Material;
import org.bukkit.block.Biome;
import org.bukkit.block.data.BlockData;
@@ -39,10 +41,14 @@ public final class SnapshotRaytracer {
private final double maxDistance;
private final int reflectionDepth;
private final int maxSteps;
private final java.util.Map<Long, BiomeTint> tintCache = new java.util.concurrent.ConcurrentHashMap<>();
private final Map<Long, BiomeTint> tintCache = new ConcurrentHashMap<>();
public SnapshotRaytracer(BlockModelRegistry registry, BiomeTintProvider tintProvider,
SkyRenderer skyRenderer, double maxDistance, int reflectionDepth) {
public SnapshotRaytracer(
BlockModelRegistry registry,
BiomeTintProvider tintProvider,
SkyRenderer skyRenderer,
double maxDistance,
int reflectionDepth) {
this.registry = registry;
this.tintProvider = tintProvider;
this.skyRenderer = skyRenderer;
@@ -55,7 +61,8 @@ public final class SnapshotRaytracer {
return trace(snapshot, origin, direction, sky, scene, reflectionDepth);
}
private int trace(WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene, int depth) {
private int trace(
WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene, int depth) {
double ox = origin.getX(), oy = origin.getY(), oz = origin.getZ();
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
@@ -96,7 +103,8 @@ public final class SnapshotRaytracer {
if (!reflected && model.reflection > 0 && depth > 0) {
Vector reflectDir = MathUtil.reflectVector(direction, hit.normal());
Vector reflectStart = hit.point().clone().add(hit.normal().clone().multiply(1e-3));
Vector reflectStart =
hit.point().clone().add(hit.normal().clone().multiply(1e-3));
reflectionColor = trace(snapshot, reflectStart, reflectDir, sky, scene, depth - 1);
reflectionFactor = model.reflection;
reflected = true;
@@ -148,7 +156,9 @@ public final class SnapshotRaytracer {
transparencyColor,
transparencyFactor,
(1 - transparencyFactor)
* (1 + transparencyStart.distance(finalPoint == null ? transparencyStart : finalPoint) / 5.0));
* (1
+ transparencyStart.distance(finalPoint == null ? transparencyStart : finalPoint)
/ 5.0));
}
if (reflected) {
baseColor = ColorUtil.mix(baseColor, reflectionColor, 1 - reflectionFactor, reflectionFactor);
@@ -188,7 +198,9 @@ public final class SnapshotRaytracer {
* across the face. Only applied to axis-aligned faces.
*/
private double ambientOcclusion(FaceHit hit, WorldSnapshot snapshot, int bx, int by, int bz) {
double nx = hit.normal().getX(), ny = hit.normal().getY(), nz = hit.normal().getZ();
double nx = hit.normal().getX(),
ny = hit.normal().getY(),
nz = hit.normal().getZ();
double ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
if (Math.max(ax, Math.max(ay, az)) < 0.99) return 1.0; // skip rotated/diagonal faces
@@ -201,11 +213,32 @@ public final class SnapshotRaytracer {
int ux, uy, uz, vx, vy, vz;
double su, sv;
if (ay > 0.5) { // up/down
ux = 1; uy = 0; uz = 0; vx = 0; vy = 0; vz = 1; su = lx; sv = lz;
ux = 1;
uy = 0;
uz = 0;
vx = 0;
vy = 0;
vz = 1;
su = lx;
sv = lz;
} else if (ax > 0.5) { // east/west
ux = 0; uy = 0; uz = 1; vx = 0; vy = 1; vz = 0; su = lz; sv = ly;
ux = 0;
uy = 0;
uz = 1;
vx = 0;
vy = 1;
vz = 0;
su = lz;
sv = ly;
} else { // north/south
ux = 1; uy = 0; uz = 0; vx = 0; vy = 1; vz = 0; su = lx; sv = ly;
ux = 1;
uy = 0;
uz = 0;
vx = 0;
vy = 1;
vz = 0;
su = lx;
sv = ly;
}
double b00 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, -1);
@@ -218,13 +251,26 @@ public final class SnapshotRaytracer {
return top + (bottom - top) * sv;
}
private double aoCorner(WorldSnapshot snapshot, int bx, int by, int bz,
int ofx, int ofy, int ofz, int ux, int uy, int uz, int vx, int vy, int vz,
int du, int dv) {
private double aoCorner(
WorldSnapshot snapshot,
int bx,
int by,
int bz,
int ofx,
int ofy,
int ofz,
int ux,
int uy,
int uz,
int vx,
int vy,
int vz,
int du,
int dv) {
boolean side1 = solid(snapshot, bx + ofx + du * ux, by + ofy + du * uy, bz + ofz + du * uz);
boolean side2 = solid(snapshot, bx + ofx + dv * vx, by + ofy + dv * vy, bz + ofz + dv * vz);
boolean corner = solid(snapshot,
bx + ofx + du * ux + dv * vx, by + ofy + du * uy + dv * vy, bz + ofz + du * uz + dv * vz);
boolean corner = solid(
snapshot, bx + ofx + du * ux + dv * vx, by + ofy + du * uy + dv * vy, bz + ofz + du * uz + dv * vz);
int level = (side1 && side2) ? 0 : 3 - (side1 ? 1 : 0) - (side2 ? 1 : 0) - (corner ? 1 : 0);
return AO_BRIGHTNESS[Math.clamp(level, 0, 3)];
}
@@ -2,13 +2,13 @@ package eu.mhsl.minecraft.pixelpics.render.render;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityScene;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.raytrace.SnapshotRaytracer;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
@@ -20,18 +20,18 @@ import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.util.Vector;
import java.util.UUID;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Logger;
import java.util.stream.IntStream;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.util.Vector;
/**
* Renders the scene by capturing a world snapshot on the main thread ({@link #prepare}) and then
@@ -41,6 +41,7 @@ public class DefaultScreenRenderer implements Renderer {
/** Horizontal half field-of-view; the vertical half is derived from the output aspect ratio. */
private static final double H_FOV_HALF_RAD = Math.toRadians(35);
private static final Vector BASE_VEC = new Vector(1, 0, 0);
private static final double MAX_DISTANCE = 256;
@@ -54,16 +55,34 @@ public class DefaultScreenRenderer implements Renderer {
private final BlockEntityBaker blockEntityBaker;
private final DecorationBaker decorationBaker;
private final Logger logger;
/** Bounds parallel ray tracing to a fixed, low-priority pool; {@code null} = use the common pool. */
private final ForkJoinPool tracePool;
public DefaultScreenRenderer(BlockModelRegistry registry, BiomeTintProvider tintProvider,
TextureCache textures, CemBaker entityBaker,
BlockEntityBaker blockEntityBaker, Logger logger) {
public DefaultScreenRenderer(
BlockModelRegistry registry,
BiomeTintProvider tintProvider,
TextureCache textures,
CemBaker entityBaker,
BlockEntityBaker blockEntityBaker,
Logger logger) {
this(registry, tintProvider, textures, entityBaker, blockEntityBaker, logger, null);
}
public DefaultScreenRenderer(
BlockModelRegistry registry,
BiomeTintProvider tintProvider,
TextureCache textures,
CemBaker entityBaker,
BlockEntityBaker blockEntityBaker,
Logger logger,
ForkJoinPool tracePool) {
SkyRenderer skyRenderer = new SkyRenderer(textures);
this.raytracer = new SnapshotRaytracer(registry, tintProvider, skyRenderer, MAX_DISTANCE, REFLECTION_DEPTH);
this.entityBaker = entityBaker;
this.blockEntityBaker = blockEntityBaker;
this.decorationBaker = new DecorationBaker(textures);
this.logger = logger;
this.tracePool = tracePool;
}
/** Convenience: prepare and execute in one call (must run on the main thread). */
@@ -88,12 +107,29 @@ public class DefaultScreenRenderer implements Renderer {
int moonPhase = (int) (fullTime / 24000L % 8L);
SkyContext sky = new SkyContext(dayTime, moonPhase, fullTime);
return new RenderJob(snapshot, rayMap, eyeLocation.toVector(),
resolution.getWidth(), resolution.getHeight(), sky, entities, blockEntities, decorations);
return new RenderJob(
snapshot,
rayMap,
eyeLocation.toVector(),
resolution.getWidth(),
resolution.getHeight(),
sky,
entities,
blockEntities,
decorations);
}
/** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */
public BufferedImage execute(RenderJob job) {
return execute(job, new AtomicBoolean(false));
}
/**
* As {@link #execute(RenderJob)}, but cooperatively abortable: once {@code cancelled} is set, the
* parallel loops short-circuit (remaining work becomes a no-op) so a stuck/overlong render drains
* out quickly. The returned image is then partial and should be discarded by the caller.
*/
public BufferedImage execute(RenderJob job, AtomicBoolean cancelled) {
int finalW = job.width();
int finalH = job.height();
int superW = finalW * SSAA;
@@ -101,16 +137,18 @@ public class DefaultScreenRenderer implements Renderer {
WorldSnapshot snapshot = job.snapshot();
Vector origin = job.origin();
SkyContext sky = job.sky();
EntityScene scene = new EntityScene(job.entities(), entityBaker, job.blockEntities(), blockEntityBaker,
job.decorations(), decorationBaker);
EntityScene scene = new EntityScene(
job.entities(), entityBaker, job.blockEntities(), blockEntityBaker, job.decorations(), decorationBaker);
int[] superBuf = new int[rayMap.size()];
IntStream.range(0, rayMap.size()).parallel().forEach(i ->
superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene));
runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> {
if (!cancelled.get()) superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene);
}));
BufferedImage image = new BufferedImage(finalW, finalH, BufferedImage.TYPE_INT_RGB);
int[] imageData = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
IntStream.range(0, finalH).parallel().forEach(fy -> {
runParallel(() -> IntStream.range(0, finalH).parallel().forEach(fy -> {
if (cancelled.get()) return;
int[] block = new int[SSAA * SSAA];
for (int fx = 0; fx < finalW; fx++) {
int n = 0;
@@ -122,10 +160,23 @@ public class DefaultScreenRenderer implements Renderer {
}
imageData[fy * finalW + fx] = ColorUtil.averageLinear(block, 0, n);
}
});
}));
return image;
}
/**
* Runs a parallel-stream task. With a {@link #tracePool} the work is confined to that bounded,
* low-priority pool (parallel streams adopt the pool of the running fork-join worker); without one
* it runs inline on the common pool (used by the offline render tools).
*/
private void runParallel(Runnable task) {
if (tracePool == null) {
task.run();
} else {
tracePool.submit(task).join();
}
}
private List<Vector> buildRayMap(Location eyeLocation, int width, int height) {
Vector lineDirection = eyeLocation.getDirection();
@@ -148,15 +199,19 @@ public class DefaultScreenRenderer implements Renderer {
List<Vector> rayMap = new ArrayList<>(width * height);
Vector leftFraction = upperLeftCorner.clone().subtract(lowerLeftCorner).multiply(1.0 / (height - 1));
Vector rightFraction = upperRightCorner.clone().subtract(lowerRightCorner).multiply(1.0 / (height - 1));
Vector rightFraction =
upperRightCorner.clone().subtract(lowerRightCorner).multiply(1.0 / (height - 1));
for (int pitch = 0; pitch < height; pitch++) {
Vector leftPitch = upperLeftCorner.clone().subtract(leftFraction.clone().multiply(pitch));
Vector rightPitch = upperRightCorner.clone().subtract(rightFraction.clone().multiply(pitch));
Vector leftPitch =
upperLeftCorner.clone().subtract(leftFraction.clone().multiply(pitch));
Vector rightPitch =
upperRightCorner.clone().subtract(rightFraction.clone().multiply(pitch));
Vector yawFraction = rightPitch.clone().subtract(leftPitch).multiply(1.0 / (width - 1));
for (int yaw = 0; yaw < width; yaw++) {
Vector ray = leftPitch.clone().add(yawFraction.clone().multiply(yaw)).normalize();
Vector ray =
leftPitch.clone().add(yawFraction.clone().multiply(yaw)).normalize();
rayMap.add(ray);
}
}
@@ -5,28 +5,46 @@ import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import org.bukkit.util.Vector;
import java.util.List;
import org.bukkit.util.Vector;
/**
* A prepared render: the world snapshot (captured on the main thread) plus the ray map, camera origin,
* the sky context (time of day / moon phase) and the captured entity, block-entity and decoration
* (painting / item frame) states. {@link DefaultScreenRenderer#execute} can run this off the main thread.
*/
public record RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
int width, int height, SkyContext sky, List<EntityState> entities,
List<BlockEntityState> blockEntities, List<DecorationState> decorations) {
public record RenderJob(
WorldSnapshot snapshot,
List<Vector> rayMap,
Vector origin,
int width,
int height,
SkyContext sky,
List<EntityState> entities,
List<BlockEntityState> blockEntities,
List<DecorationState> decorations) {
/** Backwards-compatible constructor (no block-entities/decorations), used by the standalone harness. */
public RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
int width, int height, SkyContext sky, List<EntityState> entities) {
public RenderJob(
WorldSnapshot snapshot,
List<Vector> rayMap,
Vector origin,
int width,
int height,
SkyContext sky,
List<EntityState> entities) {
this(snapshot, rayMap, origin, width, height, sky, entities, List.of(), List.of());
}
/** Convenience for callers that supply entities + block-entities but no decorations. */
public RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
int width, int height, SkyContext sky, List<EntityState> entities,
public RenderJob(
WorldSnapshot snapshot,
List<Vector> rayMap,
Vector origin,
int width,
int height,
SkyContext sky,
List<EntityState> entities,
List<BlockEntityState> blockEntities) {
this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, List.of());
}
@@ -1,8 +1,7 @@
package eu.mhsl.minecraft.pixelpics.render.render;
import org.bukkit.Location;
import java.awt.image.BufferedImage;
import org.bukkit.Location;
public interface Renderer {
BufferedImage render(Location eyeLocation, Resolution resolution);
@@ -9,7 +9,8 @@ public final class Resolution {
public Resolution(Pixels pixels, AspectRatio aspectRatio) {
this(
(int) Math.round(Preconditions.checkNotNull(pixels).height * Preconditions.checkNotNull(aspectRatio).ratio),
(int) Math.round(
Preconditions.checkNotNull(pixels).height * Preconditions.checkNotNull(aspectRatio).ratio),
pixels.height);
}
@@ -5,5 +5,4 @@ package eu.mhsl.minecraft.pixelpics.render.sky;
* (0..7) and the absolute world time (for continuous cloud drift). Immutable so it can be read from
* worker threads.
*/
public record SkyContext(long dayTime, int moonPhase, long fullTime) {
}
public record SkyContext(long dayTime, int moonPhase, long fullTime) {}
@@ -34,16 +34,21 @@ public final class SkyRenderer {
private final int[][] cloudTexture;
public SkyRenderer(TextureCache textures) {
this.sunTexture = textures.get(ResourceLocation.parse("environment/sun")).orElse(null);
this.moonTexture = textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null);
this.cloudTexture = textures.get(ResourceLocation.parse("environment/clouds")).orElse(null);
this.sunTexture =
textures.get(ResourceLocation.parse("environment/sun")).orElse(null);
this.moonTexture =
textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null);
this.cloudTexture =
textures.get(ResourceLocation.parse("environment/clouds")).orElse(null);
}
public int colorFor(Vector direction, Vector origin, SkyContext ctx) {
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len < 1e-9) return DAY_ZENITH;
dx /= len; dy /= len; dz /= len;
dx /= len;
dy /= len;
dz /= len;
// Sun/moon position, derived exactly from Minecraft's sky transforms:
// celestialAngle ca = getTimeOfDay(dayTime); the sun is rotated by ca*360deg about the X axis
@@ -98,7 +103,8 @@ public final class SkyRenderer {
}
// Moon disc (phase shape from the texture's alpha).
if (-sunY > -0.15) {
color = overlayDisc(color, dx, dy, dz, -sunX, -sunY, 0, MOON_HALF, moonTexture, rgb(228, 228, 238), ctx.moonPhase());
color = overlayDisc(
color, dx, dy, dz, -sunX, -sunY, 0, MOON_HALF, moonTexture, rgb(228, 228, 238), ctx.moonPhase());
}
// Cloud layer: the ray crosses the cloud plane at y = CLOUD_HEIGHT; the world hit point is
@@ -121,8 +127,18 @@ public final class SkyRenderer {
}
/** Draws a sun/moon disc, sampling a texture when available (moonPhase &ge; 0 picks the phase tile). */
private int overlayDisc(int base, double dx, double dy, double dz,
double cx, double cy, double cz, double half, int[][] texture, int solid, int moonPhase) {
private int overlayDisc(
int base,
double dx,
double dy,
double dz,
double cx,
double cy,
double cz,
double half,
int[][] texture,
int solid,
int moonPhase) {
double cos = dx * cx + dy * cy + dz * cz;
if (cos <= 0) return base;
double sinHalf = Math.sin(half);
@@ -130,8 +146,15 @@ public final class SkyRenderer {
// right = normalize(body x worldUp); discUp = right x body
double crx = cz, cry = 0, crz = -cx;
double crl = Math.sqrt(crx * crx + crz * crz);
if (crl < 1e-6) { crx = 1; cry = 0; crz = 0; crl = 1; }
crx /= crl; cry /= crl; crz /= crl;
if (crl < 1e-6) {
crx = 1;
cry = 0;
crz = 0;
crl = 1;
}
crx /= crl;
cry /= crl;
crz /= crl;
// discUp = right cross body
double ux = cry * cz - crz * cy;
double uy = crz * cx - crx * cz;
@@ -201,8 +224,7 @@ public final class SkyRenderer {
return alpha > 16 ? 0.85 : 0.0;
}
double scale = 0.012;
double n = valueNoise(x * scale, z * scale) * 0.6
+ valueNoise(x * scale * 2.3, z * scale * 2.3) * 0.4;
double n = valueNoise(x * scale, z * scale) * 0.6 + valueNoise(x * scale * 2.3, z * scale * 2.3) * 0.4;
return smoothstep(0.52, 0.72, n) * 0.8;
}
@@ -230,7 +252,9 @@ public final class SkyRenderer {
// --- small color/math helpers ---
private static int rgb(int r, int g, int b) { return (r << 16) | (g << 8) | b; }
private static int rgb(int r, int g, int b) {
return (r << 16) | (g << 8) | b;
}
private static int lerp(int a, int b, double t) {
t = Math.clamp(t, 0, 1);
@@ -260,5 +284,7 @@ public final class SkyRenderer {
return t * t * (3 - 2 * t);
}
private static int clamp(int v, int lo, int hi) { return v < lo ? lo : Math.min(v, hi); }
private static int clamp(int v, int lo, int hi) {
return v < lo ? lo : Math.min(v, hi);
}
}
@@ -7,6 +7,11 @@ import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BellAttach;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.ChestKind;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.Kind;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.bukkit.DyeColor;
import org.bukkit.Location;
import org.bukkit.Material;
@@ -15,18 +20,20 @@ import org.bukkit.block.Banner;
import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockState;
import org.bukkit.block.DecoratedPot;
import org.bukkit.block.Sign;
import org.bukkit.block.Skull;
import org.bukkit.block.banner.Pattern;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Directional;
import org.bukkit.block.data.Rotatable;
import org.bukkit.block.data.type.Bed;
import org.bukkit.block.data.type.Bell;
import org.bukkit.block.data.type.Chest;
import org.bukkit.block.sign.Side;
import org.bukkit.block.sign.SignSide;
import org.bukkit.profile.PlayerProfile;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* Captures block-entities (chests, signs, banners, beds, heads, …) near the view frustum into immutable
* {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each
@@ -74,30 +81,34 @@ public final class BlockEntitySnapshotBuilder {
// --- chests ---
if (mat == Material.CHEST || mat == Material.TRAPPED_CHEST || mat == Material.ENDER_CHEST) {
Kind kind = mat == Material.TRAPPED_CHEST ? Kind.TRAPPED_CHEST
Kind kind = mat == Material.TRAPPED_CHEST
? Kind.TRAPPED_CHEST
: mat == Material.ENDER_CHEST ? Kind.ENDER_CHEST : Kind.CHEST;
ChestKind ck = ChestKind.SINGLE;
if (data instanceof org.bukkit.block.data.type.Chest cd) {
if (data instanceof Chest cd) {
ck = switch (cd.getType()) {
case LEFT -> ChestKind.LEFT;
case RIGHT -> ChestKind.RIGHT;
case SINGLE -> ChestKind.SINGLE;
};
case SINGLE -> ChestKind.SINGLE;};
}
return base(kind, bx, by, bz, facingYaw(data)).chestKind(ck).build();
}
// --- beds ---
if (data instanceof org.bukkit.block.data.type.Bed bed) {
BedPart part = bed.getPart() == org.bukkit.block.data.type.Bed.Part.HEAD ? BedPart.HEAD : BedPart.FOOT;
if (data instanceof Bed bed) {
BedPart part = bed.getPart() == Bed.Part.HEAD ? BedPart.HEAD : BedPart.FOOT;
return base(Kind.BED, bx, by, bz, faceToYaw(bed.getFacing()))
.bedPart(part).colorName(stripColor(n, "_BED")).build();
.bedPart(part)
.colorName(stripColor(n, "_BED"))
.build();
}
// --- shulker boxes ---
if (n.endsWith("SHULKER_BOX")) {
String color = n.equals("SHULKER_BOX") ? null : stripColor(n, "_SHULKER_BOX");
return base(Kind.SHULKER_BOX, bx, by, bz, facingYaw(data)).colorName(color).build();
return base(Kind.SHULKER_BOX, bx, by, bz, facingYaw(data))
.colorName(color)
.build();
}
// --- banners ---
@@ -120,15 +131,24 @@ 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 Sign sign) {
b.frontText(signText(sign.getSide(Side.FRONT)));
b.backText(signText(sign.getSide(Side.BACK)));
}
return base(Kind.SIGN, bx, by, bz, rotationYaw(data)).wood(wood).build();
return b.build();
}
// --- heads / skulls ---
@@ -158,15 +178,16 @@ public final class BlockEntitySnapshotBuilder {
// --- bell ---
if (mat == Material.BELL) {
BellAttach attach = BellAttach.FLOOR;
if (data instanceof org.bukkit.block.data.type.Bell bd) {
if (data instanceof Bell bd) {
attach = switch (bd.getAttachment()) {
case FLOOR -> BellAttach.FLOOR;
case CEILING -> BellAttach.CEILING;
case SINGLE_WALL -> BellAttach.SINGLE_WALL;
case DOUBLE_WALL -> BellAttach.DOUBLE_WALL;
};
case DOUBLE_WALL -> BellAttach.DOUBLE_WALL;};
}
return base(Kind.BELL, bx, by, bz, facingYaw(data)).bellAttach(attach).build();
return base(Kind.BELL, bx, by, bz, facingYaw(data))
.bellAttach(attach)
.build();
}
return null;
@@ -219,15 +240,35 @@ public final class BlockEntitySnapshotBuilder {
// --- data extraction helpers ---
private static String stripColor(String name, String suffix) {
return name.substring(0, name.length() - suffix.length()).toLowerCase(java.util.Locale.ROOT);
return name.substring(0, name.length() - suffix.length()).toLowerCase(Locale.ROOT);
}
/** One sign side → {@link BlockEntityState.SignText}, or null when all four lines are blank. */
private static BlockEntityState.SignText signText(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"}) {
if (s.endsWith(suf)) { s = s.substring(0, s.length() - suf.length()); break; }
if (s.endsWith(suf)) {
s = s.substring(0, s.length() - suf.length());
break;
}
return s.toLowerCase(java.util.Locale.ROOT);
}
return s.toLowerCase(Locale.ROOT);
}
private static String headType(String name) {
@@ -238,7 +279,6 @@ public final class BlockEntitySnapshotBuilder {
case "CREEPER_HEAD" -> "creeper";
case "DRAGON_HEAD" -> "dragon";
case "PIGLIN_HEAD" -> "piglin";
case "SKELETON_SKULL" -> "skeleton";
case "WITHER_SKELETON_SKULL" -> "wither_skeleton";
default -> "skeleton";
};
@@ -248,16 +288,17 @@ public final class BlockEntitySnapshotBuilder {
// Order: front, left, right, back — matches the CEM decorated_pot face parts.
List<String> out = new ArrayList<>(4);
for (DecoratedPot.Side side : new DecoratedPot.Side[] {
DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK}) {
DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK
}) {
Material m = pot.getSherd(side);
out.add(m.name().toLowerCase(java.util.Locale.ROOT));
out.add(m.name().toLowerCase(Locale.ROOT));
}
return out;
}
private static String skinUrl(Skull skull) {
try {
org.bukkit.profile.PlayerProfile profile = skull.getOwnerProfile();
PlayerProfile profile = skull.getOwnerProfile();
if (profile != null && profile.getTextures().getSkin() != null) {
return profile.getTextures().getSkin().toString();
}
@@ -293,25 +334,96 @@ 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;
this.kind = kind;
this.bx = bx;
this.by = by;
this.bz = bz;
this.yaw = yaw;
}
Builder chestKind(ChestKind v) { this.chestKind = v; return this; }
Builder baseColorArgb(int v) { this.baseColorArgb = v; return this; }
Builder colorName(String v) { this.colorName = v; return this; }
Builder wood(String v) { this.wood = v; return this; }
Builder bedPart(BedPart v) { this.bedPart = v; return this; }
Builder headType(String v) { this.headType = v; return this; }
Builder skinUrl(String v) { this.skinUrl = v; return this; }
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 chestKind(ChestKind v) {
this.chestKind = v;
return this;
}
Builder baseColorArgb(int v) {
this.baseColorArgb = v;
return this;
}
Builder colorName(String v) {
this.colorName = v;
return this;
}
Builder wood(String v) {
this.wood = v;
return this;
}
Builder bedPart(BedPart v) {
this.bedPart = v;
return this;
}
Builder headType(String v) {
this.headType = v;
return this;
}
Builder skinUrl(String v) {
this.skinUrl = v;
return this;
}
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);
return new BlockEntityState(
kind,
bx,
by,
bz,
yaw,
chestKind,
baseColorArgb,
colorName,
wood,
bedPart,
headType,
skinUrl,
patterns,
sherds,
bellAttach,
frontText,
backText);
}
}
}
@@ -1,19 +1,19 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.bukkit.Location;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity;
import org.bukkit.entity.GlowItemFrame;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Painting;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* Captures flat wall decorations (paintings + item frames) near the view frustum into immutable
* {@link DecorationState}s. MUST run on the main thread. Uses each entity's world bounding box directly,
@@ -41,19 +41,39 @@ public final class DecorationSnapshotBuilder {
if (e instanceof Painting painting) {
BoundingBox bb = e.getBoundingBox();
String art = painting.getArt().assetId().value();
return new DecorationState(DecorationState.Kind.PAINTING,
bb.getMinX(), bb.getMinY(), bb.getMinZ(), bb.getMaxX(), bb.getMaxY(), bb.getMaxZ(),
facing(e.getFacing()), art, null, 0, false);
return new DecorationState(
DecorationState.Kind.PAINTING,
bb.getMinX(),
bb.getMinY(),
bb.getMinZ(),
bb.getMaxX(),
bb.getMaxY(),
bb.getMaxZ(),
facing(e.getFacing()),
art,
null,
0,
false);
}
if (e instanceof ItemFrame frame) {
BoundingBox bb = e.getBoundingBox();
boolean glow = e instanceof org.bukkit.entity.GlowItemFrame
|| e.getType().getKey().getKey().equals("glow_item_frame");
boolean glow =
e instanceof GlowItemFrame || e.getType().getKey().getKey().equals("glow_item_frame");
String itemId = itemId(frame.getItem());
int rot = frame.getRotation().ordinal() * 45;
return new DecorationState(DecorationState.Kind.ITEM_FRAME,
bb.getMinX(), bb.getMinY(), bb.getMinZ(), bb.getMaxX(), bb.getMaxY(), bb.getMaxZ(),
facing(e.getFacing()), null, itemId, rot, glow);
return new DecorationState(
DecorationState.Kind.ITEM_FRAME,
bb.getMinX(),
bb.getMinY(),
bb.getMinZ(),
bb.getMaxX(),
bb.getMaxY(),
bb.getMaxZ(),
facing(e.getFacing()),
null,
itemId,
rot,
glow);
}
return null;
}
@@ -1,18 +1,60 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import com.destroystokyo.paper.profile.ProfileProperty;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.Location;
import org.bukkit.entity.Ageable;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.util.Vector;
import io.papermc.paper.datacomponent.DataComponentTypes;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.function.DoubleSupplier;
import org.bukkit.Keyed;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.AbstractHorse;
import org.bukkit.entity.AbstractNautilus;
import org.bukkit.entity.Ageable;
import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.Axolotl;
import org.bukkit.entity.Cat;
import org.bukkit.entity.ChestedHorse;
import org.bukkit.entity.Chicken;
import org.bukkit.entity.Cow;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Fox;
import org.bukkit.entity.Frog;
import org.bukkit.entity.Horse;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Llama;
import org.bukkit.entity.MushroomCow;
import org.bukkit.entity.Panda;
import org.bukkit.entity.Parrot;
import org.bukkit.entity.Pig;
import org.bukkit.entity.Player;
import org.bukkit.entity.Rabbit;
import org.bukkit.entity.Sheep;
import org.bukkit.entity.Shulker;
import org.bukkit.entity.Slime;
import org.bukkit.entity.TraderLlama;
import org.bukkit.entity.Villager;
import org.bukkit.entity.Wolf;
import org.bukkit.entity.Zombie;
import org.bukkit.entity.ZombieVillager;
import org.bukkit.inventory.EntityEquipment;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ArmorMeta;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.LeatherArmorMeta;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.util.Vector;
/**
* Captures entities near the view frustum into immutable {@link EntityState}s. MUST run on the main
@@ -24,14 +66,47 @@ public final class EntitySnapshotBuilder {
// Technical / non-mob entity types that have no meaningful geometry; rendering them would only
// produce stray fallback boxes. Markers, displays, item frames, paintings, projectiles, drops, etc.
private static final java.util.Set<String> NON_RENDERABLE = java.util.Set.of(
"area_effect_cloud", "marker", "interaction",
"item_frame", "glow_item_frame", "painting",
"block_display", "item_display", "text_display",
"fishing_bobber", "lightning_bolt", "eye_of_ender",
"experience_orb", "experience_bottle", "egg", "snowball",
"potion", "ender_pearl", "tnt", "falling_block", "item"
);
private static final Set<String> NON_RENDERABLE = Set.of(
"area_effect_cloud",
"marker",
"interaction",
"item_frame",
"glow_item_frame",
"painting",
"block_display",
"item_display",
"text_display",
"fishing_bobber",
"lightning_bolt",
"eye_of_ender",
"experience_orb",
"experience_bottle",
"egg",
"snowball",
"potion",
"ender_pearl",
"tnt",
"falling_block",
"item");
// Entities whose vanilla renderer draws the humanoid armor layers (HumanoidArmorLayer) and held
// items. Their CEM bodies share standard humanoid proportions, so the armor_layer_1/2 models align.
private static final Set<String> HUMANOID_ARMOR_WEARERS = Set.of(
"player",
"mannequin",
"armor_stand",
"giant",
"zombie",
"husk",
"drowned",
"zombie_villager",
"zombified_piglin",
"skeleton",
"stray",
"wither_skeleton",
"bogged",
"piglin",
"piglin_brute");
public static List<EntityState> build(Location eye, List<Vector> rayMap, double maxDistance, UUID shooter) {
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
@@ -57,8 +132,13 @@ public final class EntitySnapshotBuilder {
bodyYaw = le.getBodyYaw();
}
boolean baby = (e instanceof Ageable a && !a.isAdult())
|| (e instanceof org.bukkit.entity.Zombie z && z.isBaby());
boolean baby = (e instanceof Ageable a && !a.isAdult()) || (e instanceof Zombie z && z.isAdult());
// Invisible entities render only their equipment (like vanilla): the generic invisible flag, an
// invisibility potion effect, or an explicitly-hidden armor stand.
boolean invisible = e.isInvisible()
|| (e instanceof ArmorStand as && !as.isVisible())
|| (e instanceof LivingEntity inv && inv.hasPotionEffect(PotionEffectType.INVISIBILITY));
double width = safeDim(e::getWidth, () -> e.getBoundingBox().getWidthX());
double height = safeDim(e::getHeight, () -> e.getBoundingBox().getHeight());
@@ -83,117 +163,208 @@ public final class EntitySnapshotBuilder {
String bodyEquip = null;
try {
// Slime & magma cube (MagmaCube extends Slime) scale their model by size (1/2/4).
if (e instanceof org.bukkit.entity.Slime sl) sizeScale = sl.getSize();
if (e instanceof Slime sl) sizeScale = sl.getSize();
// MushroomCow extends Cow, ZombieVillager does not extend Villager — order matters.
if (e instanceof org.bukkit.entity.Sheep sh) {
if (e instanceof Sheep sh) {
tint = ColorUtil.dyeArgb(sh.getColor(), 0);
} else if (e instanceof org.bukkit.entity.Cat c) {
} else if (e instanceof Cat c) {
variant = keyOf(c.getCatType());
} else if (e instanceof org.bukkit.entity.Wolf w) {
} else if (e instanceof Wolf w) {
variant = keyOf(w.getVariant());
} else if (e instanceof org.bukkit.entity.Axolotl a) {
} else if (e instanceof Axolotl a) {
variant = keyOf(a.getVariant());
} else if (e instanceof org.bukkit.entity.Parrot p) {
} else if (e instanceof Parrot p) {
variant = keyOf(p.getVariant());
} else if (e instanceof org.bukkit.entity.Rabbit r) {
} else if (e instanceof Rabbit r) {
variant = keyOf(r.getRabbitType());
} else if (e instanceof org.bukkit.entity.Horse h) {
} else if (e instanceof Horse h) {
variant = keyOf(h.getColor());
markings = markingsKey(h.getStyle());
saddle = isSaddled(h);
bodyEquip = horseArmorKey(h);
} else if (e instanceof org.bukkit.entity.Llama l) {
} else if (e instanceof Llama l) {
variant = keyOf(l.getColor());
chest = l.isCarryingChest();
// Trader llamas wear a fixed decor; normal llamas carry a dyed carpet in the decor slot.
bodyEquip = (e instanceof org.bukkit.entity.TraderLlama) ? "trader_llama" : carpetKey(l);
} else if (e instanceof org.bukkit.entity.ChestedHorse ch) {
bodyEquip = (e instanceof TraderLlama) ? "trader_llama" : carpetKey(l);
} else if (e instanceof ChestedHorse ch) {
// Donkey & mule (llama already handled above).
chest = ch.isCarryingChest();
saddle = isSaddled(ch);
} else if (e instanceof org.bukkit.entity.AbstractHorse ah) {
} else if (e instanceof AbstractHorse ah) {
// Skeleton/zombie horse: only saddle (no colour/markings/armor variants).
saddle = isSaddled(ah);
} else if (e instanceof org.bukkit.entity.Fox f) {
} else if (e instanceof AbstractNautilus nl) {
// Nautilus body armor + saddle are same-UV overlays (like horse armor).
EntityEquipment eq = nl.getEquipment();
bodyEquip = equipAsset(eq.getItem(EquipmentSlot.BODY));
ItemStack sd = eq.getItem(EquipmentSlot.SADDLE);
saddle = !sd.getType().isAir();
} else if (e instanceof Fox f) {
variant = keyOf(f.getFoxType());
} else if (e instanceof org.bukkit.entity.MushroomCow mc) {
} else if (e instanceof MushroomCow mc) {
variant = keyOf(mc.getVariant());
} else if (e instanceof org.bukkit.entity.Panda pa) {
} else if (e instanceof Panda pa) {
variant = keyOf(pa.getMainGene());
} else if (e instanceof org.bukkit.entity.Frog fr) {
} else if (e instanceof Frog fr) {
variant = keyOf(fr.getVariant());
} else if (e instanceof org.bukkit.entity.Shulker s) {
} else if (e instanceof Shulker s) {
variant = s.getColor() == null ? null : keyOf(s.getColor());
} else if (e instanceof org.bukkit.entity.ZombieVillager zv) {
} else if (e instanceof ZombieVillager zv) {
variant = keyOf(zv.getVillagerType());
profession = keyOf(zv.getVillagerProfession());
// ZombieVillager exposes no level via Bukkit -> no profession-level badge (matches vanilla).
} else if (e instanceof org.bukkit.entity.Villager vi) {
} else if (e instanceof Villager vi) {
variant = keyOf(vi.getVillagerType());
profession = keyOf(vi.getProfession());
villagerLevel = vi.getVillagerLevel();
} else if (e instanceof org.bukkit.entity.Cow co) {
} else if (e instanceof Cow co) {
variant = keyOf(co.getVariant());
} else if (e instanceof org.bukkit.entity.Pig pg) {
} else if (e instanceof Pig pg) {
variant = keyOf(pg.getVariant());
} else if (e instanceof org.bukkit.entity.Chicken ch) {
} else if (e instanceof Chicken ch) {
variant = keyOf(ch.getVariant());
}
} catch (Throwable ignored) {
// Unsupported on this server version — fall back to the base texture.
}
return new EntityState(type, loc.getX(), loc.getY(), loc.getZ(),
bodyYaw, baby, width, height,
player, skinUrl, slim, variant, tint, sizeScale, profession, villagerLevel,
markings, saddle, chest, bodyEquip);
EntityState.Equipment equipment = null;
if (HUMANOID_ARMOR_WEARERS.contains(type) && e instanceof LivingEntity wearer) {
equipment = captureEquipment(wearer);
}
return new EntityState(
type,
loc.getX(),
loc.getY(),
loc.getZ(),
bodyYaw,
baby,
width,
height,
player,
skinUrl,
slim,
variant,
tint,
sizeScale,
profession,
villagerLevel,
markings,
saddle,
chest,
bodyEquip,
equipment,
invisible);
}
/** Worn armor (4 slots) from a humanoid wearer; null when nothing is equipped. */
private static EntityState.Equipment captureEquipment(LivingEntity le) {
try {
EntityEquipment eq = le.getEquipment();
if (eq == null) return null;
EntityState.Equipment equip = new EntityState.Equipment(
armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()),
armorPiece(eq.getLeggings()), armorPiece(eq.getBoots()));
return equip.isEmpty() ? null : equip;
} catch (Throwable t) {
return null;
}
}
/** One armor slot -> EquipPiece (asset, leather dye, trim, glint); null for empty / non-armor items. */
private static EntityState.EquipPiece armorPiece(ItemStack it) {
if (it == null || it.getType().isAir()) return null;
String asset = armorAsset(it.getType());
if (asset == null) return null; // not a humanoid-armor item (e.g. a mob head / pumpkin)
int dye = 0;
String trimMat = null, trimPat = null;
boolean glint = false;
if (it.hasItemMeta()) {
ItemMeta meta = it.getItemMeta();
glint = meta.hasEnchants();
if (meta instanceof LeatherArmorMeta lam) dye = lam.getColor().asARGB();
if (meta instanceof ArmorMeta am && am.getTrim() != null) {
trimMat = keyOf(am.getTrim().getMaterial());
trimPat = keyOf(am.getTrim().getPattern());
}
}
return new EntityState.EquipPiece(asset, dye, trimMat, trimPat, glint);
}
/** Item Material -> equipment asset id (= texture name): strips the slot suffix; null if not armor. */
private static String armorAsset(Material m) {
String key = m.getKey().getKey();
if (key.equals("elytra")) return "elytra";
if (key.equals("turtle_helmet")) return "turtle_scute";
String base = null;
for (String suf : new String[] {"_helmet", "_chestplate", "_leggings", "_boots"}) {
if (key.endsWith(suf)) {
base = key.substring(0, key.length() - suf.length());
break;
}
}
if (base == null) return null;
return base.equals("golden") ? "gold" : base;
}
/** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */
private static String markingsKey(org.bukkit.entity.Horse.Style style) {
if (style == null || style == org.bukkit.entity.Horse.Style.NONE) return null;
return style.name().toLowerCase(java.util.Locale.ROOT).replace("_", ""); // WHITE_DOTS -> whitedots
private static String markingsKey(Horse.Style style) {
if (style == null || style == Horse.Style.NONE) return null;
return style.name().toLowerCase(Locale.ROOT).replace("_", ""); // WHITE_DOTS -> whitedots
}
/** Horse armor material -> equipment/horse_body texture key (golden uses the "gold" file); null if none. */
private static String horseArmorKey(org.bukkit.entity.Horse h) {
org.bukkit.inventory.ItemStack a = h.getInventory().getArmor();
private static String horseArmorKey(Horse h) {
ItemStack a = h.getInventory().getArmor();
if (a == null || a.getType().isAir()) return null;
String k = a.getType().getKey().getKey().replace("_horse_armor", "");
return k.equals("golden") ? "gold" : k;
}
/** Llama carpet decor -> equipment/llama_body colour key; null if none. */
private static String carpetKey(org.bukkit.entity.Llama l) {
org.bukkit.inventory.ItemStack d = l.getInventory().getDecor();
private static String carpetKey(Llama l) {
ItemStack d = l.getInventory().getDecor();
if (d == null || d.getType().isAir()) return null;
String k = d.getType().getKey().getKey();
return k.endsWith("_carpet") ? k.substring(0, k.length() - "_carpet".length()) : null;
}
/** Equipment asset id (= equipment/<asset> texture name) from an item's EQUIPPABLE component; null if none. */
private static String equipAsset(ItemStack it) {
if (it == null || it.getType().isAir()) return null;
try {
var comp = it.getData(DataComponentTypes.EQUIPPABLE);
if (comp != null && comp.assetId() != null) return comp.assetId().value();
} catch (Throwable ignored) {
}
return null;
}
/** Whether a horse-like mount carries a saddle in its dedicated saddle slot. */
private static boolean isSaddled(org.bukkit.entity.AbstractHorse h) {
org.bukkit.inventory.ItemStack st = h.getInventory().getSaddle();
private static boolean isSaddled(AbstractHorse h) {
ItemStack st = h.getInventory().getSaddle();
return st != null && !st.getType().isAir();
}
/** Registry/Keyed values yield their key path; plain enums yield their lower-case name. */
private static String keyOf(Object o) {
if (o == null) return null;
if (o instanceof org.bukkit.Keyed k) return k.getKey().getKey();
if (o instanceof Enum<?> en) return en.name().toLowerCase(java.util.Locale.ROOT);
return o.toString().toLowerCase(java.util.Locale.ROOT);
return switch (o) {
case null -> null;
case Keyed k -> k.getKey().getKey();
case Enum<?> en -> en.name().toLowerCase(Locale.ROOT);
default -> o.toString().toLowerCase(Locale.ROOT);
};
}
/** Returns {skinUrl, model} from the player's profile texture property, or {null, null}. */
private static String[] resolveSkin(Player player) {
try {
for (com.destroystokyo.paper.profile.ProfileProperty prop : player.getPlayerProfile().getProperties()) {
for (ProfileProperty prop : player.getPlayerProfile().getProperties()) {
if (!prop.getName().equals("textures")) continue;
String json = new String(java.util.Base64.getDecoder().decode(prop.getValue()),
java.nio.charset.StandardCharsets.UTF_8);
com.google.gson.JsonObject root = com.google.gson.JsonParser.parseString(json).getAsJsonObject();
com.google.gson.JsonObject skin = root.getAsJsonObject("textures").getAsJsonObject("SKIN");
String json = new String(Base64.getDecoder().decode(prop.getValue()), StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
JsonObject skin = root.getAsJsonObject("textures").getAsJsonObject("SKIN");
String url = skin.get("url").getAsString();
String model = null;
if (skin.has("metadata") && skin.getAsJsonObject("metadata").has("model")) {
@@ -207,7 +378,7 @@ public final class EntitySnapshotBuilder {
}
/** Reads a dimension via {@code primary}, falling back to {@code fallback} on any version mismatch. */
private static double safeDim(java.util.function.DoubleSupplier primary, java.util.function.DoubleSupplier fallback) {
private static double safeDim(DoubleSupplier primary, DoubleSupplier fallback) {
try {
return primary.getAsDouble();
} catch (Throwable t) {
@@ -1,13 +1,12 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import java.util.Collection;
import java.util.List;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.util.Vector;
import java.util.Collection;
import java.util.List;
/**
* Axis-aligned world-space bounds of the camera frustum: the min/max corner of the camera origin
* together with every ray endpoint at {@code maxDistance}. Shared by all snapshot builders to size
@@ -19,8 +18,12 @@ final class FrustumBounds {
final double maxX, maxY, maxZ;
private FrustumBounds(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) {
this.minX = minX; this.minY = minY; this.minZ = minZ;
this.maxX = maxX; this.maxY = maxY; this.maxZ = maxZ;
this.minX = minX;
this.minY = minY;
this.minZ = minZ;
this.maxX = maxX;
this.maxY = maxY;
this.maxZ = maxZ;
}
static FrustumBounds of(Vector origin, List<Vector> rayMap, double maxDistance) {
@@ -30,9 +33,12 @@ final class FrustumBounds {
double fx = origin.getX() + ray.getX() * maxDistance;
double fy = origin.getY() + ray.getY() * maxDistance;
double fz = origin.getZ() + ray.getZ() * maxDistance;
minX = Math.min(minX, fx); maxX = Math.max(maxX, fx);
minY = Math.min(minY, fy); maxY = Math.max(maxY, fy);
minZ = Math.min(minZ, fz); maxZ = Math.max(maxZ, fz);
minX = Math.min(minX, fx);
maxX = Math.max(maxX, fx);
minY = Math.min(minY, fy);
maxY = Math.max(maxY, fy);
minZ = Math.min(minZ, fz);
maxZ = Math.max(maxZ, fz);
}
return new FrustumBounds(minX, minY, minZ, maxX, maxY, maxZ);
}
@@ -1,15 +1,14 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import org.bukkit.ChunkSnapshot;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.util.Vector;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
import org.bukkit.ChunkSnapshot;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.util.Vector;
/**
* Captures the world region covered by the camera frustum into a {@link WorldSnapshot}.
@@ -58,7 +57,8 @@ public final class SnapshotBuilder {
}
if (skipped > 0) {
logger.fine(String.format("Snapshot captured %d chunks, skipped %d (unloaded or over cap)", captured, skipped));
logger.fine(
String.format("Snapshot captured %d chunks, skipped %d (unloaded or over cap)", captured, skipped));
}
return new WorldSnapshot(chunks, clampedMinY, clampedMaxY, Material.AIR.createBlockData());
@@ -1,11 +1,10 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import java.util.Map;
import org.bukkit.ChunkSnapshot;
import org.bukkit.block.Biome;
import org.bukkit.block.data.BlockData;
import java.util.Map;
/**
* An immutable, thread-safe view of a bounded region of the world, backed by {@link ChunkSnapshot}s.
* Block/biome lookups outside the captured region return air/null so rays simply terminate there.
@@ -42,6 +41,11 @@ public final class WorldSnapshot {
return cs.getBiome(x & 15, y, z & 15);
}
public int minY() { return minY; }
public int maxY() { return maxY; }
public int minY() {
return minY;
}
public int maxY() {
return maxY;
}
}
@@ -17,8 +17,13 @@ public final class BiomeClimate {
private static final Map<String, Climate> TABLE = new HashMap<>();
private static void put(String key, double t, double d) { TABLE.put(key, new Climate(t, d, DEFAULT_WATER)); }
private static void put(String key, double t, double d, int water) { TABLE.put(key, new Climate(t, d, water)); }
private static void put(String key, double t, double d) {
TABLE.put(key, new Climate(t, d, DEFAULT_WATER));
}
private static void put(String key, double t, double d, int water) {
TABLE.put(key, new Climate(t, d, water));
}
static {
put("plains", 0.8, 0.4);
@@ -3,5 +3,4 @@ package eu.mhsl.minecraft.pixelpics.render.tint;
/**
* The biome-dependent tint colors (RGB) for the colormap-driven channels.
*/
public record BiomeTint(int grass, int foliage, int dryFoliage, int water) {
}
public record BiomeTint(int grass, int foliage, int dryFoliage, int water) {}
@@ -2,10 +2,9 @@ package eu.mhsl.minecraft.pixelpics.render.tint;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import org.bukkit.block.Biome;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.block.Biome;
/**
* Computes per-biome grass/foliage tint colors by sampling the resource pack's colormaps using the
@@ -20,8 +19,10 @@ public final class BiomeTintProvider {
public BiomeTintProvider(TextureCache textures) {
this.grassMap = textures.get(ResourceLocation.parse("colormap/grass")).orElse(null);
this.foliageMap = textures.get(ResourceLocation.parse("colormap/foliage")).orElse(null);
this.dryFoliageMap = textures.get(ResourceLocation.parse("colormap/dry_foliage")).orElse(null);
this.foliageMap =
textures.get(ResourceLocation.parse("colormap/foliage")).orElse(null);
this.dryFoliageMap =
textures.get(ResourceLocation.parse("colormap/dry_foliage")).orElse(null);
}
public BiomeTint forBiome(Biome biome) {
@@ -71,8 +72,8 @@ public final class BiomeTintProvider {
int y = (int) ((1.0 - down) * 255.0);
int h = colormap.length;
int w = colormap[0].length;
x = Math.max(0, Math.min(w - 1, x));
y = Math.max(0, Math.min(h - 1, y));
x = Math.clamp(x, 0, w - 1);
y = Math.clamp(y, 0, h - 1);
return 0xFF000000 | (colormap[y][x] & 0xFFFFFF);
}
}
@@ -32,8 +32,11 @@ public final class TintResolver {
if (name.endsWith("stem")) return STEM;
// grass_block (top/overlay), short_grass, tall_grass, fern, large_fern, sugar_cane, ...
if (name.contains("grass") || name.equals("fern") || name.equals("large_fern")
|| name.equals("sugar_cane") || name.equals("potted_fern")) {
if (name.contains("grass")
|| name.equals("fern")
|| name.equals("large_fern")
|| name.equals("sugar_cane")
|| name.equals("potted_fern")) {
return biomeTint.grass();
}
@@ -1,5 +1,8 @@
package eu.mhsl.minecraft.pixelpics.render.util;
import org.bukkit.Color;
import org.bukkit.DyeColor;
/**
* Helpers for packed ARGB integer colors.
*/
@@ -7,22 +10,79 @@ public final class ColorUtil {
private ColorUtil() {}
public static int alpha(int argb) { return (argb >> 24) & 0xFF; }
public static int red(int argb) { return (argb >> 16) & 0xFF; }
public static int green(int argb) { return (argb >> 8) & 0xFF; }
public static int blue(int argb) { return argb & 0xFF; }
public static int alpha(int argb) {
return (argb >> 24) & 0xFF;
}
public static int red(int argb) {
return (argb >> 16) & 0xFF;
}
public static int green(int argb) {
return (argb >> 8) & 0xFF;
}
public static int blue(int argb) {
return argb & 0xFF;
}
public static int argb(int a, int r, int g, int b) {
return (a << 24) | (r << 16) | (g << 8) | b;
}
/** Opaque ARGB for a Bukkit dye colour, or {@code fallback} when {@code dye} is null. */
public static int dyeArgb(org.bukkit.DyeColor dye, int fallback) {
public static int dyeArgb(DyeColor dye, int fallback) {
if (dye == null) return fallback;
org.bukkit.Color c = dye.getColor();
Color c = dye.getColor();
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(DyeColor dye) {
int rgb =
switch (dye == null ? 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(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(DyeColor dye) {
if ((dye == null ? DyeColor.BLACK : dye) == 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);
@@ -60,6 +120,7 @@ public final class ColorUtil {
// --- Gamma-correct (linear-light) averaging ---
private static final float[] SRGB_TO_LINEAR = new float[256];
static {
for (int i = 0; i < 256; i++) {
double c = i / 255.0;
@@ -24,8 +24,8 @@ public class MathUtil {
return new Vector(newX, newY, newZ);
}
public static Vector doubleYawPitchRotation(Vector base, double firstYaw, double firstPitch, double secondYaw,
double secondPitch) {
public static Vector doubleYawPitchRotation(
Vector base, double firstYaw, double firstPitch, double secondYaw, double secondPitch) {
return yawPitchRotation(yawPitchRotation(base, firstYaw, firstPitch), secondYaw, secondPitch);
}
@@ -0,0 +1,152 @@
package eu.mhsl.minecraft.pixelpics.survival;
import com.destroystokyo.paper.profile.PlayerProfile;
import com.destroystokyo.paper.profile.ProfileProperty;
import eu.mhsl.minecraft.pixelpics.Main;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.SkullMeta;
import org.bukkit.inventory.meta.components.EquippableComponent;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
/**
* Factory and identity helpers for the survival items: the camera and the film roll are custom-skinned
* {@code PLAYER_HEAD}s (the texture renders client-side without a resource pack), the photo is the
* existing {@code FILLED_MAP}. Identity is carried by PDC markers (robust against renames), and the
* camera's loaded film count lives in PDC as well.
*/
public final class CameraItems {
/** Maximum film rolls a camera can hold; one roll yields one photo. */
public static final int MAX_FILM = 8;
private static final int CAMERA_MODEL_DATA = 1001;
private static final int FILM_MODEL_DATA = 1002;
static final String CAMERA_TEXTURE_B64 =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDlkMmNiZjAyZDMwOGI2MDY1YTZmZThjNjU3MWI2MzU2NjMzZjQxOTJlOGVjNzEyMTNjNzcwNzgwZTNkZTRlMiJ9fX0=";
static final String FILM_TEXTURE_B64 =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMTVkMGY0OGJlNzNkYmIwZDJjYjE1NTRjMmUzODZiNWNjM2FiMjFhNGRjYWU4ZmYzOGI3NzRhZDNkMDFkMGE1OSJ9fX0=";
private CameraItems() {}
// --- factories ---
/** A camera holding {@code filmCount} (clamped to 0..{@link #MAX_FILM}) loaded film rolls. */
public static ItemStack createCamera(int filmCount) {
int count = Math.clamp(filmCount, 0, MAX_FILM);
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta();
applyHead(meta, "pixelpics:camera", CAMERA_TEXTURE_B64);
meta.displayName(Component.text("Kamera", NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false));
meta.setCustomModelData(CAMERA_MODEL_DATA);
meta.getPersistentDataContainer().set(Main.getInstance().cameraMarker, PersistentDataType.BYTE, (byte) 1);
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
applyCameraLore(meta, count);
makeUnwearable(meta);
item.setItemMeta(meta);
return item;
}
/** A single film roll. */
public static ItemStack createFilm() {
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta();
applyHead(meta, "pixelpics:film", FILM_TEXTURE_B64);
meta.displayName(Component.text("Filmrolle", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false));
meta.setCustomModelData(FILM_MODEL_DATA);
meta.lore(List.of(
Component.text("Lädt eine Kamera auf.", NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)));
meta.getPersistentDataContainer().set(Main.getInstance().filmMarker, PersistentDataType.BYTE, (byte) 1);
makeUnwearable(meta);
item.setItemMeta(meta);
return item;
}
/** Returns a copy of {@code camera} with its film count set to {@code newCount} and lore refreshed. */
public static ItemStack withFilmCount(ItemStack camera, int newCount) {
int count = Math.clamp(newCount, 0, MAX_FILM);
ItemStack copy = camera.clone();
SkullMeta meta = (SkullMeta) copy.getItemMeta();
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
applyCameraLore(meta, count);
copy.setItemMeta(meta);
return copy;
}
// --- identity ---
public static boolean isCamera(ItemStack item) {
return hasMarker(item, Main.getInstance().cameraMarker);
}
public static boolean isFilm(ItemStack item) {
return hasMarker(item, Main.getInstance().filmMarker);
}
/** A photo is a filled map carrying our picture id. */
public static boolean isPhoto(ItemStack item) {
if (item == null || item.getType() != Material.FILLED_MAP || !item.hasItemMeta()) return false;
return item.getItemMeta()
.getPersistentDataContainer()
.has(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
}
/** Loaded film on a camera, or 0 if the item is not a camera. */
public static int getFilmCount(ItemStack item) {
if (!isCamera(item)) return 0;
Integer v = item.getItemMeta()
.getPersistentDataContainer()
.get(Main.getInstance().filmCountKey, PersistentDataType.INTEGER);
return v == null ? 0 : v;
}
// --- internals ---
private static boolean hasMarker(ItemStack item, NamespacedKey key) {
if (item == null || item.getType() != Material.PLAYER_HEAD || !item.hasItemMeta()) return false;
PersistentDataContainer pdc = item.getItemMeta().getPersistentDataContainer();
return pdc.has(key, PersistentDataType.BYTE);
}
private static void applyCameraLore(ItemMeta meta, int count) {
meta.lore(List.of(
Component.text(
"Film: " + count + " / " + MAX_FILM,
count > 0 ? NamedTextColor.YELLOW : NamedTextColor.RED)
.decoration(TextDecoration.ITALIC, false),
Component.text("Rechtsklick: Foto aufnehmen", NamedTextColor.DARK_GRAY)
.decoration(TextDecoration.ITALIC, false)));
}
/**
* Overrides the {@code PLAYER_HEAD}'s default head-equippable component so the item cannot be worn:
* the slot is moved to {@code SADDLE} (no player slot accepts it, so the helmet slot rejects it —
* blocking right-click, shift-click and dispenser equipping) and right-click swap is disabled.
*/
private static void makeUnwearable(ItemMeta meta) {
EquippableComponent eq = meta.getEquippable();
eq.setSlot(EquipmentSlot.SADDLE);
eq.setSwappable(false);
meta.setEquippable(eq);
}
private static void applyHead(SkullMeta meta, String seed, String textureB64) {
if (textureB64 == null || textureB64.isBlank()) return;
UUID synthetic = UUID.nameUUIDFromBytes(seed.getBytes(StandardCharsets.UTF_8));
PlayerProfile profile = Bukkit.createProfileExact(synthetic, null);
profile.setProperty(new ProfileProperty("textures", textureB64));
meta.setPlayerProfile(profile);
}
}
@@ -0,0 +1,59 @@
package eu.mhsl.minecraft.pixelpics.survival;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
/**
* Takes a photo when a player right-clicks while holding a camera. Consumes one loaded film per shot;
* with no film loaded it gives a short fail feedback. Guards against the well-known double-fire of
* {@link PlayerInteractEvent} (off-hand fire + air/block) via a hand filter and a per-player cooldown.
*/
public class CameraListener implements Listener {
private static final long COOLDOWN_MILLIS = 500;
private final Map<UUID, Long> lastUse = new HashMap<>();
@EventHandler
public void onInteract(PlayerInteractEvent event) {
if (event.getHand() != EquipmentSlot.HAND) return;
Action action = event.getAction();
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) return;
ItemStack inHand = event.getItem();
if (!CameraItems.isCamera(inHand)) return;
Player player = event.getPlayer();
long now = System.currentTimeMillis();
if (now - lastUse.getOrDefault(player.getUniqueId(), 0L) < COOLDOWN_MILLIS) {
event.setCancelled(true);
return;
}
lastUse.put(player.getUniqueId(), now);
event.setCancelled(true);
int film = CameraItems.getFilmCount(inHand);
if (film <= 0) {
// Dry "empty shutter" click — no film loaded.
player.playSound(player.getLocation(), Sound.BLOCK_DISPENSER_FAIL, 1f, 1.2f);
player.sendActionBar(Component.text("Kein Film geladen!", NamedTextColor.RED));
return;
}
// Only charge film if the shot was actually accepted (renderer ready, not queue-rejected).
if (PhotoService.takePhoto(player)) {
player.getInventory().setItemInMainHand(CameraItems.withFilmCount(inHand, film - 1));
}
}
}
@@ -0,0 +1,179 @@
package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main;
import java.util.function.Predicate;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Keyed;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.CraftItemEvent;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.persistence.PersistentDataType;
/**
* Drives the two dynamic recipes whose inputs carry variable NBT: <em>load film</em>
* (camera + film → camera+1) and <em>copy photo</em> (photo + film → 2 photos sharing the same map).
* {@link PrepareItemCraftEvent} computes the result preview and validates the loose
* {@code MaterialChoice} matches; {@link CraftItemEvent} performs the consumption/doubling manually
* (the vanilla machinery cannot, given the variable-NBT ingredients) and blocks shift-click and any
* attempt to leak our custom items into other recipes.
*/
public class CraftingListener implements Listener {
private enum Kind {
LOAD,
COPY,
NONE
}
/** Scanned crafting grid: the relevant items and whether any of our marked items are present. */
private record Scan(Kind kind, ItemStack camera, ItemStack film, ItemStack photo, boolean hasOurItems) {}
@EventHandler
public void onPrepare(PrepareItemCraftEvent event) {
CraftingInventory inv = event.getInventory();
Scan scan = scan(inv.getMatrix());
switch (scan.kind()) {
case LOAD -> {
int count = CameraItems.getFilmCount(scan.camera());
inv.setResult(
count >= CameraItems.MAX_FILM ? null : CameraItems.withFilmCount(scan.camera(), count + 1));
}
case COPY -> inv.setResult(buildPhotoCopy(scan.photo()));
case NONE -> {
// Block our loose MaterialChoice recipes from matching garbage, and stop our items
// from being consumed by any unrelated (vanilla) recipe.
if (isDynamicRecipe(event.getRecipe()) || scan.hasOurItems()) {
inv.setResult(null);
}
}
}
}
@EventHandler
public void onCraft(CraftItemEvent event) {
CraftingInventory inv = event.getInventory();
Scan scan = scan(inv.getMatrix());
if (scan.kind() == Kind.NONE) {
// Defensive: never let our items be consumed by another recipe.
if (scan.hasOurItems()) event.setCancelled(true);
return;
}
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
if (event.isShiftClick()) {
player.sendActionBar(Component.text("Bitte einzeln herstellen.", NamedTextColor.YELLOW));
return;
}
ItemStack result;
ItemStack[] matrix = inv.getMatrix();
if (scan.kind() == Kind.LOAD) {
int count = CameraItems.getFilmCount(scan.camera());
if (count >= CameraItems.MAX_FILM) return;
result = CameraItems.withFilmCount(scan.camera(), count + 1);
consumeFirst(matrix, CameraItems::isCamera);
consumeFirst(matrix, CameraItems::isFilm);
} else { // COPY
result = buildPhotoCopy(scan.photo());
if (result == null) return;
consumeFirst(matrix, CameraItems::isFilm); // original photo stays
}
// Apply on the next tick so the click settles before we mutate the grid and hand over the item.
ItemStack finalResult = result;
Bukkit.getScheduler().runTask(Main.getInstance(), () -> {
inv.setMatrix(matrix);
give(player, finalResult);
player.playSound(player.getLocation(), Sound.UI_TOAST_IN, 0.7f, 1.4f);
player.updateInventory();
});
}
// --- helpers ---
private static Scan scan(ItemStack[] matrix) {
int cameras = 0, films = 0, photos = 0, others = 0;
ItemStack camera = null, film = null, photo = null;
for (ItemStack it : matrix) {
if (it == null || it.getType().isAir()) continue;
if (CameraItems.isCamera(it)) {
cameras++;
camera = it;
} else if (CameraItems.isFilm(it)) {
films++;
film = it;
} else if (CameraItems.isPhoto(it)) {
photos++;
photo = it;
} else others++;
}
boolean ours = cameras + films + photos > 0;
Kind kind = Kind.NONE;
if (others == 0) {
if (cameras == 1 && films == 1 && photos == 0) kind = Kind.LOAD;
else if (photos == 1 && films == 1 && cameras == 0) kind = Kind.COPY;
}
return new Scan(kind, camera, film, photo, ours);
}
private static boolean isDynamicRecipe(Recipe recipe) {
if (!(recipe instanceof Keyed keyed)) return false;
NamespacedKey key = keyed.getKey();
return SurvivalRecipes.LOAD.equals(key) || SurvivalRecipes.COPY.equals(key);
}
/** A second photo referencing the same {@link org.bukkit.map.MapView} and picture id as {@code photo}. */
private static ItemStack buildPhotoCopy(ItemStack photo) {
MapMeta src = (MapMeta) photo.getItemMeta();
if (!src.hasMapView() || src.getMapView() == null) return null;
ItemStack copy = new ItemStack(Material.FILLED_MAP);
MapMeta dst = (MapMeta) copy.getItemMeta();
dst.setMapView(src.getMapView());
String pid = src.getPersistentDataContainer().get(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
if (pid != null) {
dst.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag, PersistentDataType.STRING, pid);
}
copy.setItemMeta(dst);
return copy;
}
private static void consumeFirst(ItemStack[] matrix, Predicate<ItemStack> pred) {
for (int i = 0; i < matrix.length; i++) {
ItemStack it = matrix[i];
if (it != null && !it.getType().isAir() && pred.test(it)) {
int amt = it.getAmount();
if (amt <= 1) matrix[i] = null;
else {
it.setAmount(amt - 1);
matrix[i] = it;
}
return;
}
}
}
private static void give(Player player, ItemStack item) {
ItemStack cursor = player.getItemOnCursor();
if (cursor.getType().isAir()) {
player.setItemOnCursor(item);
} else {
player.getInventory().addItem(item).values().forEach(left -> player.getWorld()
.dropItemNaturally(player.getLocation(), left));
}
}
}
@@ -0,0 +1,14 @@
package eu.mhsl.minecraft.pixelpics.survival;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
/** Unlocks the survival recipes in each player's recipe book on join (idempotent). */
public class JoinListener implements Listener {
@EventHandler
public void onJoin(PlayerJoinEvent event) {
event.getPlayer().discoverRecipes(SurvivalRecipes.allKeys());
}
}
@@ -0,0 +1,126 @@
package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main;
import eu.mhsl.minecraft.pixelpics.render.RenderManager;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.render.RenderJob;
import eu.mhsl.minecraft.pixelpics.render.render.Resolution;
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import java.awt.image.BufferedImage;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.MapView;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.util.Vector;
/**
* Renders a photo of a player's current view and delivers it as a developing {@code FILLED_MAP},
* with a shutter sound + flash particles on capture and a chime when the picture finishes developing.
* Heavy work is funneled through {@link RenderManager} (per-player single slot, global concurrency
* limit + queue, bounded low-priority CPU). Shared by the camera item (survival) and the
* {@code /pixelPic} debug command. Film accounting is the caller's responsibility.
*/
public final class PhotoService {
/** Render result carried back to the main thread: PNG source + dithered map indices. */
private record RenderOutput(BufferedImage image, byte[] indices) {}
private PhotoService() {}
/**
* Captures and delivers a photo. Returns {@code false} (with action-bar feedback) if the renderer
* is unavailable or the request is rejected by the queue — callers should not charge film then.
*/
public static boolean takePhoto(Player player) {
Main plugin = Main.getInstance();
DefaultScreenRenderer renderer = plugin.getScreenRenderer();
if (renderer == null) {
player.sendActionBar(
Component.text("PixelPics ist nicht einsatzbereit (kein Resource-Pack).", NamedTextColor.RED));
return false;
}
RenderManager manager = plugin.getRenderManager();
UUID user = player.getUniqueId();
RenderManager.Outcome outcome = manager.tryReserve(user);
if (outcome != RenderManager.Outcome.ACCEPTED) {
player.sendActionBar(Component.text(
outcome == RenderManager.Outcome.USER_BUSY
? "Deine letzte Aufnahme wird noch entwickelt …"
: "Zu viele Aufnahmen gerade — versuch es gleich erneut.",
NamedTextColor.RED));
return false;
}
boolean dispatched = false;
try {
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
// Capture the world snapshot on the main thread (the moment of the shot).
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, user);
// Hand the map over immediately, showing blank "film"; it develops once the render is ready.
ImageMapRenderer mapRenderer = new ImageMapRenderer();
MapView mapView = Bukkit.createMap(player.getWorld());
int id = mapView.getId();
MapManager.attachView(mapView, mapRenderer);
ItemStack map = new ItemStack(Material.FILLED_MAP, 1);
MapMeta meta = (MapMeta) map.getItemMeta();
meta.getPersistentDataContainer()
.set(
plugin.pictureIdFlag,
PersistentDataType.STRING,
UUID.randomUUID().toString());
meta.setMapView(mapView);
map.setItemMeta(meta);
player.getInventory().addItem(map);
// Feedback is sound + particles only; no action-bar text on a normal shot.
playShutter(player);
// Trace + dither off-thread (queued), then develop on the main thread. The cancel token is
// tripped by the watchdog on timeout; we bail out before the (now pointless) dithering.
manager.dispatch(
user,
cancelled -> {
BufferedImage image = renderer.execute(job, cancelled);
if (cancelled.get()) return null;
return new RenderOutput(image, MapImageDither.dither(image));
},
out -> {
MapManager.saveImage(out.image(), id);
MapManager.saveIndices(out.indices(), id);
mapRenderer.develop(out.indices());
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f);
},
() -> player.sendActionBar(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
dispatched = true;
} finally {
// prepare()/map setup threw before handing off — free the reserved slot.
if (!dispatched) manager.release(user);
}
return true;
}
private static void playShutter(Player player) {
player.playSound(player.getLocation(), Sound.BLOCK_VAULT_OPEN_SHUTTER, 1f, 1.4f);
Location eye = player.getEyeLocation();
Vector forward = eye.getDirection().normalize().multiply(1.0);
Location flash = eye.clone().add(forward);
player.getWorld().spawnParticle(Particle.END_ROD, flash, 12, 0.2, 0.2, 0.2, 0.01);
player.getWorld().spawnParticle(Particle.FIREWORK, flash, 6, 0.1, 0.1, 0.1, 0.02);
}
}
@@ -0,0 +1,77 @@
package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main;
import java.util.List;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.ShapelessRecipe;
/**
* Registers the four survival recipes and makes them discoverable in the recipe book. Camera and
* film have fixed results; load-film and copy-photo use {@code MaterialChoice} ingredients (their
* inputs carry variable NBT) and their real results are computed dynamically in {@link CraftingListener}
* — the registered result here is only the book/preview icon.
*/
public final class SurvivalRecipes {
public static final NamespacedKey CAMERA = key("camera_recipe");
public static final NamespacedKey FILM = key("film_recipe");
public static final NamespacedKey LOAD = key("load_recipe");
public static final NamespacedKey COPY = key("copy_recipe");
private SurvivalRecipes() {}
private static NamespacedKey key(String name) {
return new NamespacedKey(Main.getInstance(), name);
}
public static List<NamespacedKey> allKeys() {
return List.of(CAMERA, FILM, LOAD, COPY);
}
public static void register() {
unregister();
// Camera
ShapedRecipe camera = new ShapedRecipe(CAMERA, CameraItems.createCamera(0));
camera.shape("IDI", "GLG", "IRI");
camera.setIngredient('I', Material.IRON_INGOT);
camera.setIngredient('D', Material.DIAMOND);
camera.setIngredient('G', Material.GLASS_PANE);
camera.setIngredient('L', Material.ENDER_EYE);
camera.setIngredient('R', Material.REDSTONE);
Bukkit.addRecipe(camera, false);
// Film roll
ShapedRecipe film = new ShapedRecipe(FILM, CameraItems.createFilm());
film.shape(" P ", "PIP", " P ");
film.setIngredient('P', Material.PAPER);
film.setIngredient('I', Material.INK_SAC);
Bukkit.addRecipe(film, false);
// Load film: camera + film
ShapelessRecipe load = new ShapelessRecipe(LOAD, CameraItems.createCamera(1));
load.addIngredient(new RecipeChoice.MaterialChoice(Material.PLAYER_HEAD));
load.addIngredient(new RecipeChoice.MaterialChoice(Material.PLAYER_HEAD));
Bukkit.addRecipe(load, false);
// Copy photo: photo + film
ShapelessRecipe copy = new ShapelessRecipe(COPY, new ItemStack(Material.FILLED_MAP));
copy.addIngredient(new RecipeChoice.MaterialChoice(Material.FILLED_MAP));
copy.addIngredient(new RecipeChoice.MaterialChoice(Material.PLAYER_HEAD));
Bukkit.addRecipe(copy, false);
// Cover /reload while players are online; fresh joins are handled by JoinListener.
Bukkit.getOnlinePlayers().forEach(p -> p.discoverRecipes(allKeys()));
}
public static void unregister() {
for (NamespacedKey k : allKeys()) {
Bukkit.removeRecipe(k, false);
}
}
}
@@ -1,13 +1,12 @@
package eu.mhsl.minecraft.pixelpics.utils;
import java.awt.image.BufferedImage;
import org.bukkit.entity.Player;
import org.bukkit.map.MapCanvas;
import org.bukkit.map.MapRenderer;
import org.bukkit.map.MapView;
import org.jetbrains.annotations.NotNull;
import java.awt.image.BufferedImage;
/**
* Draws precomputed map palette indices onto the canvas. Supports a Polaroid-style "developing"
* animation: the picture dissolves in over {@link #DEVELOP_MILLIS} once the render is ready, like an
@@ -88,8 +87,7 @@ public class ImageMapRenderer extends MapRenderer {
byte film = film();
for (int y = 0; y < MAP_SIZE; y++) {
for (int x = 0; x < MAP_SIZE; x++) {
byte value = (progress >= 1.0 || revealThreshold(x, y) <= progress)
? data[y * MAP_SIZE + x] : film;
byte value = (progress >= 1.0 || revealThreshold(x, y) <= progress) ? data[y * MAP_SIZE + x] : film;
canvas.setPixel(x, y, value);
}
}
@@ -1,11 +1,10 @@
package eu.mhsl.minecraft.pixelpics.utils;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.map.MapPalette;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.map.MapPalette;
/**
* The set of usable Minecraft map colors, with nearest-color matching in CIELAB (perceptual) space.
@@ -1,16 +1,6 @@
package eu.mhsl.minecraft.pixelpics.utils;
import eu.mhsl.minecraft.pixelpics.Main;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.MapView;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
@@ -19,6 +9,15 @@ import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.imageio.ImageIO;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.MapView;
/**
* Persists rendered images (PNG, source of truth) and their dithered map-color indices (cache) to
+17
View File
@@ -0,0 +1,17 @@
# PixelPics rendering performance settings.
render:
# CPU cores the ray tracer may use. 0 = auto (available processors minus 2, at least 1).
# Lower this to leave more CPU headroom for the server tick.
threads: 0
# Maximum number of photos rendered at the same time, server-wide.
# These jobs share the 'threads' cores above.
max-concurrent: 2
# Maximum number of photos that may wait in the queue once max-concurrent is reached.
# Further requests are rejected with a hint. Each player may only have one photo in the system.
queue-size: 8
# Safety net: a render exceeding this many seconds is aborted (cooperatively) so a buggy/overlong
# job never hangs forever, freeing its slot and telling the player it failed.
timeout-seconds: 30
+2 -2
View File
@@ -8,8 +8,8 @@ commands:
usage: "/pixelPic [cleanup [confirm] [days]]"
permissions:
pixelpic.use:
description: "Allows taking PixelPics camera screenshots"
description: "Allows running /pixelPic (the render branch itself requires pixelpic.admin)"
default: true
pixelpic.admin:
description: "Allows managing PixelPics (e.g. cleanup)"
description: "Allows the /pixelPic debug render (no camera/film) and cleanup management"
default: op
+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,
+39 -4
View File
@@ -62,12 +62,41 @@ public class EntityTestRender {
// Horse/llama/donkey equipment for the standalone render.
static final Map<String, String> MARK = Map.of("horse", "blackdots"); // coat markings
static final java.util.Set<String> SADDLE = java.util.Set.of("horse", "donkey", "mule");
static final java.util.Set<String> SADDLE = java.util.Set.of("horse", "donkey", "mule", "nautilus");
static final java.util.Set<String> CHEST = java.util.Set.of("llama", "donkey");
static final Map<String, String> EQUIP = Map.ofEntries( // armor / carpet
Map.entry("horse", "diamond"), Map.entry("llama", "red"), Map.entry("trader_llama", "trader_llama")
Map.entry("horse", "diamond"), Map.entry("llama", "red"), Map.entry("trader_llama", "trader_llama"),
Map.entry("nautilus", "diamond")
);
// Humanoid armor test cases (worn equipment, trims, glint, elytra).
static EntityState.EquipPiece P(String asset) { return new EntityState.EquipPiece(asset, 0, null, null, false); }
static EntityState.EquipPiece P(String asset, int dye, String trimMat, String trimPat, boolean glint) {
return new EntityState.EquipPiece(asset, dye, trimMat, trimPat, glint);
}
static final Map<String, EntityState.Equipment> ARMOR = new HashMap<>();
static {
// Full diamond armor.
ARMOR.put("skeleton", new EntityState.Equipment(P("diamond"), P("diamond"), P("diamond"), P("diamond")));
// Dyed (orange) leather armor.
EntityState.EquipPiece le = P("leather", 0xFFFF8000, null, null, false);
ARMOR.put("zombie", new EntityState.Equipment(le, le, le, le));
// Iron armor with a coast/diamond trim.
EntityState.EquipPiece ir = P("iron", 0, "diamond", "coast", false);
ARMOR.put("armor_stand", new EntityState.Equipment(ir, ir, ir, ir));
// Enchanted netherite armor (glint).
EntityState.EquipPiece ne = P("netherite", 0, null, null, true);
ARMOR.put("wither_skeleton", new EntityState.Equipment(ne, ne, ne, ne));
// Enchanted diamond helmet + elytra in the chest slot.
ARMOR.put("player", new EntityState.Equipment(P("diamond", 0, null, null, true), P("elytra"), null, null));
// Gold helmet.
ARMOR.put("piglin", new EntityState.Equipment(P("gold"), null, null, null));
}
// Invisible test cases: armor_stand -> only its (trimmed iron) armor floats; creeper -> nothing renders.
static final java.util.Set<String> INVISIBLE = java.util.Set.of("armor_stand", "creeper");
public static void main(String[] args) throws Exception {
Logger log = Logger.getLogger("test");
ResourcePack pack = ResourcePackLoader.load(new File(ROOT, "resourcepack"), log).orElseThrow();
@@ -80,7 +109,9 @@ public class EntityTestRender {
log.info("Loaded " + n + " geometries");
SkinCache skins = new SkinCache();
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker baker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(geo, textures, skins);
eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker beBaker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(geo, textures, skins);
eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont font =
eu.mhsl.minecraft.pixelpics.assets.font.FontLoader.load(pack, textures, log);
eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker beBaker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(geo, textures, skins, font);
DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log);
BlockData air = (BlockData) Proxy.newProxyInstance(EntityTestRender.class.getClassLoader(),
@@ -144,8 +175,12 @@ public class EntityTestRender {
boolean isPlayer = key.equals("player");
EntityState s = new EntityState(key, 0, 0, 0, yaw, false, 0.8, 1.0,
isPlayer, null, false, VAR.get(key), 0, 1.0, PROF.get(key), LVL.getOrDefault(key, 0),
MARK.get(key), SADDLE.contains(key), CHEST.contains(key), EQUIP.get(key));
MARK.get(key), SADDLE.contains(key), CHEST.contains(key), EQUIP.get(key), ARMOR.get(key),
INVISIBLE.contains(key));
RenderedEntity re = baker.bake(s);
if (re == null || re.cubes.isEmpty()) { // invisible with no equipment -> nothing renders
return new BufferedImage(TW, TH, BufferedImage.TYPE_INT_RGB);
}
double cx = (re.aabbMin[0] + re.aabbMax[0]) / 2;
double cy = (re.aabbMin[1] + re.aabbMax[1]) / 2;
double cz = (re.aabbMin[2] + re.aabbMax[2]) / 2;