add Spotless plugin: enforce code formatting

This commit is contained in:
2026-06-21 18:32:46 +02:00
parent dce7804fed
commit 7063d27fb4
72 changed files with 1335 additions and 816 deletions
+9
View File
@@ -1,5 +1,6 @@
plugins { plugins {
id 'java' id 'java'
id 'com.diffplug.spotless' version '7.0.2'
} }
group = 'eu.mhsl.minecraft.pixelpics' group = 'eu.mhsl.minecraft.pixelpics'
@@ -48,6 +49,14 @@ processResources {
} }
} }
spotless {
java {
target 'src/main/java/**/*.java'
palantirJavaFormat()
removeUnusedImports()
}
}
if (file("local.gradle").exists()) { if (file("local.gradle").exists()) {
apply from: "local.gradle" apply from: "local.gradle"
} }
@@ -21,14 +21,13 @@ import eu.mhsl.minecraft.pixelpics.survival.CraftingListener;
import eu.mhsl.minecraft.pixelpics.survival.JoinListener; import eu.mhsl.minecraft.pixelpics.survival.JoinListener;
import eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes; import eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes;
import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette; import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File; import java.io.File;
import java.io.InputStream; import java.io.InputStream;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.plugin.java.JavaPlugin;
public final class Main extends JavaPlugin { public final class Main extends JavaPlugin {
@@ -75,8 +74,9 @@ public final class Main extends JavaPlugin {
int timeoutSeconds = Math.max(1, getConfig().getInt("render.timeout-seconds", 30)); int timeoutSeconds = Math.max(1, getConfig().getInt("render.timeout-seconds", 30));
this.renderManager = new RenderManager(this, threads, maxConcurrent, queueSize, timeoutSeconds); this.renderManager = new RenderManager(this, threads, maxConcurrent, queueSize, timeoutSeconds);
getLogger().info("Render pool: " + threads + " core(s), max " + maxConcurrent getLogger()
+ " concurrent, queue " + queueSize + ", timeout " + timeoutSeconds + "s."); .info("Render pool: " + threads + " core(s), max " + maxConcurrent + " concurrent, queue " + queueSize
+ ", timeout " + timeoutSeconds + "s.");
} }
private void initRenderer() { private void initRenderer() {
@@ -87,9 +87,10 @@ public final class Main extends JavaPlugin {
Optional<ResourcePack> pack = ResourcePackLoader.load(resourcePackDir, getLogger()); Optional<ResourcePack> pack = ResourcePackLoader.load(resourcePackDir, getLogger());
if (pack.isEmpty()) { if (pack.isEmpty()) {
getLogger().severe("No resource pack found in " + resourcePackDir.getPath() getLogger()
+ " — place a vanilla resource pack (directory with assets/minecraft/... or a .zip) there. " .severe("No resource pack found in " + resourcePackDir.getPath()
+ "/pixelPic is disabled until a pack is available."); + " — place a vanilla resource pack (directory with assets/minecraft/... or a .zip) there. "
+ "/pixelPic is disabled until a pack is available.");
return; return;
} }
@@ -99,8 +100,7 @@ public final class Main extends JavaPlugin {
BlockModelRegistry registry = new BlockModelRegistry(reader, textures); BlockModelRegistry registry = new BlockModelRegistry(reader, textures);
BiomeTintProvider tintProvider = new BiomeTintProvider(textures); BiomeTintProvider tintProvider = new BiomeTintProvider(textures);
CemModelLoader cemLoader = CemModelLoader cemLoader = new CemModelLoader();
new CemModelLoader();
try (InputStream in = getResource("cem/cem_template_models.json")) { try (InputStream in = getResource("cem/cem_template_models.json")) {
int n = in == null ? 0 : cemLoader.load(in, getLogger()); int n = in == null ? 0 : cemLoader.load(in, getLogger());
getLogger().info("Loaded " + n + " CEM entity models."); getLogger().info("Loaded " + n + " CEM entity models.");
@@ -108,16 +108,19 @@ public final class Main extends JavaPlugin {
getLogger().severe("Failed to load CEM entity models: " + e.getMessage()); getLogger().severe("Failed to load CEM entity models: " + e.getMessage());
} }
SkinCache skinCache = new SkinCache(); SkinCache skinCache = new SkinCache();
BitmapFont font = BitmapFont font = FontLoader.load(resourcePack, textures, getLogger());
FontLoader.load(resourcePack, textures, getLogger());
getLogger().info("Loaded sign font (" + (font.isEmpty() ? "no glyphs — text disabled" : "ok") + ")."); getLogger().info("Loaded sign font (" + (font.isEmpty() ? "no glyphs — text disabled" : "ok") + ").");
CemBaker entityBaker = CemBaker entityBaker = new CemBaker(cemLoader, textures, skinCache);
new CemBaker(cemLoader, textures, skinCache); BlockEntityBaker blockEntityBaker = new BlockEntityBaker(cemLoader, textures, skinCache, font);
BlockEntityBaker blockEntityBaker =
new BlockEntityBaker(cemLoader, textures, skinCache, font);
this.screenRenderer = new DefaultScreenRenderer(registry, tintProvider, textures, entityBaker, this.screenRenderer = new DefaultScreenRenderer(
blockEntityBaker, getLogger(), renderManager.tracePool()); 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. // Warm the map palette on the main thread so off-thread dithering never triggers its first init.
MapColorPalette.size(); MapColorPalette.size();
getLogger().info("PixelPics renderer initialized with resource pack assets."); getLogger().info("PixelPics renderer initialized with resource pack assets.");
@@ -10,7 +10,8 @@ public final class AssetPaths {
/** {@code assets/<ns>/blockstates/<name>.json} for a plain block name (no {@code block/} prefix). */ /** {@code assets/<ns>/blockstates/<name>.json} for a plain block name (no {@code block/} prefix). */
public static String blockState(String blockName) { 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}. */ /** {@code assets/<ns>/models/<path>.json}. The id path already contains e.g. {@code block/stone}. */
@@ -3,7 +3,6 @@ package eu.mhsl.minecraft.pixelpics.assets;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Optional; import java.util.Optional;
@@ -32,7 +31,8 @@ public final class AssetReader {
public Optional<JsonObject> readJsonObject(String path) { public Optional<JsonObject> readJsonObject(String path) {
return pack.read(path).flatMap(bytes -> { return pack.read(path).flatMap(bytes -> {
try { 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) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
@@ -5,14 +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.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel; 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.ArrayList;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; 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 * Top-level entry point that turns a {@link BlockData} into a baked {@link ResolvedModel}, combining
@@ -88,8 +87,12 @@ public final class BlockModelRegistry {
private static EnumSet<Material> buildInvisibleMaterials() { private static EnumSet<Material> buildInvisibleMaterials() {
EnumSet<Material> set = EnumSet.noneOf(Material.class); EnumSet<Material> set = EnumSet.noneOf(Material.class);
for (String n : new String[]{"BARRIER", "LIGHT", "STRUCTURE_VOID"}) { 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; return set;
} }
@@ -107,14 +110,17 @@ public final class BlockModelRegistry {
EnumSet<Material> set = EnumSet.noneOf(Material.class); EnumSet<Material> set = EnumSet.noneOf(Material.class);
for (Material m : Material.values()) { for (Material m : Material.values()) {
String n = m.name(); String n = m.name();
boolean match = boolean match = n.endsWith("_SIGN") // signs: standing/wall/(wall_)hanging
n.endsWith("_SIGN") // signs: standing/wall/(wall_)hanging || n.endsWith("_BANNER") // banners: standing/wall
|| n.endsWith("_BANNER") // banners: standing/wall || n.endsWith("_BED")
|| n.endsWith("_BED") || n.endsWith("SHULKER_BOX") // SHULKER_BOX + <color>_SHULKER_BOX
|| n.endsWith("SHULKER_BOX") // SHULKER_BOX + <color>_SHULKER_BOX || ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD)
|| ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD) || m == Material.CHEST
|| m == Material.CHEST || m == Material.TRAPPED_CHEST || m == Material.ENDER_CHEST || m == Material.TRAPPED_CHEST
|| m == Material.CONDUIT || m == Material.DECORATED_POT || m == Material.BELL; || m == Material.ENDER_CHEST
|| m == Material.CONDUIT
|| m == Material.DECORATED_POT
|| m == Material.BELL;
if (match) set.add(m); if (match) set.add(m);
} }
return set; return set;
@@ -127,7 +133,7 @@ public final class BlockModelRegistry {
for (Direction d : Direction.values()) { for (Direction d : Direction.values()) {
faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1); faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
} }
return new Element(new double[]{0, 0, 0}, new double[]{1, 1, 1}, faces, null, -1, 0, false); return new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces, null, -1, 0, false);
} }
private int fallbackColor(FlatModel flat) { private int fallbackColor(FlatModel flat) {
@@ -169,7 +175,7 @@ public final class BlockModelRegistry {
for (Direction d : Direction.values()) { for (Direction d : Direction.values()) {
faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, tintIndex); faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, tintIndex);
} }
Element cube = new Element(new double[]{0, 0, 0}, new double[]{1, 1, 1}, faces, null, -1, 0, false); Element cube = new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces, null, -1, 0, false);
int avg = AverageColor.of(tex); int avg = AverageColor.of(tex);
return new ResolvedModel(List.of(cube), avg, transparency, reflection, true, true); return new ResolvedModel(List.of(cube), avg, transparency, reflection, true, true);
} }
@@ -1,9 +1,8 @@
package eu.mhsl.minecraft.pixelpics.assets; package eu.mhsl.minecraft.pixelpics.assets;
import org.bukkit.block.data.BlockData;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; 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 * 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.JsonArray;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import org.bukkit.block.data.BlockData;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; 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 * 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; package eu.mhsl.minecraft.pixelpics.assets;
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto; import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
import java.util.List; import java.util.List;
import java.util.Map; 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.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Element; import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -61,8 +60,9 @@ public final class ModelBaker {
double rotAngle = 0; double rotAngle = 0;
boolean rescale = false; boolean rescale = false;
if (dto.rotation != null && dto.rotation.angle != 0 && dto.rotation.origin != null) { if (dto.rotation != null && dto.rotation.angle != 0 && dto.rotation.origin != null) {
rotOrigin = new double[]{ 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); rotAxis = axisIndex(dto.rotation.axis);
rotAngle = Math.toRadians(dto.rotation.angle); rotAngle = Math.toRadians(dto.rotation.angle);
rescale = dto.rotation.rescale; rescale = dto.rotation.rescale;
@@ -106,8 +106,8 @@ public final class ModelBaker {
return new BakedGeometry(baked, avgColor.average(0xFF7F7F7F), !baked.isEmpty()); return new BakedGeometry(baked, avgColor.average(0xFF7F7F7F), !baked.isEmpty());
} }
private Face buildFace(Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to, private Face buildFace(
Map<String, String> textureVars) { Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to, Map<String, String> textureVars) {
int[][] tex = resolveTexture(dto.texture, textureVars); int[][] tex = resolveTexture(dto.texture, textureVars);
if (tex == null) return null; if (tex == null) return null;
@@ -134,9 +134,9 @@ public final class ModelBaker {
*/ */
private double[] defaultUv(Direction dir, double[] f, double[] t) { private double[] defaultUv(Direction dir, double[] f, double[] t) {
return switch (dir) { return switch (dir) {
case UP, DOWN -> new double[]{f[0], f[2], t[0], t[2]}; case UP, DOWN -> new double[] {f[0], f[2], t[0], t[2]};
case NORTH, SOUTH -> new double[]{f[0], 1 - t[1], t[0], 1 - f[1]}; case NORTH, SOUTH -> new double[] {f[0], 1 - t[1], t[0], 1 - f[1]};
case WEST, EAST -> new double[]{f[2], 1 - t[1], t[2], 1 - f[1]}; case WEST, EAST -> new double[] {f[2], 1 - t[1], t[2], 1 - f[1]};
}; };
} }
@@ -179,18 +179,18 @@ public final class ModelBaker {
private double[] rotatePointY(double[] p) { private double[] rotatePointY(double[] p) {
double x = p[0] - 0.5, z = p[2] - 0.5; double x = p[0] - 0.5, z = p[2] - 0.5;
return new double[]{0.5 + z, p[1], 0.5 - x}; return new double[] {0.5 + z, p[1], 0.5 - x};
} }
private double[] rotatePointX(double[] p) { private double[] rotatePointX(double[] p) {
double y = p[1] - 0.5, z = p[2] - 0.5; double y = p[1] - 0.5, z = p[2] - 0.5;
return new double[]{p[0], 0.5 + z, 0.5 - y}; return new double[] {p[0], 0.5 + z, 0.5 - y};
} }
private double[][] minMax(double[] a, double[] b) { private double[][] minMax(double[] a, double[] b) {
double[] from = {Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2])}; double[] from = {Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2])};
double[] to = {Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(a[2], b[2])}; double[] to = {Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(a[2], b[2])};
return new double[][]{from, to}; return new double[][] {from, to};
} }
private Face[] rotateFacesY(Face[] faces) { private Face[] rotateFacesY(Face[] faces) {
@@ -216,7 +216,7 @@ public final class ModelBaker {
private AxisRotation rotateAxisY(int axis) { private AxisRotation rotateAxisY(int axis) {
// Y rotation maps x<->z; the y axis is unchanged. // Y rotation maps x<->z; the y axis is unchanged.
return switch (axis) { return switch (axis) {
case 0 -> new AxisRotation(2, true); // x -> z case 0 -> new AxisRotation(2, true); // x -> z
case 2 -> new AxisRotation(0, false); // z -> x case 2 -> new AxisRotation(0, false); // z -> x
default -> new AxisRotation(axis, false); default -> new AxisRotation(axis, false);
}; };
@@ -225,7 +225,7 @@ public final class ModelBaker {
private AxisRotation rotateAxisX(int axis) { private AxisRotation rotateAxisX(int axis) {
// X rotation maps y<->z; the x axis is unchanged. // X rotation maps y<->z; the x axis is unchanged.
return switch (axis) { return switch (axis) {
case 1 -> new AxisRotation(2, true); // y -> z case 1 -> new AxisRotation(2, true); // y -> z
case 2 -> new AxisRotation(1, false); // z -> y case 2 -> new AxisRotation(1, false); // z -> y
default -> new AxisRotation(axis, false); default -> new AxisRotation(axis, false);
}; };
@@ -1,7 +1,6 @@
package eu.mhsl.minecraft.pixelpics.assets; package eu.mhsl.minecraft.pixelpics.assets;
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto; import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -31,7 +30,8 @@ public final class ModelResolver {
} }
private FlatModel resolve(ResourceLocation modelId, int depth) { 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) { if (dto == null) {
return new FlatModel(new HashMap<>(), null); return new FlatModel(new HashMap<>(), null);
} }
@@ -45,15 +45,13 @@ public final class ResourcePackLoader {
// Zip packs anywhere under the resourcepack folder. // Zip packs anywhere under the resourcepack folder.
try (Stream<Path> walk = Files.walk(resourcePackDir.toPath())) { try (Stream<Path> walk = Files.walk(resourcePackDir.toPath())) {
List<Path> zips = walk List<Path> zips = walk.filter(Files::isRegularFile)
.filter(Files::isRegularFile) .filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip")) .toList();
.toList();
for (Path zip : zips) { for (Path zip : zips) {
try { try {
ZipResourcePack pack = new ZipResourcePack(zip); ZipResourcePack pack = new ZipResourcePack(zip);
if (pack.exists(MARKER + "/blockstates") || pack.exists("pack.mcmeta") if (pack.exists(MARKER + "/blockstates") || pack.exists("pack.mcmeta") || hasAnyBlockstate(pack)) {
|| hasAnyBlockstate(pack)) {
packs.add(pack); packs.add(pack);
logger.info("Loaded resource pack zip: " + zip); logger.info("Loaded resource pack zip: " + zip);
} else { } else {
@@ -1,12 +1,12 @@
package eu.mhsl.minecraft.pixelpics.assets; package eu.mhsl.minecraft.pixelpics.assets;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.InputStream; import java.io.InputStream;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.net.URLConnection; import java.net.URLConnection;
import java.util.Optional; import java.util.Optional;
import javax.imageio.ImageIO;
/** /**
* Downloads and caches player skin textures (by URL) as ARGB pixel grids. Legacy 64x32 skins are * 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; package eu.mhsl.minecraft.pixelpics.assets;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import javax.imageio.ImageIO;
/** /**
* Loads and caches block textures as raw ARGB pixel grids. Textures are stored unflipped (vanilla * 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 * A resolved blockstate variant: which model to use plus its {@code x}/{@code y} rotation (in
* degrees, multiples of 90) and {@code uvlock}. * 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 { public ZipResourcePack(Path zipPath) throws IOException {
this.zipFile = new ZipFile(zipPath.toFile()); this.zipFile = new ZipFile(zipPath.toFile());
zipFile.stream() zipFile.stream().filter(entry -> !entry.isDirectory()).forEach(entry -> entries.put(entry.getName(), entry));
.filter(entry -> !entry.isDirectory())
.forEach(entry -> entries.put(entry.getName(), entry));
} }
@Override @Override
@@ -13,24 +13,24 @@ public class ModelFileDto {
public List<ElementDto> elements; public List<ElementDto> elements;
public static class ElementDto { public static class ElementDto {
public double[] from; // 0..16 public double[] from; // 0..16
public double[] to; // 0..16 public double[] to; // 0..16
public RotationDto rotation; // optional public RotationDto rotation; // optional
public Map<String, FaceDto> faces; // keys: down/up/north/south/west/east public Map<String, FaceDto> faces; // keys: down/up/north/south/west/east
} }
public static class FaceDto { public static class FaceDto {
public double[] uv; // optional, 0..16 (x1,y1,x2,y2) public double[] uv; // optional, 0..16 (x1,y1,x2,y2)
public String texture; // e.g. "#side" or "minecraft:block/oak_planks" public String texture; // e.g. "#side" or "minecraft:block/oak_planks"
public Integer tintindex; public Integer tintindex;
public String cullface; // ignored by the renderer public String cullface; // ignored by the renderer
public int rotation; // 0/90/180/270 public int rotation; // 0/90/180/270
} }
public static class RotationDto { public static class RotationDto {
public double[] origin; // 0..16 public double[] origin; // 0..16
public String axis; // "x" | "y" | "z" public String axis; // "x" | "y" | "z"
public double angle; // -45..45 in 22.5 steps public double angle; // -45..45 in 22.5 steps
public boolean rescale; public boolean rescale;
} }
} }
@@ -8,7 +8,6 @@ import eu.mhsl.minecraft.pixelpics.assets.AssetPaths;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation; import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.ResourcePack; import eu.mhsl.minecraft.pixelpics.assets.ResourcePack;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache; import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
@@ -63,7 +62,8 @@ public final class FontLoader {
if (bytes.isEmpty()) return; if (bytes.isEmpty()) return;
JsonObject root; JsonObject root;
try { try {
root = JsonParser.parseString(new String(bytes.get(), StandardCharsets.UTF_8)).getAsJsonObject(); root = JsonParser.parseString(new String(bytes.get(), StandardCharsets.UTF_8))
.getAsJsonObject();
} catch (Exception e) { } catch (Exception e) {
if (log != null) log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage()); if (log != null) log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage());
return; return;
@@ -84,7 +84,9 @@ public final class FontLoader {
} }
case "space" -> space(p); case "space" -> space(p);
case "bitmap" -> bitmap(p); case "bitmap" -> bitmap(p);
default -> { /* unihex, legacy_unicode, ttf, … — out of scope */ } default -> {
/* unihex, legacy_unicode, ttf, … — out of scope */
}
} }
} }
@@ -117,7 +119,9 @@ public final class FontLoader {
JsonArray rows = p.getAsJsonArray("chars"); JsonArray rows = p.getAsJsonArray("chars");
int nRows = rows.size(); int nRows = rows.size();
if (nRows == 0) return; if (nRows == 0) return;
int nCols = rows.get(0).getAsString().codePointCount(0, rows.get(0).getAsString().length()); int nCols = rows.get(0)
.getAsString()
.codePointCount(0, rows.get(0).getAsString().length());
if (nCols == 0) return; if (nCols == 0) return;
int cellW = imgW / nCols; int cellW = imgW / nCols;
int cellH = imgH / nRows; int cellH = imgH / nRows;
@@ -131,12 +135,12 @@ public final class FontLoader {
int cp = row.codePointAt(ci); int cp = row.codePointAt(ci);
ci += Character.charCount(cp); ci += Character.charCount(cp);
int thisCol = col++; int thisCol = col++;
if (cp == 0 || thisCol >= nCols) continue; // 0x0000 = empty slot if (cp == 0 || thisCol >= nCols) continue; // 0x0000 = empty slot
if (glyphs.containsKey(cp)) continue; // earlier provider wins if (glyphs.containsKey(cp)) continue; // earlier provider wins
int srcX = thisCol * cellW; int srcX = thisCol * cellW;
int srcY = r * cellH; int srcY = r * cellH;
int glyphPx = rightmostOpaqueColumn(tex, srcX, srcY, cellW, cellH) + 1; int glyphPx = rightmostOpaqueColumn(tex, srcX, srcY, cellW, cellH) + 1;
if (glyphPx <= 0) continue; // blank glyph (handled by space provider) if (glyphPx <= 0) continue; // blank glyph (handled by space provider)
int advance = (int) (glyphPx * scale + 0.5) + 1; int advance = (int) (glyphPx * scale + 0.5) + 1;
glyphs.put(cp, new Glyph(tex, srcX, srcY, cellW, cellH, glyphPx, height, ascent, advance)); glyphs.put(cp, new Glyph(tex, srcX, srcY, cellW, cellH, glyphPx, height, ascent, advance));
maxAscent = Math.max(maxAscent, ascent); maxAscent = Math.max(maxAscent, ascent);
@@ -151,7 +155,10 @@ public final class FontLoader {
for (int x = 0; x < cellW; x++) { for (int x = 0; x < cellW; x++) {
for (int y = 0; y < cellH; y++) { for (int y = 0; y < cellH; y++) {
int argb = tex[srcY + y][srcX + x]; int argb = tex[srcY + y][srcX + x];
if (((argb >>> 24) & 0xFF) != 0) { last = x; break; } if (((argb >>> 24) & 0xFF) != 0) {
last = x;
break;
}
} }
} }
return last; return last;
@@ -9,8 +9,8 @@ package eu.mhsl.minecraft.pixelpics.assets.font;
* <p>Glyphs from different providers may declare different cell sizes and ascents (e.g. ascii 8px/ascent * <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. * 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, public record Glyph(
int glyphPx, int height, int ascent, int advance) { 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). */ /** Vertical pixels below the baseline (font px). */
public int descent() { public int descent() {
@@ -11,7 +11,6 @@ public enum Direction {
SOUTH(0, 0, 1), SOUTH(0, 0, 1),
WEST(-1, 0, 0), WEST(-1, 0, 0),
EAST(1, 0, 0); EAST(1, 0, 0);
public final int nx, ny, nz; public final int nx, ny, nz;
Direction(int nx, int ny, int nz) { Direction(int nx, int ny, int nz) {
@@ -42,8 +41,8 @@ public enum Direction {
case EAST -> SOUTH; case EAST -> SOUTH;
case SOUTH -> WEST; case SOUTH -> WEST;
case WEST -> NORTH; case WEST -> NORTH;
default -> d; // up/down unchanged // up/down unchanged
}; default -> d;};
} }
return d; return d;
} }
@@ -58,8 +57,8 @@ public enum Direction {
case NORTH -> DOWN; case NORTH -> DOWN;
case DOWN -> SOUTH; case DOWN -> SOUTH;
case SOUTH -> UP; case SOUTH -> UP;
default -> d; // east/west unchanged // east/west unchanged
}; default -> d;};
} }
return d; return d;
} }
@@ -10,17 +10,23 @@ package eu.mhsl.minecraft.pixelpics.assets.model;
public final class Element { public final class Element {
public final double[] from; // length 3, 0..1 public final double[] from; // length 3, 0..1
public final double[] to; // length 3, 0..1 public final double[] to; // length 3, 0..1
public final Face[] faces; // length 6, indexed by Direction.ordinal() public final Face[] faces; // length 6, indexed by Direction.ordinal()
// Element rotation (0..1 origin), null/zero when axis-aligned. // Element rotation (0..1 origin), null/zero when axis-aligned.
public final double[] rotOrigin; // length 3, 0..1, may be null public final double[] rotOrigin; // length 3, 0..1, may be null
public final int rotAxis; // 0=x,1=y,2=z, -1 when none public final int rotAxis; // 0=x,1=y,2=z, -1 when none
public final double rotAngleRad; // radians public final double rotAngleRad; // radians
public final boolean rescale; public final boolean rescale;
public Element(double[] from, double[] to, Face[] faces, public Element(
double[] rotOrigin, int rotAxis, double rotAngleRad, boolean rescale) { double[] from,
double[] to,
Face[] faces,
double[] rotOrigin,
int rotAxis,
double rotAngleRad,
boolean rescale) {
this.from = from; this.from = from;
this.to = to; this.to = to;
this.faces = faces; this.faces = faces;
@@ -29,10 +29,21 @@ public final class Face {
// Apply face rotation by rotating the (s,t) lookup. // Apply face rotation by rotating the (s,t) lookup.
double rs = s, rt = t; double rs = s, rt = t;
switch (((rotation % 360) + 360) % 360) { switch (((rotation % 360) + 360) % 360) {
case 90 -> { rs = t; rt = 1.0 - s; } case 90 -> {
case 180 -> { rs = 1.0 - s; rt = 1.0 - t; } rs = t;
case 270 -> { rs = 1.0 - t; rt = s; } rt = 1.0 - s;
default -> { /* 0 */ } }
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; double u = u1 + (u2 - u1) * rs;
@@ -15,12 +15,17 @@ public final class ResolvedModel {
public final List<Element> elements; public final List<Element> elements;
public final int averageColor; // ARGB public final int averageColor; // ARGB
public final double transparency; // 0..1 public final double transparency; // 0..1
public final double reflection; // 0..1 public final double reflection; // 0..1
public final boolean occluding; public final boolean occluding;
public final boolean hasGeometry; public final boolean hasGeometry;
public ResolvedModel(List<Element> elements, int averageColor, public ResolvedModel(
double transparency, double reflection, boolean occluding, boolean hasGeometry) { List<Element> elements,
int averageColor,
double transparency,
double reflection,
boolean occluding,
boolean hasGeometry) {
this.elements = elements; this.elements = elements;
this.averageColor = averageColor; this.averageColor = averageColor;
this.transparency = transparency; this.transparency = transparency;
@@ -2,6 +2,8 @@ package eu.mhsl.minecraft.pixelpics.commands;
import eu.mhsl.minecraft.pixelpics.survival.PhotoService; import eu.mhsl.minecraft.pixelpics.survival.PhotoService;
import eu.mhsl.minecraft.pixelpics.utils.MapManager; 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.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.command.Command; import org.bukkit.command.Command;
@@ -10,31 +12,31 @@ import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Set;
public class PixelPicsCommand implements CommandExecutor { public class PixelPicsCommand implements CommandExecutor {
private static final int DEFAULT_CLEANUP_DAYS = 30; private static final int DEFAULT_CLEANUP_DAYS = 30;
@Override @Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, public boolean onCommand(
@NotNull String @NotNull [] args) { @NotNull CommandSender sender,
@NotNull Command command,
@NotNull String label,
@NotNull String @NotNull [] args) {
if (args.length >= 1 && args[0].equalsIgnoreCase("cleanup")) { if (args.length >= 1 && args[0].equalsIgnoreCase("cleanup")) {
return cleanup(sender, args); return cleanup(sender, args);
} }
if (!(sender instanceof Player player)) { if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("Dieser Command kann nur von einem Spieler ausgeführt werden!", sender.sendMessage(
NamedTextColor.RED)); Component.text("Dieser Command kann nur von einem Spieler ausgeführt werden!", NamedTextColor.RED));
return true; return true;
} }
if (args.length > 0) return false; if (args.length > 0) return false;
// Debug shortcut: render a photo from the player's view without needing a camera or film. // Debug shortcut: render a photo from the player's view without needing a camera or film.
if (!player.hasPermission("pixelpic.admin")) { if (!player.hasPermission("pixelpic.admin")) {
player.sendActionBar(Component.text("Dafür fehlt dir die Berechtigung — nutze eine Kamera.", player.sendActionBar(
NamedTextColor.RED)); Component.text("Dafür fehlt dir die Berechtigung — nutze eine Kamera.", NamedTextColor.RED));
return true; return true;
} }
@@ -45,7 +47,8 @@ public class PixelPicsCommand implements CommandExecutor {
/** {@code /pixelPic cleanup [days]} dry-run; {@code /pixelPic cleanup confirm [days]} deletes orphans. */ /** {@code /pixelPic cleanup [days]} dry-run; {@code /pixelPic cleanup confirm [days]} deletes orphans. */
private boolean cleanup(CommandSender sender, String[] args) { private boolean cleanup(CommandSender sender, String[] args) {
if (!sender.hasPermission("pixelpic.admin") && !sender.isOp()) { 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; return true;
} }
@@ -64,21 +67,25 @@ public class PixelPicsCommand implements CommandExecutor {
Set<Integer> inUse = MapManager.collectInUseMapIds(); Set<Integer> inUse = MapManager.collectInUseMapIds();
long cutoff = System.currentTimeMillis() - days * 86_400_000L; long cutoff = System.currentTimeMillis() - days * 86_400_000L;
List<MapManager.StoredMap> candidates = MapManager.listStored().stream() List<MapManager.StoredMap> candidates = MapManager.listStored().stream()
.filter(s -> !inUse.contains(s.id()) && s.lastModified() < cutoff) .filter(s -> !inUse.contains(s.id()) && s.lastModified() < cutoff)
.toList(); .toList();
if (candidates.isEmpty()) { if (candidates.isEmpty()) {
sender.sendMessage(Component.text("Keine aufräumbaren Aufnahmen gefunden (älter als " + days sender.sendMessage(Component.text(
+ " Tage und nicht in Benutzung).", NamedTextColor.YELLOW)); "Keine aufräumbaren Aufnahmen gefunden (älter als " + days + " Tage und nicht in Benutzung).",
NamedTextColor.YELLOW));
return true; return true;
} }
if (!confirm) { if (!confirm) {
sender.sendMessage(Component.text(candidates.size() + " Aufnahme(n) könnten gelöscht werden " sender.sendMessage(Component.text(
+ "(älter als " + days + " Tage, nicht in geladenen Itemframes/Online-Inventaren).", candidates.size() + " Aufnahme(n) könnten gelöscht werden " + "(älter als " + days
NamedTextColor.YELLOW)); + " Tage, nicht in geladenen Itemframes/Online-Inventaren).",
sender.sendMessage(Component.text("Achtung: Maps in lange ungeladenen Bereichen werden hierbei nicht " NamedTextColor.YELLOW));
+ "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; 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.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither; import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
import eu.mhsl.minecraft.pixelpics.utils.MapManager; import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import java.awt.image.BufferedImage;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.server.MapInitializeEvent; import org.bukkit.event.server.MapInitializeEvent;
import java.awt.image.BufferedImage;
public class OnMapInitialize implements Listener { public class OnMapInitialize implements Listener {
@EventHandler @EventHandler
@@ -1,8 +1,5 @@
package eu.mhsl.minecraft.pixelpics.render; package eu.mhsl.minecraft.pixelpics.render;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -19,6 +16,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function; 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: * Server-friendly scheduling for photo renders. Bounds CPU use and protects the server tick by:
@@ -37,7 +36,11 @@ import java.util.function.Function;
*/ */
public final class RenderManager { public final class RenderManager {
public enum Outcome { ACCEPTED, USER_BUSY, QUEUE_FULL } public enum Outcome {
ACCEPTED,
USER_BUSY,
QUEUE_FULL
}
private final Plugin plugin; private final Plugin plugin;
private final ForkJoinPool tracePool; private final ForkJoinPool tracePool;
@@ -54,9 +57,12 @@ public final class RenderManager {
this.timeoutMillis = Math.max(1L, timeoutSeconds) * 1000L; this.timeoutMillis = Math.max(1L, timeoutSeconds) * 1000L;
this.tracePool = new ForkJoinPool(threads, lowPriorityForkJoinFactory(), null, false); this.tracePool = new ForkJoinPool(threads, lowPriorityForkJoinFactory(), null, false);
this.dispatcher = new ThreadPoolExecutor( this.dispatcher = new ThreadPoolExecutor(
maxConcurrent, maxConcurrent, 30, TimeUnit.SECONDS, maxConcurrent,
new LinkedBlockingQueue<>(), // capacity is enforced by inFlight, not this queue maxConcurrent,
lowPriorityThreadFactory()); 30,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), // capacity is enforced by inFlight, not this queue
lowPriorityThreadFactory());
this.dispatcher.allowCoreThreadTimeOut(true); this.dispatcher.allowCoreThreadTimeOut(true);
this.watchdog = Executors.newSingleThreadScheduledExecutor(runnable -> { this.watchdog = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "PixelPics-render-watchdog"); Thread thread = new Thread(runnable, "PixelPics-render-watchdog");
@@ -98,7 +104,8 @@ public final class RenderManager {
public <T> void dispatch(UUID user, Function<AtomicBoolean, T> work, Consumer<T> onSuccess, Runnable onFailure) { public <T> void dispatch(UUID user, Function<AtomicBoolean, T> work, Consumer<T> onSuccess, Runnable onFailure) {
dispatcher.execute(() -> { dispatcher.execute(() -> {
AtomicBoolean cancelled = new AtomicBoolean(false); AtomicBoolean cancelled = new AtomicBoolean(false);
ScheduledFuture<?> deadline = watchdog.schedule(() -> cancelled.set(true), timeoutMillis, TimeUnit.MILLISECONDS); ScheduledFuture<?> deadline =
watchdog.schedule(() -> cancelled.set(true), timeoutMillis, TimeUnit.MILLISECONDS);
T result = null; T result = null;
boolean ok = false; boolean ok = false;
try { try {
@@ -109,8 +116,9 @@ public final class RenderManager {
} finally { } finally {
deadline.cancel(false); deadline.cancel(false);
if (cancelled.get()) { if (cancelled.get()) {
plugin.getLogger().warning("Render for " + user + " aborted after " plugin.getLogger()
+ (timeoutMillis / 1000) + "s (timeout)."); .warning(
"Render for " + user + " aborted after " + (timeoutMillis / 1000) + "s (timeout).");
} }
release(user); release(user);
} }
@@ -17,30 +17,30 @@ public final class Affine {
} }
public static Affine identity() { public static Affine identity() {
return new Affine(new double[]{1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[]{0, 0, 0}); return new Affine(new double[] {1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[] {0, 0, 0});
} }
public static Affine translation(double x, double y, double z) { public static Affine translation(double x, double y, double z) {
return new Affine(new double[]{1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[]{x, y, z}); return new Affine(new double[] {1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[] {x, y, z});
} }
public static Affine scale(double s) { public static Affine scale(double s) {
return new Affine(new double[]{s, 0, 0, 0, s, 0, 0, 0, s}, new double[]{0, 0, 0}); return new Affine(new double[] {s, 0, 0, 0, s, 0, 0, 0, s}, new double[] {0, 0, 0});
} }
public static Affine rotX(double a) { public static Affine rotX(double a) {
double c = Math.cos(a), s = Math.sin(a); double c = Math.cos(a), s = Math.sin(a);
return new Affine(new double[]{1, 0, 0, 0, c, -s, 0, s, c}, new double[]{0, 0, 0}); return new Affine(new double[] {1, 0, 0, 0, c, -s, 0, s, c}, new double[] {0, 0, 0});
} }
public static Affine rotY(double a) { public static Affine rotY(double a) {
double c = Math.cos(a), s = Math.sin(a); double c = Math.cos(a), s = Math.sin(a);
return new Affine(new double[]{c, 0, s, 0, 1, 0, -s, 0, c}, new double[]{0, 0, 0}); return new Affine(new double[] {c, 0, s, 0, 1, 0, -s, 0, c}, new double[] {0, 0, 0});
} }
public static Affine rotZ(double a) { public static Affine rotZ(double a) {
double c = Math.cos(a), s = Math.sin(a); double c = Math.cos(a), s = Math.sin(a);
return new Affine(new double[]{c, -s, 0, s, c, 0, 0, 0, 1}, new double[]{0, 0, 0}); return new Affine(new double[] {c, -s, 0, s, c, 0, 0, 0, 1}, new double[] {0, 0, 0});
} }
/** this ∘ o (apply o first, then this). */ /** this ∘ o (apply o first, then this). */
@@ -53,7 +53,7 @@ public final class Affine {
} }
} }
double[] ot = o.t; double[] ot = o.t;
double[] nt = new double[]{ double[] nt = new double[] {
a[0] * ot[0] + a[1] * ot[1] + a[2] * ot[2] + this.t[0], a[0] * ot[0] + a[1] * ot[1] + a[2] * ot[2] + this.t[0],
a[3] * ot[0] + a[4] * ot[1] + a[5] * ot[2] + this.t[1], a[3] * ot[0] + a[4] * ot[1] + a[5] * ot[2] + this.t[1],
a[6] * ot[0] + a[7] * ot[1] + a[8] * ot[2] + this.t[2] a[6] * ot[0] + a[7] * ot[1] + a[8] * ot[2] + this.t[2]
@@ -62,7 +62,7 @@ public final class Affine {
} }
public double[] apply(double x, double y, double z) { public double[] apply(double x, double y, double z) {
return new double[]{ return new double[] {
r[0] * x + r[1] * y + r[2] * z + t[0], r[0] * x + r[1] * y + r[2] * z + t[0],
r[3] * x + r[4] * y + r[5] * z + t[1], r[3] * x + r[4] * y + r[5] * z + t[1],
r[6] * x + r[7] * y + r[8] * z + t[2] r[6] * x + r[7] * y + r[8] * z + t[2]
@@ -71,10 +71,8 @@ public final class Affine {
/** Linear part only (for directions). */ /** Linear part only (for directions). */
public double[] applyLinear(double x, double y, double z) { public double[] applyLinear(double x, double y, double z) {
return new double[]{ return new double[] {
r[0] * x + r[1] * y + r[2] * 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
r[3] * x + r[4] * y + r[5] * z,
r[6] * x + r[7] * y + r[8] * z
}; };
} }
@@ -83,12 +81,12 @@ public final class Affine {
double a = r[0], b = r[1], c = r[2], d = r[3], e = r[4], f = r[5], g = r[6], h = r[7], i = r[8]; double a = r[0], b = r[1], c = r[2], d = r[3], e = r[4], f = r[5], g = r[6], h = r[7], i = r[8];
double det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); double det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
double inv = Math.abs(det) < 1e-12 ? 0 : 1.0 / det; double inv = Math.abs(det) < 1e-12 ? 0 : 1.0 / det;
double[] ir = new double[]{ double[] ir = new double[] {
(e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv, (e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv,
(f * g - d * i) * inv, (a * i - c * g) * inv, (c * d - a * f) * inv, (f * g - d * i) * inv, (a * i - c * g) * inv, (c * d - a * f) * inv,
(d * h - e * g) * inv, (b * g - a * h) * inv, (a * e - b * d) * inv (d * h - e * g) * inv, (b * g - a * h) * inv, (a * e - b * d) * inv
}; };
double[] it = new double[]{ double[] it = new double[] {
-(ir[0] * t[0] + ir[1] * t[1] + ir[2] * t[2]), -(ir[0] * t[0] + ir[1] * t[1] + ir[2] * t[2]),
-(ir[3] * t[0] + ir[4] * t[1] + ir[5] * t[2]), -(ir[3] * t[0] + ir[4] * t[1] + ir[5] * t[2]),
-(ir[6] * t[0] + ir[7] * t[1] + ir[8] * t[2]) -(ir[6] * t[0] + ir[7] * t[1] + ir[8] * t[2])
@@ -1,7 +1,6 @@
package eu.mhsl.minecraft.pixelpics.render.entity; package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation; import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -17,7 +16,9 @@ public final class BlockEntityModels {
/** The CEM ({@code .jem}) model name for a block-entity. */ /** The CEM ({@code .jem}) model name for a block-entity. */
public static String cemModel(BlockEntityState s) { public static String cemModel(BlockEntityState s) {
return switch (s.kind()) { 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 LEFT -> "chest_left";
case RIGHT -> "chest_right"; case RIGHT -> "chest_right";
case SINGLE -> "chest"; case SINGLE -> "chest";
@@ -56,7 +57,7 @@ public final class BlockEntityModels {
case SIGN, WALL_SIGN -> { case SIGN, WALL_SIGN -> {
String wood = s.wood() == null ? "oak" : s.wood(); String wood = s.wood() == null ? "oak" : s.wood();
paths.add("entity/signs/" + wood); paths.add("entity/signs/" + wood);
paths.add("entity/sign"); // legacy single-texture fallback paths.add("entity/sign"); // legacy single-texture fallback
} }
case HANGING_SIGN -> { case HANGING_SIGN -> {
String wood = s.wood() == null ? "oak" : s.wood(); String wood = s.wood() == null ? "oak" : s.wood();
@@ -69,7 +70,7 @@ public final class BlockEntityModels {
} }
case SHULKER_BOX -> { case SHULKER_BOX -> {
if (s.colorName() != null) paths.add("entity/shulker/shulker_" + s.colorName()); if (s.colorName() != null) paths.add("entity/shulker/shulker_" + s.colorName());
paths.add("entity/shulker/shulker"); // uncoloured (purpur) default paths.add("entity/shulker/shulker"); // uncoloured (purpur) default
} }
case CONDUIT -> paths.add("entity/conduit/base"); case CONDUIT -> paths.add("entity/conduit/base");
case DECORATED_POT -> paths.add("entity/decorated_pot/decorated_pot_base"); case DECORATED_POT -> paths.add("entity/decorated_pot/decorated_pot_base");
@@ -92,7 +93,10 @@ public final class BlockEntityModels {
} }
private static void headTextures(List<String> paths, String headType) { 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) { switch (headType) {
case "wither_skeleton" -> paths.add("entity/skeleton/wither_skeleton"); case "wither_skeleton" -> paths.add("entity/skeleton/wither_skeleton");
case "zombie" -> paths.add("entity/zombie/zombie"); case "zombie" -> paths.add("entity/zombie/zombie");
@@ -12,36 +12,59 @@ import java.util.List;
* facing/rotation, vanilla convention). Type-specific fields are null/0/empty when unused. * facing/rotation, vanilla convention). Type-specific fields are null/0/empty when unused.
*/ */
public record BlockEntityState( public record BlockEntityState(
Kind kind, Kind kind,
int bx, int by, int bz, int bx,
float facingDeg, int by,
ChestKind chestKind, // double-chest half (CHEST/TRAPPED_CHEST/ENDER_CHEST) int bz,
int baseColorArgb, // banner base tint (white texture); 0 = none float facingDeg,
String colorName, // bed/shulker/banner colour variant ("red", "white", …); null = default ChestKind chestKind, // double-chest half (CHEST/TRAPPED_CHEST/ENDER_CHEST)
String wood, // sign/hanging-sign wood ("oak", "spruce", …); null = default int baseColorArgb, // banner base tint (white texture); 0 = none
BedPart bedPart, // bed half String colorName, // bed/shulker/banner colour variant ("red", "white", …); null = default
String headType, // "skeleton","wither_skeleton","zombie","creeper","piglin","dragon","player" String wood, // sign/hanging-sign wood ("oak", "spruce", …); null = default
String skinUrl, // player-head owner skin URL; null otherwise BedPart bedPart, // bed half
List<BannerPattern> patterns, // banner overlay patterns (may be empty) String headType, // "skeleton","wither_skeleton","zombie","creeper","piglin","dragon","player"
List<String> sherds, // decorated-pot sherds: front/back/left/right item keys (may be empty) String skinUrl, // player-head owner skin URL; null otherwise
BellAttach bellAttach, // bell attachment; null when not a bell List<BannerPattern> patterns, // banner overlay patterns (may be empty)
SignText frontText, // sign front-side text; null when not a sign or blank List<String> sherds, // decorated-pot sherds: front/back/left/right item keys (may be empty)
SignText backText // sign back-side text; null when not a sign or blank 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 { public enum Kind {
CHEST, TRAPPED_CHEST, ENDER_CHEST, CHEST,
SIGN, WALL_SIGN, HANGING_SIGN, TRAPPED_CHEST,
BANNER, WALL_BANNER, ENDER_CHEST,
BED, SHULKER_BOX, SIGN,
HEAD, WALL_HEAD, WALL_SIGN,
CONDUIT, DECORATED_POT, BELL 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. */ /** 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) {} public record BannerPattern(String patternKey, int colorArgb) {}
@@ -46,8 +46,13 @@ public final class BoxUv {
double[] down = {u + dz + 2 * dx, v, -dx, dz}; double[] down = {u + dz + 2 * dx, v, -dx, dz};
if (cube.mirror) { if (cube.mirror) {
for (double[] f : new double[][]{east, west, up, down, south, north}) { f[0] += f[2]; f[2] = -f[2]; } for (double[] f : new double[][] {east, west, up, down, south, north}) {
double[] tmp = east; east = west; west = tmp; // mirror swaps the left/right faces 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]; 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.TextureCache;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction; import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -29,7 +28,8 @@ public final class DecorationBaker implements EntityBaker<DecorationState> {
} }
private RenderedEntity bakePainting(DecorationState s) { 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; if (art == null) return null;
int[][] back = textures.get(ResourceLocation.parse("painting/back")).orElse(art); int[][] back = textures.get(ResourceLocation.parse("painting/back")).orElse(art);
@@ -53,10 +53,12 @@ public final class DecorationBaker implements EntityBaker<DecorationState> {
private RenderedEntity bakeItemFrame(DecorationState s) { private RenderedEntity bakeItemFrame(DecorationState s) {
String frameTex = s.glow() ? "block/glow_item_frame" : "block/item_frame"; String frameTex = s.glow() ? "block/glow_item_frame" : "block/item_frame";
int[][] leather = textures.get(ResourceLocation.parse(frameTex)).orElse(null); 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; 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(); int si = Direction.SOUTH.ordinal();
List<EntityCube> cubes = new ArrayList<>(3); List<EntityCube> cubes = new ArrayList<>(3);
@@ -83,7 +85,7 @@ public final class DecorationBaker implements EntityBaker<DecorationState> {
/** A local-space corner in model pixels (1/16 block); z is the outward (front) offset from the wall. */ /** A local-space corner in model pixels (1/16 block); z is the outward (front) offset from the wall. */
private static double[] px(double x, double y, double z) { private static double[] px(double x, double y, double z) {
return new double[]{x / 16.0, y / 16.0, z / 16.0}; return new double[] {x / 16.0, y / 16.0, z / 16.0};
} }
/** Item sprite: generated items live under item/, block items fall back to block/. */ /** Item sprite: generated items live under item/, block items fall back to block/. */
@@ -7,19 +7,31 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
* already encodes vanilla's awkward multi-block painting offset), avoiding placement math. * already encodes vanilla's awkward multi-block painting offset), avoiding placement math.
*/ */
public record DecorationState( public record DecorationState(
Kind kind, Kind kind,
double minX, double minY, double minZ, double minX,
double maxX, double maxY, double maxZ, double minY,
Facing facing, // direction the front faces (away from the wall) double minZ,
String paintingArt, // painting art asset key (texture painting/<art>); null for frames double maxX,
String itemId, // item-frame contents material key (e.g. "diamond"); null if empty double maxY,
int itemRotationDeg, // item-frame content rotation (0/45/…/315) double maxZ,
boolean glow // glow item frame Facing facing, // direction the front faces (away from the wall)
) { String paintingArt, // painting art asset key (texture painting/<art>); null for frames
public enum Kind { PAINTING, ITEM_FRAME } 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 Facing { 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. */ /** The axis this face's normal lies on: 0=x, 1=y, 2=z. */
public int axis() { public int axis() {
@@ -8,11 +8,11 @@ import eu.mhsl.minecraft.pixelpics.assets.model.Face;
* broad-phase culling. * broad-phase culling.
*/ */
public final class EntityCube { public final class EntityCube {
public final double[] from; // local min (px, inflated) public final double[] from; // local min (px, inflated)
public final double[] to; // local max public final double[] to; // local max
public final Face[] faces; // by Direction.ordinal() public final Face[] faces; // by Direction.ordinal()
public final Affine toWorld; public final Affine toWorld;
public final Affine toLocal; // inverse public final Affine toLocal; // inverse
public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE}; public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
public final double[] aabbMax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE}; public final double[] aabbMax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
@@ -18,8 +18,7 @@ public final class EntityIntersector {
private EntityIntersector() {} private EntityIntersector() {}
public static FaceHit intersect(EntityCube cube, double ox, double oy, double oz, public static FaceHit intersect(EntityCube cube, double ox, double oy, double oz, double dx, double dy, double dz) {
double dx, double dy, double dz) {
double[] o = cube.toLocal.apply(ox, oy, oz); double[] o = cube.toLocal.apply(ox, oy, oz);
double[] d = cube.toLocal.applyLinear(dx, dy, dz); double[] d = cube.toLocal.applyLinear(dx, dy, dz);
@@ -36,23 +35,33 @@ public final class EntityIntersector {
double t2 = (cube.to[a] - o[a]) * inv; double t2 = (cube.to[a] - o[a]) * inv;
boolean n = true; boolean n = true;
if (t1 > t2) { if (t1 > t2) {
double tmp = t1; t1 = t2; t2 = tmp; double tmp = t1;
t1 = t2;
t2 = tmp;
n = false; 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 (t2 < tmax) tmax = t2;
if (tmin > tmax) return null; if (tmin > tmax) return null;
} }
if (axis < 0) return null; if (axis < 0) return null;
double t = tmin; 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; 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 =
case 0 -> neg ? Direction.WEST : Direction.EAST; switch (axis) {
case 1 -> neg ? Direction.DOWN : Direction.UP; case 0 -> neg ? Direction.WEST : Direction.EAST;
default -> neg ? Direction.NORTH : Direction.SOUTH; case 1 -> neg ? Direction.DOWN : Direction.UP;
}; default -> neg ? Direction.NORTH : Direction.SOUTH;
};
Face face = cube.faces[dir.ordinal()]; Face face = cube.faces[dir.ordinal()];
if (face == null) return null; if (face == null) return null;
@@ -63,12 +72,30 @@ public final class EntityIntersector {
// run their horizontal axis opposite to back/left (they're viewed from the other side). // run their horizontal axis opposite to back/left (they're viewed from the other side).
double s, tt; double s, tt;
switch (dir) { switch (dir) {
case UP -> { s = fx; tt = fz; } case UP -> {
case DOWN -> { s = fx; tt = 1 - fz; } s = fx;
case NORTH -> { s = 1 - fx; tt = 1 - fy; } tt = fz;
case SOUTH -> { s = fx; tt = 1 - fy; } }
case EAST -> { s = 1 - fz; tt = 1 - fy; } case DOWN -> {
default -> { s = fz; tt = 1 - fy; } // WEST 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); int color = face.sample(s, tt);
if (ColorUtil.alpha(color) <= ALPHA_THRESHOLD) return null; if (ColorUtil.alpha(color) <= ALPHA_THRESHOLD) return null;
@@ -1,7 +1,6 @@
package eu.mhsl.minecraft.pixelpics.render.entity; package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation; import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -18,33 +17,32 @@ public final class EntityModels {
// Type key -> CEM (.jem) model name. Default is the type key itself; these are the exceptions // Type key -> CEM (.jem) model name. Default is the type key itself; these are the exceptions
// (mob reuses another mob's model, or the CEM set only ships a version-suffixed/renamed name). // (mob reuses another mob's model, or the CEM set only ships a version-suffixed/renamed name).
private static final Map<String, String> CEM_OVERRIDE = Map.ofEntries( private static final Map<String, String> CEM_OVERRIDE = Map.ofEntries(
Map.entry("husk", "zombie"), Map.entry("husk", "zombie"),
Map.entry("giant", "zombie"), Map.entry("giant", "zombie"),
Map.entry("mooshroom", "cow"), Map.entry("mooshroom", "cow"),
Map.entry("ocelot", "cat"), Map.entry("ocelot", "cat"),
Map.entry("cave_spider", "spider"), Map.entry("cave_spider", "spider"),
Map.entry("elder_guardian", "guardian"), Map.entry("elder_guardian", "guardian"),
Map.entry("glow_squid", "squid"), Map.entry("glow_squid", "squid"),
Map.entry("mule", "donkey"), Map.entry("mule", "donkey"),
Map.entry("skeleton_horse", "horse"), Map.entry("skeleton_horse", "horse"),
Map.entry("zombie_horse", "horse"), Map.entry("zombie_horse", "horse"),
Map.entry("trader_llama", "llama"), Map.entry("trader_llama", "llama"),
Map.entry("stray", "skeleton"), Map.entry("stray", "skeleton"),
Map.entry("wither_skeleton", "skeleton"), Map.entry("wither_skeleton", "skeleton"),
Map.entry("zoglin", "hoglin"), Map.entry("zoglin", "hoglin"),
Map.entry("piglin_brute", "piglin"), Map.entry("piglin_brute", "piglin"),
Map.entry("zombified_piglin", "piglin"), Map.entry("zombified_piglin", "piglin"),
Map.entry("evoker", "illager"), Map.entry("evoker", "illager"),
Map.entry("vindicator", "illager"), Map.entry("vindicator", "illager"),
Map.entry("illusioner", "illager"), Map.entry("illusioner", "illager"),
Map.entry("wandering_trader", "villager"), Map.entry("wandering_trader", "villager"),
Map.entry("ender_dragon", "dragon"), Map.entry("ender_dragon", "dragon"),
Map.entry("mannequin", "player"), Map.entry("mannequin", "player"),
Map.entry("camel_husk", "camel"), Map.entry("camel_husk", "camel"),
Map.entry("rabbit", "rabbit_21.11"), Map.entry("rabbit", "rabbit_21.11"),
Map.entry("pufferfish", "puffer_fish_big"), 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). */ /** The CEM model name for an entity type (boats/rafts share the boat hull). */
public static String cemModel(String typeKey) { public static String cemModel(String typeKey) {
@@ -53,64 +51,63 @@ public final class EntityModels {
} }
// Type key -> texture path override (where the first derived candidate is wrong). // 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("cow", "entity/cow/cow_temperate"),
Map.entry("mooshroom", "entity/cow/mooshroom_red"), Map.entry("mooshroom", "entity/cow/mooshroom_red"),
Map.entry("zombie", "entity/zombie/zombie"), Map.entry("zombie", "entity/zombie/zombie"),
Map.entry("husk", "entity/zombie/husk"), Map.entry("husk", "entity/zombie/husk"),
Map.entry("drowned", "entity/zombie/drowned"), Map.entry("drowned", "entity/zombie/drowned"),
Map.entry("zombified_piglin", "entity/piglin/zombified_piglin"), Map.entry("zombified_piglin", "entity/piglin/zombified_piglin"),
Map.entry("skeleton", "entity/skeleton/skeleton"), Map.entry("skeleton", "entity/skeleton/skeleton"),
Map.entry("stray", "entity/skeleton/stray"), Map.entry("stray", "entity/skeleton/stray"),
Map.entry("wither_skeleton", "entity/skeleton/wither_skeleton"), Map.entry("wither_skeleton", "entity/skeleton/wither_skeleton"),
Map.entry("creeper", "entity/creeper/creeper"), Map.entry("creeper", "entity/creeper/creeper"),
Map.entry("spider", "entity/spider/spider"), Map.entry("spider", "entity/spider/spider"),
Map.entry("enderman", "entity/enderman/enderman"), Map.entry("enderman", "entity/enderman/enderman"),
Map.entry("player", "entity/player/wide/steve"), Map.entry("player", "entity/player/wide/steve"),
// Textures whose folder/name doesn't follow the "entity/<key>/<key>" pattern. // Textures whose folder/name doesn't follow the "entity/<key>/<key>" pattern.
Map.entry("iron_golem", "entity/iron_golem/iron_golem"), Map.entry("iron_golem", "entity/iron_golem/iron_golem"),
Map.entry("polar_bear", "entity/bear/polarbear"), Map.entry("polar_bear", "entity/bear/polarbear"),
Map.entry("ender_dragon", "entity/enderdragon/dragon"), Map.entry("ender_dragon", "entity/enderdragon/dragon"),
Map.entry("magma_cube", "entity/slime/magmacube"), Map.entry("magma_cube", "entity/slime/magmacube"),
Map.entry("tropical_fish", "entity/fish/tropical_a"), Map.entry("tropical_fish", "entity/fish/tropical_a"),
Map.entry("bogged", "entity/skeleton/bogged"), Map.entry("bogged", "entity/skeleton/bogged"),
Map.entry("donkey", "entity/horse/donkey"), Map.entry("donkey", "entity/horse/donkey"),
Map.entry("mule", "entity/horse/mule"), Map.entry("mule", "entity/horse/mule"),
Map.entry("skeleton_horse", "entity/horse/horse_skeleton"), Map.entry("skeleton_horse", "entity/horse/horse_skeleton"),
Map.entry("zombie_horse", "entity/horse/horse_zombie"), Map.entry("zombie_horse", "entity/horse/horse_zombie"),
Map.entry("trader_llama", "entity/llama/llama_creamy"), Map.entry("trader_llama", "entity/llama/llama_creamy"),
Map.entry("cave_spider", "entity/spider/cave_spider"), Map.entry("cave_spider", "entity/spider/cave_spider"),
Map.entry("guardian", "entity/guardian/guardian"), Map.entry("guardian", "entity/guardian/guardian"),
Map.entry("elder_guardian", "entity/guardian/guardian_elder"), Map.entry("elder_guardian", "entity/guardian/guardian_elder"),
Map.entry("piglin_brute", "entity/piglin/piglin_brute"), Map.entry("piglin_brute", "entity/piglin/piglin_brute"),
Map.entry("zoglin", "entity/hoglin/zoglin"), Map.entry("zoglin", "entity/hoglin/zoglin"),
Map.entry("illusioner", "entity/illager/illusioner"), Map.entry("illusioner", "entity/illager/illusioner"),
Map.entry("giant", "entity/zombie/zombie"), Map.entry("giant", "entity/zombie/zombie"),
// Illagers share one texture folder; none follow the entity/<key>/<key> pattern. // Illagers share one texture folder; none follow the entity/<key>/<key> pattern.
Map.entry("pillager", "entity/illager/pillager"), Map.entry("pillager", "entity/illager/pillager"),
Map.entry("vindicator", "entity/illager/vindicator"), Map.entry("vindicator", "entity/illager/vindicator"),
Map.entry("evoker", "entity/illager/evoker"), Map.entry("evoker", "entity/illager/evoker"),
Map.entry("ravager", "entity/illager/ravager"), Map.entry("ravager", "entity/illager/ravager"),
Map.entry("vex", "entity/illager/vex"), Map.entry("vex", "entity/illager/vex"),
// Fish share entity/fish/; squids share entity/squid/. // Fish share entity/fish/; squids share entity/squid/.
Map.entry("cod", "entity/fish/cod"), Map.entry("cod", "entity/fish/cod"),
Map.entry("salmon", "entity/fish/salmon"), Map.entry("salmon", "entity/fish/salmon"),
Map.entry("pufferfish", "entity/fish/pufferfish"), Map.entry("pufferfish", "entity/fish/pufferfish"),
Map.entry("glow_squid", "entity/squid/glow_squid"), Map.entry("glow_squid", "entity/squid/glow_squid"),
// Variant-only textures with no plain base file — pick a sensible default variant. // Variant-only textures with no plain base file — pick a sensible default variant.
Map.entry("cat", "entity/cat/cat_tabby"), Map.entry("cat", "entity/cat/cat_tabby"),
Map.entry("ocelot", "entity/cat/ocelot"), // ocelot texture lives in the cat folder now Map.entry("ocelot", "entity/cat/ocelot"), // ocelot texture lives in the cat folder now
Map.entry("axolotl", "entity/axolotl/axolotl_wild"), Map.entry("axolotl", "entity/axolotl/axolotl_wild"),
Map.entry("parrot", "entity/parrot/parrot_red_blue"), Map.entry("parrot", "entity/parrot/parrot_red_blue"),
Map.entry("turtle", "entity/turtle/turtle"), Map.entry("turtle", "entity/turtle/turtle"),
Map.entry("wind_charge", "entity/projectiles/wind_charge"), Map.entry("wind_charge", "entity/projectiles/wind_charge"),
Map.entry("camel_husk", "entity/camel/camel_husk"), Map.entry("camel_husk", "entity/camel/camel_husk"),
Map.entry("armor_stand", "entity/armorstand/armorstand"), // texture folder is "armorstand" Map.entry("armor_stand", "entity/armorstand/armorstand"), // texture folder is "armorstand"
Map.entry("happy_ghast", "entity/ghast/happy_ghast"), Map.entry("happy_ghast", "entity/ghast/happy_ghast"),
Map.entry("parched", "entity/skeleton/parched"), // husk-style skeleton, texture in skeleton/ Map.entry("parched", "entity/skeleton/parched"), // husk-style skeleton, texture in skeleton/
Map.entry("zombie_nautilus_coral", "entity/nautilus/zombie_nautilus_coral"), 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. */ /** Ordered texture-path candidates; the baker uses the first that loads. */
public static List<ResourceLocation> textureCandidates(String typeKey, String variant) { public static List<ResourceLocation> textureCandidates(String typeKey, String variant) {
@@ -139,30 +136,30 @@ public final class EntityModels {
*/ */
private static List<String> variantPaths(String typeKey, String v) { private static List<String> variantPaths(String typeKey, String v) {
return switch (typeKey) { return switch (typeKey) {
case "cat" -> List.of("entity/cat/cat_" + v); case "cat" -> List.of("entity/cat/cat_" + v);
case "axolotl" -> List.of("entity/axolotl/axolotl_" + v); case "axolotl" -> List.of("entity/axolotl/axolotl_" + v);
case "wolf" -> List.of("entity/wolf/wolf_" + v, "entity/wolf/wolf"); case "wolf" -> List.of("entity/wolf/wolf_" + v, "entity/wolf/wolf");
case "horse" -> List.of("entity/horse/horse_" + HORSE_COLOR.getOrDefault(v, v)); case "horse" -> List.of("entity/horse/horse_" + HORSE_COLOR.getOrDefault(v, v));
case "llama" -> List.of("entity/llama/llama_" + v); case "llama" -> List.of("entity/llama/llama_" + v);
case "cow" -> List.of("entity/cow/cow_" + v); case "cow" -> List.of("entity/cow/cow_" + v);
case "pig" -> List.of("entity/pig/pig_" + v); case "pig" -> List.of("entity/pig/pig_" + v);
case "chicken" -> List.of("entity/chicken/chicken_" + v); case "chicken" -> List.of("entity/chicken/chicken_" + v);
case "frog" -> List.of("entity/frog/frog_" + v); case "frog" -> List.of("entity/frog/frog_" + v);
case "panda" -> List.of(v.equals("normal") ? "entity/panda/panda" : "entity/panda/panda_" + 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 "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 "parrot" -> List.of("entity/parrot/parrot_" + PARROT_COLOR.getOrDefault(v, v));
case "rabbit" -> List.of("entity/rabbit/rabbit_" + RABBIT_TYPE.getOrDefault(v, v)); case "rabbit" -> List.of("entity/rabbit/rabbit_" + RABBIT_TYPE.getOrDefault(v, v));
case "mooshroom" -> List.of("entity/cow/mooshroom_" + v); case "mooshroom" -> List.of("entity/cow/mooshroom_" + v);
case "shulker" -> List.of("entity/shulker/shulker_" + v); case "shulker" -> List.of("entity/shulker/shulker_" + v);
// villager/zombie_villager: type/<biome> and profession are transparent OVERLAYS (clothing // 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. // only); the opaque base body is entity/<folder>/<folder> — handled by the generic candidates.
default -> List.of(); default -> List.of();
}; };
} }
private static final Map<String, String> HORSE_COLOR = Map.of("dark_brown", "darkbrown"); private static final Map<String, String> HORSE_COLOR = Map.of("dark_brown", "darkbrown");
private static final Map<String, String> PARROT_COLOR = Map.of( private static final Map<String, String> PARROT_COLOR =
"red", "red_blue", "cyan", "yellow_blue", "gray", "grey"); Map.of("red", "red_blue", "cyan", "yellow_blue", "gray", "grey");
private static final Map<String, String> RABBIT_TYPE = Map.of( private static final Map<String, String> RABBIT_TYPE =
"black_and_white", "white_splotched", "salt_and_pepper", "salt", "the_killer_bunny", "caerbannog"); 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.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker; import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit; import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -18,9 +17,13 @@ public final class EntityScene {
private static final double EPS = 1e-7; private static final double EPS = 1e-7;
private final List<RenderedEntity> entities; private final List<RenderedEntity> entities;
public EntityScene(List<EntityState> states, CemBaker baker, public EntityScene(
List<BlockEntityState> blockEntities, BlockEntityBaker beBaker, List<EntityState> states,
List<DecorationState> decorations, DecorationBaker decoBaker) { CemBaker baker,
List<BlockEntityState> blockEntities,
BlockEntityBaker beBaker,
List<DecorationState> decorations,
DecorationBaker decoBaker) {
this.entities = new ArrayList<>(states.size() + blockEntities.size() + decorations.size()); this.entities = new ArrayList<>(states.size() + blockEntities.size() + decorations.size());
addAll(states, baker); addAll(states, baker);
addAll(blockEntities, beBaker); addAll(blockEntities, beBaker);
@@ -58,8 +61,8 @@ public final class EntityScene {
return best; return best;
} }
private static boolean rayAabb(double[] min, double[] max, double ox, double oy, double oz, private static boolean rayAabb(
double dx, double dy, double dz, double maxT) { double[] min, double[] max, double ox, double oy, double oz, double dx, double dy, double dz, double maxT) {
double tmin = 0, tmax = maxT; double tmin = 0, tmax = maxT;
double[] o = {ox, oy, oz}, d = {dx, dy, dz}; double[] o = {ox, oy, oz}, d = {dx, dy, dz};
for (int a = 0; a < 3; a++) { for (int a = 0; a < 3; a++) {
@@ -69,7 +72,11 @@ public final class EntityScene {
double inv = 1.0 / d[a]; double inv = 1.0 / d[a];
double t1 = (min[a] - o[a]) * inv; double t1 = (min[a] - o[a]) * inv;
double t2 = (max[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 (t1 > tmin) tmin = t1;
if (t2 < tmax) tmax = t2; if (t2 < tmax) tmax = t2;
if (tmin > tmax) return false; if (tmin > tmax) return false;
@@ -5,24 +5,31 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
* off-thread. Angles are in degrees (Minecraft convention). * off-thread. Angles are in degrees (Minecraft convention).
*/ */
public record EntityState( public record EntityState(
String typeKey, // e.g. "cow", "zombie", "player" String typeKey, // e.g. "cow", "zombie", "player"
double x, double y, double z, double x,
float bodyYaw, double y,
boolean baby, double z,
double width, double height, float bodyYaw,
boolean player, String skinUrl, boolean slim, boolean baby,
String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"); for villagers the biome type, or null double width,
int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none double height,
double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default boolean player,
String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null String skinUrl,
int villagerLevel, // villager profession level 1-5 (badge tier); 0 = none/unknown boolean slim,
String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"); for villagers the biome type,
boolean saddle, // horse/donkey/mule is saddled // or null
boolean chest, // donkey/mule/llama is carrying a chest int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none
String bodyEquip, // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama"; null = none double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default
Equipment equipment, // worn humanoid armor (players, armor stands, zombies, …); null = none/not a wearer String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null
boolean invisible // entity is invisible -> render only its equipment (like vanilla), not the body int villagerLevel, // villager profession level 1-5 (badge tier); 0 = none/unknown
) { 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
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. */ /** 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 record Equipment(EquipPiece head, EquipPiece chest, EquipPiece legs, EquipPiece feet) {
@@ -33,10 +40,10 @@ public record EntityState(
/** One worn armor piece. */ /** One worn armor piece. */
public record EquipPiece( public record EquipPiece(
String asset, // equipment asset id, e.g. "diamond", "leather", "elytra", "turtle_scute" String asset, // equipment asset id, e.g. "diamond", "leather", "elytra", "turtle_scute"
int dyeColor, // ARGB tint for dyeable (leather) armor; 0 = undyed/not dyeable 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 trimMaterial, // armor-trim material key (e.g. "diamond"); null = no trim
String trimPattern, // armor-trim pattern key (e.g. "coast"); null = no trim String trimPattern, // armor-trim pattern key (e.g. "coast"); null = no trim
boolean glint // item is enchanted -> render the enchantment glint boolean glint // item is enchanted -> render the enchantment glint
) {} ) {}
} }
@@ -5,10 +5,10 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
* {@code origin} is the minimum corner, {@code uv} is the box-UV texture offset. * {@code origin} is the minimum corner, {@code uv} is the box-UV texture offset.
*/ */
public final class ModelCube { public final class ModelCube {
public final double[] origin; // 3, min corner (px) public final double[] origin; // 3, min corner (px)
public final double[] size; // 3 (px) public final double[] size; // 3 (px)
public final double inflate; // px, expands the box on all sides (overlay layers) public final double inflate; // px, expands the box on all sides (overlay layers)
public final double[] uv; // 2, box-UV offset (texels) public final double[] uv; // 2, box-UV offset (texels)
public final boolean mirror; public final boolean mirror;
/** 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. */ /** 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 final double[][] faceUv;
@@ -39,7 +39,10 @@ public final class TextureOps {
int sp = src[y][x]; int sp = src[y][x];
int sa = (sp >>> 24) & 0xFF; int sa = (sp >>> 24) & 0xFF;
if (sa == 0) continue; 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 dp = dst[y][x];
int da = (dp >>> 24) & 0xFF; int da = (dp >>> 24) & 0xFF;
int outA = sa + da * (255 - sa) / 255; int outA = sa + da * (255 - sa) / 255;
@@ -73,7 +76,10 @@ public final class TextureOps {
int c = from[i]; int c = from[i];
int dr = pr - ((c >> 16) & 0xFF), dg = pg - ((c >> 8) & 0xFF), db = pb - (c & 0xFF); int dr = pr - ((c >> 16) & 0xFF), dg = pg - ((c >> 8) & 0xFF), db = pb - (c & 0xFF);
int d = dr * dr + dg * dg + db * db; int d = dr * dr + dg * dg + db * db;
if (d < bestD) { bestD = d; best = i; } if (d < bestD) {
bestD = d;
best = i;
}
} }
row[x] = (a << 24) | (to[best] & 0xFFFFFF); row[x] = (a << 24) | (to[best] & 0xFFFFFF);
} }
@@ -13,7 +13,6 @@ import eu.mhsl.minecraft.pixelpics.render.entity.EntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube; import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity; import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps; import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -36,13 +35,13 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
// Sign-text placement (model px in the board's local frame; calibrated against the test harness). // 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 // 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. // 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 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 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 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 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 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_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 static final double TEXT_THICK = 0.5; // slab thickness so the ray test never degenerates
private final CemModelLoader models; private final CemModelLoader models;
private final TextureCache textures; private final TextureCache textures;
@@ -69,12 +68,13 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
Place p = place(s); Place p = place(s);
Affine pre = Affine.scale(p.scale / 16.0); Affine pre = Affine.scale(p.scale / 16.0);
Affine placement = Affine.translation(s.bx() + 0.5, s.by(), s.bz() + 0.5) Affine placement = Affine.translation(s.bx() + 0.5, s.by(), s.bz() + 0.5)
.mul(Affine.rotY(Math.toRadians(p.yaw))) .mul(Affine.rotY(Math.toRadians(p.yaw)))
.mul(Affine.translation(p.lx, p.ly, p.lz)); .mul(Affine.translation(p.lx, p.ly, p.lz));
List<EntityCube> cubes = new ArrayList<>(); List<EntityCube> cubes = new ArrayList<>();
for (Layer layer : layers) { 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()))); cubes.add(new EntityCube(b.from(), b.to(), b.faces(), placement.mul(b.world())));
} }
} }
@@ -106,10 +106,18 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
addSide(s.backText(), Direction.SOUTH, 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, private void addSide(
double cy, Affine toWorld, List<EntityCube> cubes) { BlockEntityState.SignText t,
Direction faceDir,
double tw,
double th,
double cap,
double cy,
Affine toWorld,
List<EntityCube> cubes) {
if (t == null) return; if (t == null) return;
int[][] bmp = SignTextRasterizer.rasterize(trimBlankLines(t.lines()), font, t.fillArgb(), t.outlineArgb(), t.glowing()); int[][] bmp = SignTextRasterizer.rasterize(
trimBlankLines(t.lines()), font, t.fillArgb(), t.outlineArgb(), t.glowing());
if (bmp == null) return; if (bmp == null) return;
// Scale so the text block fills the board's writable area, whichever dimension binds, capped. // 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 fpm = Math.min(cap, Math.min(tw / bmp[0].length, th / bmp.length));
@@ -117,10 +125,10 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
double blockH = bmp.length * fpm; double blockH = bmp.length * fpm;
double z0, z1; double z0, z1;
if (faceDir == Direction.NORTH) { // front: Z face, text sits just past 1 if (faceDir == Direction.NORTH) { // front: Z face, text sits just past 1
z1 = -BOARD_FRONT_Z - TEXT_Z_EPS; z1 = -BOARD_FRONT_Z - TEXT_Z_EPS;
z0 = z1 - TEXT_THICK; z0 = z1 - TEXT_THICK;
} else { // back: +Z face, text sits just past +1 } else { // back: +Z face, text sits just past +1
z0 = BOARD_FRONT_Z + TEXT_Z_EPS; z0 = BOARD_FRONT_Z + TEXT_Z_EPS;
z1 = z0 + TEXT_THICK; z1 = z0 + TEXT_THICK;
} }
@@ -156,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 /** 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). */ * 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) { 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). */ /** Some types paint different parts with different textures (pot sherds, conduit cage/heart). */
@@ -175,13 +185,17 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
/** The conduit's cage and inner heart use separate textures; the eye/wind (active state) are skipped. */ /** The conduit's cage and inner heart use separate textures; the eye/wind (active state) are skipped. */
private List<Layer> conduitLayers() { private List<Layer> conduitLayers() {
int[][] cage = textures.get(ResourceLocation.parse("entity/conduit/cage")).orElse(null); int[][] cage =
int[][] base = textures.get(ResourceLocation.parse("entity/conduit/base")).orElse(null); 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 // 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). // 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); 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 (cage != null)
if (base != null) layers.add(new Layer(base, onlyPart("base", CONDUIT_PARTS), base[0].length, base.length, true)); 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; return layers;
} }
@@ -196,8 +210,10 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
private static final String[] POT_FACES = {"front", "left", "right", "back"}; // matches sherd capture order private static final String[] POT_FACES = {"front", "left", "right", "back"}; // matches sherd capture order
private List<Layer> potLayers(BlockEntityState s) { private List<Layer> potLayers(BlockEntityState s) {
int[][] base = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_base")).orElse(null); int[][] base = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_base"))
int[][] side = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_side")).orElse(null); .orElse(null);
int[][] side = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_side"))
.orElse(null);
if (base == null) return List.of(); if (base == null) return List.of();
List<Layer> layers = new ArrayList<>(); List<Layer> layers = new ArrayList<>();
// Structure (rim/neck/foot) comes from the combined base texture; the four sides are NOT in it. // Structure (rim/neck/foot) comes from the combined base texture; the four sides are NOT in it.
@@ -210,7 +226,8 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
String sherd = s.sherds().get(i); String sherd = s.sherds().get(i);
if (sherd != null && sherd.endsWith("_pottery_sherd")) { if (sherd != null && sherd.endsWith("_pottery_sherd")) {
int[][] pat = textures.get(ResourceLocation.parse( 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; if (pat != null) tex = pat;
} }
} }
@@ -230,7 +247,7 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
return switch (s.kind()) { return switch (s.kind()) {
case SIGN -> new Place(yaw, SIGN_SCALE, 0, 0, 0); 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 WALL_SIGN -> new Place(yaw, SIGN_SCALE, 0, -0.3125, 0.4375); // drop to mid-block, push to wall
case WALL_HEAD -> new Place(yaw, 1.0, 0, 0.25, 0.25); // mid-height, against the wall 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); case WALL_BANNER -> new Place(yaw, 1.0, 0, -0.16, 0.4375);
default -> new Place(yaw, 1.0, 0, 0, 0); default -> new Place(yaw, 1.0, 0, 0, 0);
}; };
@@ -240,8 +257,8 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
private Set<String> hiddenParts(BlockEntityState s) { private Set<String> hiddenParts(BlockEntityState s) {
return switch (s.kind()) { return switch (s.kind()) {
case BED -> s.bedPart() == BlockEntityState.BedPart.HEAD case BED -> s.bedPart() == BlockEntityState.BedPart.HEAD
? Set.of("foot", "leg3", "leg4") ? Set.of("foot", "leg3", "leg4")
: Set.of("head", "leg1", "leg2"); : Set.of("head", "leg1", "leg2");
case CONDUIT -> Set.of("eye", "wind"); case CONDUIT -> Set.of("eye", "wind");
default -> Set.of(); default -> Set.of();
}; };
@@ -251,7 +268,8 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
private int[][] resolveTexture(BlockEntityState s) { private int[][] resolveTexture(BlockEntityState s) {
// Player heads use the owner's skin when available. // 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); int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin; if (skin != null) return skin;
} }
@@ -273,7 +291,8 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
int[][] out = TextureOps.deepCopy(base); int[][] out = TextureOps.deepCopy(base);
if (s.baseColorArgb() != 0) TextureOps.tint(out, s.baseColorArgb()); if (s.baseColorArgb() != 0) TextureOps.tint(out, s.baseColorArgb());
for (BlockEntityState.BannerPattern pat : s.patterns()) { 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; if (mask == null) continue;
int[][] dyed = TextureOps.deepCopy(mask); int[][] dyed = TextureOps.deepCopy(mask);
TextureOps.tint(dyed, pat.colorArgb()); TextureOps.tint(dyed, pat.colorArgb());
@@ -13,7 +13,6 @@ import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube; import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity; import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps; import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -31,9 +30,8 @@ public final class CemBaker implements EntityBaker<EntityState> {
// Parts representing an alternate state (rolled-up, sleeping, …) that must not render in the idle pose. // Parts representing an alternate state (rolled-up, sleeping, …) that must not render in the idle pose.
private static final Map<String, Set<String>> HIDDEN_PARTS = Map.of( private static final Map<String, Set<String>> HIDDEN_PARTS = Map.of(
"armadillo", Set.of("cube"), // the rolled-up ball "armadillo", Set.of("cube"), // the rolled-up ball
"illager", Set.of("left_arm", "right_arm") "illager", Set.of("left_arm", "right_arm"));
);
private final CemModelLoader models; private final CemModelLoader models;
private final TextureCache textures; private final TextureCache textures;
@@ -53,11 +51,11 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (s.typeKey().equals("villager") || s.typeKey().equals("zombie_villager")) { if (s.typeKey().equals("villager") || s.typeKey().equals("zombie_villager")) {
tex = compositeVillager(s, tex); tex = compositeVillager(s, tex);
} else if (cem.equals("horse")) { } else if (cem.equals("horse")) {
tex = compositeHorse(s, tex); // coat markings + horse armor tex = compositeHorse(s, tex); // coat markings + horse armor
} else if (cem.equals("llama")) { } else if (cem.equals("llama")) {
tex = compositeLlama(s, tex); // dyed/trader carpet decor tex = compositeLlama(s, tex); // dyed/trader carpet decor
} else if (cem.equals("nautilus")) { } else if (cem.equals("nautilus")) {
tex = compositeNautilus(s, tex); // body armor + saddle (same-UV overlays) tex = compositeNautilus(s, tex); // body armor + saddle (same-UV overlays)
} }
CemModelLoader.CemModel model = models.get(cem); CemModelLoader.CemModel model = models.get(cem);
// A visible entity needs its body model+texture; an invisible one renders only its equipment // A visible entity needs its body model+texture; an invisible one renders only its equipment
@@ -73,13 +71,18 @@ public final class CemBaker implements EntityBaker<EntityState> {
Set<String> hidden = new HashSet<>(HIDDEN_PARTS.getOrDefault(cem, 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. // Donkeys/llamas carry the chest boxes inside the base model; hide them unless a chest is equipped.
if (!s.chest()) { if (!s.chest()) {
if (cem.equals("donkey")) { hidden.add("left_chest"); hidden.add("right_chest"); } if (cem.equals("donkey")) {
else if (cem.equals("llama")) { hidden.add("chest_left"); hidden.add("chest_right"); } hidden.add("left_chest");
hidden.add("right_chest");
} else if (cem.equals("llama")) {
hidden.add("chest_left");
hidden.add("chest_right");
}
} }
// The body model is baked even when invisible — not drawn, but used as the ground-snap reference // 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). // 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) List<CemGeometry.Baked> body =
? CemGeometry.bakeModel(model, tex, pre, hidden) : List.of(); (model != null && tex != null) ? CemGeometry.bakeModel(model, tex, pre, hidden) : List.of();
List<CemGeometry.Baked> baked = new ArrayList<>(); List<CemGeometry.Baked> baked = new ArrayList<>();
if (!invisible) { if (!invisible) {
@@ -87,7 +90,8 @@ public final class CemBaker implements EntityBaker<EntityState> {
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows). // Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
if (s.typeKey().equals("sheep")) { if (s.typeKey().equals("sheep")) {
CemModelLoader.CemModel wool = models.get("sheep_wool"); 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) { if (wool != null && woolTex != null) {
int[][] t = TextureOps.deepCopy(woolTex); int[][] t = TextureOps.deepCopy(woolTex);
if (s.tint() != 0) TextureOps.tint(t, s.tint()); if (s.tint() != 0) TextureOps.tint(t, s.tint());
@@ -98,9 +102,10 @@ public final class CemBaker implements EntityBaker<EntityState> {
// left face is transparent in the texture → a see-through hole on the left. Add the mirrored left panel. // left face is transparent in the texture → a see-through hole on the left. Add the mirrored left panel.
if (cem.equals("guardian")) { if (cem.equals("guardian")) {
double[] org = {-8, 2, -6}, size = {2, 12, 12}; double[] org = {-8, 2, -6}, size = {2, 12, 12};
ModelCube mc = new ModelCube(org, size, 0, new double[]{0, 28}, true); ModelCube mc = new ModelCube(org, size, 0, new double[] {0, 28}, true);
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH()); 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. // 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 (s.saddle()) addSaddleLayer(s, cem, model, pre, baked);
@@ -116,8 +121,8 @@ public final class CemBaker implements EntityBaker<EntityState> {
for (CemGeometry.Baked b : ref) 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()) Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw()))) .mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
.mul(Affine.translation(0, -minY, 0)); .mul(Affine.translation(0, -minY, 0));
List<EntityCube> cubes = new ArrayList<>(baked.size()); List<EntityCube> cubes = new ArrayList<>(baked.size());
for (CemGeometry.Baked b : baked) cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()))); for (CemGeometry.Baked b : baked) cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world())));
@@ -129,8 +134,9 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (s.player()) { if (s.player()) {
int[][] skin = skins.get(s.skinUrl()).orElse(null); int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin; if (skin != null) return skin;
int[][] def = textures.get(ResourceLocation.parse( int[][] def = textures.get(
s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve")).orElse(null); ResourceLocation.parse(s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve"))
.orElse(null);
if (def != null) return def; if (def != null) return def;
} }
for (ResourceLocation rl : EntityModels.textureCandidates(s.typeKey(), s.variant())) { for (ResourceLocation rl : EntityModels.textureCandidates(s.typeKey(), s.variant())) {
@@ -195,12 +201,14 @@ public final class CemBaker implements EntityBaker<EntityState> {
} }
/** Bake the saddle as a separate inflated layer; only the saddle-specific parts (those not in the base model). */ /** 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); 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 if (saddleModel == null) return; // llamas and other mounts have no saddle model
CemModelLoader.CemModel sm = models.get(saddleModel); CemModelLoader.CemModel sm = models.get(saddleModel);
if (sm == null) return; 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; if (saddleTex == null) return;
// Show only the saddle-specific parts: hide every part the base body model also defines. // Show only the saddle-specific parts: hide every part the base body model also defines.
Set<String> hideBase = new HashSet<>(); Set<String> hideBase = new HashSet<>();
@@ -214,10 +222,10 @@ public final class CemBaker implements EntityBaker<EntityState> {
// shoes=boots; // shoes=boots;
// armor_layer_2 (texture entity/equipment/humanoid_leggings/<mat>): waist+legs=leggings. // 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. // 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_HEAD = Set.of("head");
private static final Set<String> ARMOR1_CHEST = Set.of("body", "left_arm", "right_arm"); 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> 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 Set<String> ARMOR2_LEGS = Set.of("waist", "left_leg", "right_leg");
private static final int GLINT_COLOR = 0xFF8040CC; // approximated enchantment-glint purple private static final int GLINT_COLOR = 0xFF8040CC; // approximated enchantment-glint purple
private void addArmorLayers(EntityState s, Affine pre, List<CemGeometry.Baked> baked) { private void addArmorLayers(EntityState s, Affine pre, List<CemGeometry.Baked> baked) {
@@ -234,8 +242,13 @@ public final class CemBaker implements EntityBaker<EntityState> {
} }
/** Bake one armor slot: its layer model with only {@code show} parts, textured for the material. */ /** 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, private void bakeArmorPiece(
String layerFolder, Affine pre, List<CemGeometry.Baked> baked) { EntityState.EquipPiece piece,
String modelName,
Set<String> show,
String layerFolder,
Affine pre,
List<CemGeometry.Baked> baked) {
if (piece == null) return; if (piece == null) return;
CemModelLoader.CemModel model = models.get(modelName); CemModelLoader.CemModel model = models.get(modelName);
if (model == null) return; if (model == null) return;
@@ -249,7 +262,8 @@ public final class CemBaker implements EntityBaker<EntityState> {
/** Resolve + composite a single armor slot's texture: material base (+leather dye/overlay), trim, glint. */ /** Resolve + composite a single armor slot's texture: material base (+leather dye/overlay), trim, glint. */
private int[][] buildArmorTexture(EntityState.EquipPiece piece, String layerFolder) { private int[][] buildArmorTexture(EntityState.EquipPiece piece, String layerFolder) {
String asset = piece.asset(); String asset = piece.asset();
int[][] base = textures.get(ResourceLocation.parse("entity/equipment/" + layerFolder + "/" + asset)).orElse(null); int[][] base = textures.get(ResourceLocation.parse("entity/equipment/" + layerFolder + "/" + asset))
.orElse(null);
if (base == null) return null; if (base == null) return null;
int[][] out = TextureOps.deepCopy(base); int[][] out = TextureOps.deepCopy(base);
// Leather is dyeable: tint the base layer, then composite the (undyed) overlay layer on top. // Leather is dyeable: tint the base layer, then composite the (undyed) overlay layer on top.
@@ -266,13 +280,15 @@ public final class CemBaker implements EntityBaker<EntityState> {
/** Armor trim: recolour the grayscale pattern via the material palette (same UV) and overlay it. */ /** 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) { 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); 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; if (mask == null || mask.length != armorTex.length || mask[0].length != armorTex[0].length) return;
int[] from = palette8("trims/color_palettes/trim_palette"); int[] from = palette8("trims/color_palettes/trim_palette");
// Vanilla uses the darker palette variant when the trim material matches the armor material. // Vanilla uses the darker palette variant when the trim material matches the armor material.
String palette = material; String palette = material;
if (material.equals(armorAsset) if (material.equals(armorAsset)
&& textures.get(ResourceLocation.parse("trims/color_palettes/" + material + "_darker")).isPresent()) { && textures.get(ResourceLocation.parse("trims/color_palettes/" + material + "_darker"))
.isPresent()) {
palette = material + "_darker"; palette = material + "_darker";
} }
int[] to = palette8("trims/color_palettes/" + palette); int[] to = palette8("trims/color_palettes/" + palette);
@@ -291,14 +307,15 @@ public final class CemBaker implements EntityBaker<EntityState> {
/** Static enchantment-glint approximation over an armor/elytra/item texture (in place). */ /** Static enchantment-glint approximation over an armor/elytra/item texture (in place). */
private void applyGlint(int[][] tex) { private void applyGlint(int[][] tex) {
textures.get(ResourceLocation.parse("misc/enchanted_glint_armor")) textures.get(ResourceLocation.parse("misc/enchanted_glint_armor"))
.ifPresent(glint -> TextureOps.addGlint(tex, glint, GLINT_COLOR, 0.6)); .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). */ /** 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) { private void bakeElytra(EntityState.EquipPiece piece, Affine pre, List<CemGeometry.Baked> baked) {
CemModelLoader.CemModel model = models.get("elytra"); CemModelLoader.CemModel model = models.get("elytra");
if (model == null) return; if (model == null) return;
int[][] tex = textures.get(ResourceLocation.parse("entity/equipment/wings/elytra")).orElse(null); int[][] tex = textures.get(ResourceLocation.parse("entity/equipment/wings/elytra"))
.orElse(null);
if (tex == null) return; if (tex == null) return;
int[][] out = TextureOps.deepCopy(tex); int[][] out = TextureOps.deepCopy(tex);
if (piece.glint()) applyGlint(out); if (piece.glint()) applyGlint(out);
@@ -310,12 +327,12 @@ public final class CemBaker implements EntityBaker<EntityState> {
double[] from = {-w / 2, 0, -w / 2}; double[] from = {-w / 2, 0, -w / 2};
double[] to = {w / 2, h, w / 2}; double[] to = {w / 2, h, w / 2};
int[][] t = tex != null ? tex : flat(0xFF8C8C8C); int[][] t = tex != null ? tex : flat(0xFF8C8C8C);
ModelCube box = new ModelCube(new double[]{-w/2, 0, -w/2}, new double[]{w, h, w}, 0, ModelCube box =
new double[]{0, 0}, false); 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)))); 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()) Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw()))) .mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
.mul(Affine.scale(1.0 / 16.0)); .mul(Affine.scale(1.0 / 16.0));
List<EntityCube> cubes = new ArrayList<>(); List<EntityCube> cubes = new ArrayList<>();
cubes.add(new EntityCube(from, to, faces, place)); cubes.add(new EntityCube(from, to, faces, place));
return RenderedEntity.of(cubes); return RenderedEntity.of(cubes);
@@ -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.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv; import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube; import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set; 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 * {@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. * 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, static List<Baked> bakeModel(
int texW, int texH, boolean ignoreFaceUv) { CemModelLoader.CemModel model,
int[][] tex,
Affine pre,
Set<String> hidden,
int texW,
int texH,
boolean ignoreFaceUv) {
int nw = texW > 0 ? texW : model.texW(); int nw = texW > 0 ? texW : model.texW();
int nh = texH > 0 ? texH : model.texH(); int nh = texH > 0 ? texH : model.texH();
List<Baked> out = new ArrayList<>(); List<Baked> out = new ArrayList<>();
@@ -63,15 +68,24 @@ final class CemGeometry {
* parent origin from the 2nd nesting level on). Top-level boxes are absolute; nested boxes are offset * 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)}. * 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, private static void bakePart(
Set<String> hidden, int texW, int texH, int[][] tex, boolean ignoreFaceUv, List<Baked> out) { 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; if (hidden.contains(part.name())) return;
Affine world = parentWorld Affine world = parentWorld
.mul(Affine.translation(o[0], o[1], o[2])) .mul(Affine.translation(o[0], o[1], o[2]))
.mul(Affine.rotZ(Math.toRadians(part.rotate()[2]))) .mul(Affine.rotZ(Math.toRadians(part.rotate()[2])))
.mul(Affine.rotY(Math.toRadians(part.rotate()[1]))) .mul(Affine.rotY(Math.toRadians(part.rotate()[1])))
.mul(Affine.rotX(Math.toRadians(part.rotate()[0]))) .mul(Affine.rotX(Math.toRadians(part.rotate()[0])))
.mul(Affine.translation(-o[0], -o[1], -o[2])); .mul(Affine.translation(-o[0], -o[1], -o[2]));
double ox = depth > 0 ? o[0] : 0, oy = depth > 0 ? o[1] : 0, oz = depth > 0 ? o[2] : 0; double ox = depth > 0 ? o[0] : 0, oy = depth > 0 ? o[1] : 0, oz = depth > 0 ? o[2] : 0;
for (CemModelLoader.CemBox b : part.boxes()) { for (CemModelLoader.CemBox b : part.boxes()) {
@@ -87,9 +101,9 @@ final class CemGeometry {
for (CemModelLoader.CemPart child : part.children()) { for (CemModelLoader.CemPart child : part.children()) {
double[] t = child.translate(); double[] t = child.translate();
// submodel origin = its translate, accumulated with this group's origin from the 2nd level on. // 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); 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.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; 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} * 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). * 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 /** 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. */ * 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. */ /** A whole model: declared texture size and its top-level parts. */
public record CemModel(int texW, int texH, List<CemPart> 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. */ /** Parse the CEM template-models JSON stream. Returns the number of models loaded. */
public int load(InputStream in, Logger logger) { 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"); JsonObject modelsObj = root.getAsJsonObject("models");
for (Map.Entry<String, JsonElement> e : modelsObj.entrySet()) { for (Map.Entry<String, JsonElement> e : modelsObj.entrySet()) {
try { try {
JsonObject entry = e.getValue().getAsJsonObject(); JsonObject entry = e.getValue().getAsJsonObject();
if (!entry.has("model")) continue; 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 tw = model.getAsJsonArray("textureSize").get(0).getAsInt();
int th = model.getAsJsonArray("textureSize").get(1).getAsInt(); int th = model.getAsJsonArray("textureSize").get(1).getAsInt();
List<CemPart> parts = new ArrayList<>(); List<CemPart> parts = new ArrayList<>();
@@ -72,26 +75,36 @@ public final class CemModelLoader {
private CemPart parsePart(JsonObject p) { private CemPart parsePart(JsonObject p) {
double[] translate = arr3(p, "translate"); double[] translate = arr3(p, "translate");
double[] rotate = arr3(p, "rotate"); double[] rotate = arr3(p, "rotate");
boolean partMirror = mirrorsU(p); // mirrorTexture "u" — applies to all of the part's boxes boolean partMirror = mirrorsU(p); // mirrorTexture "u" — applies to all of the part's boxes
List<CemBox> boxes = new ArrayList<>(); List<CemBox> boxes = new ArrayList<>();
if (p.has("boxes")) { if (p.has("boxes")) {
for (JsonElement be : p.getAsJsonArray("boxes")) { for (JsonElement be : p.getAsJsonArray("boxes")) {
JsonObject b = be.getAsJsonObject(); JsonObject b = be.getAsJsonObject();
if (!b.has("coordinates")) continue; if (!b.has("coordinates")) continue;
JsonArray c = b.getAsJsonArray("coordinates"); JsonArray c = b.getAsJsonArray("coordinates");
double[] origin = {c.get(0).getAsDouble(), c.get(1).getAsDouble(), c.get(2).getAsDouble()}; double[] origin = {
double[] size = {c.get(3).getAsDouble(), c.get(4).getAsDouble(), c.get(5).getAsDouble()}; 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 inflate = b.has("sizeAdd") ? b.get("sizeAdd").getAsDouble() : 0;
double[] uv = b.has("textureOffset") double[] uv = b.has("textureOffset")
? new double[]{b.getAsJsonArray("textureOffset").get(0).getAsDouble(), b.getAsJsonArray("textureOffset").get(1).getAsDouble()} ? new double[] {
: new double[]{0, 0}; 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))); boxes.add(new CemBox(origin, size, inflate, uv, partMirror || mirrorsU(b), parseFaceUv(b)));
} }
} }
List<CemPart> children = new ArrayList<>(); 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"))); 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); 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. */ /** 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) { private static double[][] parseFaceUv(JsonObject b) {
boolean any = false; 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; if (!any) return null;
double[][] faces = new double[6][]; double[][] faces = new double[6][];
for (int i = 0; i < FACE_UV_KEYS.length; i++) { for (int i = 0; i < FACE_UV_KEYS.length; i++) {
@@ -109,7 +126,7 @@ public final class CemModelLoader {
JsonArray a = b.getAsJsonArray(FACE_UV_KEYS[i]); JsonArray a = b.getAsJsonArray(FACE_UV_KEYS[i]);
double u1 = a.get(0).getAsDouble(), v1 = a.get(1).getAsDouble(); double u1 = a.get(0).getAsDouble(), v1 = a.get(1).getAsDouble();
double u2 = a.get(2).getAsDouble(), v2 = a.get(3).getAsDouble(); double u2 = a.get(2).getAsDouble(), v2 = a.get(3).getAsDouble();
faces[i] = new double[]{u1, v1, u2 - u1, v2 - v1}; faces[i] = new double[] {u1, v1, u2 - u1, v2 - v1};
} }
return faces; return faces;
} }
@@ -119,8 +136,10 @@ public final class CemModelLoader {
} }
private static double[] arr3(JsonObject o, String key) { private static double[] arr3(JsonObject o, String key) {
if (!o.has(key) || !o.get(key).isJsonArray()) return new double[]{0, 0, 0}; if (!o.has(key) || !o.get(key).isJsonArray()) return new double[] {0, 0, 0};
JsonArray a = o.getAsJsonArray(key); 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()
};
} }
} }
@@ -2,7 +2,6 @@ package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont; import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont;
import eu.mhsl.minecraft.pixelpics.assets.font.Glyph; import eu.mhsl.minecraft.pixelpics.assets.font.Glyph;
import java.util.List; import java.util.List;
/** /**
@@ -22,8 +21,8 @@ final class SignTextRasterizer {
if (font.isEmpty() || lines.isEmpty()) return null; if (font.isEmpty() || lines.isEmpty()) return null;
int ascent = font.maxAscent(); int ascent = font.maxAscent();
int pitch = font.lineHeight(); // ascent + descent int pitch = font.lineHeight(); // ascent + descent
int margin = glow ? 1 : 0; // room for the outline int margin = glow ? 1 : 0; // room for the outline
int contentW = 1; int contentW = 1;
for (String line : lines) contentW = Math.max(contentW, lineWidth(line, font)); for (String line : lines) contentW = Math.max(contentW, lineWidth(line, font));
@@ -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.Face;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel; import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.util.Vector;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.bukkit.util.Vector;
/** /**
* Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns * Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns
@@ -28,16 +27,23 @@ public final class ElementIntersector {
* @param dx,dy,dz ray direction (need not be normalized) * @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) * @param bx,by,bz block min corner in world coordinates (for reconstructing the world hit point)
*/ */
public static FaceHit intersect(ResolvedModel model, public static FaceHit intersect(
double ox, double oy, double oz, ResolvedModel model,
double dx, double dy, double dz, double ox,
int bx, int by, int bz) { double oy,
double oz,
double dx,
double dy,
double dz,
int bx,
int by,
int bz) {
List<Candidate> candidates = new ArrayList<>(model.elements.size()); List<Candidate> candidates = new ArrayList<>(model.elements.size());
for (int i = 0; i < model.elements.size(); i++) { for (int i = 0; i < model.elements.size(); i++) {
Element element = model.elements.get(i); Element element = model.elements.get(i);
Candidate c = element.isAxisAligned() Candidate c = element.isAxisAligned()
? intersectAabb(element, ox, oy, oz, dx, dy, dz) ? intersectAabb(element, ox, oy, oz, dx, dy, dz)
: intersectObb(element, ox, oy, oz, dx, dy, dz); : intersectObb(element, ox, oy, oz, dx, dy, dz);
if (c != null) candidates.add(new Candidate(c.element, c.t, c.dir, c.s, c.t2, c.normal, i)); if (c != null) candidates.add(new Candidate(c.element, c.t, c.dir, c.s, c.t2, c.normal, i));
} }
if (candidates.isEmpty()) return null; if (candidates.isEmpty()) return null;
@@ -61,16 +67,16 @@ public final class ElementIntersector {
return null; 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, private static Candidate intersectAabb(
double dx, double dy, double dz) { 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); 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. */ /** 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, private static Candidate intersectObb(Element e, double ox, double oy, double oz, double dx, double dy, double dz) {
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); 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[0] += e.rotOrigin[0];
o[1] += e.rotOrigin[1]; 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); 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, private static Candidate slab(
double dx, double dy, double dz, Element e,
double[] from, double[] to, Element obb) { double ox,
double oy,
double oz,
double dx,
double dy,
double dz,
double[] from,
double[] to,
Element obb) {
double tmin = Double.NEGATIVE_INFINITY; double tmin = Double.NEGATIVE_INFINITY;
double tmax = Double.POSITIVE_INFINITY; double tmax = Double.POSITIVE_INFINITY;
int axis = -1; int axis = -1;
@@ -99,7 +113,9 @@ public final class ElementIntersector {
double t2 = (to[a] - o[a]) * inv; double t2 = (to[a] - o[a]) * inv;
boolean neg = true; boolean neg = true;
if (t1 > t2) { if (t1 > t2) {
double tmp = t1; t1 = t2; t2 = tmp; double tmp = t1;
t1 = t2;
t2 = tmp;
neg = false; neg = false;
} }
if (t1 > tmin) { if (t1 > tmin) {
@@ -136,11 +152,20 @@ public final class ElementIntersector {
double s, t; double s, t;
switch (dir) { switch (dir) {
// Texture V is top-down (0 = texture top). For side faces the texture top is the block // Texture V is top-down (0 = texture top). For side faces the texture top is the block
// top (high Y), so t = 1 - fracY. // top (high Y), so t = 1 - fracY.
case UP, DOWN -> { s = fracX; t = fracZ; } case UP, DOWN -> {
case NORTH, SOUTH -> { s = fracX; t = 1 - fracY; } s = fracX;
default -> { s = fracZ; t = 1 - fracY; } // WEST, EAST 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); return new Candidate(e, tEntry, dir, s, t, normal, 0);
} }
@@ -165,9 +190,9 @@ public final class ElementIntersector {
double cos = Math.cos(angle); double cos = Math.cos(angle);
double sin = Math.sin(angle); double sin = Math.sin(angle);
return switch (axis) { return switch (axis) {
case 0 -> new double[]{x, y * cos - z * sin, y * sin + z * cos}; case 0 -> new double[] {x, y * cos - z * sin, y * sin + z * cos};
case 1 -> new double[]{x * cos + z * sin, y, -x * sin + z * cos}; case 1 -> new double[] {x * cos + z * sin, y, -x * sin + z * cos};
default -> new double[]{x * cos - y * sin, x * sin + y * cos, z}; default -> new double[] {x * cos - y * sin, x * sin + y * cos, z};
}; };
} }
} }
@@ -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 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). * 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,14 +11,13 @@ import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver; import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil; import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.block.Biome; import org.bukkit.block.Biome;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* Traces a single ray against a {@link WorldSnapshot}, sampling block models via the * Traces a single ray against a {@link WorldSnapshot}, sampling block models via the
* {@link ElementIntersector} and applying biome tint, directional face shading, transparency and * {@link ElementIntersector} and applying biome tint, directional face shading, transparency and
@@ -44,8 +43,12 @@ public final class SnapshotRaytracer {
private final int maxSteps; private final int maxSteps;
private final Map<Long, BiomeTint> tintCache = new ConcurrentHashMap<>(); private final Map<Long, BiomeTint> tintCache = new ConcurrentHashMap<>();
public SnapshotRaytracer(BlockModelRegistry registry, BiomeTintProvider tintProvider, public SnapshotRaytracer(
SkyRenderer skyRenderer, double maxDistance, int reflectionDepth) { BlockModelRegistry registry,
BiomeTintProvider tintProvider,
SkyRenderer skyRenderer,
double maxDistance,
int reflectionDepth) {
this.registry = registry; this.registry = registry;
this.tintProvider = tintProvider; this.tintProvider = tintProvider;
this.skyRenderer = skyRenderer; this.skyRenderer = skyRenderer;
@@ -58,7 +61,8 @@ public final class SnapshotRaytracer {
return trace(snapshot, origin, direction, sky, scene, reflectionDepth); 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 ox = origin.getX(), oy = origin.getY(), oz = origin.getZ();
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ(); double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
@@ -99,7 +103,8 @@ public final class SnapshotRaytracer {
if (!reflected && model.reflection > 0 && depth > 0) { if (!reflected && model.reflection > 0 && depth > 0) {
Vector reflectDir = MathUtil.reflectVector(direction, hit.normal()); 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); reflectionColor = trace(snapshot, reflectStart, reflectDir, sky, scene, depth - 1);
reflectionFactor = model.reflection; reflectionFactor = model.reflection;
reflected = true; reflected = true;
@@ -147,11 +152,13 @@ public final class SnapshotRaytracer {
if (transparencyStart != null) { if (transparencyStart != null) {
baseColor = ColorUtil.mix( baseColor = ColorUtil.mix(
baseColor, baseColor,
transparencyColor, transparencyColor,
transparencyFactor, transparencyFactor,
(1 - transparencyFactor) (1 - transparencyFactor)
* (1 + transparencyStart.distance(finalPoint == null ? transparencyStart : finalPoint) / 5.0)); * (1
+ transparencyStart.distance(finalPoint == null ? transparencyStart : finalPoint)
/ 5.0));
} }
if (reflected) { if (reflected) {
baseColor = ColorUtil.mix(baseColor, reflectionColor, 1 - reflectionFactor, reflectionFactor); baseColor = ColorUtil.mix(baseColor, reflectionColor, 1 - reflectionFactor, reflectionFactor);
@@ -191,7 +198,9 @@ public final class SnapshotRaytracer {
* across the face. Only applied to axis-aligned faces. * across the face. Only applied to axis-aligned faces.
*/ */
private double ambientOcclusion(FaceHit hit, WorldSnapshot snapshot, int bx, int by, int bz) { 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); 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 if (Math.max(ax, Math.max(ay, az)) < 0.99) return 1.0; // skip rotated/diagonal faces
@@ -203,12 +212,33 @@ public final class SnapshotRaytracer {
int ofx = (int) Math.round(nx), ofy = (int) Math.round(ny), ofz = (int) Math.round(nz); int ofx = (int) Math.round(nx), ofy = (int) Math.round(ny), ofz = (int) Math.round(nz);
int ux, uy, uz, vx, vy, vz; int ux, uy, uz, vx, vy, vz;
double su, sv; double su, sv;
if (ay > 0.5) { // up/down if (ay > 0.5) { // up/down
ux = 1; uy = 0; uz = 0; vx = 0; vy = 0; vz = 1; su = lx; sv = lz; ux = 1;
} else if (ax > 0.5) { // east/west uy = 0;
ux = 0; uy = 0; uz = 1; vx = 0; vy = 1; vz = 0; su = lz; sv = ly; uz = 0;
} else { // north/south vx = 0;
ux = 1; uy = 0; uz = 0; vx = 0; vy = 1; vz = 0; su = lx; sv = ly; 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;
} else { // north/south
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); double b00 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, -1);
@@ -221,13 +251,26 @@ public final class SnapshotRaytracer {
return top + (bottom - top) * sv; return top + (bottom - top) * sv;
} }
private double aoCorner(WorldSnapshot snapshot, int bx, int by, int bz, private double aoCorner(
int ofx, int ofy, int ofz, int ux, int uy, int uz, int vx, int vy, int vz, WorldSnapshot snapshot,
int du, int dv) { 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 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 side2 = solid(snapshot, bx + ofx + dv * vx, by + ofy + dv * vy, bz + ofz + dv * vz);
boolean corner = solid(snapshot, boolean corner = solid(
bx + ofx + du * ux + dv * vx, by + ofy + du * uy + dv * vy, bz + ofz + du * uz + dv * vz); 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); int level = (side1 && side2) ? 0 : 3 - (side1 ? 1 : 0) - (side2 ? 1 : 0) - (corner ? 1 : 0);
return AO_BRIGHTNESS[Math.clamp(level, 0, 3)]; 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.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache; 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.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationBaker; import eu.mhsl.minecraft.pixelpics.render.entity.DecorationBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState; import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityScene; import eu.mhsl.minecraft.pixelpics.render.entity.EntityScene;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState; 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.raytrace.SnapshotRaytracer;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext; import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer; import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
@@ -20,20 +20,18 @@ import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider; import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil; 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.BufferedImage;
import java.awt.image.DataBufferInt; import java.awt.image.DataBufferInt;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.stream.IntStream; 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 * Renders the scene by capturing a world snapshot on the main thread ({@link #prepare}) and then
@@ -43,6 +41,7 @@ public class DefaultScreenRenderer implements Renderer {
/** Horizontal half field-of-view; the vertical half is derived from the output aspect ratio. */ /** 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 double H_FOV_HALF_RAD = Math.toRadians(35);
private static final Vector BASE_VEC = new Vector(1, 0, 0); private static final Vector BASE_VEC = new Vector(1, 0, 0);
private static final double MAX_DISTANCE = 256; private static final double MAX_DISTANCE = 256;
@@ -59,15 +58,24 @@ public class DefaultScreenRenderer implements Renderer {
/** Bounds parallel ray tracing to a fixed, low-priority pool; {@code null} = use the common pool. */ /** Bounds parallel ray tracing to a fixed, low-priority pool; {@code null} = use the common pool. */
private final ForkJoinPool tracePool; private final ForkJoinPool tracePool;
public DefaultScreenRenderer(BlockModelRegistry registry, BiomeTintProvider tintProvider, public DefaultScreenRenderer(
TextureCache textures, CemBaker entityBaker, BlockModelRegistry registry,
BlockEntityBaker blockEntityBaker, Logger logger) { BiomeTintProvider tintProvider,
TextureCache textures,
CemBaker entityBaker,
BlockEntityBaker blockEntityBaker,
Logger logger) {
this(registry, tintProvider, textures, entityBaker, blockEntityBaker, logger, null); this(registry, tintProvider, textures, entityBaker, blockEntityBaker, logger, null);
} }
public DefaultScreenRenderer(BlockModelRegistry registry, BiomeTintProvider tintProvider, public DefaultScreenRenderer(
TextureCache textures, CemBaker entityBaker, BlockModelRegistry registry,
BlockEntityBaker blockEntityBaker, Logger logger, ForkJoinPool tracePool) { BiomeTintProvider tintProvider,
TextureCache textures,
CemBaker entityBaker,
BlockEntityBaker blockEntityBaker,
Logger logger,
ForkJoinPool tracePool) {
SkyRenderer skyRenderer = new SkyRenderer(textures); SkyRenderer skyRenderer = new SkyRenderer(textures);
this.raytracer = new SnapshotRaytracer(registry, tintProvider, skyRenderer, MAX_DISTANCE, REFLECTION_DEPTH); this.raytracer = new SnapshotRaytracer(registry, tintProvider, skyRenderer, MAX_DISTANCE, REFLECTION_DEPTH);
this.entityBaker = entityBaker; this.entityBaker = entityBaker;
@@ -99,8 +107,16 @@ public class DefaultScreenRenderer implements Renderer {
int moonPhase = (int) (fullTime / 24000L % 8L); int moonPhase = (int) (fullTime / 24000L % 8L);
SkyContext sky = new SkyContext(dayTime, moonPhase, fullTime); SkyContext sky = new SkyContext(dayTime, moonPhase, fullTime);
return new RenderJob(snapshot, rayMap, eyeLocation.toVector(), return new RenderJob(
resolution.getWidth(), resolution.getHeight(), sky, entities, blockEntities, decorations); 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. */ /** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */
@@ -121,8 +137,8 @@ public class DefaultScreenRenderer implements Renderer {
WorldSnapshot snapshot = job.snapshot(); WorldSnapshot snapshot = job.snapshot();
Vector origin = job.origin(); Vector origin = job.origin();
SkyContext sky = job.sky(); SkyContext sky = job.sky();
EntityScene scene = new EntityScene(job.entities(), entityBaker, job.blockEntities(), blockEntityBaker, EntityScene scene = new EntityScene(
job.decorations(), decorationBaker); job.entities(), entityBaker, job.blockEntities(), blockEntityBaker, job.decorations(), decorationBaker);
int[] superBuf = new int[rayMap.size()]; int[] superBuf = new int[rayMap.size()];
runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> { runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> {
@@ -183,15 +199,19 @@ public class DefaultScreenRenderer implements Renderer {
List<Vector> rayMap = new ArrayList<>(width * height); List<Vector> rayMap = new ArrayList<>(width * height);
Vector leftFraction = upperLeftCorner.clone().subtract(lowerLeftCorner).multiply(1.0 / (height - 1)); 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++) { for (int pitch = 0; pitch < height; pitch++) {
Vector leftPitch = upperLeftCorner.clone().subtract(leftFraction.clone().multiply(pitch)); Vector leftPitch =
Vector rightPitch = upperRightCorner.clone().subtract(rightFraction.clone().multiply(pitch)); 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)); Vector yawFraction = rightPitch.clone().subtract(leftPitch).multiply(1.0 / (width - 1));
for (int yaw = 0; yaw < width; yaw++) { 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); rayMap.add(ray);
} }
} }
@@ -5,29 +5,47 @@ import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState; import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext; import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot; import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import org.bukkit.util.Vector;
import java.util.List; 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, * 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 * 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. * (painting / item frame) states. {@link DefaultScreenRenderer#execute} can run this off the main thread.
*/ */
public record RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin, public record RenderJob(
int width, int height, SkyContext sky, List<EntityState> entities, WorldSnapshot snapshot,
List<BlockEntityState> blockEntities, List<DecorationState> decorations) { 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. */ /** Backwards-compatible constructor (no block-entities/decorations), used by the standalone harness. */
public RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin, public RenderJob(
int width, int height, SkyContext sky, List<EntityState> entities) { 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()); this(snapshot, rayMap, origin, width, height, sky, entities, List.of(), List.of());
} }
/** Convenience for callers that supply entities + block-entities but no decorations. */ /** Convenience for callers that supply entities + block-entities but no decorations. */
public RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin, public RenderJob(
int width, int height, SkyContext sky, List<EntityState> entities, WorldSnapshot snapshot,
List<BlockEntityState> blockEntities) { 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()); this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, List.of());
} }
} }
@@ -1,8 +1,7 @@
package eu.mhsl.minecraft.pixelpics.render.render; package eu.mhsl.minecraft.pixelpics.render.render;
import org.bukkit.Location;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import org.bukkit.Location;
public interface Renderer { public interface Renderer {
BufferedImage render(Location eyeLocation, Resolution resolution); BufferedImage render(Location eyeLocation, Resolution resolution);
@@ -9,8 +9,9 @@ public final class Resolution {
public Resolution(Pixels pixels, AspectRatio aspectRatio) { public Resolution(Pixels pixels, AspectRatio aspectRatio) {
this( this(
(int) Math.round(Preconditions.checkNotNull(pixels).height * Preconditions.checkNotNull(aspectRatio).ratio), (int) Math.round(
pixels.height); Preconditions.checkNotNull(pixels).height * Preconditions.checkNotNull(aspectRatio).ratio),
pixels.height);
} }
public Resolution(int width, int height) { public Resolution(int width, int 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 * (0..7) and the absolute world time (for continuous cloud drift). Immutable so it can be read from
* worker threads. * worker threads.
*/ */
public record SkyContext(long dayTime, int moonPhase, long fullTime) { public record SkyContext(long dayTime, int moonPhase, long fullTime) {}
}
@@ -14,10 +14,10 @@ public final class SkyRenderer {
private static final double TICKS_PER_DAY = 24000.0; private static final double TICKS_PER_DAY = 24000.0;
private static final double CLOUD_HEIGHT = 192.0; private static final double CLOUD_HEIGHT = 192.0;
private static final double CLOUD_CELL = 12.0; // world blocks per cloud texel private static final double CLOUD_CELL = 12.0; // world blocks per cloud texel
private static final double CLOUD_SPEED = 0.03; // blocks per tick, drift along +X private static final double CLOUD_SPEED = 0.03; // blocks per tick, drift along +X
private static final double SUN_HALF = 0.085; // angular half-size (radians) private static final double SUN_HALF = 0.085; // angular half-size (radians)
private static final double MOON_HALF = 0.075; private static final double MOON_HALF = 0.075;
// Gradient endpoints (RGB). // Gradient endpoints (RGB).
@@ -34,16 +34,21 @@ public final class SkyRenderer {
private final int[][] cloudTexture; private final int[][] cloudTexture;
public SkyRenderer(TextureCache textures) { public SkyRenderer(TextureCache textures) {
this.sunTexture = textures.get(ResourceLocation.parse("environment/sun")).orElse(null); this.sunTexture =
this.moonTexture = textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null); textures.get(ResourceLocation.parse("environment/sun")).orElse(null);
this.cloudTexture = textures.get(ResourceLocation.parse("environment/clouds")).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) { public int colorFor(Vector direction, Vector origin, SkyContext ctx) {
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ(); double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
double len = Math.sqrt(dx * dx + dy * dy + dz * dz); double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len < 1e-9) return DAY_ZENITH; 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: // 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 // celestialAngle ca = getTimeOfDay(dayTime); the sun is rotated by ca*360deg about the X axis
@@ -65,8 +70,8 @@ public final class SkyRenderer {
if (twilight > 0) { if (twilight > 0) {
double az = Math.clamp(dx * Math.signum(sunX) * 0.5 + 0.5, 0, 1); // 1 toward sun .. 0 away double az = Math.clamp(dx * Math.signum(sunX) * 0.5 + 0.5, 0, 1); // 1 toward sun .. 0 away
int grad = up < 0.40 int grad = up < 0.40
? lerp(SUNSET_ORANGE, SUNSET_RED, up / 0.40) ? lerp(SUNSET_ORANGE, SUNSET_RED, up / 0.40)
: lerp(SUNSET_RED, TWI_PURPLE, (up - 0.40) / 0.60); : lerp(SUNSET_RED, TWI_PURPLE, (up - 0.40) / 0.60);
int twiColor = lerp(lerp(TWI_PURPLE, grad, 0.55), grad, az); // cooler away from the sun int twiColor = lerp(lerp(TWI_PURPLE, grad, 0.55), grad, az); // cooler away from the sun
color = lerp(color, twiColor, twilight * 0.85); color = lerp(color, twiColor, twilight * 0.85);
} }
@@ -98,7 +103,8 @@ public final class SkyRenderer {
} }
// Moon disc (phase shape from the texture's alpha). // Moon disc (phase shape from the texture's alpha).
if (-sunY > -0.15) { 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 // 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). */ /** 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, private int overlayDisc(
double cx, double cy, double cz, double half, int[][] texture, int solid, int moonPhase) { 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; double cos = dx * cx + dy * cy + dz * cz;
if (cos <= 0) return base; if (cos <= 0) return base;
double sinHalf = Math.sin(half); double sinHalf = Math.sin(half);
@@ -130,8 +146,15 @@ public final class SkyRenderer {
// right = normalize(body x worldUp); discUp = right x body // right = normalize(body x worldUp); discUp = right x body
double crx = cz, cry = 0, crz = -cx; double crx = cz, cry = 0, crz = -cx;
double crl = Math.sqrt(crx * crx + crz * crz); double crl = Math.sqrt(crx * crx + crz * crz);
if (crl < 1e-6) { crx = 1; cry = 0; crz = 0; crl = 1; } if (crl < 1e-6) {
crx /= crl; cry /= crl; crz /= crl; crx = 1;
cry = 0;
crz = 0;
crl = 1;
}
crx /= crl;
cry /= crl;
crz /= crl;
// discUp = right cross body // discUp = right cross body
double ux = cry * cz - crz * cy; double ux = cry * cz - crz * cy;
double uy = crz * cx - crx * cz; double uy = crz * cx - crx * cz;
@@ -201,8 +224,7 @@ public final class SkyRenderer {
return alpha > 16 ? 0.85 : 0.0; return alpha > 16 ? 0.85 : 0.0;
} }
double scale = 0.012; double scale = 0.012;
double n = valueNoise(x * scale, z * scale) * 0.6 double n = valueNoise(x * scale, z * scale) * 0.6 + valueNoise(x * scale * 2.3, z * scale * 2.3) * 0.4;
+ valueNoise(x * scale * 2.3, z * scale * 2.3) * 0.4;
return smoothstep(0.52, 0.72, n) * 0.8; return smoothstep(0.52, 0.72, n) * 0.8;
} }
@@ -230,7 +252,9 @@ public final class SkyRenderer {
// --- small color/math helpers --- // --- 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) { private static int lerp(int a, int b, double t) {
t = Math.clamp(t, 0, 1); t = Math.clamp(t, 0, 1);
@@ -260,5 +284,7 @@ public final class SkyRenderer {
return t * t * (3 - 2 * t); 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.ChestKind;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.Kind; import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.Kind;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; 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.DyeColor;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
@@ -29,12 +34,6 @@ import org.bukkit.block.sign.SignSide;
import org.bukkit.profile.PlayerProfile; import org.bukkit.profile.PlayerProfile;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/** /**
* Captures block-entities (chests, signs, banners, beds, heads, …) near the view frustum into immutable * 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 * {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each
@@ -82,15 +81,15 @@ public final class BlockEntitySnapshotBuilder {
// --- chests --- // --- chests ---
if (mat == Material.CHEST || mat == Material.TRAPPED_CHEST || mat == Material.ENDER_CHEST) { 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
: mat == Material.ENDER_CHEST ? Kind.ENDER_CHEST : Kind.CHEST; ? Kind.TRAPPED_CHEST
: mat == Material.ENDER_CHEST ? Kind.ENDER_CHEST : Kind.CHEST;
ChestKind ck = ChestKind.SINGLE; ChestKind ck = ChestKind.SINGLE;
if (data instanceof Chest cd) { if (data instanceof Chest cd) {
ck = switch (cd.getType()) { ck = switch (cd.getType()) {
case LEFT -> ChestKind.LEFT; case LEFT -> ChestKind.LEFT;
case RIGHT -> ChestKind.RIGHT; case RIGHT -> ChestKind.RIGHT;
case SINGLE -> ChestKind.SINGLE; case SINGLE -> ChestKind.SINGLE;};
};
} }
return base(kind, bx, by, bz, facingYaw(data)).chestKind(ck).build(); return base(kind, bx, by, bz, facingYaw(data)).chestKind(ck).build();
} }
@@ -99,13 +98,17 @@ public final class BlockEntitySnapshotBuilder {
if (data instanceof Bed bed) { if (data instanceof Bed bed) {
BedPart part = bed.getPart() == Bed.Part.HEAD ? BedPart.HEAD : BedPart.FOOT; BedPart part = bed.getPart() == Bed.Part.HEAD ? BedPart.HEAD : BedPart.FOOT;
return base(Kind.BED, bx, by, bz, faceToYaw(bed.getFacing())) 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 --- // --- shulker boxes ---
if (n.endsWith("SHULKER_BOX")) { if (n.endsWith("SHULKER_BOX")) {
String color = n.equals("SHULKER_BOX") ? null : stripColor(n, "_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 --- // --- banners ---
@@ -131,12 +134,14 @@ public final class BlockEntitySnapshotBuilder {
Kind kind; Kind kind;
float yaw; float yaw;
if (n.endsWith("_WALL_SIGN")) { if (n.endsWith("_WALL_SIGN")) {
kind = Kind.WALL_SIGN; yaw = facingYaw(data); kind = Kind.WALL_SIGN;
yaw = facingYaw(data);
} else if (n.endsWith("_HANGING_SIGN")) { } else if (n.endsWith("_HANGING_SIGN")) {
kind = Kind.HANGING_SIGN; kind = Kind.HANGING_SIGN;
yaw = n.endsWith("_WALL_HANGING_SIGN") ? facingYaw(data) : rotationYaw(data); yaw = n.endsWith("_WALL_HANGING_SIGN") ? facingYaw(data) : rotationYaw(data);
} else { } else {
kind = Kind.SIGN; yaw = rotationYaw(data); kind = Kind.SIGN;
yaw = rotationYaw(data);
} }
Builder b = base(kind, bx, by, bz, yaw).wood(wood); Builder b = base(kind, bx, by, bz, yaw).wood(wood);
if (ts instanceof Sign sign) { if (ts instanceof Sign sign) {
@@ -178,10 +183,11 @@ public final class BlockEntitySnapshotBuilder {
case FLOOR -> BellAttach.FLOOR; case FLOOR -> BellAttach.FLOOR;
case CEILING -> BellAttach.CEILING; case CEILING -> BellAttach.CEILING;
case SINGLE_WALL -> BellAttach.SINGLE_WALL; 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; return null;
@@ -250,14 +256,17 @@ public final class BlockEntitySnapshotBuilder {
if (!any) return null; if (!any) return null;
DyeColor dye = side.getColor(); DyeColor dye = side.getColor();
boolean glow = side.isGlowingText(); boolean glow = side.isGlowingText();
return new BlockEntityState.SignText(lines, ColorUtil.signFillArgb(dye, glow), return new BlockEntityState.SignText(
ColorUtil.signOutlineArgb(dye), glow); lines, ColorUtil.signFillArgb(dye, glow), ColorUtil.signOutlineArgb(dye), glow);
} }
private static String signWood(String name) { private static String signWood(String name) {
String s = name; String s = name;
for (String suf : new String[]{"_WALL_HANGING_SIGN", "_HANGING_SIGN", "_WALL_SIGN", "_SIGN"}) { 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(Locale.ROOT); return s.toLowerCase(Locale.ROOT);
} }
@@ -278,8 +287,9 @@ public final class BlockEntitySnapshotBuilder {
private static List<String> sherds(DecoratedPot pot) { private static List<String> sherds(DecoratedPot pot) {
// Order: front, left, right, back — matches the CEM decorated_pot face parts. // Order: front, left, right, back — matches the CEM decorated_pot face parts.
List<String> out = new ArrayList<>(4); List<String> out = new ArrayList<>(4);
for (DecoratedPot.Side side : new DecoratedPot.Side[]{ 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); Material m = pot.getSherd(side);
out.add(m.name().toLowerCase(Locale.ROOT)); out.add(m.name().toLowerCase(Locale.ROOT));
} }
@@ -328,25 +338,92 @@ public final class BlockEntitySnapshotBuilder {
private BlockEntityState.SignText backText; private BlockEntityState.SignText backText;
Builder(Kind kind, int bx, int by, int bz, float yaw) { 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 chestKind(ChestKind v) {
Builder baseColorArgb(int v) { this.baseColorArgb = v; return this; } this.chestKind = v;
Builder colorName(String v) { this.colorName = v; return this; } 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 baseColorArgb(int v) {
Builder skinUrl(String v) { this.skinUrl = v; return this; } this.baseColorArgb = v;
Builder patterns(List<BannerPattern> v) { this.patterns = v; return this; } 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 colorName(String v) {
Builder backText(BlockEntityState.SignText v) { this.backText = v; return this; } 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() { BlockEntityState build() {
return new BlockEntityState(kind, bx, by, bz, yaw, chestKind, baseColorArgb, colorName, wood, return new BlockEntityState(
bedPart, headType, skinUrl, patterns, sherds, bellAttach, frontText, backText); kind,
bx,
by,
bz,
yaw,
chestKind,
baseColorArgb,
colorName,
wood,
bedPart,
headType,
skinUrl,
patterns,
sherds,
bellAttach,
frontText,
backText);
} }
} }
} }
@@ -1,6 +1,9 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot; package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState; 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.Location;
import org.bukkit.block.BlockFace; import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity; import org.bukkit.entity.Entity;
@@ -11,10 +14,6 @@ import org.bukkit.inventory.ItemStack;
import org.bukkit.util.BoundingBox; import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector; 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 * 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, * {@link DecorationState}s. MUST run on the main thread. Uses each entity's world bounding box directly,
@@ -42,19 +41,39 @@ public final class DecorationSnapshotBuilder {
if (e instanceof Painting painting) { if (e instanceof Painting painting) {
BoundingBox bb = e.getBoundingBox(); BoundingBox bb = e.getBoundingBox();
String art = painting.getArt().assetId().value(); String art = painting.getArt().assetId().value();
return new DecorationState(DecorationState.Kind.PAINTING, return new DecorationState(
bb.getMinX(), bb.getMinY(), bb.getMinZ(), bb.getMaxX(), bb.getMaxY(), bb.getMaxZ(), DecorationState.Kind.PAINTING,
facing(e.getFacing()), art, null, 0, false); bb.getMinX(),
bb.getMinY(),
bb.getMinZ(),
bb.getMaxX(),
bb.getMaxY(),
bb.getMaxZ(),
facing(e.getFacing()),
art,
null,
0,
false);
} }
if (e instanceof ItemFrame frame) { if (e instanceof ItemFrame frame) {
BoundingBox bb = e.getBoundingBox(); BoundingBox bb = e.getBoundingBox();
boolean glow = e instanceof GlowItemFrame boolean glow =
|| e.getType().getKey().getKey().equals("glow_item_frame"); e instanceof GlowItemFrame || e.getType().getKey().getKey().equals("glow_item_frame");
String itemId = itemId(frame.getItem()); String itemId = itemId(frame.getItem());
int rot = frame.getRotation().ordinal() * 45; int rot = frame.getRotation().ordinal() * 45;
return new DecorationState(DecorationState.Kind.ITEM_FRAME, return new DecorationState(
bb.getMinX(), bb.getMinY(), bb.getMinZ(), bb.getMaxX(), bb.getMaxY(), bb.getMaxZ(), DecorationState.Kind.ITEM_FRAME,
facing(e.getFacing()), null, itemId, rot, glow); bb.getMinX(),
bb.getMinY(),
bb.getMinZ(),
bb.getMaxX(),
bb.getMaxY(),
bb.getMaxZ(),
facing(e.getFacing()),
null,
itemId,
rot,
glow);
} }
return null; return null;
} }
@@ -6,6 +6,15 @@ import com.google.gson.JsonParser;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState; import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import io.papermc.paper.datacomponent.DataComponentTypes; 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.Keyed;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
@@ -47,16 +56,6 @@ import org.bukkit.inventory.meta.LeatherArmorMeta;
import org.bukkit.potion.PotionEffectType; import org.bukkit.potion.PotionEffectType;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
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;
/** /**
* Captures entities near the view frustum into immutable {@link EntityState}s. MUST run on the main * Captures entities near the view frustum into immutable {@link EntityState}s. MUST run on the main
* thread (live entity access). The camera entity is skipped. * thread (live entity access). The camera entity is skipped.
@@ -68,22 +67,46 @@ public final class EntitySnapshotBuilder {
// Technical / non-mob entity types that have no meaningful geometry; rendering them would only // 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. // produce stray fallback boxes. Markers, displays, item frames, paintings, projectiles, drops, etc.
private static final Set<String> NON_RENDERABLE = Set.of( private static final Set<String> NON_RENDERABLE = Set.of(
"area_effect_cloud", "marker", "interaction", "area_effect_cloud",
"item_frame", "glow_item_frame", "painting", "marker",
"block_display", "item_display", "text_display", "interaction",
"fishing_bobber", "lightning_bolt", "eye_of_ender", "item_frame",
"experience_orb", "experience_bottle", "egg", "snowball", "glow_item_frame",
"potion", "ender_pearl", "tnt", "falling_block", "item" "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 // 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. // 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( private static final Set<String> HUMANOID_ARMOR_WEARERS = Set.of(
"player", "mannequin", "armor_stand", "giant", "player",
"zombie", "husk", "drowned", "zombie_villager", "zombified_piglin", "mannequin",
"skeleton", "stray", "wither_skeleton", "bogged", "armor_stand",
"piglin", "piglin_brute" "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) { public static List<EntityState> build(Location eye, List<Vector> rayMap, double maxDistance, UUID shooter) {
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance); FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
@@ -109,14 +132,13 @@ public final class EntitySnapshotBuilder {
bodyYaw = le.getBodyYaw(); bodyYaw = le.getBodyYaw();
} }
boolean baby = (e instanceof Ageable a && !a.isAdult()) boolean baby = (e instanceof Ageable a && !a.isAdult()) || (e instanceof Zombie z && z.isAdult());
|| (e instanceof Zombie z && z.isAdult());
// Invisible entities render only their equipment (like vanilla): the generic invisible flag, an // Invisible entities render only their equipment (like vanilla): the generic invisible flag, an
// invisibility potion effect, or an explicitly-hidden armor stand. // invisibility potion effect, or an explicitly-hidden armor stand.
boolean invisible = e.isInvisible() boolean invisible = e.isInvisible()
|| (e instanceof ArmorStand as && !as.isVisible()) || (e instanceof ArmorStand as && !as.isVisible())
|| (e instanceof LivingEntity inv && inv.hasPotionEffect(PotionEffectType.INVISIBILITY)); || (e instanceof LivingEntity inv && inv.hasPotionEffect(PotionEffectType.INVISIBILITY));
double width = safeDim(e::getWidth, () -> e.getBoundingBox().getWidthX()); double width = safeDim(e::getWidth, () -> e.getBoundingBox().getWidthX());
double height = safeDim(e::getHeight, () -> e.getBoundingBox().getHeight()); double height = safeDim(e::getHeight, () -> e.getBoundingBox().getHeight());
@@ -211,10 +233,29 @@ public final class EntitySnapshotBuilder {
equipment = captureEquipment(wearer); equipment = captureEquipment(wearer);
} }
return new EntityState(type, loc.getX(), loc.getY(), loc.getZ(), return new EntityState(
bodyYaw, baby, width, height, type,
player, skinUrl, slim, variant, tint, sizeScale, profession, villagerLevel, loc.getX(),
markings, saddle, chest, bodyEquip, equipment, invisible); 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. */ /** Worn armor (4 slots) from a humanoid wearer; null when nothing is equipped. */
@@ -223,8 +264,8 @@ public final class EntitySnapshotBuilder {
EntityEquipment eq = le.getEquipment(); EntityEquipment eq = le.getEquipment();
if (eq == null) return null; if (eq == null) return null;
EntityState.Equipment equip = new EntityState.Equipment( EntityState.Equipment equip = new EntityState.Equipment(
armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()), armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()),
armorPiece(eq.getLeggings()), armorPiece(eq.getBoots())); armorPiece(eq.getLeggings()), armorPiece(eq.getBoots()));
return equip.isEmpty() ? null : equip; return equip.isEmpty() ? null : equip;
} catch (Throwable t) { } catch (Throwable t) {
return null; return null;
@@ -257,8 +298,11 @@ public final class EntitySnapshotBuilder {
if (key.equals("elytra")) return "elytra"; if (key.equals("elytra")) return "elytra";
if (key.equals("turtle_helmet")) return "turtle_scute"; if (key.equals("turtle_helmet")) return "turtle_scute";
String base = null; String base = null;
for (String suf : new String[]{"_helmet", "_chestplate", "_leggings", "_boots"}) { for (String suf : new String[] {"_helmet", "_chestplate", "_leggings", "_boots"}) {
if (key.endsWith(suf)) { base = key.substring(0, key.length() - suf.length()); break; } if (key.endsWith(suf)) {
base = key.substring(0, key.length() - suf.length());
break;
}
} }
if (base == null) return null; if (base == null) return null;
return base.equals("golden") ? "gold" : base; return base.equals("golden") ? "gold" : base;
@@ -305,7 +349,7 @@ public final class EntitySnapshotBuilder {
/** Registry/Keyed values yield their key path; plain enums yield their lower-case name. */ /** Registry/Keyed values yield their key path; plain enums yield their lower-case name. */
private static String keyOf(Object o) { private static String keyOf(Object o) {
return switch(o) { return switch (o) {
case null -> null; case null -> null;
case Keyed k -> k.getKey().getKey(); case Keyed k -> k.getKey().getKey();
case Enum<?> en -> en.name().toLowerCase(Locale.ROOT); case Enum<?> en -> en.name().toLowerCase(Locale.ROOT);
@@ -318,8 +362,7 @@ public final class EntitySnapshotBuilder {
try { try {
for (ProfileProperty prop : player.getPlayerProfile().getProperties()) { for (ProfileProperty prop : player.getPlayerProfile().getProperties()) {
if (!prop.getName().equals("textures")) continue; if (!prop.getName().equals("textures")) continue;
String json = new String(Base64.getDecoder().decode(prop.getValue()), String json = new String(Base64.getDecoder().decode(prop.getValue()), StandardCharsets.UTF_8);
StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(json).getAsJsonObject(); JsonObject root = JsonParser.parseString(json).getAsJsonObject();
JsonObject skin = root.getAsJsonObject("textures").getAsJsonObject("SKIN"); JsonObject skin = root.getAsJsonObject("textures").getAsJsonObject("SKIN");
String url = skin.get("url").getAsString(); String url = skin.get("url").getAsString();
@@ -327,11 +370,11 @@ public final class EntitySnapshotBuilder {
if (skin.has("metadata") && skin.getAsJsonObject("metadata").has("model")) { if (skin.has("metadata") && skin.getAsJsonObject("metadata").has("model")) {
model = skin.getAsJsonObject("metadata").get("model").getAsString(); model = skin.getAsJsonObject("metadata").get("model").getAsString();
} }
return new String[]{url, model}; return new String[] {url, model};
} }
} catch (Exception ignored) { } catch (Exception ignored) {
} }
return new String[]{null, null}; return new String[] {null, null};
} }
/** Reads a dimension via {@code primary}, falling back to {@code fallback} on any version mismatch. */ /** Reads a dimension via {@code primary}, falling back to {@code fallback} on any version mismatch. */
@@ -1,13 +1,12 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot; package eu.mhsl.minecraft.pixelpics.render.snapshot;
import java.util.Collection;
import java.util.List;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.entity.Entity; import org.bukkit.entity.Entity;
import org.bukkit.util.Vector; 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 * 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 * 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; final double maxX, maxY, maxZ;
private FrustumBounds(double minX, double minY, double minZ, double maxX, double maxY, double 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.minX = minX;
this.maxX = maxX; this.maxY = maxY; this.maxZ = maxZ; 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) { 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 fx = origin.getX() + ray.getX() * maxDistance;
double fy = origin.getY() + ray.getY() * maxDistance; double fy = origin.getY() + ray.getY() * maxDistance;
double fz = origin.getZ() + ray.getZ() * maxDistance; double fz = origin.getZ() + ray.getZ() * maxDistance;
minX = Math.min(minX, fx); maxX = Math.max(maxX, fx); minX = Math.min(minX, fx);
minY = Math.min(minY, fy); maxY = Math.max(maxY, fy); maxX = Math.max(maxX, fx);
minZ = Math.min(minZ, fz); maxZ = Math.max(maxZ, fz); 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); return new FrustumBounds(minX, minY, minZ, maxX, maxY, maxZ);
} }
@@ -1,15 +1,14 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot; 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.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.logging.Logger; 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}. * Captures the world region covered by the camera frustum into a {@link WorldSnapshot}.
@@ -58,7 +57,8 @@ public final class SnapshotBuilder {
} }
if (skipped > 0) { 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()); return new WorldSnapshot(chunks, clampedMinY, clampedMaxY, Material.AIR.createBlockData());
@@ -1,11 +1,10 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot; package eu.mhsl.minecraft.pixelpics.render.snapshot;
import java.util.Map;
import org.bukkit.ChunkSnapshot; import org.bukkit.ChunkSnapshot;
import org.bukkit.block.Biome; import org.bukkit.block.Biome;
import org.bukkit.block.data.BlockData; 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. * 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. * 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); return cs.getBiome(x & 15, y, z & 15);
} }
public int minY() { return minY; } public int minY() {
public int maxY() { return maxY; } 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 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) {
private static void put(String key, double t, double d, int water) { TABLE.put(key, new Climate(t, d, water)); } 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 { static {
put("plains", 0.8, 0.4); 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. * 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.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache; import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import org.bukkit.block.Biome;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; 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 * 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) { public BiomeTintProvider(TextureCache textures) {
this.grassMap = textures.get(ResourceLocation.parse("colormap/grass")).orElse(null); this.grassMap = textures.get(ResourceLocation.parse("colormap/grass")).orElse(null);
this.foliageMap = textures.get(ResourceLocation.parse("colormap/foliage")).orElse(null); this.foliageMap =
this.dryFoliageMap = textures.get(ResourceLocation.parse("colormap/dry_foliage")).orElse(null); textures.get(ResourceLocation.parse("colormap/foliage")).orElse(null);
this.dryFoliageMap =
textures.get(ResourceLocation.parse("colormap/dry_foliage")).orElse(null);
} }
public BiomeTint forBiome(Biome biome) { public BiomeTint forBiome(Biome biome) {
@@ -57,7 +58,7 @@ public final class BiomeTintProvider {
grass = 0xFF000000 | (((grass & 0xFEFEFE) + 0x28340A) >> 1); grass = 0xFF000000 | (((grass & 0xFEFEFE) + 0x28340A) >> 1);
foliage = 0xFF000000 | (((foliage & 0xFEFEFE) + 0x28340A) >> 1); foliage = 0xFF000000 | (((foliage & 0xFEFEFE) + 0x28340A) >> 1);
} }
default -> { } default -> {}
} }
return new BiomeTint(grass, foliage, dry, 0xFF000000 | climate.water()); return new BiomeTint(grass, foliage, dry, 0xFF000000 | climate.water());
} }
@@ -32,8 +32,11 @@ public final class TintResolver {
if (name.endsWith("stem")) return STEM; if (name.endsWith("stem")) return STEM;
// grass_block (top/overlay), short_grass, tall_grass, fern, large_fern, sugar_cane, ... // grass_block (top/overlay), short_grass, tall_grass, fern, large_fern, sugar_cane, ...
if (name.contains("grass") || name.equals("fern") || name.equals("large_fern") if (name.contains("grass")
|| name.equals("sugar_cane") || name.equals("potted_fern")) { || name.equals("fern")
|| name.equals("large_fern")
|| name.equals("sugar_cane")
|| name.equals("potted_fern")) {
return biomeTint.grass(); return biomeTint.grass();
} }
@@ -10,10 +10,21 @@ public final class ColorUtil {
private ColorUtil() {} private ColorUtil() {}
public static int alpha(int argb) { return (argb >> 24) & 0xFF; } public static int alpha(int argb) {
public static int red(int argb) { return (argb >> 16) & 0xFF; } return (argb >> 24) & 0xFF;
public static int green(int argb) { return (argb >> 8) & 0xFF; } }
public static int blue(int argb) { return argb & 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) { public static int argb(int a, int r, int g, int b) {
return (a << 24) | (r << 16) | (g << 8) | b; return (a << 24) | (r << 16) | (g << 8) | b;
@@ -31,24 +42,25 @@ public final class ColorUtil {
* table (the firework/text colours, NOT the cloth colours). Null = black (the default sign ink). * table (the firework/text colours, NOT the cloth colours). Null = black (the default sign ink).
*/ */
public static int signTextColor(DyeColor dye) { public static int signTextColor(DyeColor dye) {
int rgb = switch (dye == null ? DyeColor.BLACK : dye) { int rgb =
case WHITE -> 0xF9FFFE; switch (dye == null ? DyeColor.BLACK : dye) {
case ORANGE -> 0xF9801D; case WHITE -> 0xF9FFFE;
case MAGENTA -> 0xC74EBD; case ORANGE -> 0xF9801D;
case LIGHT_BLUE -> 0x3AB3DA; case MAGENTA -> 0xC74EBD;
case YELLOW -> 0xFED83D; case LIGHT_BLUE -> 0x3AB3DA;
case LIME -> 0x80C71F; case YELLOW -> 0xFED83D;
case PINK -> 0xF38BAA; case LIME -> 0x80C71F;
case GRAY -> 0x474F52; case PINK -> 0xF38BAA;
case LIGHT_GRAY -> 0x9D9D97; case GRAY -> 0x474F52;
case CYAN -> 0x169C9C; case LIGHT_GRAY -> 0x9D9D97;
case PURPLE -> 0x8932B8; case CYAN -> 0x169C9C;
case BLUE -> 0x3C44AA; case PURPLE -> 0x8932B8;
case BROWN -> 0x835432; case BLUE -> 0x3C44AA;
case GREEN -> 0x5E7C16; case BROWN -> 0x835432;
case RED -> 0xB02E26; case GREEN -> 0x5E7C16;
case BLACK -> 0x1D1D21; case RED -> 0xB02E26;
}; case BLACK -> 0x1D1D21;
};
return 0xFF000000 | rgb; return 0xFF000000 | rgb;
} }
@@ -108,6 +120,7 @@ public final class ColorUtil {
// --- Gamma-correct (linear-light) averaging --- // --- Gamma-correct (linear-light) averaging ---
private static final float[] SRGB_TO_LINEAR = new float[256]; private static final float[] SRGB_TO_LINEAR = new float[256];
static { static {
for (int i = 0; i < 256; i++) { for (int i = 0; i < 256; i++) {
double c = i / 255.0; double c = i / 255.0;
@@ -5,36 +5,36 @@ import org.bukkit.util.Vector;
public class MathUtil { public class MathUtil {
private MathUtil() {} private MathUtil() {}
private static Vector yawPitchRotation(Vector base, double angleYaw, double anglePitch) { private static Vector yawPitchRotation(Vector base, double angleYaw, double anglePitch) {
double oldX = base.getX(); double oldX = base.getX();
double oldY = base.getY(); double oldY = base.getY();
double oldZ = base.getZ(); double oldZ = base.getZ();
double sinOne = Math.sin(angleYaw); double sinOne = Math.sin(angleYaw);
double sinTwo = Math.sin(anglePitch); double sinTwo = Math.sin(anglePitch);
double cosOne = Math.cos(angleYaw); double cosOne = Math.cos(angleYaw);
double cosTwo = Math.cos(anglePitch); double cosTwo = Math.cos(anglePitch);
double newX = oldX * cosOne * cosTwo - oldY * cosOne * sinTwo - oldZ * sinOne; double newX = oldX * cosOne * cosTwo - oldY * cosOne * sinTwo - oldZ * sinOne;
double newY = oldX * sinTwo + oldY * cosTwo; double newY = oldX * sinTwo + oldY * cosTwo;
double newZ = oldX * sinOne * cosTwo - oldY * sinOne * sinTwo + oldZ * cosOne; double newZ = oldX * sinOne * cosTwo - oldY * sinOne * sinTwo + oldZ * cosOne;
return new Vector(newX, newY, newZ); return new Vector(newX, newY, newZ);
} }
public static Vector doubleYawPitchRotation(Vector base, double firstYaw, double firstPitch, double secondYaw, public static Vector doubleYawPitchRotation(
double secondPitch) { Vector base, double firstYaw, double firstPitch, double secondYaw, double secondPitch) {
return yawPitchRotation(yawPitchRotation(base, firstYaw, firstPitch), secondYaw, secondPitch); return yawPitchRotation(yawPitchRotation(base, firstYaw, firstPitch), secondYaw, secondPitch);
} }
/** Reflects {@code direction} across the plane with the given (unit) {@code normal}. */ /** Reflects {@code direction} across the plane with the given (unit) {@code normal}. */
public static Vector reflectVector(Vector direction, Vector normal) { public static Vector reflectVector(Vector direction, Vector normal) {
return direction.clone().subtract(normal.clone().multiply(2 * direction.dot(normal))); return direction.clone().subtract(normal.clone().multiply(2 * direction.dot(normal)));
} }
public static Vector toVector(BlockFace face) { public static Vector toVector(BlockFace face) {
return new Vector(face.getModX(), face.getModY(), face.getModZ()); return new Vector(face.getModX(), face.getModY(), face.getModZ());
} }
} }
@@ -3,6 +3,9 @@ package eu.mhsl.minecraft.pixelpics.survival;
import com.destroystokyo.paper.profile.PlayerProfile; import com.destroystokyo.paper.profile.PlayerProfile;
import com.destroystokyo.paper.profile.ProfileProperty; import com.destroystokyo.paper.profile.ProfileProperty;
import eu.mhsl.minecraft.pixelpics.Main; 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.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.format.TextDecoration;
@@ -17,10 +20,6 @@ import org.bukkit.inventory.meta.components.EquippableComponent;
import org.bukkit.persistence.PersistentDataContainer; import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
/** /**
* Factory and identity helpers for the survival items: the camera and the film roll are custom-skinned * 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 * {@code PLAYER_HEAD}s (the texture renders client-side without a resource pack), the photo is the
@@ -35,8 +34,10 @@ public final class CameraItems {
private static final int CAMERA_MODEL_DATA = 1001; private static final int CAMERA_MODEL_DATA = 1001;
private static final int FILM_MODEL_DATA = 1002; private static final int FILM_MODEL_DATA = 1002;
static final String CAMERA_TEXTURE_B64 = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDlkMmNiZjAyZDMwOGI2MDY1YTZmZThjNjU3MWI2MzU2NjMzZjQxOTJlOGVjNzEyMTNjNzcwNzgwZTNkZTRlMiJ9fX0="; static final String CAMERA_TEXTURE_B64 =
static final String FILM_TEXTURE_B64 = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMTVkMGY0OGJlNzNkYmIwZDJjYjE1NTRjMmUzODZiNWNjM2FiMjFhNGRjYWU4ZmYzOGI3NzRhZDNkMDFkMGE1OSJ9fX0="; "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDlkMmNiZjAyZDMwOGI2MDY1YTZmZThjNjU3MWI2MzU2NjMzZjQxOTJlOGVjNzEyMTNjNzcwNzgwZTNkZTRlMiJ9fX0=";
static final String FILM_TEXTURE_B64 =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMTVkMGY0OGJlNzNkYmIwZDJjYjE1NTRjMmUzODZiNWNjM2FiMjFhNGRjYWU4ZmYzOGI3NzRhZDNkMDFkMGE1OSJ9fX0=";
private CameraItems() {} private CameraItems() {}
@@ -48,8 +49,7 @@ public final class CameraItems {
ItemStack item = new ItemStack(Material.PLAYER_HEAD); ItemStack item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta(); SkullMeta meta = (SkullMeta) item.getItemMeta();
applyHead(meta, "pixelpics:camera", CAMERA_TEXTURE_B64); applyHead(meta, "pixelpics:camera", CAMERA_TEXTURE_B64);
meta.displayName(Component.text("Kamera", NamedTextColor.AQUA) meta.displayName(Component.text("Kamera", NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false));
.decoration(TextDecoration.ITALIC, false));
meta.setCustomModelData(CAMERA_MODEL_DATA); meta.setCustomModelData(CAMERA_MODEL_DATA);
meta.getPersistentDataContainer().set(Main.getInstance().cameraMarker, PersistentDataType.BYTE, (byte) 1); meta.getPersistentDataContainer().set(Main.getInstance().cameraMarker, PersistentDataType.BYTE, (byte) 1);
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count); meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
@@ -64,11 +64,10 @@ public final class CameraItems {
ItemStack item = new ItemStack(Material.PLAYER_HEAD); ItemStack item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta(); SkullMeta meta = (SkullMeta) item.getItemMeta();
applyHead(meta, "pixelpics:film", FILM_TEXTURE_B64); applyHead(meta, "pixelpics:film", FILM_TEXTURE_B64);
meta.displayName(Component.text("Filmrolle", NamedTextColor.GREEN) meta.displayName(Component.text("Filmrolle", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false));
.decoration(TextDecoration.ITALIC, false));
meta.setCustomModelData(FILM_MODEL_DATA); meta.setCustomModelData(FILM_MODEL_DATA);
meta.lore(List.of(Component.text("Lädt eine Kamera auf.", NamedTextColor.GRAY) meta.lore(List.of(
.decoration(TextDecoration.ITALIC, false))); Component.text("Lädt eine Kamera auf.", NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false)));
meta.getPersistentDataContainer().set(Main.getInstance().filmMarker, PersistentDataType.BYTE, (byte) 1); meta.getPersistentDataContainer().set(Main.getInstance().filmMarker, PersistentDataType.BYTE, (byte) 1);
makeUnwearable(meta); makeUnwearable(meta);
item.setItemMeta(meta); item.setItemMeta(meta);
@@ -99,15 +98,17 @@ public final class CameraItems {
/** A photo is a filled map carrying our picture id. */ /** A photo is a filled map carrying our picture id. */
public static boolean isPhoto(ItemStack item) { public static boolean isPhoto(ItemStack item) {
if (item == null || item.getType() != Material.FILLED_MAP || !item.hasItemMeta()) return false; if (item == null || item.getType() != Material.FILLED_MAP || !item.hasItemMeta()) return false;
return item.getItemMeta().getPersistentDataContainer() return item.getItemMeta()
.has(Main.getInstance().pictureIdFlag, PersistentDataType.STRING); .getPersistentDataContainer()
.has(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
} }
/** Loaded film on a camera, or 0 if the item is not a camera. */ /** Loaded film on a camera, or 0 if the item is not a camera. */
public static int getFilmCount(ItemStack item) { public static int getFilmCount(ItemStack item) {
if (!isCamera(item)) return 0; if (!isCamera(item)) return 0;
Integer v = item.getItemMeta().getPersistentDataContainer() Integer v = item.getItemMeta()
.get(Main.getInstance().filmCountKey, PersistentDataType.INTEGER); .getPersistentDataContainer()
.get(Main.getInstance().filmCountKey, PersistentDataType.INTEGER);
return v == null ? 0 : v; return v == null ? 0 : v;
} }
@@ -121,11 +122,12 @@ public final class CameraItems {
private static void applyCameraLore(ItemMeta meta, int count) { private static void applyCameraLore(ItemMeta meta, int count) {
meta.lore(List.of( meta.lore(List.of(
Component.text("Film: " + count + " / " + MAX_FILM, Component.text(
count > 0 ? NamedTextColor.YELLOW : NamedTextColor.RED).decoration(TextDecoration.ITALIC, false), "Film: " + count + " / " + MAX_FILM,
Component.text("Rechtsklick: Foto aufnehmen", NamedTextColor.DARK_GRAY) count > 0 ? NamedTextColor.YELLOW : NamedTextColor.RED)
.decoration(TextDecoration.ITALIC, false) .decoration(TextDecoration.ITALIC, false),
)); Component.text("Rechtsklick: Foto aufnehmen", NamedTextColor.DARK_GRAY)
.decoration(TextDecoration.ITALIC, false)));
} }
/** /**
@@ -1,5 +1,8 @@
package eu.mhsl.minecraft.pixelpics.survival; 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.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Sound; import org.bukkit.Sound;
@@ -11,10 +14,6 @@ import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/** /**
* Takes a photo when a player right-clicks while holding a camera. Consumes one loaded film per shot; * 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 * with no film loaded it gives a short fail feedback. Guards against the well-known double-fire of
@@ -1,6 +1,7 @@
package eu.mhsl.minecraft.pixelpics.survival; package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main; import eu.mhsl.minecraft.pixelpics.Main;
import java.util.function.Predicate;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
@@ -19,8 +20,6 @@ import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.meta.MapMeta; import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
import java.util.function.Predicate;
/** /**
* Drives the two dynamic recipes whose inputs carry variable NBT: <em>load film</em> * 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). * (camera + film → camera+1) and <em>copy photo</em> (photo + film → 2 photos sharing the same map).
@@ -31,7 +30,11 @@ import java.util.function.Predicate;
*/ */
public class CraftingListener implements Listener { public class CraftingListener implements Listener {
private enum Kind { LOAD, COPY, NONE } private enum Kind {
LOAD,
COPY,
NONE
}
/** Scanned crafting grid: the relevant items and whether any of our marked items are present. */ /** 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) {} private record Scan(Kind kind, ItemStack camera, ItemStack film, ItemStack photo, boolean hasOurItems) {}
@@ -44,9 +47,8 @@ public class CraftingListener implements Listener {
switch (scan.kind()) { switch (scan.kind()) {
case LOAD -> { case LOAD -> {
int count = CameraItems.getFilmCount(scan.camera()); int count = CameraItems.getFilmCount(scan.camera());
inv.setResult(count >= CameraItems.MAX_FILM inv.setResult(
? null count >= CameraItems.MAX_FILM ? null : CameraItems.withFilmCount(scan.camera(), count + 1));
: CameraItems.withFilmCount(scan.camera(), count + 1));
} }
case COPY -> inv.setResult(buildPhotoCopy(scan.photo())); case COPY -> inv.setResult(buildPhotoCopy(scan.photo()));
case NONE -> { case NONE -> {
@@ -109,10 +111,16 @@ public class CraftingListener implements Listener {
ItemStack camera = null, film = null, photo = null; ItemStack camera = null, film = null, photo = null;
for (ItemStack it : matrix) { for (ItemStack it : matrix) {
if (it == null || it.getType().isAir()) continue; if (it == null || it.getType().isAir()) continue;
if (CameraItems.isCamera(it)) { cameras++; camera = it; } if (CameraItems.isCamera(it)) {
else if (CameraItems.isFilm(it)) { films++; film = it; } cameras++;
else if (CameraItems.isPhoto(it)) { photos++; photo = it; } camera = it;
else others++; } else if (CameraItems.isFilm(it)) {
films++;
film = it;
} else if (CameraItems.isPhoto(it)) {
photos++;
photo = it;
} else others++;
} }
boolean ours = cameras + films + photos > 0; boolean ours = cameras + films + photos > 0;
Kind kind = Kind.NONE; Kind kind = Kind.NONE;
@@ -136,11 +144,9 @@ public class CraftingListener implements Listener {
ItemStack copy = new ItemStack(Material.FILLED_MAP); ItemStack copy = new ItemStack(Material.FILLED_MAP);
MapMeta dst = (MapMeta) copy.getItemMeta(); MapMeta dst = (MapMeta) copy.getItemMeta();
dst.setMapView(src.getMapView()); dst.setMapView(src.getMapView());
String pid = src.getPersistentDataContainer() String pid = src.getPersistentDataContainer().get(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
.get(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
if (pid != null) { if (pid != null) {
dst.getPersistentDataContainer() dst.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag, PersistentDataType.STRING, pid);
.set(Main.getInstance().pictureIdFlag, PersistentDataType.STRING, pid);
} }
copy.setItemMeta(dst); copy.setItemMeta(dst);
return copy; return copy;
@@ -152,7 +158,10 @@ public class CraftingListener implements Listener {
if (it != null && !it.getType().isAir() && pred.test(it)) { if (it != null && !it.getType().isAir() && pred.test(it)) {
int amt = it.getAmount(); int amt = it.getAmount();
if (amt <= 1) matrix[i] = null; if (amt <= 1) matrix[i] = null;
else { it.setAmount(amt - 1); matrix[i] = it; } else {
it.setAmount(amt - 1);
matrix[i] = it;
}
return; return;
} }
} }
@@ -163,8 +172,8 @@ public class CraftingListener implements Listener {
if (cursor.getType().isAir()) { if (cursor.getType().isAir()) {
player.setItemOnCursor(item); player.setItemOnCursor(item);
} else { } else {
player.getInventory().addItem(item).values() player.getInventory().addItem(item).values().forEach(left -> player.getWorld()
.forEach(left -> player.getWorld().dropItemNaturally(player.getLocation(), left)); .dropItemNaturally(player.getLocation(), left));
} }
} }
} }
@@ -8,6 +8,8 @@ import eu.mhsl.minecraft.pixelpics.render.render.Resolution;
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer; import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither; import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
import eu.mhsl.minecraft.pixelpics.utils.MapManager; 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.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
@@ -22,9 +24,6 @@ import org.bukkit.map.MapView;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
import java.awt.image.BufferedImage;
import java.util.UUID;
/** /**
* Renders a photo of a player's current view and delivers it as a developing {@code FILLED_MAP}, * 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. * with a shutter sound + flash particles on capture and a chime when the picture finishes developing.
@@ -47,8 +46,8 @@ public final class PhotoService {
Main plugin = Main.getInstance(); Main plugin = Main.getInstance();
DefaultScreenRenderer renderer = plugin.getScreenRenderer(); DefaultScreenRenderer renderer = plugin.getScreenRenderer();
if (renderer == null) { if (renderer == null) {
player.sendActionBar(Component.text("PixelPics ist nicht einsatzbereit (kein Resource-Pack).", player.sendActionBar(
NamedTextColor.RED)); Component.text("PixelPics ist nicht einsatzbereit (kein Resource-Pack).", NamedTextColor.RED));
return false; return false;
} }
@@ -56,9 +55,11 @@ public final class PhotoService {
UUID user = player.getUniqueId(); UUID user = player.getUniqueId();
RenderManager.Outcome outcome = manager.tryReserve(user); RenderManager.Outcome outcome = manager.tryReserve(user);
if (outcome != RenderManager.Outcome.ACCEPTED) { if (outcome != RenderManager.Outcome.ACCEPTED) {
player.sendActionBar(Component.text(outcome == RenderManager.Outcome.USER_BUSY player.sendActionBar(Component.text(
? "Deine letzte Aufnahme wird noch entwickelt …" outcome == RenderManager.Outcome.USER_BUSY
: "Zu viele Aufnahmen gerade — versuch es gleich erneut.", NamedTextColor.RED)); ? "Deine letzte Aufnahme wird noch entwickelt …"
: "Zu viele Aufnahmen gerade — versuch es gleich erneut.",
NamedTextColor.RED));
return false; return false;
} }
@@ -77,8 +78,11 @@ public final class PhotoService {
ItemStack map = new ItemStack(Material.FILLED_MAP, 1); ItemStack map = new ItemStack(Material.FILLED_MAP, 1);
MapMeta meta = (MapMeta) map.getItemMeta(); MapMeta meta = (MapMeta) map.getItemMeta();
meta.getPersistentDataContainer().set(plugin.pictureIdFlag, meta.getPersistentDataContainer()
PersistentDataType.STRING, UUID.randomUUID().toString()); .set(
plugin.pictureIdFlag,
PersistentDataType.STRING,
UUID.randomUUID().toString());
meta.setMapView(mapView); meta.setMapView(mapView);
map.setItemMeta(meta); map.setItemMeta(meta);
player.getInventory().addItem(map); player.getInventory().addItem(map);
@@ -88,19 +92,20 @@ public final class PhotoService {
// Trace + dither off-thread (queued), then develop on the main thread. The cancel token is // 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. // tripped by the watchdog on timeout; we bail out before the (now pointless) dithering.
manager.dispatch(user, manager.dispatch(
cancelled -> { user,
BufferedImage image = renderer.execute(job, cancelled); cancelled -> {
if (cancelled.get()) return null; BufferedImage image = renderer.execute(job, cancelled);
return new RenderOutput(image, MapImageDither.dither(image)); if (cancelled.get()) return null;
}, return new RenderOutput(image, MapImageDither.dither(image));
out -> { },
MapManager.saveImage(out.image(), id); out -> {
MapManager.saveIndices(out.indices(), id); MapManager.saveImage(out.image(), id);
mapRenderer.develop(out.indices()); MapManager.saveIndices(out.indices(), id);
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f); 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))); },
() -> player.sendActionBar(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
dispatched = true; dispatched = true;
} finally { } finally {
// prepare()/map setup threw before handing off — free the reserved slot. // prepare()/map setup threw before handing off — free the reserved slot.
@@ -1,6 +1,7 @@
package eu.mhsl.minecraft.pixelpics.survival; package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main; import eu.mhsl.minecraft.pixelpics.Main;
import java.util.List;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
@@ -9,8 +10,6 @@ import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe; import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.ShapelessRecipe; import org.bukkit.inventory.ShapelessRecipe;
import java.util.List;
/** /**
* Registers the four survival recipes and makes them discoverable in the recipe book. Camera and * 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 * film have fixed results; load-film and copy-photo use {@code MaterialChoice} ingredients (their
@@ -1,13 +1,12 @@
package eu.mhsl.minecraft.pixelpics.utils; package eu.mhsl.minecraft.pixelpics.utils;
import java.awt.image.BufferedImage;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.map.MapCanvas; import org.bukkit.map.MapCanvas;
import org.bukkit.map.MapRenderer; import org.bukkit.map.MapRenderer;
import org.bukkit.map.MapView; import org.bukkit.map.MapView;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.awt.image.BufferedImage;
/** /**
* Draws precomputed map palette indices onto the canvas. Supports a Polaroid-style "developing" * 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 * animation: the picture dissolves in over {@link #DEVELOP_MILLIS} once the render is ready, like an
@@ -24,9 +23,9 @@ public class ImageMapRenderer extends MapRenderer {
private static byte filmIndex = 0; private static byte filmIndex = 0;
private static boolean filmResolved = false; private static boolean filmResolved = false;
private volatile byte[] indices; // MAP_SIZE*MAP_SIZE, null while still rendering private volatile byte[] indices; // MAP_SIZE*MAP_SIZE, null while still rendering
private final boolean animate; private final boolean animate;
private volatile long developStart = 0; // 0 until indices are available private volatile long developStart = 0; // 0 until indices are available
private boolean finished = false; private boolean finished = false;
private boolean blankDrawn = false; private boolean blankDrawn = false;
@@ -88,8 +87,7 @@ public class ImageMapRenderer extends MapRenderer {
byte film = film(); byte film = film();
for (int y = 0; y < MAP_SIZE; y++) { for (int y = 0; y < MAP_SIZE; y++) {
for (int x = 0; x < MAP_SIZE; x++) { for (int x = 0; x < MAP_SIZE; x++) {
byte value = (progress >= 1.0 || revealThreshold(x, y) <= progress) byte value = (progress >= 1.0 || revealThreshold(x, y) <= progress) ? data[y * MAP_SIZE + x] : film;
? data[y * MAP_SIZE + x] : film;
canvas.setPixel(x, y, value); canvas.setPixel(x, y, value);
} }
} }
@@ -1,11 +1,10 @@
package eu.mhsl.minecraft.pixelpics.utils; package eu.mhsl.minecraft.pixelpics.utils;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.map.MapPalette;
import java.awt.Color; import java.awt.Color;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.bukkit.map.MapPalette;
/** /**
* The set of usable Minecraft map colors, with nearest-color matching in CIELAB (perceptual) space. * The set of usable Minecraft map colors, with nearest-color matching in CIELAB (perceptual) space.
@@ -92,7 +91,7 @@ public final class MapColorPalette {
double fx = pivotXyz(x / 0.95047); double fx = pivotXyz(x / 0.95047);
double fy = pivotXyz(y); double fy = pivotXyz(y);
double fz = pivotXyz(z / 1.08883); double fz = pivotXyz(z / 1.08883);
return new double[]{116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)}; return new double[] {116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)};
} }
private static double pivotXyz(double t) { private static double pivotXyz(double t) {
@@ -1,16 +1,6 @@
package eu.mhsl.minecraft.pixelpics.utils; package eu.mhsl.minecraft.pixelpics.utils;
import eu.mhsl.minecraft.pixelpics.Main; 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.awt.image.BufferedImage;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@@ -19,6 +9,15 @@ import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; 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 * Persists rendered images (PNG, source of truth) and their dithered map-color indices (cache) to