render entities from vanilla client models, add items, displays and leashes

- dump entity models straight from the unobfuscated client jar
  (tools/VanillaModelDump.java, tools/update-vanilla-models.sh): models posed
  by vanilla setupAnim, baby/variant/layer models, renderer transforms,
  default textures, pose variants (sitting, holding items, illager arms)
  and all layer definitions; the old CEM template is only a fallback now
- fix adult zombies captured as babies, zero-thickness planes (wings,
  fins) and back faces in the entity intersector
- held items, head items (pumpkins, skulls), shields, elytra, saddles and
  body equipment for every mount, armor following the pose
- block/item/text display entities, dropped items, falling blocks, primed
  TNT, minecart contents, block entities inside displays, leash ropes
- item models resolved from the 26.x item definitions with display
  transforms; generated items extruded like vanilla
- bundle the vanilla font definitions as fallback so sign/text rendering
  works with packs that lack font/*.json
- one failing entity no longer aborts the whole photo
- edge-case test plugin (tools/edgecase-plugin) and offline harnesses
This commit is contained in:
MineTec committed 2026-11-04 13:11:39 +01:00
1 parent 5904814e69
commit 32afe08f03
41 files changed
+3761 -370

No files matched your search

@@ -107,6 +107,20 @@ public final class Main extends JavaPlugin {
} catch (Exception e) {
getLogger().severe("Failed to load CEM entity models: " + e.getMessage());
}
// Vanilla models dumped from the client jar (tools/VanillaModelDump.java) override the CEM set: an
// optional newer dump in the data folder (no recompile on MC updates), else the bundled copy.
File dump = new File(getDataFolder(), "vanilla_entity_models.json");
try (InputStream in =
dump.isFile() ? new java.io.FileInputStream(dump) : getResource("cem/vanilla_entity_models.json")) {
if (in != null) {
int n = cemLoader.load(in, getLogger());
getLogger()
.info("Loaded vanilla entity models" + (dump.isFile() ? " from " + dump.getName() : "") + " ("
+ n + " models total).");
}
} catch (Exception e) {
getLogger().severe("Failed to load vanilla entity models: " + e.getMessage());
}
SkinCache skinCache = new SkinCache();
BitmapFont font = FontLoader.load(resourcePack, textures, getLogger());
getLogger().info("Loaded sign font (" + (font.isEmpty() ? "no glyphs — text disabled" : "ok") + ").");
@@ -29,7 +29,15 @@ public final class BlockModelRegistry {
private volatile ResolvedModel waterModel;
private volatile ResolvedModel lavaModel;
private final AssetReader reader;
/** The asset reader this registry resolves models with (shared by item-model resolution). */
public AssetReader reader() {
return reader;
}
public BlockModelRegistry(AssetReader reader, TextureCache textures) {
this.reader = reader;
this.textures = textures;
this.blockStateResolver = new BlockStateResolver(reader);
this.modelResolver = new ModelResolver(reader);
@@ -59,6 +59,9 @@ public final class FontLoader {
String path = AssetPaths.font(id);
if (!visited.add(path)) return; // cycle guard
Optional<byte[]> bytes = pack.read(path);
// Packs built from just the textures/models lack font/*.json — use the bundled vanilla font
// definitions (tiny JSON; the glyph sheets still come from the pack's textures/font/).
if (bytes.isEmpty()) bytes = bundled(path);
if (bytes.isEmpty()) return;
JsonObject root;
try {
@@ -76,6 +79,16 @@ public final class FontLoader {
}
}
private Optional<byte[]> bundled(String path) {
try (java.io.InputStream in = FontLoader.class.getResourceAsStream("/fallback/" + path)) {
if (in == null) return Optional.empty();
if (log != null) log.info("PixelPics: resource pack has no " + path + ", using the bundled copy.");
return Optional.of(in.readAllBytes());
} catch (java.io.IOException e) {
return Optional.empty();
}
}
private void provider(JsonObject p, Set<String> visited) {
String type = p.has("type") ? p.get("type").getAsString() : "";
switch (type) {
@@ -24,10 +24,62 @@ public final class Affine {
return new Affine(new double[] {1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[] {x, y, z});
}
/** From a column-major 4x4 matrix (JOML / OpenGL layout). */
public static Affine fromColumnMajor(double[] m) {
return new Affine(
new double[] {m[0], m[4], m[8], m[1], m[5], m[9], m[2], m[6], m[10]},
new double[] {m[12], m[13], m[14]});
}
/** Rotation of a (normalised on the fly) quaternion {@code (x, y, z, w)}. */
public static Affine fromQuaternion(double x, double y, double z, double w) {
double n = Math.sqrt(x * x + y * y + z * z + w * w);
if (n < 1e-9) return identity();
x /= n;
y /= n;
z /= n;
w /= n;
return new Affine(
new double[] {
1 - 2 * (y * y + z * z),
2 * (x * y - z * w),
2 * (x * z + y * w),
2 * (x * y + z * w),
1 - 2 * (x * x + z * z),
2 * (y * z - x * w),
2 * (x * z - y * w),
2 * (y * z + x * w),
1 - 2 * (x * x + y * y)
},
new double[] {0, 0, 0});
}
/** Right-handed rotation by {@code a} radians about the unit axis {@code (x, y, z)} (Rodrigues). */
public static Affine rotAxis(double x, double y, double z, double a) {
double c = Math.cos(a), s = Math.sin(a), k = 1 - c;
return new Affine(
new double[] {
c + x * x * k,
x * y * k - z * s,
x * z * k + y * s,
y * x * k + z * s,
c + y * y * k,
y * z * k - x * s,
z * x * k - y * s,
z * y * k + x * s,
c + z * z * k
},
new double[] {0, 0, 0});
}
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});
}
public static Affine scale(double sx, double sy, double sz) {
return new Affine(new double[] {sx, 0, 0, 0, sy, 0, 0, 0, sz}, new double[] {0, 0, 0});
}
public static Affine rotX(double 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});
@@ -16,7 +16,15 @@ public final class EntityCube {
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};
/** Faces use BLOCK-model UV orientation (resolved block-model faces) instead of entity box-UV. */
public final boolean blockUv;
public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld) {
this(from, to, faces, toWorld, false);
}
public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld, boolean blockUv) {
this.blockUv = blockUv;
this.from = from;
this.to = to;
this.faces = faces;
@@ -14,7 +14,6 @@ import org.bukkit.util.Vector;
public final class EntityIntersector {
private static final double EPS = 1e-7;
private static final int ALPHA_THRESHOLD = 16;
private EntityIntersector() {}
@@ -23,38 +22,56 @@ public final class EntityIntersector {
double[] d = cube.toLocal.applyLinear(dx, dy, dz);
double tmin = Double.NEGATIVE_INFINITY, tmax = Double.POSITIVE_INFINITY;
int axis = -1;
boolean neg = false;
int inAxis = -1, outAxis = -1;
boolean inNeg = false, outNeg = false;
for (int a = 0; a < 3; a++) {
if (Math.abs(d[a]) < EPS) {
if (o[a] < cube.from[a] - EPS || o[a] > cube.to[a] + EPS) return null;
continue;
}
double inv = 1.0 / d[a];
double t1 = (cube.from[a] - o[a]) * inv;
double t2 = (cube.to[a] - o[a]) * inv;
boolean n = true;
if (t1 > t2) {
double tmp = t1;
t1 = t2;
t2 = tmp;
n = false;
// Moving towards +a the ray enters through the min ("negative") face and leaves through the
// max face. Decided by the direction, not by comparing t1/t2: for a zero-thickness box
// (wing membranes, fins) both are equal and the comparison can't tell the sides apart.
boolean forward = inv > 0;
double tNear = ((forward ? cube.from[a] : cube.to[a]) - o[a]) * inv;
double tFar = ((forward ? cube.to[a] : cube.from[a]) - o[a]) * inv;
if (tNear > tmin) {
tmin = tNear;
inAxis = a;
inNeg = forward;
}
if (t1 > tmin) {
tmin = t1;
axis = a;
neg = n;
if (tFar < tmax) {
tmax = tFar;
outAxis = a;
outNeg = !forward;
}
if (t2 < tmax) tmax = t2;
if (tmin > tmax) return null;
}
if (axis < 0) return null;
double t = tmin;
if (t < EPS) {
t = tmax;
if (t < EPS) return null;
if (tmin > tmax + EPS) return null;
}
if (inAxis < 0 || tmax < EPS) return null;
// Vanilla draws entity models without back-face culling: a ray looking through a transparent
// texel of the entry face still sees the inside of the far face (and both sides of a plane).
if (tmin >= EPS) {
FaceHit hit = sample(cube, o, d, tmin, inAxis, inNeg, ox, oy, oz, dx, dy, dz);
if (hit != null) return hit;
}
return outAxis < 0 ? null : sample(cube, o, d, tmax, outAxis, outNeg, ox, oy, oz, dx, dy, dz);
}
private static FaceHit sample(
EntityCube cube,
double[] o,
double[] d,
double t,
int axis,
boolean neg,
double ox,
double oy,
double oz,
double dx,
double dy,
double dz) {
double px = o[0] + d[0] * t, py = o[1] + d[1] * t, pz = o[2] + d[2] * t;
Direction dir =
switch (axis) {
@@ -71,38 +88,57 @@ public final class EntityIntersector {
// (s,t) = Blockbench/Java box-UV (lerp_x, lerp_y) for this face (see BoxUv). Front/right faces
// run their horizontal axis opposite to back/left (they're viewed from the other side).
double s, tt;
switch (dir) {
case UP -> {
s = fx;
tt = fz;
if (cube.blockUv) {
// Block-model faces (see ElementIntersector): side faces run along +x / +z, up/down along x,z.
switch (dir) {
case UP, DOWN -> {
s = fx;
tt = fz;
}
case NORTH, SOUTH -> {
s = fx;
tt = 1 - fy;
}
default -> {
s = fz;
tt = 1 - fy;
}
}
case DOWN -> {
s = fx;
tt = 1 - fz;
} else
switch (dir) {
case UP -> {
s = fx;
tt = fz;
}
case DOWN -> {
s = fx;
tt = 1 - fz;
}
case NORTH -> {
s = 1 - fx;
tt = 1 - fy;
}
case SOUTH -> {
s = fx;
tt = 1 - fy;
}
case EAST -> {
s = 1 - fz;
tt = 1 - fy;
}
default -> {
s = fz;
tt = 1 - fy;
} // WEST
}
case NORTH -> {
s = 1 - fx;
tt = 1 - fy;
}
case SOUTH -> {
s = fx;
tt = 1 - fy;
}
case EAST -> {
s = 1 - fz;
tt = 1 - fy;
}
default -> {
s = fz;
tt = 1 - fy;
} // WEST
}
int color = face.sample(s, tt);
if (ColorUtil.alpha(color) <= ALPHA_THRESHOLD) return null;
if (!ColorUtil.opaque(color)) return null;
Vector world = new Vector(ox + dx * t, oy + dy * t, oz + dz * t);
double[] n = cube.toWorld.applyLinear(dir.nx, dir.ny, dir.nz);
Vector normal = new Vector(n[0], n[1], n[2]).normalize();
// A far/back face is seen from inside: shade it as facing the viewer.
if (normal.getX() * dx + normal.getY() * dy + normal.getZ() * dz > 0) normal.multiply(-1);
return new FaceHit(t, world, normal, color, -1);
}
@@ -50,72 +50,41 @@ public final class EntityModels {
return CEM_OVERRIDE.getOrDefault(typeKey, typeKey);
}
// Type key -> texture path override (where the first derived candidate is wrong).
// Type key -> texture path where the vanilla dump's default texture (entities.<type>.texture) is missing
// or not the right default: variant-only mobs whose renderer default is a variant/"missingno", the dump's
// piglin_brute (= piglin), players/mannequins, and non-mob entities whose renderer texture isn't captured.
// Everything else comes from the dump (see CemBaker#resolveTexture).
private static final Map<String, String> TEX_OVERRIDE = Map.<String, String>ofEntries(
Map.entry("cow", "entity/cow/cow_temperate"),
Map.entry("mooshroom", "entity/cow/mooshroom_red"),
Map.entry("zombie", "entity/zombie/zombie"),
Map.entry("husk", "entity/zombie/husk"),
Map.entry("drowned", "entity/zombie/drowned"),
Map.entry("zombified_piglin", "entity/piglin/zombified_piglin"),
Map.entry("skeleton", "entity/skeleton/skeleton"),
Map.entry("stray", "entity/skeleton/stray"),
Map.entry("wither_skeleton", "entity/skeleton/wither_skeleton"),
Map.entry("creeper", "entity/creeper/creeper"),
Map.entry("spider", "entity/spider/spider"),
Map.entry("enderman", "entity/enderman/enderman"),
Map.entry("player", "entity/player/wide/steve"),
// Textures whose folder/name doesn't follow the "entity/<key>/<key>" pattern.
Map.entry("iron_golem", "entity/iron_golem/iron_golem"),
Map.entry("polar_bear", "entity/bear/polarbear"),
Map.entry("ender_dragon", "entity/enderdragon/dragon"),
Map.entry("magma_cube", "entity/slime/magmacube"),
Map.entry("tropical_fish", "entity/fish/tropical_a"),
Map.entry("bogged", "entity/skeleton/bogged"),
Map.entry("donkey", "entity/horse/donkey"),
Map.entry("mule", "entity/horse/mule"),
Map.entry("skeleton_horse", "entity/horse/horse_skeleton"),
Map.entry("zombie_horse", "entity/horse/horse_zombie"),
Map.entry("trader_llama", "entity/llama/llama_creamy"),
Map.entry("cave_spider", "entity/spider/cave_spider"),
Map.entry("guardian", "entity/guardian/guardian"),
Map.entry("elder_guardian", "entity/guardian/guardian_elder"),
Map.entry("piglin_brute", "entity/piglin/piglin_brute"),
Map.entry("zoglin", "entity/hoglin/zoglin"),
Map.entry("illusioner", "entity/illager/illusioner"),
Map.entry("giant", "entity/zombie/zombie"),
// Illagers share one texture folder; none follow the entity/<key>/<key> pattern.
Map.entry("pillager", "entity/illager/pillager"),
Map.entry("vindicator", "entity/illager/vindicator"),
Map.entry("evoker", "entity/illager/evoker"),
Map.entry("ravager", "entity/illager/ravager"),
Map.entry("vex", "entity/illager/vex"),
// Fish share entity/fish/; squids share entity/squid/.
Map.entry("cod", "entity/fish/cod"),
Map.entry("salmon", "entity/fish/salmon"),
Map.entry("pufferfish", "entity/fish/pufferfish"),
Map.entry("glow_squid", "entity/squid/glow_squid"),
// Variant-only textures with no plain base file — pick a sensible default variant.
Map.entry("cat", "entity/cat/cat_tabby"),
Map.entry("ocelot", "entity/cat/ocelot"), // ocelot texture lives in the cat folder now
Map.entry("axolotl", "entity/axolotl/axolotl_wild"),
Map.entry("parrot", "entity/parrot/parrot_red_blue"),
Map.entry("turtle", "entity/turtle/turtle"),
Map.entry("wind_charge", "entity/projectiles/wind_charge"),
Map.entry("camel_husk", "entity/camel/camel_husk"),
Map.entry("armor_stand", "entity/armorstand/armorstand"), // texture folder is "armorstand"
Map.entry("happy_ghast", "entity/ghast/happy_ghast"),
Map.entry("parched", "entity/skeleton/parched"), // husk-style skeleton, texture in skeleton/
Map.entry("zombie_nautilus_coral", "entity/nautilus/zombie_nautilus_coral"),
Map.entry("mannequin", "entity/player/wide/steve"));
Map.entry("mannequin", "entity/player/wide/steve"),
Map.entry("leash_knot", "entity/lead_knot/lead_knot"),
Map.entry("evoker_fangs", "entity/illager/evoker_fangs"),
Map.entry("shulker_bullet", "entity/shulker/spark"),
Map.entry("llama_spit", "entity/llama/llama_spit"),
Map.entry("trident", "entity/trident/trident"),
Map.entry("cushion", "entity/cushion/white_cushion"),
Map.entry("breeze_wind_charge", "entity/projectiles/wind_charge"),
Map.entry("chest_minecart", "entity/minecart/minecart"),
Map.entry("furnace_minecart", "entity/minecart/minecart"),
Map.entry("hopper_minecart", "entity/minecart/minecart"),
Map.entry("tnt_minecart", "entity/minecart/minecart"),
Map.entry("spawner_minecart", "entity/minecart/minecart"),
Map.entry("command_block_minecart", "entity/minecart/minecart"));
/** Ordered texture-path candidates; the baker uses the first that loads. */
public static List<ResourceLocation> textureCandidates(String typeKey, String variant) {
List<ResourceLocation> list = new ArrayList<>();
if (typeKey.endsWith("_boat")) {
String wood = typeKey.substring(0, typeKey.length() - "_boat".length());
if (wood.endsWith("_chest")) wood = wood.substring(0, wood.length() - "_chest".length());
list.add(ResourceLocation.parse("entity/boat/" + wood));
if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) {
String wood = typeKey.substring(0, typeKey.length() - 5);
boolean chest = wood.endsWith("_chest");
if (chest) wood = wood.substring(0, wood.length() - "_chest".length());
list.add(ResourceLocation.parse("entity/" + (chest ? "chest_boat/" : "boat/") + wood));
return list;
}
if (variant != null) {
@@ -129,6 +98,13 @@ public final class EntityModels {
return list;
}
/** How many leading {@link #textureCandidates} entries are type/variant-specific (not generic guesses). */
public static int specificCandidateCount(String typeKey, String variant) {
if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) return 1;
return (variant != null ? variantPaths(typeKey, variant).size() : 0)
+ (TEX_OVERRIDE.containsKey(typeKey) ? 1 : 0);
}
/**
* Variant-specific texture paths (modern pack naming is "entity/<folder>/<entity>_<variant>", with a
* handful of mismatches the small maps below normalise). Returned paths are tried before the generic
@@ -151,12 +127,40 @@ public final class EntityModels {
case "rabbit" -> List.of("entity/rabbit/rabbit_" + RABBIT_TYPE.getOrDefault(v, v));
case "mooshroom" -> List.of("entity/cow/mooshroom_" + v);
case "shulker" -> List.of("entity/shulker/shulker_" + v);
case "cushion" -> List.of("entity/cushion/" + v + "_cushion");
case "tropical_fish" -> List.of("entity/fish/tropical_" + (tropicalPattern(v) >= 6 ? "b" : "a"));
case "zombie_nautilus" -> List.of(
v.equals("warm") ? "entity/nautilus/zombie_nautilus_coral" : "entity/nautilus/zombie_nautilus");
case "copper_golem" -> List.of(
v.equals("unaffected")
? "entity/copper_golem/copper_golem"
: "entity/copper_golem/copper_golem_" + v);
// villager/zombie_villager: type/<biome> and profession are transparent OVERLAYS (clothing
// only); the opaque base body is entity/<folder>/<folder> — handled by the generic candidates.
default -> List.of();
};
}
/** Vanilla tropical-fish patterns in id order: 0-5 use body shape A (small), 6-11 shape B (large). */
private static final List<String> TROPICAL_PATTERNS = List.of(
"kob",
"sunstreak",
"snooper",
"dasher",
"brinely",
"spotty",
"flopper",
"stripey",
"glitter",
"blockfish",
"betty",
"clayfish");
/** Index of a tropical-fish pattern key (0-11), or -1 if unknown. */
public static int tropicalPattern(String pattern) {
return pattern == null ? -1 : TROPICAL_PATTERNS.indexOf(pattern);
}
private static final Map<String, String> HORSE_COLOR = Map.of("dark_brown", "darkbrown");
private static final Map<String, String> PARROT_COLOR =
Map.of("red", "red_blue", "cyan", "yellow_blue", "gray", "grey");
@@ -16,6 +16,7 @@ public final class EntityScene {
private static final double EPS = 1e-7;
private final List<RenderedEntity> entities;
private static volatile boolean warned;
public EntityScene(
List<EntityState> states,
@@ -23,18 +24,32 @@ public final class EntityScene {
List<BlockEntityState> blockEntities,
BlockEntityBaker beBaker,
List<DecorationState> decorations,
DecorationBaker decoBaker) {
this.entities = new ArrayList<>(states.size() + blockEntities.size() + decorations.size());
DecorationBaker decoBaker,
List<ObjectState> objects,
ObjectBaker objectBaker) {
this.entities = new ArrayList<>(states.size() + blockEntities.size() + decorations.size() + objects.size());
addAll(states, baker);
addAll(blockEntities, beBaker);
addAll(decorations, decoBaker);
addAll(objects, objectBaker);
}
/** Bakes each state and keeps the ones with geometry. A null baker (path disabled) is skipped. */
private <S> void addAll(List<S> states, EntityBaker<S> baker) {
if (baker == null) return;
for (S s : states) {
RenderedEntity e = baker.bake(s);
RenderedEntity e;
try {
e = baker.bake(s);
} catch (RuntimeException ex) {
// One unexpected entity/asset must never fail the whole photo: skip just this one.
if (!warned) {
warned = true;
java.util.logging.Logger.getLogger("PixelPics")
.warning("PixelPics: skipped an entity that failed to bake: " + ex);
}
continue;
}
if (e != null && !e.cubes.isEmpty()) entities.add(e);
}
}
@@ -25,10 +25,17 @@ public record EntityState(
String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null
boolean saddle, // horse/donkey/mule is saddled
boolean chest, // donkey/mule/llama is carrying a chest
String bodyEquip, // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama";
// null = none
String bodyEquip, // BODY-slot equipment asset (horse armor material, llama carpet, harness, wolf armor,
// nautilus armor) 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
boolean invisible, // entity is invisible -> render only its equipment (like vanilla), not the body
float pitch, // entity pitch (deg) — orients projectiles (arrows, tridents, skulls)
int bodyDye, // ARGB dye of dyeable BODY equipment (leather horse armor, wolf armor); 0 = none
int tint2, // second tint (tropical-fish pattern colour); 0 = none
boolean passenger, // riding something (vanilla sitting pose)
ItemSpec rightHand, // item in the RIGHT hand (main or off hand, resolved by handedness); null = empty
ItemSpec leftHand, // item in the LEFT hand; null = empty
ItemSpec headItem // non-armor item worn in the head slot (pumpkin, skull, block, …); null = none
) {
/** Worn armor (4 slots) of a humanoid wearer; any field may be null. */
@@ -0,0 +1,11 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import org.bukkit.block.data.BlockData;
/**
* An item to render (held, worn on the head, in an item display, dropped): its item key, the block data
* when the item places a block (block-model geometry), the block-entity state when that block is
* block-entity rendered (chests, heads, banners, …), and whether it is enchanted. Captured on the main
* thread; immutable afterwards.
*/
public record ItemSpec(String id, BlockData block, BlockEntityState blockEntity, boolean glint) {}
@@ -0,0 +1,174 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.SignTextRasterizer;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList;
import java.util.List;
/**
* Bakes {@link ObjectState}s (display entities, dropped items, falling blocks, primed TNT, minecart
* contents) into world-space {@link EntityCube}s: block content reuses the resolved BLOCK MODEL elements
* (any block, any version — straight from the resource pack), items use their block model or a flat
* sprite, text is rasterized with the pack's bitmap font (as for signs).
*/
public final class ObjectBaker implements EntityBaker<ObjectState> {
private static final int MIN_BACKGROUND_ALPHA =
0x80; // translucent text backgrounds can't be drawn: skip faint ones
// Vanilla leash colours (LeashRenderer): brown, alternating with a darker shade per segment.
private static final int LEASH_LIGHT = 0xFF7F6650, LEASH_DARK = 0xFF59473A;
private static final int LEASH_SEGMENTS = 24;
private static final double LEASH_HALF_WIDTH = 0.025;
private final ItemGeometry items;
private final BitmapFont font;
public ObjectBaker(ItemGeometry items, BitmapFont font) {
this.items = items;
this.font = font;
}
@Override
public RenderedEntity bake(ObjectState s) {
List<EntityCube> cubes = new ArrayList<>();
switch (s.kind()) {
case BLOCK -> {
items.bakeBlock(s.block(), s.toWorld(), cubes);
if (cubes.isEmpty()) items.bakeBlockEntity(s.blockEntity(), s.toWorld(), cubes);
}
case ITEM -> {
Affine at = s.toWorld();
if (s.dropped()) {
double lift = 0.1 + 0.25 * items.displayScaleY(s.item(), s.context());
at = Affine.translation(0, lift, 0).mul(at);
}
items.bake(s.item(), s.context(), false, at, cubes);
}
case TEXT -> addText(s, cubes);
case LEASH -> addLeash(s.leash(), cubes);
}
return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
}
// --- leash ---------------------------------------------------------------------------------------
/**
* Vanilla LeashRenderer curve: 24 segments from the leashed entity to the holder, horizontal position
* linear, height {@code dy·(t² + t)/2} (sags below the straight line), alternating light/dark brown.
*/
private static void addLeash(double[] p, List<EntityCube> cubes) {
if (p == null) return;
double dx = p[3] - p[0], dy = p[4] - p[1], dz = p[5] - p[2];
double[] prev = {p[0], p[1], p[2]};
for (int i = 1; i <= LEASH_SEGMENTS; i++) {
double t = (double) i / LEASH_SEGMENTS;
double[] cur = {p[0] + dx * t, p[1] + dy * (t * t + t) * 0.5, p[2] + dz * t};
int color = (i % 2 == 0) ? LEASH_LIGHT : LEASH_DARK;
EntityCube seg = segment(prev, cur, color);
if (seg != null) cubes.add(seg);
prev = cur;
}
}
/** A thin square rope box from a to b. */
private static EntityCube segment(double[] a, double[] b, int argb) {
double[] d = {b[0] - a[0], b[1] - a[1], b[2] - a[2]};
double len = Math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
if (len < 1e-6) return null;
for (int i = 0; i < 3; i++) d[i] /= len;
double[] ref = Math.abs(d[1]) < 0.9 ? new double[] {0, 1, 0} : new double[] {1, 0, 0};
double[] u = cross(ref, d);
normalize(u);
double[] v = cross(d, u);
Affine frame = new Affine(
new double[] {u[0], v[0], d[0], u[1], v[1], d[1], u[2], v[2], d[2]}, new double[] {a[0], a[1], a[2]});
int[][] solid = {{argb}};
Face[] f = new Face[6];
for (int i = 0; i < 6; i++) f[i] = new Face(solid, 0, 0, 1, 1, 0, -1);
double w = LEASH_HALF_WIDTH;
return new EntityCube(new double[] {-w, -w, 0}, new double[] {w, w, len + w}, f, frame);
}
private static double[] cross(double[] a, double[] b) {
return new double[] {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]};
}
private static void normalize(double[] v) {
double l = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
if (l > 0) for (int i = 0; i < 3; i++) v[i] /= l;
}
// --- text ------------------------------------------------------------------------------------
private void addText(ObjectState s, List<EntityCube> cubes) {
if (font == null || font.isEmpty()) return;
List<String> lines = wrap(s.lines(), Math.max(1, s.lineWidth()));
boolean blank = lines.stream().allMatch(String::isBlank);
int[][] text = blank ? null : SignTextRasterizer.rasterize(lines, font, s.textArgb(), 0, false);
int w = text == null ? 0 : text[0].length, h = text == null ? 0 : text.length;
if (text != null && s.shadow()) text = withShadow(text, s.textArgb());
// Text block: bottom edge on the origin, horizontally centred, front facing +Z.
double x0 = -w / 2.0, x1 = x0 + (text == null ? 0 : text[0].length);
double y1 = h, y0 = text == null ? 0 : h - text.length;
if (text != null) {
cubes.add(
new EntityCube(new double[] {x0, y0, 0.05}, new double[] {x1, y1, 0.05}, card(text), s.toWorld()));
}
int bg = s.backgroundArgb();
if ((bg >>> 24) >= MIN_BACKGROUND_ALPHA && (w > 0 || !s.lines().isEmpty())) {
int[][] solid = {{bg | 0xFF000000}};
cubes.add(new EntityCube(
new double[] {-w / 2.0 - 1, -1, 0}, new double[] {w / 2.0 + 1, h, 0}, card(solid), s.toWorld()));
}
}
/**
* Faces of a planar (z-thickness 0) card: front (+Z) and back show the SAME texel at each point (the
* back is seen mirrored, as in vanilla), so a ray through a transparent texel misses both sides.
*/
private static Face[] card(int[][] tex) {
Face[] f = new Face[6];
f[Direction.SOUTH.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
f[Direction.NORTH.ordinal()] = new Face(tex, 1, 0, 0, 1, 0, -1);
return f;
}
/** Greedy word wrap to the display's line width (vanilla wraps at {@code line_width} font px). */
private List<String> wrap(List<String> lines, int maxWidth) {
List<String> out = new ArrayList<>();
for (String line : lines) {
StringBuilder cur = new StringBuilder();
int curW = 0;
for (String word : line.split(" ", -1)) {
int ww = SignTextRasterizer.lineWidth(word, font);
int sp = cur.isEmpty() ? 0 : font.advance(' ');
if (!cur.isEmpty() && curW + sp + ww > maxWidth) {
out.add(cur.toString());
cur.setLength(0);
curW = 0;
sp = 0;
}
if (sp > 0) cur.append(' ');
cur.append(word);
curW += sp + ww;
}
out.add(cur.toString());
}
return out;
}
/** Vanilla text shadow: the glyphs again, 1px right/down, at a quarter of the brightness. */
private static int[][] withShadow(int[][] text, int argb) {
int h = text.length, w = text[0].length;
int[][] out = new int[h + 1][w + 1];
int shadow = ColorUtil.shade(argb, 0.25);
for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) if ((text[y][x] >>> 24) > 0) out[y + 1][x + 1] = shadow;
for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) if ((text[y][x] >>> 24) > 0) out[y][x] = text[y][x];
return out;
}
}
@@ -0,0 +1,68 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List;
import org.bukkit.block.data.BlockData;
/**
* Snapshot of a non-mob object that renders block/item/text content instead of an entity model:
* block/item/text DISPLAY entities, dropped items, falling blocks, primed TNT and the block inside a
* minecart, and leash ropes. Captured on the main thread with its complete local→world transform already resolved
* (entity position, yaw/pitch or billboard facing, display transformation), so baking is pure geometry.
*
* <p>Local frames: BLOCK = the block cube {@code [0,1]³}; ITEM = an item centred on the origin
* ({@code [-0.5,0.5]³}); TEXT = font pixels (x right, y up, text front facing +Z), already scaled.
*/
public record ObjectState(
Kind kind,
Affine toWorld,
BlockData block, // BLOCK: the block (block model geometry)
BlockEntityState blockEntity, // BLOCK: set when the block is block-entity rendered (chest, banner, …)
ItemSpec item, // ITEM: the item
String context, // ITEM: display context ("none", "fixed", "ground", "head", …)
boolean dropped, // ITEM: a dropped item entity (vanilla lifts it by 0.1 + 0.25·groundScaleY)
double[] leash, // LEASH: rope points {fromX,fromY,fromZ, toX,toY,toZ} (entity -> holder)
List<String> lines, // TEXT: explicit lines (wrapped by the baker to lineWidth)
int lineWidth, // TEXT: wrap width in font px
int textArgb, // TEXT: text colour (alpha = text opacity)
int backgroundArgb, // TEXT: background colour (0 = none)
boolean shadow) { // TEXT: drop shadow
public static ObjectState block(Affine toWorld, BlockData block, BlockEntityState blockEntity) {
return new ObjectState(
Kind.BLOCK, toWorld, block, blockEntity, null, null, false, null, List.of(), 0, 0, 0, false);
}
public static ObjectState item(Affine toWorld, ItemSpec item, String context, boolean dropped) {
return new ObjectState(Kind.ITEM, toWorld, null, null, item, context, dropped, null, List.of(), 0, 0, 0, false);
}
public static ObjectState text(
Affine toWorld, List<String> lines, int lineWidth, int textArgb, int backgroundArgb, boolean shadow) {
return new ObjectState(
Kind.TEXT,
toWorld,
null,
null,
null,
null,
false,
null,
lines,
lineWidth,
textArgb,
backgroundArgb,
shadow);
}
public static ObjectState leash(double[] points) {
return new ObjectState(
Kind.LEASH, Affine.identity(), null, null, null, null, false, points, List.of(), 0, 0, 0, false);
}
public enum Kind {
BLOCK,
ITEM,
TEXT,
LEASH
}
}
@@ -48,6 +48,11 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
private final SkinCache skins;
private final BitmapFont font;
/** The sign/text font (shared with text-display rendering). */
public BitmapFont font() {
return font;
}
public BlockEntityBaker(CemModelLoader models, TextureCache textures, SkinCache skins, BitmapFont font) {
this.models = models;
this.textures = textures;
@@ -59,11 +64,15 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
@Override
public RenderedEntity bake(BlockEntityState s) {
List<Layer> layers = layers(s);
if (layers.isEmpty()) return null;
// Head model depends on the texture aspect (skeleton 64x32 vs zombie/player 64x64), so resolve it
// from the chosen texture rather than statically.
CemModelLoader.CemModel model = models.get(modelName(s, layers.getFirst().tex));
if (model == null) return null;
CemModelLoader.CemModel model = layers.isEmpty() ? null : models.get(modelName(s, layers.getFirst().tex));
// Since 26.x signs (and beds) are real block models (textures under block/, no entity/signs/): the
// board then comes from the block-model pipeline and only the sign TEXT is added here.
boolean signKind = s.kind() == BlockEntityState.Kind.SIGN
|| s.kind() == BlockEntityState.Kind.WALL_SIGN
|| s.kind() == BlockEntityState.Kind.HANGING_SIGN;
if (model == null && !signKind) return null;
Place p = place(s);
Affine pre = Affine.scale(p.scale / 16.0);
@@ -72,12 +81,13 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
.mul(Affine.translation(p.lx, p.ly, p.lz));
List<EntityCube> cubes = new ArrayList<>();
for (Layer layer : layers) {
for (CemGeometry.Baked b :
CemGeometry.bakeModel(model, layer.tex, pre, layer.hidden, layer.texW, layer.texH, layer.boxUv)) {
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), placement.mul(b.world())));
if (model != null)
for (Layer layer : layers) {
for (CemGeometry.Baked b : CemGeometry.bakeModel(
model, layer.tex, pre, layer.hidden, layer.texW, layer.texH, layer.boxUv)) {
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), placement.mul(b.world())));
}
}
}
addSignText(s, placement.mul(pre), cubes);
return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
}
@@ -5,37 +5,82 @@ import eu.mhsl.minecraft.pixelpics.assets.SkinCache;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityModels;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Bakes an {@link EntityState} into world-space cubes using a vanilla Java {@link CemModelLoader.CemModel}
* (OptiFine-CEM format). These models are already correctly posed (standing), so no animation/lay-down
* logic is needed. The CEM model space (px, invertAxis "xy") is mapped to the world by an inner X/Y flip
* + px→block scale + an outer Y-flip (upright); the model is then dropped onto the ground and placed at
* the entity's position/yaw. Calibrated against fox/pig/cow.
* Bakes an {@link EntityState} into world-space cubes.
*
* <p>Primary source: the VANILLA models dumped from the client jar ({@code tools/VanillaModelDump.java},
* "absolute" models). They are posed by vanilla's own {@code setupAnim} (idle, adult, peaceful), carry
* vanilla part visibility, real baby models, per-variant models, and the renderer's layer models
* (outer layers, saddles, body equipment, humanoid armor, elytra). Model choice is generic and
* name-based ({@code <type>}, {@code <type>/baby}, {@code <type>/<…variant…>}, {@code <type>/<Layer>.…}),
* so mobs of a newer dump work without code changes. Vanilla models are placed with their feet at the
* entity position (model y=0), exactly like vanilla — floating mobs keep their height.
*
* <p>Fallback: the CEM template set (rest geometry only, ground-snapped, no layers) for a type the dump
* lacks.
*/
public final class CemBaker implements EntityBaker<EntityState> {
// Parts representing an alternate state (rolled-up, sleeping, …) that must not render in the idle pose.
private static final Map<String, Set<String>> HIDDEN_PARTS = Map.of(
"armadillo", Set.of("cube"), // the rolled-up ball
"illager", Set.of("left_arm", "right_arm"));
// Textured render layers that are conditional in vanilla (charged creeper, tamed collar, riptide, …) or
// handled explicitly below; every OTHER textured layer of the dump is drawn automatically.
private static final Set<String> CONDITIONAL_LAYERS = Set.of(
"CreeperPowerLayer",
"CatCollarLayer",
"WolfCollarLayer",
"RopesLayer",
"ArrowLayer",
"BeeStingerLayer",
"SpinAttackEffectLayer",
"Deadmau5EarsLayer",
"CapeLayer",
"ParrotOnShoulderLayer",
"WitherArmorLayer",
"SheepWoolLayer",
"SheepWoolUndercoatLayer",
"TropicalFishPatternLayer",
"LlamaDecorLayer",
"HumanoidArmorLayer",
"WingsLayer",
"VillagerProfessionLayer");
// LivingEntityRenderer at yaw 0: rotY(180)·scale(-1,-1,1)·translate(0,-1.501,0) — used when the dump has no
// captured transform for a type.
private static final Affine DEFAULT_LIVING_TRANSFORM =
new Affine(new double[] {1, 0, 0, 0, -1, 0, 0, 0, -1}, new double[] {0, 1.501, 0});
private final CemModelLoader models;
private final TextureCache textures;
private final SkinCache skins;
private final Map<String, List<String>> subModelCache = new ConcurrentHashMap<>();
private volatile ItemGeometry items; // held/head items; null = not drawn
/** Enables held-item and head-item rendering. */
public void setItemGeometry(ItemGeometry items) {
this.items = items;
}
public CemModelLoader models() {
return models;
}
public CemBaker(CemModelLoader models, TextureCache textures, SkinCache skins) {
this.models = models;
@@ -43,123 +88,440 @@ public final class CemBaker implements EntityBaker<EntityState> {
this.skins = skins;
}
/**
* The chosen body model: the model, its name, the base type its layers live under, and whether it IS a
* baby model ({@code model.absolute()} tells vanilla dump from legacy CEM template).
*/
private record Choice(CemModelLoader.CemModel model, String name, String base, boolean babyModel) {}
@Override
public RenderedEntity bake(EntityState s) {
int[][] tex = resolveTexture(s);
String cem = EntityModels.cemModel(s.typeKey());
// Same-UV overlays are composited straight into the base texture (no extra geometry -> no ray Z-fighting).
if (s.typeKey().equals("villager") || s.typeKey().equals("zombie_villager")) {
tex = compositeVillager(s, tex);
} else if (cem.equals("horse")) {
tex = compositeHorse(s, tex); // coat markings + horse armor
} else if (cem.equals("llama")) {
tex = compositeLlama(s, tex); // dyed/trader carpet decor
} else if (cem.equals("nautilus")) {
tex = compositeNautilus(s, tex); // body armor + saddle (same-UV overlays)
Choice ch = chooseModel(s);
CemModelLoader.CemModel model = ch == null ? null : ch.model;
boolean vanilla = model != null && model.absolute();
String base = ch == null ? s.typeKey() : ch.base;
int[][] tex = resolveTexture(s, ch);
// Same-UV overlays are composited straight into the base texture (no extra geometry -> no Z-fighting).
if (base.equals("villager") || base.equals("zombie_villager")) {
tex = compositeVillager(s, tex, base, ch != null && ch.babyModel);
} else if (s.markings() != null && tex != null) {
tex = TextureOps.deepCopy(tex); // horse coat markings
String m = "entity/horse/horse_markings_" + s.markings();
overlayIfPresent(tex, ch != null && ch.babyModel ? m + "_baby" : m);
}
CemModelLoader.CemModel model = models.get(cem);
// A visible entity needs its body model+texture; an invisible one renders only its equipment
// (vanilla hides the body but still draws worn armor).
if (s.typeKey().equals("tropical_fish") && tex != null && s.tint() != 0) {
tex = TextureOps.deepCopy(tex);
TextureOps.tint(tex, s.tint()); // white base body tinted with the body colour
}
boolean invisible = s.invisible();
if (!invisible && (model == null || tex == null)) return fallbackBox(s, tex);
double sc = (s.baby() ? 0.5 : 1.0) * s.sizeScale();
// CEM model px -> entity-local blocks. Identity orientation (no axis flip) preserves ALL part
// rotations and handedness; only px->block scaling is applied.
Affine pre = Affine.scale(sc / 16.0);
Set<String> hidden = new HashSet<>(HIDDEN_PARTS.getOrDefault(cem, Set.of()));
// Donkeys/llamas carry the chest boxes inside the base model; hide them unless a chest is equipped.
if (!s.chest()) {
if (cem.equals("donkey")) {
hidden.add("left_chest");
hidden.add("right_chest");
} else if (cem.equals("llama")) {
hidden.add("chest_left");
hidden.add("chest_right");
CemModelLoader.EntityInfo info = models.entityInfo(s.typeKey());
if (info == null) info = models.entityInfo(base);
boolean halfBaby = s.baby() && (ch == null || !ch.babyModel); // no dedicated baby model -> 0.5x adult
double extra = (halfBaby ? 0.5 : 1.0) * s.sizeScale();
Affine pre;
Affine place;
if (vanilla) {
// Vanilla placement: the renderer's own captured pose (incl. its scale / offsets), yaw and pitch
// applied the way that renderer applies them. CEM px -> Java model units: F·T(0,-24,0)/16.
boolean captured = info != null && info.transform() != null;
Affine t0 = captured ? Affine.fromColumnMajor(info.transform()) : DEFAULT_LIVING_TRANSFORM;
pre = t0.mul(Affine.scale(1 / 16.0)).mul(CemGeometry.CEM_TO_JAVA);
int yawSign = captured ? info.yawSign() : -1;
place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.scale(extra))
.mul(Affine.rotY(yawSign * Math.toRadians(s.bodyYaw())));
if (info != null && info.pitchAxis() != null && s.pitch() != 0) {
double[] a = info.pitchAxis();
place = place.mul(Affine.rotAxis(a[0], a[1], a[2], Math.toRadians(s.pitch())));
}
} else {
pre = Affine.scale(extra / 16.0);
place = null; // legacy: computed after baking (ground snap)
}
// The body model is baked even when invisible — not drawn, but used as the ground-snap reference
// so equipment stays at body height (e.g. a lone helmet sits at the head, not on the floor).
List<CemGeometry.Baked> body =
(model != null && tex != null) ? CemGeometry.bakeModel(model, tex, pre, hidden) : List.of();
// Parts that vanilla hides by default but this entity's state shows.
Set<String> show = new HashSet<>();
if (s.typeKey().equals("armor_stand") && "arms".equals(s.variant()))
show.addAll(Set.of("right_arm", "left_arm"));
if (s.chest()) show.addAll(Set.of("left_chest", "right_chest"));
// Body: baked even when invisible — not drawn, but it yields the part frames (and legacy ground snap).
Pose pose = new Pose(pre, vanilla ? selectPose(s, info) : Map.of());
Map<String, Affine> frames = new java.util.HashMap<>();
List<CemGeometry.Baked> body = (model != null && tex != null)
? CemGeometry.bakePosed(model, tex, pre, show, pose.parts, frames)
: List.of();
List<CemGeometry.Baked> baked = new ArrayList<>();
if (!invisible) {
baked.addAll(body);
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
if (s.typeKey().equals("sheep")) {
CemModelLoader.CemModel wool = models.get("sheep_wool");
int[][] woolTex = textures.get(ResourceLocation.parse("entity/sheep/sheep_wool"))
.orElse(null);
if (wool != null && woolTex != null) {
int[][] t = TextureOps.deepCopy(woolTex);
if (s.tint() != 0) TextureOps.tint(t, s.tint());
baked.addAll(CemGeometry.bakeModel(wool, t, pre, hidden));
}
}
// Guardian: the CEM model ships a RIGHT body side-panel but no left one, and the main body box's
// left face is transparent in the texture → a see-through hole on the left. Add the mirrored left panel.
if (cem.equals("guardian")) {
double[] org = {-8, 2, -6}, size = {2, 12, 12};
ModelCube mc = new ModelCube(org, size, 0, new double[] {0, 28}, true);
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
baked.add(new CemGeometry.Baked(
org, new double[] {org[0] + size[0], org[1] + size[1], org[2] + size[2]}, faces, pre));
}
// 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 (vanilla) addVanillaLayers(s, ch, pose, show, baked);
}
// Humanoid armor (players, armor stands, zombies, …): extra inflated armor_layer models. Rendered
// even when invisible, so an invisible armored entity shows floating armor like in vanilla.
if (s.equipment() != null) addArmorLayers(s, pre, baked);
if (baked.isEmpty()) return invisible ? null : fallbackBox(s, tex);
// Humanoid armor / elytra: drawn even when invisible (vanilla "floating armor").
if (vanilla && s.equipment() != null) addArmorLayers(s, ch, pose, baked);
List<EntityCube> itemCubes = new ArrayList<>();
if (vanilla && items != null) addItems(s, frames, isIllager(info), itemCubes);
if (baked.isEmpty() && itemCubes.isEmpty()) return invisible ? null : fallbackBox(s, tex);
// Ground-snap from the body extent (preserves visible behaviour); fall back to the drawn geometry.
List<CemGeometry.Baked> ref = (invisible && !body.isEmpty()) ? body : baked;
double minY = Double.MAX_VALUE;
for (CemGeometry.Baked b : ref) minY = Math.min(minY, b.minWorldY());
Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
.mul(Affine.translation(0, -minY, 0));
// Legacy CEM models: snap the lowest point to the ground (their rest geometry has no reliable origin).
if (place == null) {
List<CemGeometry.Baked> ref = (invisible && !body.isEmpty()) ? body : baked;
double minY = Double.MAX_VALUE;
for (CemGeometry.Baked b : ref) minY = Math.min(minY, b.minWorldY());
place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
.mul(Affine.translation(0, -minY, 0));
}
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 (EntityCube c : itemCubes)
cubes.add(new EntityCube(c.from, c.to, c.faces, place.mul(c.toWorld), c.blockUv));
return RenderedEntity.of(cubes);
}
// --- texture resolution (player skin, dyed sheep wool, variant candidates) ---
private int[][] resolveTexture(EntityState s) {
// --- poses & items -------------------------------------------------------------------------------
/** The model→pre-space transform plus the pose overrides shared by every vanilla model of one entity. */
private record Pose(Affine pre, Map<String, CemModelLoader.PartPose> parts) {
List<CemGeometry.Baked> bake(CemModelLoader.CemModel m, int[][] tex, Set<String> show) {
return CemGeometry.bakePosed(m, tex, pre, show, parts, null);
}
}
/**
* Picks the vanilla pose variant for the entity's state: sitting ({@code passenger}), holding items
* ({@code item_r}/{@code item_l}), illagers' crossbow hold. Falls back to fewer flags when the exact
* combination has no own variant (identical to a simpler pose).
*/
private static Map<String, CemModelLoader.PartPose> selectPose(EntityState s, CemModelLoader.EntityInfo info) {
if (info == null || info.poses().isEmpty()) return Map.of();
List<String> flags = new ArrayList<>();
if (isIllager(info)) {
if (isCrossbow(s.rightHand()) || isCrossbow(s.leftHand())) flags.add("arms_crossbow_hold");
} else {
if (s.rightHand() != null) flags.add("item_r");
if (s.leftHand() != null) flags.add("item_l");
}
if (s.passenger()) flags.add("passenger");
// Exact combination first, then without the item flags (e.g. zombies' arms ignore held items).
List<List<String>> tries = new ArrayList<>();
tries.add(flags);
tries.add(flags.stream()
.filter(f -> f.equals("passenger") || f.startsWith("arms_"))
.toList());
tries.add(flags.stream().filter(f -> f.equals("passenger")).toList());
for (List<String> t : tries) {
if (t.isEmpty()) continue;
Map<String, CemModelLoader.PartPose> p =
info.poses().get(String.join("+", t.stream().sorted().toList()));
if (p != null) return p;
}
return Map.of();
}
private static boolean isCrossbow(ItemSpec i) {
return i != null && i.id().equals("crossbow");
}
/**
* Held items (vanilla ItemInHandLayer: hand frame · rotX(-90) · rotY(180) · T(±1/16, 0.125, -0.625),
* third-person display context), crossed-arms items (villagers/witches: root · T(0, 0.4, -0.4) ·
* rotX(180), ground context) and head items (CustomHeadLayer: blocks/items at 0.625 with the "head"
* context, skulls at 1.1875 through the block-entity model). Frames are Java-model frames (blocks).
*/
private void addItems(EntityState s, Map<String, Affine> frames, boolean illager, List<EntityCube> out) {
// Frames exist only for drawn parts: a visible arm holds the item; with crossed arms only villager-
// like models show it (illagers hide held items while their arms are crossed).
String d = CemGeometry.DRAWN;
boolean crossed = frames.containsKey(d + "arms") && !frames.containsKey(d + "right_arm") && !illager;
for (int side = 0; side < 2; side++) {
boolean left = side == 1;
ItemSpec item = left ? s.leftHand() : s.rightHand();
if (item == null) continue;
Affine arm = frames.get(left ? "left_arm" : "right_arm");
if (arm != null && !crossed && !(illager && !frames.containsKey(d + "right_arm"))) {
Affine x = arm.mul(CemGeometry.JAVA_LOCAL)
.mul(Affine.rotX(Math.toRadians(-90)))
.mul(Affine.rotY(Math.PI))
.mul(Affine.translation((left ? -1 : 1) / 16.0, 0.125, -0.625));
items.bake(item, left ? "thirdperson_lefthand" : "thirdperson_righthand", left, x, out);
} else if (crossed && !left && frames.containsKey("root")) {
Affine x = frames.get("root")
.mul(CemGeometry.JAVA_LOCAL)
.mul(Affine.translation(0, 0.4, -0.4))
.mul(Affine.rotX(Math.PI));
items.bake(item, "ground", false, x, out);
}
}
ItemSpec head = s.headItem();
Affine h = frames.get("head");
if (head != null && h != null) {
Affine hj = h.mul(CemGeometry.JAVA_LOCAL);
if (head.blockEntity() != null && head.blockEntity().kind() == BlockEntityState.Kind.HEAD) {
items.bakeBlockEntity(
head.blockEntity(),
hj.mul(Affine.scale(1.1875, -1.1875, -1.1875)).mul(Affine.translation(-0.5, 0, -0.5)),
out);
} else {
// Snow golems wear their pumpkin a bit lower (SnowGolemHeadLayer) than CustomHeadLayer items.
double dy = s.typeKey().equals("snow_golem") ? -0.34375 : -0.25;
Affine x = hj.mul(Affine.translation(0, dy, 0))
.mul(Affine.rotY(Math.PI))
.mul(Affine.scale(0.625, -0.625, -0.625));
items.bake(head, "head", false, x, out);
}
}
}
private static boolean isIllager(CemModelLoader.EntityInfo info) {
return info != null && info.poses().keySet().stream().anyMatch(k -> k.startsWith("arms_"));
}
// --- model choice --------------------------------------------------------------------------------
/**
* Picks the body model: a baby model for babies when one exists, a variant-specific model (cow/pig/
* chicken temperature models, salmon size, pufferfish puff state, …) matched by name token, else the
* type's main model; falls back to the legacy CEM template mapping.
*/
private Choice chooseModel(EntityState s) {
String type = s.typeKey();
if (s.player()) type = s.slim() && models.hasVanilla("player_slim") ? "player_slim" : "player";
String legacy = EntityModels.cemModel(type);
// Types whose dump entry only exists under another type (mannequin -> player) reuse that base.
String base = models.hasVanilla(type) || !subModels(type).isEmpty() ? type : legacy;
if (base.equals("player") && s.slim() && models.hasVanilla("player_slim")) base = "player_slim";
String variant = s.variant() == null ? null : s.variant().toLowerCase(Locale.ROOT);
if (type.equals("tropical_fish") && variant != null) {
variant = EntityModels.tropicalPattern(variant) >= 6 ? "large" : "small"; // pattern -> body shape
}
if (s.baby() && !models.hasVanilla(base + "/baby") && models.hasVanilla(base + "/smallModel")) {
return vanilla(base + "/smallModel", base, true); // renderers with a "small" instead of a baby model
}
if (s.baby()) {
String n = findSub(base, variant, true);
if (n == null && models.hasVanilla(base + "/baby")) n = base + "/baby";
if (n != null) return vanilla(n, base, true);
}
String n = findSub(base, variant, false);
if (n != null) return vanilla(n, base, false);
if (models.hasVanilla(base)) return vanilla(base, base, false);
// Legacy CEM-template model (rest pose, ground-snapped).
CemModelLoader.CemModel lm = models.get(legacy);
return lm == null ? null : new Choice(lm, legacy, base, false);
}
private Choice vanilla(String name, String base, boolean baby) {
return new Choice(models.vanilla(name), name, base, baby);
}
/** A body sub-model of {@code base} whose name contains the variant token (or "normal"), baby or adult. */
private String findSub(String base, String variant, boolean baby) {
List<String> subs = subModels(base);
if (subs.isEmpty()) return null;
String[] tokens = variant == null ? new String[] {"normal"} : new String[] {variant, "normal"};
for (String tok : tokens) {
for (String n : subs) {
String lower = n.substring(base.length() + 1).toLowerCase(Locale.ROOT);
boolean isBaby = lower.contains("baby");
if (isBaby != baby) continue;
// Segment match by prefix: "cold" ~ models.cold.adultModel, "large" ~ largeSalmonModel.
for (String seg : lower.split("[./_]")) if (seg.startsWith(tok)) return n;
}
}
return null;
}
/** Body sub-models of a type (variant/baby/size models), excluding render-layer models. */
private List<String> subModels(String base) {
return namesCached("B:" + base, n -> {
if (!n.startsWith(base + "/")) return false;
String rest = n.substring(base.length() + 1);
return !rest.contains("Layer.") && !rest.startsWith("equip.") && !rest.contains("waterPatch");
});
}
/** Render-layer models of a type: {@code <base>/<LayerClass>.<field>}. */
private List<String> layerModels(String base) {
return namesCached(
"L:" + base,
n -> n.startsWith(base + "/") && n.substring(base.length() + 1).contains("Layer."));
}
/** Equipment-layer models of a type: {@code <base>/equip.<layerType>.<field>}. */
private List<String> equipModels(String base) {
return namesCached("E:" + base, n -> n.startsWith(base + "/equip."));
}
private List<String> namesCached(String key, java.util.function.Predicate<String> filter) {
return subModelCache.computeIfAbsent(key, k -> {
List<String> out = new ArrayList<>();
for (String n : models.vanillaNames()) if (filter.test(n)) out.add(n);
out.sort(null);
return out;
});
}
// --- vanilla render layers -----------------------------------------------------------------------
private void addVanillaLayers(
EntityState s, Choice ch, Pose pose, Set<String> show, List<CemGeometry.Baked> baked) {
String base = ch.base;
boolean baby = ch.babyModel;
for (String n : layerModels(base)) {
String rest = n.substring(base.length() + 1);
String layerClass = rest.substring(0, rest.indexOf('.'));
if (rest.toLowerCase(Locale.ROOT).contains("baby") != baby) continue;
CemModelLoader.CemModel lm = models.vanilla(n);
if (lm == null) continue;
switch (layerClass) {
case "SheepWoolLayer", "SheepWoolUndercoatLayer" -> {
if ("sheared".equals(s.variant()) || lm.texture() == null) continue;
int[][] t = texture(lm.texture());
if (t == null) continue;
t = TextureOps.deepCopy(t);
if (s.tint() != 0) TextureOps.tint(t, s.tint());
baked.addAll(pose.bake(lm, t, show));
}
case "TropicalFishPatternLayer" -> addTropicalPattern(s, n, lm, pose, baked);
case "LlamaDecorLayer", "WolfArmorLayer" -> {
// Body equipment drawn by a dedicated layer: texture folder <base>_body (trader llama: llama_body).
if (s.bodyEquip() == null) continue;
int[][] t = equipmentTexture(base + "_body", s.bodyEquip(), baby, s.bodyDye());
if (t == null) {
t = equipmentTexture(
base.substring(base.indexOf('_') + 1) + "_body", s.bodyEquip(), baby, s.bodyDye());
}
if (t != null) baked.addAll(pose.bake(lm, t, show));
}
case "RopesLayer" -> {
if (s.bodyEquip() == null || lm.texture() == null) continue; // ropes only with a harness
int[][] t = texture(lm.texture());
if (t != null) baked.addAll(pose.bake(lm, t, show));
}
default -> {
if (CONDITIONAL_LAYERS.contains(layerClass) || lm.texture() == null) continue;
int[][] t = texture(lm.texture()); // always-on overlay (drowned/stray/bogged outer, wind, …)
if (t != null) baked.addAll(pose.bake(lm, t, show));
}
}
}
// Equipment layers (saddles, body armor/harness/carpets), named <base>/equip.<layerType>.<field>.
for (String n : equipModels(base)) {
String rest = n.substring(base.length() + "/equip.".length());
String layerType = rest.substring(0, rest.indexOf('.'));
if (rest.toLowerCase(Locale.ROOT).contains("baby") != baby) continue;
CemModelLoader.CemModel em = models.vanilla(n);
int[][] t = null;
if (layerType.endsWith("_saddle") && s.saddle()) {
t = texture("entity/equipment/" + layerType + "/saddle");
} else if (layerType.endsWith("_body")
&& s.bodyEquip() != null
&& !s.bodyEquip().equals("trader_llama")) {
t = equipmentTexture(layerType, s.bodyEquip(), baby, s.bodyDye());
}
if (t != null) baked.addAll(pose.bake(em, t, Set.of()));
}
}
/** Tropical fish pattern overlay: pattern texture (tinted with the pattern colour) on the pattern model. */
private void addTropicalPattern(
EntityState s, String name, CemModelLoader.CemModel lm, Pose pose, List<CemGeometry.Baked> baked) {
int idx = EntityModels.tropicalPattern(s.variant());
if (idx < 0) return;
boolean small = idx < 6; // patterns 0-5 use shape A (small model), 6-11 shape B (large model)
if (name.toLowerCase(Locale.ROOT).contains("small") != small) return;
int[][] t = texture("entity/fish/tropical_" + (small ? "a" : "b") + "_pattern_" + (idx % 6 + 1));
if (t == null) return;
t = TextureOps.deepCopy(t);
if (s.tint2() != 0) TextureOps.tint(t, s.tint2());
baked.addAll(pose.bake(lm, t, Set.of()));
}
/** entity/equipment/<layerType>/<asset> (+ a "_carpet"-stripped name; leather dye + overlay). */
private int[][] equipmentTexture(String layerType, String asset, boolean baby, int dye) {
String folder = "entity/equipment/" + layerType + "/";
int[][] t = null;
List<String> names = new ArrayList<>();
if (baby) names.add(asset + "_baby");
names.add(asset);
if (asset.endsWith("_carpet")) names.add(asset.substring(0, asset.length() - "_carpet".length()));
for (String n : names) {
t = texture(folder + n);
if (t != null) {
asset = n;
break;
}
}
if (t == null) return null;
int[][] out = TextureOps.deepCopy(t);
if (dye != 0) {
TextureOps.tint(out, dye);
overlayIfPresent(out, folder + asset + "_overlay");
}
return out;
}
// --- texture resolution --------------------------------------------------------------------------
private int[][] texture(String path) {
return textures.get(ResourceLocation.parse(path)).orElse(null);
}
/**
* Player skin, else the variant-aware candidates ({@link EntityModels}), the vanilla renderer's default
* texture as a generic fallback, and — for a dedicated baby model — the {@code _baby} counterparts first.
*/
private int[][] resolveTexture(EntityState s, Choice ch) {
if (s.player()) {
int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin;
int[][] def = textures.get(
ResourceLocation.parse(s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve"))
.orElse(null);
int[][] def = texture(s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve");
if (def != null) return def;
}
for (ResourceLocation rl : EntityModels.textureCandidates(s.typeKey(), s.variant())) {
int[][] t = textures.get(rl).orElse(null);
CemModelLoader.EntityInfo info = models.entityInfo(s.typeKey());
if (info == null && ch != null) info = models.entityInfo(ch.base);
List<String> adult = new ArrayList<>();
for (ResourceLocation rl : EntityModels.textureCandidates(s.typeKey(), s.variant())) adult.add(rl.path());
if (info != null && info.texture() != null && !info.texture().equals("missingno")) {
adult.add(
Math.min(adult.size(), EntityModels.specificCandidateCount(s.typeKey(), s.variant())),
info.texture());
}
List<String> order = new ArrayList<>();
if (ch != null && ch.babyModel) {
for (String a : adult) order.add(a + "_baby");
if (info != null && info.babyTexture() != null) order.add(info.babyTexture());
}
order.addAll(adult);
for (String p : order) {
int[][] t = texture(p);
if (t != null) return t;
}
return null;
}
// --- villager texture compositing (biome type + profession + level badge over the base body) ---
// Profession-level (1-5) -> badge texture, like Mojang's VillagerProfessionLayer.
private static final String[] LEVEL_BADGE = {"stone", "iron", "gold", "emerald", "diamond"};
private int[][] compositeVillager(EntityState s, int[][] base) {
private int[][] compositeVillager(EntityState s, int[][] base, String folder, boolean babyModel) {
if (base == null) return null;
String folder = s.typeKey(); // "villager" or "zombie_villager"
int[][] out = TextureOps.deepCopy(base);
// Biome-type clothing overlay (always, if known).
if (babyModel) {
// Baby villagers: their own biome-type clothing set; no profession.
if (s.variant() != null) overlayIfPresent(out, "entity/" + folder + "/baby/" + s.variant());
return out;
}
if (s.variant() != null) overlayIfPresent(out, "entity/" + folder + "/type/" + s.variant());
String prof = s.profession();
if (prof != null && !prof.equals("none")) {
overlayIfPresent(out, "entity/" + folder + "/profession/" + prof);
// Level badge: only for real professions (not the work-less nitwit) and a known level.
if (!prof.equals("nitwit") && s.villagerLevel() >= 1) {
int lvl = Math.min(5, s.villagerLevel());
overlayIfPresent(out, "entity/" + folder + "/profession_level/" + LEVEL_BADGE[lvl - 1]);
@@ -170,103 +532,52 @@ public final class CemBaker implements EntityBaker<EntityState> {
/** Alpha-composite an overlay texture onto {@code dst} in place, if it exists and matches dst's size. */
private void overlayIfPresent(int[][] dst, String path) {
int[][] o = textures.get(ResourceLocation.parse(path)).orElse(null);
int[][] o = texture(path);
if (o == null || o.length != dst.length || o[0].length != dst[0].length) return; // missing or HD-mismatch
TextureOps.overlay(dst, o);
}
// --- horse / llama equipment compositing (same UV layout as the base model) ---
private int[][] compositeHorse(EntityState s, int[][] base) {
if (base == null || (s.markings() == null && s.bodyEquip() == null)) return base;
int[][] out = TextureOps.deepCopy(base);
if (s.markings() != null) overlayIfPresent(out, "entity/horse/horse_markings_" + s.markings());
if (s.bodyEquip() != null) overlayIfPresent(out, "entity/equipment/horse_body/" + s.bodyEquip());
return out;
}
private int[][] compositeLlama(EntityState s, int[][] base) {
if (base == null || s.bodyEquip() == null) return base;
int[][] out = TextureOps.deepCopy(base);
overlayIfPresent(out, "entity/equipment/llama_body/" + s.bodyEquip());
return out;
}
/** Nautilus body armor + saddle: same 128² UV as the nautilus texture, so composited as overlays. */
private int[][] compositeNautilus(EntityState s, int[][] base) {
if (base == null || (s.bodyEquip() == null && !s.saddle())) return base;
int[][] out = TextureOps.deepCopy(base);
if (s.bodyEquip() != null) overlayIfPresent(out, "entity/equipment/nautilus_body/" + s.bodyEquip());
if (s.saddle()) overlayIfPresent(out, "entity/equipment/nautilus_saddle/saddle");
return out;
}
/** Bake the saddle as a separate inflated layer; only the saddle-specific parts (those not in the base model). */
private void addSaddleLayer(
EntityState s, String cem, CemModelLoader.CemModel base, Affine pre, List<CemGeometry.Baked> baked) {
String saddleModel = cem.equals("donkey") ? "donkey_saddle" : (cem.equals("horse") ? "horse_saddle" : null);
if (saddleModel == null) return; // llamas and other mounts have no saddle model
CemModelLoader.CemModel sm = models.get(saddleModel);
if (sm == null) return;
int[][] saddleTex = textures.get(ResourceLocation.parse("entity/equipment/" + s.typeKey() + "_saddle/saddle"))
.orElse(null);
if (saddleTex == null) return;
// Show only the saddle-specific parts: hide every part the base body model also defines.
Set<String> hideBase = new HashSet<>();
for (CemModelLoader.CemPart p : base.parts()) hideBase.add(p.name());
baked.addAll(CemGeometry.bakeModel(sm, saddleTex, pre, hideBase));
}
// --- humanoid armor ---------------------------------------------------------------------------
// Vanilla splits worn armor across two inflated layer models that overlay the standard humanoid body:
// armor_layer_1 (texture entity/equipment/humanoid/<mat>): head=helmet, body+arms=chestplate,
// shoes=boots;
// armor_layer_2 (texture entity/equipment/humanoid_leggings/<mat>): waist+legs=leggings.
// Each slot may use a different material, so each is baked separately, showing only its parts.
private static final Set<String> ARMOR1_HEAD = Set.of("head");
private static final Set<String> ARMOR1_CHEST = Set.of("body", "left_arm", "right_arm");
private static final Set<String> ARMOR1_FEET = Set.of("left_shoe", "right_shoe");
private static final Set<String> ARMOR2_LEGS = Set.of("waist", "left_leg", "right_leg");
// Per-slot armor models of the wearer's renderer (<base>/HumanoidArmorLayer.modelSet.<slot>), posed
// like the body — raised zombie arms included.
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, Choice ch, Pose pose, List<CemGeometry.Baked> baked) {
EntityState.Equipment eq = s.equipment();
bakeArmorPiece(eq.head(), "armor_layer_1", ARMOR1_HEAD, "humanoid", pre, baked);
// Chest slot: an elytra renders its own wing model instead of the chestplate layer.
String base = ch == null ? s.typeKey() : ch.base;
boolean baby = ch != null && ch.babyModel;
String set = base + "/HumanoidArmorLayer." + (baby ? "babyModelSet" : "modelSet") + ".";
String folder = baby ? "humanoid_baby" : "humanoid";
bakeArmorPiece(eq.head(), set + "head", folder, pose, baked);
if (eq.chest() != null && eq.chest().asset().equals("elytra")) {
bakeElytra(eq.chest(), pre, baked);
bakeElytra(eq.chest(), base, baby, pose, baked);
} else {
bakeArmorPiece(eq.chest(), "armor_layer_1", ARMOR1_CHEST, "humanoid", pre, baked);
bakeArmorPiece(eq.chest(), set + "chest", folder, pose, baked);
}
bakeArmorPiece(eq.legs(), "armor_layer_2", ARMOR2_LEGS, "humanoid_leggings", pre, baked);
bakeArmorPiece(eq.feet(), "armor_layer_1", ARMOR1_FEET, "humanoid", pre, baked);
bakeArmorPiece(eq.legs(), set + "legs", baby ? "humanoid_baby" : "humanoid_leggings", pose, baked);
bakeArmorPiece(eq.feet(), set + "feet", folder, pose, baked);
}
/** Bake one armor slot: its layer model with only {@code show} parts, textured for the material. */
/** Bake one armor slot: its vanilla per-slot model, textured for the material. */
private void bakeArmorPiece(
EntityState.EquipPiece piece,
String modelName,
Set<String> show,
String layerFolder,
Affine pre,
Pose pose,
List<CemGeometry.Baked> baked) {
if (piece == null) return;
CemModelLoader.CemModel model = models.get(modelName);
CemModelLoader.CemModel model = models.vanilla(modelName);
if (model == null) return;
int[][] tex = buildArmorTexture(piece, layerFolder);
if (tex == null) return;
Set<String> hidden = new HashSet<>();
for (CemModelLoader.CemPart p : model.parts()) if (!show.contains(p.name())) hidden.add(p.name());
baked.addAll(CemGeometry.bakeModel(model, tex, pre, hidden));
if (tex != null) baked.addAll(pose.bake(model, tex, Set.of()));
}
/** Resolve + composite a single armor slot's texture: material base (+leather dye/overlay), trim, glint. */
private int[][] buildArmorTexture(EntityState.EquipPiece piece, String layerFolder) {
String asset = piece.asset();
int[][] base = textures.get(ResourceLocation.parse("entity/equipment/" + layerFolder + "/" + asset))
.orElse(null);
int[][] base = texture("entity/equipment/" + layerFolder + "/" + asset);
if (base == null) return null;
int[][] out = TextureOps.deepCopy(base);
// Leather is dyeable: tint the base layer, then composite the (undyed) overlay layer on top.
if (asset.equals("leather")) {
if (piece.dyeColor() != 0) TextureOps.tint(out, piece.dyeColor());
overlayIfPresent(out, "entity/equipment/" + layerFolder + "/leather_overlay");
@@ -280,15 +591,11 @@ public final class CemBaker implements EntityBaker<EntityState> {
/** Armor trim: recolour the grayscale pattern via the material palette (same UV) and overlay it. */
private void applyTrim(int[][] armorTex, String layerFolder, String pattern, String material, String armorAsset) {
int[][] mask = textures.get(ResourceLocation.parse("trims/entity/" + layerFolder + "/" + pattern))
.orElse(null);
int[][] mask = texture("trims/entity/" + layerFolder + "/" + pattern);
if (mask == null || mask.length != armorTex.length || mask[0].length != armorTex[0].length) return;
int[] from = palette8("trims/color_palettes/trim_palette");
// Vanilla uses the darker palette variant when the trim material matches the armor material.
String palette = material;
if (material.equals(armorAsset)
&& textures.get(ResourceLocation.parse("trims/color_palettes/" + material + "_darker"))
.isPresent()) {
if (material.equals(armorAsset) && texture("trims/color_palettes/" + material + "_darker") != null) {
palette = material + "_darker";
}
int[] to = palette8("trims/color_palettes/" + palette);
@@ -298,28 +605,28 @@ public final class CemBaker implements EntityBaker<EntityState> {
TextureOps.overlay(armorTex, colored);
}
/** First row of an 8×1 trim colour-palette texture; null if missing. */
private int[] palette8(String path) {
int[][] p = textures.get(ResourceLocation.parse(path)).orElse(null);
int[][] p = texture(path);
return (p == null || p.length == 0) ? null : p[0];
}
/** Static enchantment-glint approximation over an armor/elytra/item texture (in place). */
private void applyGlint(int[][] tex) {
textures.get(ResourceLocation.parse("misc/enchanted_glint_armor"))
.ifPresent(glint -> TextureOps.addGlint(tex, glint, GLINT_COLOR, 0.6));
}
/** Elytra in the chest slot: bake the wing model with the default elytra texture (no chestplate). */
private void bakeElytra(EntityState.EquipPiece piece, Affine pre, List<CemGeometry.Baked> baked) {
CemModelLoader.CemModel model = models.get("elytra");
/** Elytra in the chest slot: the wearer's own wings model (vanilla), else the CEM elytra model. */
private void bakeElytra(
EntityState.EquipPiece piece, String base, boolean baby, Pose pose, List<CemGeometry.Baked> baked) {
CemModelLoader.CemModel model =
models.vanilla(base + "/WingsLayer." + (baby ? "elytraBabyModel" : "elytraModel"));
if (model == null) model = models.get("elytra");
if (model == null) return;
int[][] tex = textures.get(ResourceLocation.parse("entity/equipment/wings/elytra"))
.orElse(null);
int[][] tex = texture("entity/equipment/wings/elytra");
if (tex == null) return;
int[][] out = TextureOps.deepCopy(tex);
if (piece.glint()) applyGlint(out);
baked.addAll(CemGeometry.bakeModel(model, out, pre, Set.of()));
baked.addAll(pose.bake(model, out, Set.of()));
}
private RenderedEntity fallbackBox(EntityState s, int[][] tex) {
@@ -6,6 +6,7 @@ import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
@@ -18,6 +19,11 @@ final class CemGeometry {
private CemGeometry() {}
// CEM frame (x negated, y up, ground at 24 px) -> Java model px: x' = -x, y' = 24 - y, z' = z.
static final Affine CEM_TO_JAVA = new Affine(new double[] {-1, 0, 0, 0, -1, 0, 0, 0, 1}, new double[] {0, 24, 0});
// Java-model block offsets -> CEM px offsets of a part frame (x/y flip, 16 px per block, no translation).
static final Affine JAVA_LOCAL = new Affine(new double[] {-16, 0, 0, 0, -16, 0, 0, 0, 16}, new double[] {0, 0, 0});
/** A baked box: local min/max corner, its six faces and the transform mapping it into pre-space. */
record Baked(double[] from, double[] to, Face[] faces, Affine world) {
double minWorldY() {
@@ -52,14 +58,134 @@ final class CemGeometry {
int texW,
int texH,
boolean ignoreFaceUv) {
return bake(model, tex, pre, hidden, Set.of(), texW, texH, ignoreFaceUv, Map.of(), null);
}
/**
* Posed variant for vanilla ("absolute") models: {@code show} names parts vanilla hides by default but
* this entity's state draws (chests, armor-stand arms); {@code overrides} replace parts' pivot/rotation/
* visibility by name (pose variants: sitting, holding items, …; boxes follow their pivot); {@code frames},
* when non-null, receives each part's frame {@code parent·T(P)·R·S} (pre-space, maps a part-local CEM px
* offset) — used to attach held/head items to hands and heads.
*/
static List<Baked> bakePosed(
CemModelLoader.CemModel model,
int[][] tex,
Affine pre,
Set<String> show,
Map<String, CemModelLoader.PartPose> overrides,
Map<String, Affine> frames) {
return bake(model, tex, pre, Set.of(), show, 0, 0, false, overrides, frames);
}
private static List<Baked> bake(
CemModelLoader.CemModel model,
int[][] tex,
Affine pre,
Set<String> hidden,
Set<String> show,
int texW,
int texH,
boolean ignoreFaceUv,
Map<String, CemModelLoader.PartPose> overrides,
Map<String, Affine> frames) {
int nw = texW > 0 ? texW : model.texW();
int nh = texH > 0 ? texH : model.texH();
List<Baked> out = new ArrayList<>();
Ctx ctx = new Ctx(hidden, show, nw, nh, tex, ignoreFaceUv, new ArrayList<>(), overrides, frames);
for (CemModelLoader.CemPart p : model.parts()) {
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
bakePart(p, pre, o, 0, hidden, nw, nh, tex, ignoreFaceUv, out);
if (model.absolute()) {
bakeAbsolute(p, pre, ctx);
} else {
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
bakePart(p, pre, o, 0, ctx);
}
}
return out;
return ctx.out;
}
/** Key prefix in the frames map marking parts that were actually drawn (visible). */
static final String DRAWN = "drawn:";
private record Ctx(
Set<String> hidden,
Set<String> show,
int texW,
int texH,
int[][] tex,
boolean ignoreFaceUv,
List<Baked> out,
Map<String, CemModelLoader.PartPose> overrides,
Map<String, Affine> frames) {}
/**
* Vanilla-dumped ("absolute") part: pivot and boxes are absolute model px, so the group transform is
* simply {@code parent · T(P) · Rz·Ry·Rx · S · T(-P)} — the same order as vanilla's
* {@code ModelPart.translateAndRotate}. Invisible parts (vanilla {@code visible=false}) skip their
* whole subtree unless named in {@code show}; {@code skipDraw} parts only skip their own boxes.
*/
private static void bakeAbsolute(CemModelLoader.CemPart part, Affine parentWorld, Ctx ctx) {
bakeAbsolute(part, parentWorld, ctx, true);
}
/**
* {@code draw=false} walks a hidden subtree only to record part frames: vanilla attaches held items to
* an arm's transform even when the arm itself isn't drawn (armor stands without arms, …).
*/
private static void bakeAbsolute(CemModelLoader.CemPart part, Affine parentWorld, Ctx ctx, boolean draw) {
if (ctx.hidden.contains(part.name())) return;
CemModelLoader.PartPose ov = ctx.overrides.get(part.name());
boolean invisible = ov != null ? !ov.visible() : part.invisible();
if (invisible && !ctx.show.contains(part.name())) {
if (ctx.frames == null) return;
draw = false;
}
double[] o = ov != null ? ov.pivot() : part.pivot();
double[] rot = ov != null ? ov.rotate() : part.rotate();
Affine world = parentWorld
.mul(Affine.translation(o[0], o[1], o[2]))
.mul(Affine.rotZ(Math.toRadians(rot[2])))
.mul(Affine.rotY(Math.toRadians(rot[1])))
.mul(Affine.rotX(Math.toRadians(rot[0])));
if (part.scale() != null) {
// A collapsed (zero) scale is vanilla's way to hide a part; it would also be non-invertible.
for (double v : part.scale()) if (Math.abs(v) < 1e-6) return;
world = world.mul(Affine.scale(part.scale()[0], part.scale()[1], part.scale()[2]));
}
if (ctx.frames != null) {
ctx.frames.putIfAbsent(part.name(), world);
if (draw) ctx.frames.putIfAbsent(DRAWN + part.name(), world);
}
world = world.mul(Affine.translation(-o[0], -o[1], -o[2]));
Affine boxWorld = world;
if (ov != null) {
// Boxes are stored relative to the idle pivot: move them along with the overridden pivot
// (children carry their own absolute pivots, so they use the un-shifted transform).
double[] p0 = part.pivot();
boxWorld = world.mul(Affine.translation(o[0] - p0[0], o[1] - p0[1], o[2] - p0[2]));
}
if (draw && !part.skipDraw()) {
for (CemModelLoader.CemBox b : part.boxes()) ctx.out.add(bakeBox(b, b.origin(), boxWorld, ctx));
}
for (CemModelLoader.CemPart child : part.children()) bakeAbsolute(child, world, ctx, draw);
}
private static Baked bakeBox(CemModelLoader.CemBox b, double[] org, Affine world, Ctx ctx) {
double[] inf = b.inflate();
double[] from = {org[0] - inf[0], org[1] - inf[1], org[2] - inf[2]};
double[] to = {org[0] + b.size()[0] + inf[0], org[1] + b.size()[1] + inf[1], org[2] + b.size()[2] + inf[2]};
double[][] faceUv = ctx.ignoreFaceUv ? null : b.faceUv();
ModelCube mc = new ModelCube(org, b.size(), inf[0], b.uv(), b.mirror(), faceUv);
// Vanilla texScale stretches the UV normalisation (texture treated as texSize*scale).
int tw = ctx.texW, th = ctx.texH;
if (b.texScale() != null) {
tw = (int) Math.round(tw * b.texScale()[0]);
th = (int) Math.round(th * b.texScale()[1]);
}
Face[] faces = BoxUv.build(mc, ctx.tex, tw, th);
if (b.hiddenFaces() != null) {
for (int i = 0; i < 6; i++) if (b.hiddenFaces()[i]) faces[i] = null;
}
return new Baked(from, to, faces, world);
}
/**
@@ -68,18 +194,8 @@ final class CemGeometry {
* parent origin from the 2nd nesting level on). Top-level boxes are absolute; nested boxes are offset
* by their group origin. The group transform is {@code parent · T(O) · R · T(-O)}.
*/
private static void bakePart(
CemModelLoader.CemPart part,
Affine parentWorld,
double[] o,
int depth,
Set<String> hidden,
int texW,
int texH,
int[][] tex,
boolean ignoreFaceUv,
List<Baked> out) {
if (hidden.contains(part.name())) return;
private static void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth, Ctx ctx) {
if (ctx.hidden.contains(part.name())) return;
Affine world = parentWorld
.mul(Affine.translation(o[0], o[1], o[2]))
.mul(Affine.rotZ(Math.toRadians(part.rotate()[2])))
@@ -89,21 +205,15 @@ final class CemGeometry {
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()) {
double inf = b.inflate();
double[] org = {b.origin()[0] + ox, b.origin()[1] + oy, b.origin()[2] + oz};
double[] from = {org[0] - inf, org[1] - inf, org[2] - inf};
double[] to = {org[0] + b.size()[0] + inf, org[1] + b.size()[1] + inf, org[2] + b.size()[2] + inf};
double[][] faceUv = ignoreFaceUv ? null : b.faceUv();
ModelCube mc = new ModelCube(org, b.size(), inf, b.uv(), b.mirror(), faceUv);
Face[] faces = BoxUv.build(mc, tex, texW, texH);
out.add(new Baked(from, to, faces, world));
ctx.out.add(bakeBox(b, org, world, ctx));
}
for (CemModelLoader.CemPart child : part.children()) {
double[] t = child.translate();
// submodel origin = its translate, accumulated with this group's origin from the 2nd level on.
double[] co =
depth >= 1 ? new double[] {t[0] + o[0], t[1] + o[1], t[2] + o[2]} : new double[] {t[0], t[1], t[2]};
bakePart(child, world, co, depth + 1, hidden, texW, texH, tex, ignoreFaceUv, out);
bakePart(child, world, co, depth + 1, ctx);
}
}
}
@@ -29,17 +29,70 @@ public final class CemModelLoader {
* 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) {}
double[] origin,
double[] size,
double[] inflate, // per-axis grow (px)
double[] uv,
boolean mirror,
double[][] faceUv,
double[] texScale, // vanilla CubeDefinition texScale (UV normalised by texSize*scale); null = 1
boolean[] hiddenFaces) {} // by Direction ordinal; null = all faces drawn
/** A model part: its (raw) translate, rotation (deg), boxes and nested submodels. The rotation pivot
* is {@code -(sum of translates from the root to this part)} — accumulated by the baker. */
public record CemPart(
String name, double[] translate, double[] rotate, List<CemBox> boxes, List<CemPart> children) {}
String name,
double[] translate,
double[] rotate,
List<CemBox> boxes,
List<CemPart> children,
double[] pivot, // absolute rotation pivot (vanilla dump, "absolute" models); null = CEM translate rules
double[] scale, // part scale around the pivot; null = 1
boolean invisible, // vanilla ModelPart.visible == false after setupAnim (part + children hidden)
boolean skipDraw) {} // own boxes not drawn, children are
/** A whole model: declared texture size and its top-level parts. */
public record CemModel(int texW, int texH, List<CemPart> parts) {}
/**
* A whole model: declared texture size and its top-level parts. {@code absolute} models (dumped from
* the vanilla client by {@code tools/VanillaModelDump.java}) carry absolute part pivots + boxes.
*/
public record CemModel(int texW, int texH, List<CemPart> parts, boolean absolute, String texture) {}
/** Per-entity-type renderer info from the vanilla dump: extra render scale and default textures. */
public record EntityInfo(
String texture,
String babyTexture,
double[] transform, // vanilla renderer pose at yaw=pitch=0 (column-major 4x4, Java model -> blocks)
int yawSign, // how the entity yaw enters the renderer: -1 (mobs), +1 (projectiles), 0 (ignored)
double[] pitchAxis, // world-frame axis the pitch rotates about; null = pitch ignored
Map<String, Map<String, PartPose>> poses) {} // variant ("passenger+item_r", …) -> part overrides
/** A posed part override: absolute CEM pivot, CEM rotation (deg) and visibility. */
public record PartPose(double[] pivot, double[] rotate, boolean visible) {}
private final Map<String, CemModel> models = new HashMap<>();
// Vanilla dump models live in their own namespace so they never shadow CEM-template names the
// block-entity path relies on (e.g. "wither_skull" head vs. projectile).
private final Map<String, CemModel> vanilla = new HashMap<>();
private final Map<String, EntityInfo> entities = new HashMap<>();
/** A model from the vanilla client dump, or null. */
public CemModel vanilla(String name) {
return vanilla.get(name);
}
public boolean hasVanilla(String name) {
return vanilla.containsKey(name);
}
/** All vanilla dump model names. */
public java.util.Set<String> vanillaNames() {
return vanilla.keySet();
}
/** Vanilla renderer info for an entity type key, or null (not in the dump). */
public EntityInfo entityInfo(String typeKey) {
return entities.get(typeKey);
}
public CemModel get(String name) {
return models.get(name);
@@ -49,10 +102,30 @@ public final class CemModelLoader {
return models.size();
}
/** Parse the CEM template-models JSON stream. Returns the number of models loaded. */
/**
* Parse a CEM template-models JSON stream (the bundled CEM template set, or the vanilla dump). Later
* loads override earlier models of the same name. Returns the total number of models loaded.
*/
public int load(InputStream in, Logger logger) {
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
.getAsJsonObject();
boolean isVanillaDump = root.has("entities");
Map<String, CemModel> target = isVanillaDump ? vanilla : models;
if (isVanillaDump) {
for (Map.Entry<String, JsonElement> e :
root.getAsJsonObject("entities").entrySet()) {
JsonObject o = e.getValue().getAsJsonObject();
entities.put(
e.getKey(),
new EntityInfo(
o.has("texture") ? o.get("texture").getAsString() : null,
o.has("babyTexture") ? o.get("babyTexture").getAsString() : null,
doubles(o, "transform"),
o.has("yawSign") ? o.get("yawSign").getAsInt() : -1,
doubles(o, "pitchAxis"),
poses(o)));
}
}
JsonObject modelsObj = root.getAsJsonObject("models");
for (Map.Entry<String, JsonElement> e : modelsObj.entrySet()) {
try {
@@ -64,12 +137,15 @@ public final class CemModelLoader {
int th = model.getAsJsonArray("textureSize").get(1).getAsInt();
List<CemPart> parts = new ArrayList<>();
for (JsonElement pe : model.getAsJsonArray("models")) parts.add(parsePart(pe.getAsJsonObject()));
models.put(e.getKey(), new CemModel(tw, th, parts));
boolean absolute =
model.has("absolute") && model.get("absolute").getAsBoolean();
String texture = model.has("texture") ? model.get("texture").getAsString() : null;
target.put(e.getKey(), new CemModel(tw, th, parts, absolute, texture));
} catch (Exception ex) {
if (logger != null) logger.warning("Failed to parse CEM model " + e.getKey() + ": " + ex.getMessage());
}
}
return models.size();
return models.size() + vanilla.size();
}
private CemPart parsePart(JsonObject p) {
@@ -88,14 +164,30 @@ public final class CemModelLoader {
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 add = b.has("sizeAdd") ? b.get("sizeAdd").getAsDouble() : 0;
double[] inflate = b.has("sizeAddXYZ") ? arr3(b, "sizeAddXYZ") : new double[] {add, add, add};
double[] texScale = null;
if (b.has("textureScale")) {
JsonArray ts = b.getAsJsonArray("textureScale");
texScale = new double[] {ts.get(0).getAsDouble(), ts.get(1).getAsDouble()};
}
boolean[] hiddenFaces = null;
if (b.has("hiddenFaces")) {
hiddenFaces = new boolean[6];
for (JsonElement hf : b.getAsJsonArray("hiddenFaces")) {
hiddenFaces[
eu.mhsl.minecraft.pixelpics.assets.model.Direction.fromName(hf.getAsString())
.ordinal()] = true;
}
}
double[] uv = b.has("textureOffset")
? new double[] {
b.getAsJsonArray("textureOffset").get(0).getAsDouble(),
b.getAsJsonArray("textureOffset").get(1).getAsDouble()
}
: new double[] {0, 0};
boxes.add(new CemBox(origin, size, inflate, uv, partMirror || mirrorsU(b), parseFaceUv(b)));
boxes.add(new CemBox(
origin, size, inflate, uv, partMirror || mirrorsU(b), parseFaceUv(b), texScale, hiddenFaces));
}
}
List<CemPart> children = new ArrayList<>();
@@ -105,7 +197,11 @@ public final class CemModelLoader {
String name = p.has("part")
? p.get("part").getAsString()
: (p.has("id") ? p.get("id").getAsString() : "");
return new CemPart(name, translate, rotate, boxes, children);
double[] pivot = p.has("pivot") ? arr3(p, "pivot") : null;
double[] scale = p.has("scale") ? arr3(p, "scale") : null;
boolean invisible = p.has("invisible") && p.get("invisible").getAsBoolean();
boolean skipDraw = p.has("skipDraw") && p.get("skipDraw").getAsBoolean();
return new CemPart(name, translate, rotate, boxes, children, pivot, scale, invisible, skipDraw);
}
// CEM per-face UV keys ordered by Direction ordinal (DOWN, UP, NORTH, SOUTH, WEST, EAST).
@@ -135,6 +231,34 @@ public final class CemModelLoader {
return o.has("mirrorTexture") && o.get("mirrorTexture").getAsString().contains("u");
}
private static Map<String, Map<String, PartPose>> poses(JsonObject o) {
Map<String, Map<String, PartPose>> out = new HashMap<>();
if (!o.has("poses")) return out;
for (Map.Entry<String, JsonElement> v : o.getAsJsonObject("poses").entrySet()) {
Map<String, PartPose> parts = new HashMap<>();
for (Map.Entry<String, JsonElement> pe :
v.getValue().getAsJsonObject().entrySet()) {
JsonObject po = pe.getValue().getAsJsonObject();
parts.put(
pe.getKey(),
new PartPose(
arr3(po, "pivot"),
arr3(po, "rotate"),
!po.has("visible") || po.get("visible").getAsBoolean()));
}
out.put(v.getKey(), parts);
}
return out;
}
private static double[] doubles(JsonObject o, String key) {
if (!o.has(key) || !o.get(key).isJsonArray()) return null;
JsonArray a = o.getAsJsonArray(key);
double[] out = new double[a.size()];
for (int i = 0; i < out.length; i++) out[i] = a.get(i).getAsDouble();
return out;
}
private static double[] arr3(JsonObject o, String key) {
if (!o.has(key) || !o.get(key).isJsonArray()) return new double[] {0, 0, 0};
JsonArray a = o.getAsJsonArray(key);
@@ -0,0 +1,466 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import eu.mhsl.minecraft.pixelpics.assets.AssetPaths;
import eu.mhsl.minecraft.pixelpics.assets.AssetReader;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTint;
import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.block.data.BlockData;
/**
* Item (and block) geometry the way vanilla's ItemRenderer draws it, straight from the resource pack:
* the 26.x item definition ({@code items/<id>.json}: model / special / select / condition) picks the
* model for the display context, the model's parent chain supplies the {@code display} transform and the
* {@code layerN} textures. Geometry: generated items as an extruded sprite (1px thick, like vanilla),
* block items as their block model, block-entity items (chest, heads, banners, …) through the block-entity
* baker, shield/trident through their entity models. Shared by held items, head items, item displays and
* dropped items.
*
* <p>All transforms are in vanilla item space: the caller's {@code toWorld} maps the ItemRenderer origin;
* this class applies the display transform and the {@code translate(-0.5)} to the 0..1 model cube.
*/
public final class ItemGeometry {
// Tinted block faces have no biome context here: plains colours.
private static final BiomeTint DEFAULT_TINT = new BiomeTint(0xFF91BD59, 0xFF77AB2F, 0xFFA9A05B, 0xFF3F76E4);
private final AssetReader reader;
private final BlockModelRegistry registry;
private final TextureCache textures;
private final CemModelLoader models;
private final BlockEntityBaker blockEntities;
private final Map<String, Info> infos = new ConcurrentHashMap<>();
private final Map<int[][], Map<Integer, int[][]>> tinted = new IdentityHashMap<>();
private final Map<int[][], List<Box>> extrusions = new IdentityHashMap<>();
private final Map<Info, Optional<int[][]>> composedSprites = new ConcurrentHashMap<>();
public ItemGeometry(
AssetReader reader,
BlockModelRegistry registry,
TextureCache textures,
CemModelLoader models,
BlockEntityBaker blockEntities) {
this.reader = reader;
this.registry = registry;
this.textures = textures;
this.models = models;
this.blockEntities = blockEntities;
}
/** Resolved item model for one display context. */
private record Info(
double[] display, // {rx,ry,rz, tx,ty,tz, sx,sy,sz} (deg, blocks, factor); null = identity
List<String> layers, // generated-item layer textures (layer0, layer1, …); empty if not generated
List<Integer> tints, // ARGB per layer (0 = none)
String special, // special model type ("shield", "trident", "chest", "head", …) or null
Affine specialTransform) {} // item-model transformation for special models (T·Rl·S·Rr) or null
// --- public API --------------------------------------------------------------------------------
/** Bake an item in a display context ("thirdperson_righthand", "head", "ground", "fixed", "none", …). */
public void bake(ItemSpec item, String context, boolean leftHand, Affine toWorld, List<EntityCube> out) {
if (item == null) return;
Info info = info(item.id(), context);
Affine m = toWorld.mul(displayTransform(info.display, leftHand)).mul(Affine.translation(-0.5, -0.5, -0.5));
int before = out.size();
if (info.special != null) bakeSpecial(item, info, m, out);
if (out.size() == before && !info.layers.isEmpty()) bakeGenerated(info, m, out);
if (out.size() == before && item.block() != null) bakeBlock(item.block(), m, out);
if (out.size() == before && item.blockEntity() != null) bakeBlockEntity(item.blockEntity(), m, out);
if (out.size() == before) {
int[][] sprite = sprite(item.id());
if (sprite != null) addExtruded(sprite, m, out);
}
}
/** The display transform's Y scale for a context (vanilla lifts dropped items by 0.25·scaleY). */
public double displayScaleY(ItemSpec item, String context) {
if (item == null) return 1;
Info info = info(item.id(), context);
return info.display == null ? 1 : info.display[7];
}
/** A block's model elements in the block cube [0,1]³ (tinted with plains colours). */
public void bakeBlock(BlockData data, Affine toWorld, List<EntityCube> out) {
if (data == null) return;
ResolvedModel model = registry.get(data);
if (model == null || !model.hasGeometry) return;
for (Element e : model.elements) {
Face[] faces = new Face[6];
for (int i = 0; i < 6; i++) faces[i] = tint(e.faces[i], data);
out.add(new EntityCube(e.from, e.to, faces, toWorld.mul(elementTransform(e)), true));
}
}
/** A block-entity block (chest, head, banner, …) in the block cube [0,1]³ via the block-entity baker. */
public void bakeBlockEntity(BlockEntityState state, Affine toWorld, List<EntityCube> out) {
if (state == null || blockEntities == null) return;
RenderedEntity r = blockEntities.bake(state); // placed at cell (bx,by,bz) = (0,0,0)
if (r == null) return;
for (EntityCube c : r.cubes) out.add(new EntityCube(c.from, c.to, c.faces, toWorld.mul(c.toWorld), c.blockUv));
}
// --- item definition / model resolution --------------------------------------------------------
private Info info(String id, String context) {
return infos.computeIfAbsent(id + "#" + context, k -> {
try {
return resolve(id, context);
} catch (RuntimeException e) {
// Unexpected item-definition format (newer version?): fall back to the plain sprite.
return new Info(null, List.of(), List.of(), null, null);
}
});
}
private Info resolve(String id, String context) {
String modelRef = null, special = null;
Affine specialXf = null;
List<Integer> tints = new ArrayList<>();
Optional<JsonObject> def = reader.readJsonObject("assets/minecraft/items/" + id + ".json");
if (def.isPresent() && def.get().has("model")) {
JsonObject node = pick(def.get().getAsJsonObject("model"), context);
if (node != null) {
String type = str(node, "type");
if (type.endsWith("special")) {
modelRef = str(node, "base");
JsonObject sm = node.has("model") ? node.getAsJsonObject("model") : null;
special = sm == null ? null : str(sm, "type").replace("minecraft:", "");
if (node.has("transformation")) specialXf = transformation(node.getAsJsonObject("transformation"));
} else {
modelRef = str(node, "model");
}
if (node.has("tints")) {
for (JsonElement t : node.getAsJsonArray("tints")) tints.add(tintOf(t));
}
}
}
if (modelRef == null || modelRef.isEmpty()) modelRef = "minecraft:item/" + id;
// Parent chain: nearest "display" entry for the context wins; layer textures; generated flag.
double[] display = null;
Map<String, String> tex = new HashMap<>();
boolean generated = false;
String ref = modelRef;
for (int depth = 0; ref != null && depth < 16; depth++) {
ResourceLocation rl = ResourceLocation.parse(ref);
if (rl.path().startsWith("builtin/generated")) {
generated = true;
break;
}
Optional<JsonObject> m = reader.readJsonObject(AssetPaths.model(rl));
if (m.isEmpty()) break;
JsonObject mo = m.get();
if (display == null && mo.has("display")) {
JsonObject d = mo.getAsJsonObject("display");
String ctx = context;
if (!d.has(ctx) && ctx.equals("thirdperson_lefthand")) ctx = "thirdperson_righthand";
if (d.has(ctx)) display = displayOf(d.getAsJsonObject(ctx));
}
if (mo.has("textures")) {
for (Map.Entry<String, JsonElement> e :
mo.getAsJsonObject("textures").entrySet()) {
JsonElement v = e.getValue();
// 26.x textures may be objects ({"sprite": …, "force_translucent": …}).
String val = v.isJsonPrimitive()
? v.getAsString()
: (v.isJsonObject() && v.getAsJsonObject().has("sprite")
? v.getAsJsonObject().get("sprite").getAsString()
: null);
if (val != null) tex.putIfAbsent(e.getKey(), val);
}
}
// Keep walking past element models: block/block.json (the root) holds the block display transforms.
ref = mo.has("parent") ? mo.get("parent").getAsString() : null;
}
List<String> layers = new ArrayList<>();
if (generated) {
for (int i = 0; tex.containsKey("layer" + i); i++) layers.add(resolveTex(tex, tex.get("layer" + i)));
}
return new Info(display, layers, tints, special, specialXf);
}
/** Walks an item-model node to the concrete model/special for a display context. */
private static JsonObject pick(JsonObject node, String context) {
for (int depth = 0; node != null && depth < 16; depth++) {
String type = str(node, "type").replace("minecraft:", "");
switch (type) {
case "model", "special" -> {
return node;
}
case "condition" -> node = obj(node, "on_false");
case "select" -> {
JsonObject chosen = null;
if (str(node, "property").endsWith("display_context") && node.has("cases")) {
for (JsonElement c : node.getAsJsonArray("cases")) {
JsonObject co = c.getAsJsonObject();
if (matches(co.get("when"), context)) {
chosen = obj(co, "model");
break;
}
}
}
if (chosen == null) chosen = obj(node, "fallback");
if (chosen == null && node.has("cases")) {
chosen = obj(node.getAsJsonArray("cases").get(0).getAsJsonObject(), "model");
}
node = chosen;
}
case "range_dispatch" -> {
JsonObject f = obj(node, "fallback");
if (f == null
&& node.has("entries")
&& !node.getAsJsonArray("entries").isEmpty()) {
f = obj(node.getAsJsonArray("entries").get(0).getAsJsonObject(), "model");
}
node = f;
}
case "composite" -> node =
node.has("models") && !node.getAsJsonArray("models").isEmpty()
? node.getAsJsonArray("models").get(0).getAsJsonObject()
: null;
default -> node = obj(node, "fallback");
}
}
return null;
}
private static boolean matches(JsonElement when, String context) {
if (when == null) return false;
if (when.isJsonArray()) {
for (JsonElement w : when.getAsJsonArray()) if (w.getAsString().equals(context)) return true;
return false;
}
return when.getAsString().equals(context);
}
private static int tintOf(JsonElement t) {
if (!t.isJsonObject()) return 0;
JsonObject o = t.getAsJsonObject();
for (String k : new String[] {"value", "default"}) {
if (o.has(k) && o.get(k).isJsonPrimitive()) return o.get(k).getAsInt() | 0xFF000000;
}
return 0;
}
private static String resolveTex(Map<String, String> tex, String v) {
for (int i = 0; i < 8 && v.startsWith("#"); i++) v = tex.getOrDefault(v.substring(1), v);
return v;
}
private static double[] displayOf(JsonObject d) {
double[] r = arr(d, "rotation", 0), t = arr(d, "translation", 0), s = arr(d, "scale", 1);
return new double[] {r[0], r[1], r[2], t[0] / 16, t[1] / 16, t[2] / 16, s[0], s[1], s[2]};
}
/** Vanilla ItemTransform.apply: T(±tx,ty,tz) · rotationXYZ(rx,±ry,±rz) · S. */
private static Affine displayTransform(double[] d, boolean left) {
if (d == null) return Affine.identity();
int i = left ? -1 : 1;
return Affine.translation(i * d[3], d[4], d[5])
.mul(Affine.rotX(Math.toRadians(d[0])))
.mul(Affine.rotY(Math.toRadians(i * d[1])))
.mul(Affine.rotZ(Math.toRadians(i * d[2])))
.mul(Affine.scale(d[6], d[7], d[8]));
}
/** Item-model "transformation" (translation, left_rotation quat, scale, right_rotation quat) as T·L·S·R. */
private static Affine transformation(JsonObject o) {
double[] t = arr(o, "translation", 0), s = arr(o, "scale", 1);
double[] l = quat(o, "left_rotation"), r = quat(o, "right_rotation");
return Affine.translation(t[0], t[1], t[2])
.mul(Affine.fromQuaternion(l[0], l[1], l[2], l[3]))
.mul(Affine.scale(s[0], s[1], s[2]))
.mul(Affine.fromQuaternion(r[0], r[1], r[2], r[3]));
}
private static double[] quat(JsonObject o, String k) {
if (!o.has(k) || !o.get(k).isJsonArray() || o.getAsJsonArray(k).size() != 4) return new double[] {0, 0, 0, 1};
JsonArray a = o.getAsJsonArray(k);
return new double[] {
a.get(0).getAsDouble(),
a.get(1).getAsDouble(),
a.get(2).getAsDouble(),
a.get(3).getAsDouble()
};
}
private static double[] arr(JsonObject o, String k, double def) {
double[] v = {def, def, def};
if (o.has(k) && o.get(k).isJsonArray()) {
JsonArray a = o.getAsJsonArray(k);
for (int i = 0; i < 3 && i < a.size(); i++) v[i] = a.get(i).getAsDouble();
}
return v;
}
private static String str(JsonObject o, String k) {
return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : "";
}
private static JsonObject obj(JsonObject o, String k) {
return o.has(k) && o.get(k).isJsonObject() ? o.getAsJsonObject(k) : null;
}
// --- geometry ------------------------------------------------------------------------------------
private void bakeSpecial(ItemSpec item, Info info, Affine m, List<EntityCube> out) {
switch (info.special) {
case "shield", "trident" -> {
// Entity model in Java model space; the special renderer flips it with scale(1,-1,-1).
boolean shield = info.special.equals("shield");
CemModelLoader.CemModel model = models.vanilla(shield ? "layer/shield#main" : "trident");
if (model == null) model = models.vanilla("layer/" + info.special + "#main");
int[][] tex = textures.get(ResourceLocation.parse(
shield ? "entity/shield/shield_base_nopattern" : "entity/trident/trident"))
.orElse(null);
if (model == null || tex == null) return;
Affine xf = info.specialTransform != null ? m.mul(info.specialTransform) : m;
Affine pre = xf.mul(Affine.scale(1, -1, -1))
.mul(Affine.scale(1 / 16.0))
.mul(CemGeometry.CEM_TO_JAVA);
for (CemGeometry.Baked b : CemGeometry.bakeModel(model, tex, pre, Set.of())) {
out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world()));
}
}
default -> {
// Block-entity specials (chest, head, banner, shulker box, bed, conduit, pot, signs, …).
if (item.blockEntity() != null) bakeBlockEntity(item.blockEntity(), m, out);
}
}
}
private void bakeGenerated(Info info, Affine m, List<EntityCube> out) {
// Cached per Info (one per item+context): a stable texture identity keeps the extrusion cache bounded.
int[][] composed = composedSprites
.computeIfAbsent(info, k -> Optional.ofNullable(compose(k)))
.orElse(null);
if (composed != null) addExtruded(composed, m, out);
}
/** The generated item's layers composited (tinted) into one sprite, or null. */
private int[][] compose(Info info) {
int[][] composed = null;
for (int i = 0; i < info.layers.size(); i++) {
int[][] t = textures.get(ResourceLocation.parse(info.layers.get(i))).orElse(null);
if (t == null) continue;
int tint = i < info.tints.size() ? info.tints.get(i) : 0;
if (composed == null) {
composed = TextureOps.deepCopy(t);
if (tint != 0) TextureOps.tint(composed, tint);
} else if (t.length == composed.length && t[0].length == composed[0].length) {
int[][] layer = t;
if (tint != 0) {
layer = TextureOps.deepCopy(t);
TextureOps.tint(layer, tint);
}
TextureOps.overlay(composed, layer);
}
}
return composed;
}
private int[][] sprite(String id) {
int[][] t = textures.get(ResourceLocation.parse("item/" + id)).orElse(null);
if (t != null) return t;
return textures.get(ResourceLocation.parse("block/" + id)).orElse(null);
}
private record Box(double[] from, double[] to, Face[] faces) {}
/**
* Vanilla-style extruded sprite in the 0..1 cube, 1px thick at z 7.5..8.5 px: one box per horizontal run
* of opaque texels; front/back show the texture, the edges the run's texel colours.
*/
private void addExtruded(int[][] tex, Affine m, List<EntityCube> out) {
List<Box> boxes;
synchronized (extrusions) {
boxes = extrusions.computeIfAbsent(tex, ItemGeometry::extrude);
}
for (Box b : boxes) out.add(new EntityCube(b.from, b.to, b.faces, m));
}
private static List<Box> extrude(int[][] tex) {
int h = tex.length, w = h == 0 ? 0 : tex[0].length;
List<Box> boxes = new ArrayList<>();
double z0 = 7.5 / 16, z1 = 8.5 / 16;
for (int py = 0; py < h; py++) {
for (int px = 0; px < w; ) {
if (!ColorUtil.opaque(tex[py][px])) {
px++;
continue;
}
int start = px;
while (px < w && ColorUtil.opaque(tex[py][px])) px++;
double u1 = (double) start / w, u2 = (double) px / w, v1 = (double) py / h, v2 = (double) (py + 1) / h;
double vm = (py + 0.5) / h;
Face[] f = new Face[6];
f[Direction.SOUTH.ordinal()] = new Face(tex, u1, v1, u2, v2, 0, -1); // front (+Z)
f[Direction.NORTH.ordinal()] = new Face(tex, u2, v1, u1, v2, 0, -1); // back, same texel per point
f[Direction.UP.ordinal()] = new Face(tex, u1, vm, u2, vm, 0, -1);
f[Direction.DOWN.ordinal()] = new Face(tex, u1, vm, u2, vm, 0, -1);
double uw = (start + 0.5) / w, ue = (px - 0.5) / w;
f[Direction.WEST.ordinal()] = new Face(tex, uw, vm, uw, vm, 0, -1);
f[Direction.EAST.ordinal()] = new Face(tex, ue, vm, ue, vm, 0, -1);
boxes.add(new Box(new double[] {u1, 1 - v2, z0}, new double[] {u2, 1 - v1, z1}, f));
}
}
return boxes;
}
/** Element rotation (origin/axis/angle, optional rescale) as an affine in block units. */
private static Affine elementTransform(Element e) {
if (e.isAxisAligned()) return Affine.identity();
double[] o = e.rotOrigin;
Affine r =
switch (e.rotAxis) {
case 0 -> Affine.rotX(e.rotAngleRad);
case 1 -> Affine.rotY(e.rotAngleRad);
default -> Affine.rotZ(e.rotAngleRad);
};
if (e.rescale) {
double f = 1 / Math.cos(e.rotAngleRad);
r = r.mul(Affine.scale(e.rotAxis == 0 ? 1 : f, e.rotAxis == 1 ? 1 : f, e.rotAxis == 2 ? 1 : f));
}
return Affine.translation(o[0], o[1], o[2]).mul(r).mul(Affine.translation(-o[0], -o[1], -o[2]));
}
/** Bakes a biome tint into a copy of the face texture (the entity path has no per-hit tinting). */
private Face tint(Face f, BlockData data) {
if (f == null || f.tintIndex < 0) return f;
int color = TintResolver.resolve(data, f.tintIndex, DEFAULT_TINT);
if (color == -1) return f;
int[][] t;
synchronized (tinted) {
t = tinted.computeIfAbsent(f.texture, k -> new HashMap<>()).computeIfAbsent(color, c -> {
int[][] copy = TextureOps.deepCopy(f.texture);
TextureOps.tint(copy, c);
return copy;
});
}
return new Face(t, f.u1, f.v1, f.u2, f.v2, f.rotation, -1);
}
}
@@ -12,12 +12,12 @@ import java.util.List;
* text is never stretched. Glyphs from different providers (ascii/accented) are aligned on a common
* baseline. Glowing text gets an 8-directional outline.
*/
final class SignTextRasterizer {
public final class SignTextRasterizer {
private SignTextRasterizer() {}
/** Returns the rasterized panel, or null when there is nothing to draw (no font/glyphs). */
static int[][] rasterize(List<String> lines, BitmapFont font, int fillArgb, int outlineArgb, boolean glow) {
public static int[][] rasterize(List<String> lines, BitmapFont font, int fillArgb, int outlineArgb, boolean glow) {
if (font.isEmpty() || lines.isEmpty()) return null;
int ascent = font.maxAscent();
@@ -96,7 +96,8 @@ final class SignTextRasterizer {
}
/** Total advance width of a line in font px. */
private static int lineWidth(String line, BitmapFont font) {
/** Width of a line in font pixels (sum of glyph advances). */
public static int lineWidth(String line, BitmapFont font) {
int w = 0;
for (int ci = 0; ci < line.length(); ) {
int cp = line.codePointAt(ci);
@@ -18,7 +18,6 @@ import org.bukkit.util.Vector;
public final class ElementIntersector {
private static final double EPS = 1e-7;
private static final int ALPHA_THRESHOLD = 16;
private ElementIntersector() {}
@@ -58,7 +57,7 @@ public final class ElementIntersector {
Face face = c.element.faces[c.dir.ordinal()];
if (face == null) continue;
int color = face.sample(c.s, c.t2);
if (ColorUtil.alpha(color) <= ALPHA_THRESHOLD) continue;
if (!ColorUtil.opaque(color)) continue;
Vector world = new Vector(bx + ox + dx * c.t, by + oy + dy * c.t, bz + oz + dz * c.t);
Vector normal = new Vector(c.normal[0], c.normal[1], c.normal[2]);
@@ -7,14 +7,19 @@ import eu.mhsl.minecraft.pixelpics.render.entity.DecorationBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityScene;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
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.ItemGeometry;
import eu.mhsl.minecraft.pixelpics.render.raytrace.SnapshotRaytracer;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
import eu.mhsl.minecraft.pixelpics.render.snapshot.BlockEntitySnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.DecorationSnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.EntitySnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.FrustumBounds;
import eu.mhsl.minecraft.pixelpics.render.snapshot.ObjectSnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
@@ -23,6 +28,7 @@ import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ForkJoinPool;
@@ -31,6 +37,7 @@ import java.util.logging.Logger;
import java.util.stream.IntStream;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.util.Vector;
/**
@@ -54,6 +61,7 @@ public class DefaultScreenRenderer implements Renderer {
private final CemBaker entityBaker;
private final BlockEntityBaker blockEntityBaker;
private final DecorationBaker decorationBaker;
private final ObjectBaker objectBaker;
private final Logger logger;
/** Bounds parallel ray tracing to a fixed, low-priority pool; {@code null} = use the common pool. */
private final ForkJoinPool tracePool;
@@ -81,6 +89,14 @@ public class DefaultScreenRenderer implements Renderer {
this.entityBaker = entityBaker;
this.blockEntityBaker = blockEntityBaker;
this.decorationBaker = new DecorationBaker(textures);
ItemGeometry items = new ItemGeometry(
registry.reader(),
registry,
textures,
entityBaker == null ? null : entityBaker.models(),
blockEntityBaker);
if (entityBaker != null) entityBaker.setItemGeometry(items);
this.objectBaker = new ObjectBaker(items, blockEntityBaker == null ? null : blockEntityBaker.font());
this.logger = logger;
this.tracePool = tracePool;
}
@@ -97,9 +113,11 @@ public class DefaultScreenRenderer implements Renderer {
int superH = resolution.getHeight() * SSAA;
List<Vector> rayMap = buildRayMap(eyeLocation, superW, superH);
WorldSnapshot snapshot = SnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, logger);
List<EntityState> entities = EntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, shooter);
Collection<Entity> nearby = FrustumBounds.nearbyEntities(eyeLocation, rayMap, MAX_DISTANCE);
List<EntityState> entities = EntitySnapshotBuilder.build(nearby, shooter);
List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE);
List<DecorationState> decorations = DecorationSnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE);
List<DecorationState> decorations = DecorationSnapshotBuilder.build(nearby);
List<ObjectState> objects = ObjectSnapshotBuilder.build(nearby, eyeLocation);
World world = eyeLocation.getWorld();
long dayTime = world.getTime();
@@ -116,7 +134,8 @@ public class DefaultScreenRenderer implements Renderer {
sky,
entities,
blockEntities,
decorations);
decorations,
objects);
}
/** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */
@@ -138,7 +157,14 @@ public class DefaultScreenRenderer implements Renderer {
Vector origin = job.origin();
SkyContext sky = job.sky();
EntityScene scene = new EntityScene(
job.entities(), entityBaker, job.blockEntities(), blockEntityBaker, job.decorations(), decorationBaker);
job.entities(),
entityBaker,
job.blockEntities(),
blockEntityBaker,
job.decorations(),
decorationBaker,
job.objects(),
objectBaker);
int[] superBuf = new int[rayMap.size()];
runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> {
@@ -3,6 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.render;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import java.util.List;
@@ -22,7 +23,22 @@ public record RenderJob(
SkyContext sky,
List<EntityState> entities,
List<BlockEntityState> blockEntities,
List<DecorationState> decorations) {
List<DecorationState> decorations,
List<ObjectState> objects) {
/** Without display/item/falling-block objects. */
public RenderJob(
WorldSnapshot snapshot,
List<Vector> rayMap,
Vector origin,
int width,
int height,
SkyContext sky,
List<EntityState> entities,
List<BlockEntityState> blockEntities,
List<DecorationState> decorations) {
this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, decorations, List.of());
}
/** Backwards-compatible constructor (no block-entities/decorations), used by the standalone harness. */
public RenderJob(
@@ -74,10 +74,26 @@ public final class BlockEntitySnapshotBuilder {
}
private static BlockEntityState classify(BlockState ts) {
Material mat = ts.getType();
return classify(ts.getBlockData(), ts.getX(), ts.getY(), ts.getZ(), ts);
}
/**
* A block-entity state from bare block data (no world tile entity) at cell (0,0,0): for block-entity
* blocks shown by BLOCK DISPLAYS, minecarts and special ITEMS (chest/head/banner/shulker/… items).
* Tile-only details (banner patterns, sign text, head skins) are absent. MUST run on the main thread.
* Returns null for blocks that aren't block-entity rendered.
*/
public static BlockEntityState fromBlockData(BlockData data) {
try {
return classify(data, 0, 0, 0, null);
} catch (Throwable t) {
return null;
}
}
private static BlockEntityState classify(BlockData data, int bx, int by, int bz, BlockState ts) {
Material mat = data.getMaterial();
String n = mat.name();
BlockData data = ts.getBlockData();
int bx = ts.getX(), by = ts.getY(), bz = ts.getZ();
// --- chests ---
if (mat == Material.CHEST || mat == Material.TRAPPED_CHEST || mat == Material.ENDER_CHEST) {
@@ -116,6 +132,15 @@ public final class BlockEntitySnapshotBuilder {
boolean wall = n.endsWith("_WALL_BANNER");
float yaw = wall ? facingYaw(data) : rotationYaw(data);
Builder b = base(wall ? Kind.WALL_BANNER : Kind.BANNER, bx, by, bz, yaw);
if (!(ts instanceof Banner)) {
try { // no tile: the base colour is in the block name
String colour =
stripColor(n, wall ? "_WALL_BANNER" : "_BANNER").toUpperCase(Locale.ROOT);
b.baseColorArgb(ColorUtil.dyeArgb(DyeColor.valueOf(colour), 0xFFFFFFFF));
} catch (IllegalArgumentException ignored) {
// unknown colour name
}
}
if (ts instanceof Banner banner) {
DyeColor base = banner.getBaseColor();
b.baseColorArgb(ColorUtil.dyeArgb(base, 0xFFFFFFFF));
@@ -4,7 +4,6 @@ import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.bukkit.Location;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity;
import org.bukkit.entity.GlowItemFrame;
@@ -12,7 +11,6 @@ import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Painting;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;
/**
* Captures flat wall decorations (paintings + item frames) near the view frustum into immutable
@@ -23,9 +21,8 @@ public final class DecorationSnapshotBuilder {
private DecorationSnapshotBuilder() {}
public static List<DecorationState> build(Location eye, List<Vector> rayMap, double maxDistance) {
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
Collection<Entity> nearby = bounds.nearbyEntities(eye.getWorld(), 2);
/** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo. */
public static List<DecorationState> build(Collection<Entity> nearby) {
List<DecorationState> out = new ArrayList<>();
for (Entity e : nearby) {
try {
@@ -4,6 +4,7 @@ import com.destroystokyo.paper.profile.ProfileProperty;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import io.papermc.paper.datacomponent.DataComponentTypes;
import java.nio.charset.StandardCharsets;
@@ -19,7 +20,6 @@ import org.bukkit.Keyed;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.AbstractHorse;
import org.bukkit.entity.AbstractNautilus;
import org.bukkit.entity.Ageable;
import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.Axolotl;
@@ -31,30 +31,34 @@ import org.bukkit.entity.Entity;
import org.bukkit.entity.Fox;
import org.bukkit.entity.Frog;
import org.bukkit.entity.Horse;
import org.bukkit.entity.HumanEntity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Llama;
import org.bukkit.entity.Mob;
import org.bukkit.entity.MushroomCow;
import org.bukkit.entity.Panda;
import org.bukkit.entity.Parrot;
import org.bukkit.entity.Pig;
import org.bukkit.entity.Player;
import org.bukkit.entity.PufferFish;
import org.bukkit.entity.Rabbit;
import org.bukkit.entity.Sheep;
import org.bukkit.entity.Shulker;
import org.bukkit.entity.Slime;
import org.bukkit.entity.Snowman;
import org.bukkit.entity.TraderLlama;
import org.bukkit.entity.TropicalFish;
import org.bukkit.entity.Villager;
import org.bukkit.entity.Wolf;
import org.bukkit.entity.Zombie;
import org.bukkit.entity.ZombieVillager;
import org.bukkit.inventory.EntityEquipment;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.MainHand;
import org.bukkit.inventory.meta.ArmorMeta;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.LeatherArmorMeta;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.util.Vector;
/**
* Captures entities near the view frustum into immutable {@link EntityState}s. MUST run on the main
@@ -108,9 +112,8 @@ public final class EntitySnapshotBuilder {
"piglin",
"piglin_brute");
public static List<EntityState> build(Location eye, List<Vector> rayMap, double maxDistance, UUID shooter) {
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
Collection<Entity> nearby = bounds.nearbyEntities(eye.getWorld(), 2);
/** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo. */
public static List<EntityState> build(Collection<Entity> nearby, UUID shooter) {
List<EntityState> states = new ArrayList<>();
for (Entity e : nearby) {
if (shooter != null && e.getUniqueId().equals(shooter)) continue;
@@ -125,14 +128,15 @@ public final class EntitySnapshotBuilder {
// Skip non-renderable technical entities.
String type = e.getType().getKey().getKey();
// Boats now have a bundled geometry.boat; rafts use a different hull we don't ship yet — skip those.
if (NON_RENDERABLE.contains(type) || type.endsWith("_raft")) return null;
if (NON_RENDERABLE.contains(type)) return null;
float bodyYaw = loc.getYaw();
if (e instanceof LivingEntity le) {
bodyYaw = le.getBodyYaw();
}
boolean baby = (e instanceof Ageable a && !a.isAdult()) || (e instanceof Zombie z && z.isAdult());
// (Zombie implements Ageable — no extra case; an earlier "|| zombie.isAdult()" made ADULT zombies babies.)
boolean baby = (e instanceof Ageable a && !a.isAdult()) || (e instanceof ArmorStand st && st.isSmall());
// Invisible entities render only their equipment (like vanilla): the generic invisible flag, an
// invisibility potion effect, or an explicitly-hidden armor stand.
@@ -161,12 +165,24 @@ public final class EntitySnapshotBuilder {
boolean saddle = false;
boolean chest = false;
String bodyEquip = null;
int bodyDye = 0;
int tint2 = 0;
try {
// Slime & magma cube (MagmaCube extends Slime) scale their model by size (1/2/4).
if (e instanceof Slime sl) sizeScale = sl.getSize();
// MushroomCow extends Cow, ZombieVillager does not extend Villager — order matters.
if (e instanceof Sheep sh) {
tint = ColorUtil.dyeArgb(sh.getColor(), 0);
if (sh.isSheared()) variant = "sheared";
} else if (e instanceof TropicalFish tf) {
variant = keyOf(tf.getPattern());
tint = ColorUtil.dyeArgb(tf.getBodyColor(), 0);
tint2 = ColorUtil.dyeArgb(tf.getPatternColor(), 0);
} else if (e instanceof PufferFish pf) {
variant = switch (pf.getPuffState()) {
case 0 -> "small";
case 1 -> "mid";
default -> "big";};
} else if (e instanceof Cat c) {
variant = keyOf(c.getCatType());
} else if (e instanceof Wolf w) {
@@ -194,12 +210,6 @@ public final class EntitySnapshotBuilder {
} else if (e instanceof AbstractHorse ah) {
// Skeleton/zombie horse: only saddle (no colour/markings/armor variants).
saddle = isSaddled(ah);
} else if (e instanceof AbstractNautilus nl) {
// Nautilus body armor + saddle are same-UV overlays (like horse armor).
EntityEquipment eq = nl.getEquipment();
bodyEquip = equipAsset(eq.getItem(EquipmentSlot.BODY));
ItemStack sd = eq.getItem(EquipmentSlot.SADDLE);
saddle = !sd.getType().isAir();
} else if (e instanceof Fox f) {
variant = keyOf(f.getFoxType());
} else if (e instanceof MushroomCow mc) {
@@ -224,9 +234,47 @@ public final class EntitySnapshotBuilder {
} else if (e instanceof Chicken ch) {
variant = keyOf(ch.getVariant());
}
if (e instanceof ArmorStand st && st.hasArms()) variant = "arms";
// Any other mob with a variant/weathering getter (new mobs, copper golem, zombie nautilus, …).
if (variant == null) variant = genericVariant(e);
} catch (Throwable ignored) {
// Unsupported on this server version — fall back to the base texture.
}
// Equipment slots: saddle + BODY equipment (horse armor, llama carpet, harness, wolf/nautilus armor, …)
// for every mount — including ones added in newer versions — plus held items (by hand side, like
// vanilla: a left-handed mob holds its main-hand item left) and a non-armor head item.
ItemSpec rightHand = null, leftHand = null, headItem = null;
EntityEquipment eq = e instanceof LivingEntity le ? le.getEquipment() : null;
if (eq != null) {
try {
ItemStack sd = eq.getItem(EquipmentSlot.SADDLE);
if (sd != null && !sd.getType().isAir()) saddle = true;
ItemStack body = eq.getItem(EquipmentSlot.BODY);
String asset = equipAsset(body);
if (asset != null && bodyEquip == null) bodyEquip = asset;
if (asset != null) bodyDye = dyeOf(body);
} catch (Throwable ignored) {
// slot not supported by this entity / server version
}
try {
boolean leftHanded = (e instanceof Mob mob && mob.isLeftHanded())
|| (e instanceof HumanEntity he && he.getMainHand() == MainHand.LEFT);
ItemSpec main = ItemSpecs.of(eq.getItemInMainHand());
ItemSpec off = ItemSpecs.of(eq.getItemInOffHand());
rightHand = leftHanded ? off : main;
leftHand = leftHanded ? main : off;
ItemStack helmet = eq.getHelmet();
if (helmet != null && !helmet.getType().isAir() && armorAsset(helmet.getType()) == null) {
headItem = ItemSpecs.of(helmet);
}
// Snow golems wear a carved pumpkin unless sheared (vanilla SnowGolemHeadLayer).
if (headItem == null && e instanceof Snowman sg && !sg.isDerp()) {
headItem = ItemSpecs.of(new ItemStack(Material.CARVED_PUMPKIN));
}
} catch (Throwable ignored) {
// no hands / equipment on this entity
}
}
EntityState.Equipment equipment = null;
if (HUMANOID_ARMOR_WEARERS.contains(type) && e instanceof LivingEntity wearer) {
@@ -255,7 +303,14 @@ public final class EntitySnapshotBuilder {
chest,
bodyEquip,
equipment,
invisible);
invisible,
loc.getPitch(),
bodyDye,
tint2,
e.isInsideVehicle(),
rightHand,
leftHand,
headItem);
}
/** Worn armor (4 slots) from a humanoid wearer; null when nothing is equipped. */
@@ -341,6 +396,42 @@ public final class EntitySnapshotBuilder {
return null;
}
/** ARGB of an item's DYED_COLOR component (leather horse armor, wolf armor); 0 = undyed. */
private static int dyeOf(ItemStack it) {
try {
var c = it.getData(DataComponentTypes.DYED_COLOR);
return c == null ? 0 : c.color().asARGB();
} catch (Throwable t) {
return 0;
}
}
/** Per entity class: its {@code getVariant()} / {@code getWeatheringState()} getter, or null. */
private static final ClassValue<java.lang.reflect.Method> VARIANT_GETTER = new ClassValue<>() {
@Override
protected java.lang.reflect.Method computeValue(Class<?> c) {
for (String m : new String[] {"getVariant", "getWeatheringState"}) {
try {
return c.getMethod(m);
} catch (NoSuchMethodException ignored) {
// try the next name
}
}
return null;
}
};
/** Variant key via a {@code getVariant()} / {@code getWeatheringState()} getter, if the entity has one. */
private static String genericVariant(Entity e) {
java.lang.reflect.Method m = VARIANT_GETTER.get(e.getClass());
if (m == null) return null;
try {
return keyOf(m.invoke(e));
} catch (ReflectiveOperationException ex) {
return null;
}
}
/** Whether a horse-like mount carries a saddle in its dedicated saddle slot. */
private static boolean isSaddled(AbstractHorse h) {
ItemStack st = h.getInventory().getSaddle();
@@ -12,7 +12,7 @@ import org.bukkit.util.Vector;
* together with every ray endpoint at {@code maxDistance}. Shared by all snapshot builders to size
* their chunk/entity queries identically.
*/
final class FrustumBounds {
public final class FrustumBounds {
final double minX, minY, minZ;
final double maxX, maxY, maxZ;
@@ -44,6 +44,14 @@ final class FrustumBounds {
}
/** All entities whose centre falls within these bounds expanded by {@code margin} on every side. */
/**
* All entities near the view frustum, queried ONCE per photo and shared by the entity, decoration and
* object snapshot builders. MUST run on the main thread.
*/
public static Collection<Entity> nearbyEntities(Location eye, List<Vector> rayMap, double maxDistance) {
return of(eye.toVector(), rayMap, maxDistance).nearbyEntities(eye.getWorld(), 2);
}
Collection<Entity> nearbyEntities(World world, double margin) {
Location center = new Location(world, (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
double hx = (maxX - minX) / 2 + margin;
@@ -0,0 +1,44 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.bukkit.inventory.ItemStack;
/** Builds {@link ItemSpec}s from Bukkit item stacks. MUST run on the main thread. */
public final class ItemSpecs {
private ItemSpecs() {}
/** Block data + block-entity state depend only on the material: computed once per material. */
private record BlockInfo(BlockData data, BlockEntityState blockEntity) {}
private static final Map<Material, BlockInfo> BLOCKS = new ConcurrentHashMap<>();
/** The spec for a stack, or null for empty/air. */
public static ItemSpec of(ItemStack stack) {
if (stack == null || stack.getType().isAir()) return null;
Material m = stack.getType();
BlockInfo b = BLOCKS.computeIfAbsent(m, ItemSpecs::blockInfo);
boolean glint;
try {
glint = !stack.getEnchantments().isEmpty(); // no ItemMeta copy
} catch (Throwable t) {
glint = false;
}
return new ItemSpec(m.getKey().getKey(), b.data, b.blockEntity, glint);
}
private static BlockInfo blockInfo(Material m) {
BlockData data = null;
try {
if (m.isBlock()) data = m.createBlockData();
} catch (Throwable ignored) {
// item-only material
}
return new BlockInfo(data, data == null ? null : BlockEntitySnapshotBuilder.fromBlockData(data));
}
}
@@ -0,0 +1,221 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.bukkit.entity.BlockDisplay;
import org.bukkit.entity.Display;
import org.bukkit.entity.Entity;
import org.bukkit.entity.FallingBlock;
import org.bukkit.entity.HumanEntity;
import org.bukkit.entity.Item;
import org.bukkit.entity.ItemDisplay;
import org.bukkit.entity.LeashHitch;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Minecart;
import org.bukkit.entity.TNTPrimed;
import org.bukkit.entity.TextDisplay;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.Transformation;
import org.joml.Quaternionf;
/**
* Captures block/item/text content objects into {@link ObjectState}s on the main thread: display
* entities (with their transformation and billboard mode, resolved against the camera), dropped items,
* falling blocks, primed TNT and the block shown inside minecarts.
*/
public final class ObjectSnapshotBuilder {
private ObjectSnapshotBuilder() {}
/** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo; {@code eye} orients billboards. */
public static List<ObjectState> build(Collection<Entity> nearby, Location eye) {
List<ObjectState> out = new ArrayList<>();
for (Entity e : nearby) {
try {
ObjectState s = toState(e, eye);
if (s != null) out.add(s);
} catch (Throwable ignored) {
// API differences on other server versions: skip this object
}
try {
ObjectState leash = leash(e);
if (leash != null) out.add(leash);
} catch (Throwable ignored) {
// not leashable on this version
}
}
return out;
}
private static ObjectState toState(Entity e, Location eye) {
Location l = e.getLocation();
Affine at = Affine.translation(l.getX(), l.getY(), l.getZ());
if (e instanceof Display d) {
Affine base = at.mul(billboard(d, eye)).mul(transformation(d.getTransformation()));
if (d instanceof BlockDisplay bd) {
return block(base, bd.getBlock());
}
if (d instanceof ItemDisplay id) {
String ctx = id.getItemDisplayTransform().name().toLowerCase(java.util.Locale.ROOT);
return item(base, id.getItemStack(), ctx, false);
}
if (d instanceof TextDisplay td) {
return text(base, td);
}
return null;
}
if (e instanceof FallingBlock fb) {
return block(at.mul(Affine.translation(-0.5, 0, -0.5)), fb.getBlockData());
}
if (e instanceof TNTPrimed) {
return block(at.mul(Affine.translation(-0.5, 0, -0.5)), Material.TNT.createBlockData());
}
if (e instanceof Item it) {
// Dropped item: vanilla GROUND display transform, lifted by the baker; a fixed per-entity spin.
double spin = (e.getUniqueId().getLeastSignificantBits() & 0xFFFF) / 65536.0 * 2 * Math.PI;
return item(at.mul(Affine.rotY(spin)), it.getItemStack(), "ground", true);
}
if (e instanceof Minecart mc) {
BlockData data = mc.getDisplayBlockData();
if (data == null || data.getMaterial().isAir()) return null;
// Vanilla AbstractMinecartRenderer: cart pivot 0.375 up, yaw/pitch, then the block at 3/4 size.
Affine base = at.mul(Affine.translation(0, 0.375, 0))
.mul(Affine.rotY(Math.PI - Math.toRadians(l.getYaw())))
.mul(Affine.rotZ(-Math.toRadians(l.getPitch())))
.mul(Affine.scale(0.75))
.mul(Affine.translation(-0.5, (mc.getDisplayBlockOffset() - 8) / 16.0, 0.5))
.mul(Affine.rotY(Math.PI / 2));
return block(base, data);
}
return null;
}
private static ObjectState block(Affine toWorld, BlockData data) {
if (data == null || data.getMaterial().isAir()) return null;
BlockEntityState be = BlockEntitySnapshotBuilder.fromBlockData(data); // chest/banner/head/… content
return ObjectState.block(toWorld, data, be);
}
private static ObjectState item(Affine toWorld, ItemStack stack, String context, boolean dropped) {
ItemSpec spec = ItemSpecs.of(stack);
if (spec == null) return null;
return ObjectState.item(toWorld, spec, context, dropped);
}
/** Per class: {isLeashed, getLeashHolder} for non-living leashables (boats, …), or null. */
private static final ClassValue<java.lang.reflect.Method[]> LEASH_METHODS = new ClassValue<>() {
@Override
protected java.lang.reflect.Method[] computeValue(Class<?> c) {
try {
return new java.lang.reflect.Method[] {c.getMethod("isLeashed"), c.getMethod("getLeashHolder")};
} catch (NoSuchMethodException e) {
return null;
}
}
};
/**
* A leash rope from a leashed entity to its holder (vanilla LeashRenderer endpoints, approximated): the
* entity end at eye height, 0.4·width in front; the holder end at a knot's centre, a humanoid holder's
* right hand, or 0.7·eye height for other holders. Works for any entity exposing
* {@code isLeashed()/getLeashHolder()} (mobs, and boats on versions where they are leashable).
*/
private static ObjectState leash(Entity e) throws ReflectiveOperationException {
Entity holder;
if (e instanceof LivingEntity le) {
if (!le.isLeashed()) return null;
holder = le.getLeashHolder();
} else {
java.lang.reflect.Method[] m = LEASH_METHODS.get(e.getClass());
if (m == null || !(Boolean) m[0].invoke(e)) return null;
holder = (Entity) m[1].invoke(e);
}
if (holder == null) return null;
Location l = e.getLocation();
double yaw = Math.toRadians(e instanceof LivingEntity liv ? liv.getBodyYaw() : l.getYaw());
double eye = e instanceof LivingEntity liv ? liv.getEyeHeight() : e.getHeight() * 0.5;
double fwd = e.getWidth() * 0.4;
double[] from = {l.getX() - Math.sin(yaw) * fwd, l.getY() + eye, l.getZ() + Math.cos(yaw) * fwd};
Location h = holder.getLocation();
double[] to;
if (holder instanceof LeashHitch) {
to = new double[] {h.getX(), h.getY() + 0.2, h.getZ()};
} else if (holder instanceof HumanEntity
|| holder.getType().getKey().getKey().equals("mannequin")) {
double hy = Math.toRadians(holder instanceof LivingEntity hl ? hl.getBodyYaw() : h.getYaw());
double side = 0.35, f = 0.1;
to = new double[] {
h.getX() - Math.cos(hy) * side - Math.sin(hy) * f,
h.getY() + holder.getHeight() * 0.42,
h.getZ() - Math.sin(hy) * side + Math.cos(hy) * f
};
} else {
double he = holder instanceof LivingEntity hl ? hl.getEyeHeight() : holder.getHeight() * 0.5;
to = new double[] {h.getX(), h.getY() + he * 0.7, h.getZ()};
}
return ObjectState.leash(new double[] {from[0], from[1], from[2], to[0], to[1], to[2]});
}
private static ObjectState text(Affine base, TextDisplay td) {
Component c = td.text();
String plain = PlainTextComponentSerializer.plainText().serialize(c);
if (plain.isBlank()) return null;
TextColor color = c.color();
int opacity = td.getTextOpacity() & 0xFF;
if (opacity == 0) opacity = 255; // -1 (default) = fully opaque
if (opacity < 26) opacity = 255; // vanilla treats very low values as opaque too
int rgb = color == null ? 0xFFFFFF : color.value();
int textArgb = (opacity << 24) | rgb;
Color bg = td.getBackgroundColor();
int bgArgb = td.isDefaultBackground() || bg == null ? 0x40000000 : bg.asARGB();
// Vanilla text displays render font pixels at 1/40 block.
Affine toWorld = base.mul(Affine.scale(0.025));
return ObjectState.text(
toWorld, Arrays.asList(plain.split("\n", -1)), td.getLineWidth(), textArgb, bgArgb, td.isShadowed());
}
/**
* Display orientation (vanilla DisplayRenderer): FIXED uses the entity yaw/pitch; CENTER faces the
* camera; VERTICAL turns only around Y towards it; HORIZONTAL keeps the entity yaw, tilts with the camera.
*/
private static Affine billboard(Display d, Location eye) {
Location l = d.getLocation();
double yaw = Math.toRadians(l.getYaw()), pitch = Math.toRadians(l.getPitch());
double camYaw = Math.toRadians(eye.getYaw()), camPitch = Math.toRadians(eye.getPitch());
return switch (d.getBillboard()) {
case FIXED -> Affine.rotY(-yaw).mul(Affine.rotX(pitch));
case CENTER -> Affine.rotY(Math.PI - camYaw).mul(Affine.rotX(-camPitch));
case VERTICAL -> Affine.rotY(Math.PI - camYaw);
case HORIZONTAL -> Affine.rotY(-yaw).mul(Affine.rotX(-camPitch));
};
}
/** Bukkit display transformation = T(translation) · leftRotation · S(scale) · rightRotation. */
private static Affine transformation(Transformation t) {
Affine tr = Affine.translation(
t.getTranslation().x(),
t.getTranslation().y(),
t.getTranslation().z());
return tr.mul(rot(t.getLeftRotation()))
.mul(Affine.scale(
t.getScale().x(), t.getScale().y(), t.getScale().z()))
.mul(rot(t.getRightRotation()));
}
private static Affine rot(Quaternionf q) {
return Affine.fromQuaternion(q.x(), q.y(), q.z(), q.w());
}
}
@@ -93,6 +93,14 @@ public final class ColorUtil {
}
/** Scales the RGB channels by {@code factor} (0..1), keeping alpha. Used for directional face shading. */
/** Texels with alpha at or below this are holes (alpha cutout) for every intersector and the extrusion. */
public static final int ALPHA_CUTOUT = 16;
/** Whether a texel is drawn (not cut out). */
public static boolean opaque(int argb) {
return alpha(argb) > ALPHA_CUTOUT;
}
public static int shade(int argb, double factor) {
int a = alpha(argb);
int r = clamp((int) (red(argb) * factor));
@@ -0,0 +1 @@
{"version":"26.3","models":{"acacia_boat":{"model":"{\"textureSize\":[128,64],\"absolute\":true,\"layer\":\"boat/acacia#main\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,24.0,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"right_paddle\",\"pivot\":[-3.0,29.0,-9.0],\"rotate\":[37.5,-142.1347,11.25],\"boxes\":[{\"coordinates\":[-4.0,27.0,-14.0,2.0,2.0,18.0],\"textureOffset\":[62.0,20.0]},{\"coordinates\":[-4.001,26.0,-1.0,1.0,6.0,7.0],\"textureOffset\":[62.0,20.0]}]},{\"part\":\"left_paddle\",\"pivot\":[-3.0,29.0,9.0],\"rotate\":[37.5,-37.8653,11.25],\"boxes\":[{\"coordinates\":[-4.0,27.0,4.0,2.0,2.0,18.0],\"textureOffset\":[62.0,0.0]},{\"coordinates\":[-2.999,26.0,17.0,1.0,6.0,7.0],\"textureOffset\":[62.0,0.0]}]},{\"part\":\"left\",\"pivot\":[0.0,20.0,9.0],\"rotate\":[0.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,21.0,8.0,28.0,6.0,2.0],\"textureOffset\":[0.0,43.0]}]},{\"part\":\"bottom\",\"pivot\":[0.0,21.0,1.0],\"rotate\":[-90.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,14.0,-2.0,28.0,16.0,3.0],\"textureOffset\":[0.0,0.0]}]},{\"part\":\"back\",\"pivot\":[15.0,20.0,4.0],\"rotate\":[0.0,-270.0,0.0],\"boxes\":[{\"coordinates\":[10.0,21.0,3.0,18.0,6.0,2.0],\"textureOffset\":[0.0,19.0]}]},{\"part\":\"front\",\"pivot\":[-15.0,20.0,0.0],\"rotate\":[0.0,-90.0,0.0],\"boxes\":[{\"coordinates\":[-23.0,21.0,-1.0,16.0,6.0,2.0],\"textureOffset\":[0.0,27.0]}]},{\"part\":\"right\",\"pivot\":[0.0,20.0,-9.0],\"rotate\":[0.0,-180.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,21.0,-10.0,28.0,6.0,2.0],\"textureOffset\":[0.0,35.0]}]}]}]}"},"acacia_boat/waterPatchModel":{"model":"{\"textureSize\":[0,0],\"absolute\":true,\"layer\":\"boat#water_patch\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,24.0,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"water_patch\",\"pivot\":[0.0,27.0,1.0],\"rotate\":[-90.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,20.0,-2.0,28.0,16.0,3.0],\"textureOffset\":[0.0,0.0]}]}]}]}"},"acacia_chest_boat":{"model":"{\"textureSize\":[128,128],\"absolute\":true,\"layer\":\"chest_boat/acacia#main\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,24.0,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"chest_bottom\",\"pivot\":[2.0,29.0,-6.0],\"rotate\":[0.0,90.0,0.0],\"boxes\":[{\"coordinates\":[-10.0,21.0,-6.0,12.0,8.0,12.0],\"textureOffset\":[0.0,76.0]}]},{\"part\":\"right_paddle\",\"pivot\":[-3.0,29.0,-9.0],\"rotate\":[37.5,-142.1347,11.25],\"boxes\":[{\"coordinates\":[-4.0,27.0,-14.0,2.0,2.0,18.0],\"textureOffset\":[62.0,20.0]},{\"coordinates\":[-4.001,26.0,-1.0,1.0,6.0,7.0],\"textureOffset\":[62.0,20.0]}]},{\"part\":\"left_paddle\",\"pivot\":[-3.0,29.0,9.0],\"rotate\":[37.5,-37.8653,11.25],\"boxes\":[{\"coordinates\":[-4.0,27.0,4.0,2.0,2.0,18.0],\"textureOffset\":[62.0,0.0]},{\"coordinates\":[-2.999,26.0,17.0,1.0,6.0,7.0],\"textureOffset\":[62.0,0.0]}]},{\"part\":\"left\",\"pivot\":[0.0,20.0,9.0],\"rotate\":[0.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,21.0,8.0,28.0,6.0,2.0],\"textureOffset\":[0.0,43.0]}]},{\"part\":\"bottom\",\"pivot\":[0.0,21.0,1.0],\"rotate\":[-90.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,14.0,-2.0,28.0,16.0,3.0],\"textureOffset\":[0.0,0.0]}]},{\"part\":\"back\",\"pivot\":[15.0,20.0,4.0],\"rotate\":[0.0,-270.0,0.0],\"boxes\":[{\"coordinates\":[10.0,21.0,3.0,18.0,6.0,2.0],\"textureOffset\":[0.0,19.0]}]},{\"part\":\"chest_lid\",\"pivot\":[2.0,33.0,-6.0],\"rotate\":[0.0,90.0,0.0],\"boxes\":[{\"coordinates\":[-10.0,29.0,-6.0,12.0,4.0,12.0],\"textureOffset\":[0.0,59.0]}]},{\"part\":\"front\",\"pivot\":[-15.0,20.0,0.0],\"rotate\":[0.0,-90.0,0.0],\"boxes\":[{\"coordinates\":[-23.0,21.0,-1.0,16.0,6.0,2.0],\"textureOffset\":[0.0,27.0]}]},{\"part\":\"right\",\"pivot\":[0.0,20.0,-9.0],\"rotate\":[0.0,-180.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,21.0,-10.0,28.0,6.0,2.0],\"textureOffset\":[0.0,35.0]}]},{\"part\":\"chest_lock\",\"pivot\":[1.0,30.0,-1.0],\"rotate\":[0.0,90.0,0.0],\"boxes\":[{\"coordinates\":[-1.0,26.0,-1.0,2.0,4.0,1.0],\"textureOffset\":[0.0,59.0]}]}]}]}"},"acacia_chest_boat/waterPatchModel":{"model":"{\"textureSize\":[0,0],\"absolute\":true,\"layer\":\"boat#water_patch\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,24.0,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"water_patch\",\"pivot\":[0.0,27.0,1.0],\"rotate\":[-90.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-14.0,20.0,-2.0,28.0,16.0,3.0],\"textureOffset\":[0.0,0.0]}]}]}]}"},"allay":{"model":"{\"textureSize\":[32,32],\"absolute\":true,\"layer\":\"allay#main\",\"models\":[{\"part\":\"root\",\"pivot\":[0.0,0.25,0.0],\"rotate\":[0.0,0.0,0.0],\"submodels\":[{\"part\":\"head\",\"pivot\":[0.0,4.24,0.0],\"rotate\":[0.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-2.5,4.24,-2.5,5.0,5.0,5.0],\"textureOffset\":[0.0,0.0]}]},{\"part\":\"body\",\"pivot\":[0.0,4.25,0.0],\"rotate\":[0.0,0.0,0.0],\"boxes\":[{\"coordinates\":[-1.5,0.25,-1.0,3.0,4.0,2.0],\"textureOffset\":[0.0,10.0]},{\"coordinates\":[-1.5,-0.75,-1.0,3.0,5.0,2.0],\"textureOffset\":[0.0,16.0],\"sizeAdd\":-0.2}],\"submodels\":[{\"part\":\"right_arLine truncated
@@ -0,0 +1,19 @@
{
"providers": [
{
"type": "reference",
"id": "minecraft:include/space"
},
{
"type": "reference",
"id": "minecraft:include/default",
"filter": {
"uniform": false
}
},
{
"type": "reference",
"id": "minecraft:include/unifont"
}
]
}
@@ -0,0 +1,184 @@
{
"providers": [
{
"type": "bitmap",
"file": "minecraft:font/nonlatin_european.png",
"ascent": 7,
"chars": [
"\u00a1\u2030\u00ad\u00b7\u20b4\u2260\u00bf\u00d7\u00d8\u00de\u04bb\u00f0\u00f8\u00fe\u0391\u0392",
"\u0393\u0394\u0395\u0396\u0397\u0398\u0399\u039a\u039b\u039c\u039d\u039e\u039f\u03a0\u03a1\u03a3",
"\u03a4\u03a5\u03a6\u03a7\u03a8\u03a9\u03b1\u03b2\u03b3\u03b4\u03b5\u03b6\u03b7\u03b8\u03b9\u03ba",
"\u03bb\u03bc\u03bd\u03be\u03bf\u03c0\u03c1\u03c2\u03c3\u03c4\u03c5\u03c6\u03c7\u03c8\u03c9\u0402",
"\u0405\u0406\u0408\u0409\u040a\u040b\u0410\u0411\u0412\u0413\u0414\u0415\u0416\u0417\u0418\u041a",
"\u041b\u041c\u041d\u041e\u041f\u0420\u0421\u0422\u0423\u0424\u0425\u0426\u0427\u0428\u0429\u042a",
"\u042b\u042c\u042d\u042e\u042f\u0430\u0431\u0432\u0433\u0434\u0435\u0436\u0437\u0438\u043a\u043b",
"\u043c\u043d\u043e\u043f\u0440\u0441\u0442\u0443\u0444\u0445\u0446\u0447\u0448\u0449\u044a\u044b",
"\u044c\u044d\u044e\u044f\u0454\u0455\u0456\u0458\u0459\u045a\u2013\u2014\u2018\u2019\u201c\u201d",
"\u201e\u2026\u204a\u2190\u2191\u2192\u2193\u21c4\uff0b\u018f\u0259\u025b\u026a\u04ae\u04af\u04e8",
"\u04e9\u02bb\u02cc\u037e\u0138\u1e9e\u00df\u20bd\u20ac\u0462\u0463\u0474\u0475\u04c0\u0472\u0473",
"\u2070\u00b9\u00b3\u2074\u2075\u2076\u2077\u2078\u2079\u207a\u207b\u207c\u207d\u207e\u2071\u2122",
"\u0294\u0295\u29c8\u2694\u2620\u049a\u049b\u0492\u0493\u04b0\u04b1\u04d8\u04d9\u0496\u0497\u04a2",
"\u04a3\u04ba\u05d0\u05d1\u05d2\u05d3\u05d4\u05d5\u05d6\u05d7\u05d8\u05d9\u05db\u05dc\u05de\u05dd",
"\u05e0\u05df\u05e1\u05e2\u05e4\u05e3\u05e6\u05e5\u05e7\u05e8\u00a2\u00a4\u00a5\u00a9\u00ae\u00b5",
"\u00b6\u00bc\u00bd\u00be\u0387\u2010\u201a\u2020\u2021\u2022\u2031\u2032\u2033\u2034\u2035\u2036",
"\u2037\u2039\u203a\u203b\u203c\u203d\u2042\u2048\u2049\u204b\u204e\u204f\u2051\u2052\u2057\u2117",
"\u2212\u2213\u221e\u2600\u2601\u2608\u0404\u2632\u2635\u263d\u2640\u2642\u26a5\u2660\u2663\u2665",
"\u2666\u2669\u266a\u266b\u266c\u266d\u266e\u266f\u2680\u2681\u2682\u2683\u2684\u2685\u02ac\u26a1",
"\u26cf\u2714\u2744\u274c\u2764\u2b50\u2e18\u2e2e\u2e35\u2e38\u2e41\u2e4b\u295d\u1614\u0190\u07c8",
"\u03db\u3125\u2c6f\u15fa\u0186\u15e1\u018e\u2132\u2141\ua7b0\ua780\u0500\ua779\u1d1a\u27d8\u2229",
"\u0245\u2144\u0250\u0254\u01dd\u025f\u1d77\u0265\u1d09\u027e\u029e\ua781\u026f\u0279\u0287\u028c",
"\u028d\u028e\u0531\u0532\u0533\u0534\u0536\u0537\u0539\u053a\u053b\u053c\u053d\u053e\u053f\u0540",
"\u0541\u0542\u0543\u0544\u0545\u0546\u0547\u0548\u0549\u054b\u054c\u054d\u054e\u054f\u0550\u0551",
"\u0552\u0553\u0554\u0555\u0556\u0559\u0561\u0562\u0563\u0564\u0565\u0566\u0567\u0568\u0569\u056a",
"\u056b\u056c\u056d\u056e\u056f\u0570\u0571\u0572\u0573\u0574\u0575\u0576\u0577\u0578\u0579\u057a",
"\u057b\u057c\u057d\u057e\u057f\u0580\u0581\u0582\u0583\u0584\u0585\u0586\u0587\u05e9\u05ea\u0538",
"\u055a\u055b\u055c\u055d\u055e\u055f\u0560\u0588\u058f\u00af\u017f\u01b7\u0292\u01f7\u01bf\u021c",
"\u021d\u0224\u0225\u02d9\ua75a\ua75b\u2011\u214b\u23cf\u23e9\u23ea\u23ed\u23ee\u23ef\u23f4\u23f5",
"\u23f6\u23f7\u23f8\u23f9\u23fa\u23fb\u23fc\u23fd\u2b58\u25b2\u25b6\u25bc\u25c0\u25cf\u25e6\u25d8",
"\u2693\u26e8\u0132\u0133\u01c9\ua728\ua729\ua739\ua73b\ufb00\ufb01\ufb02\ufb03\ufb05\ufffd\u0535",
"\u054a\u16a0\u16a2\u16a3\u16a4\u16a5\u16a6\u16a7\u16a8\u16a9\u16aa\u16ab\u16ac\u16ad\u16ae\u16af",
"\u16b0\u16b1\u16b2\u16b3\u16b4\u16b6\u16b7\u16b8\u16b9\u16ba\u16bb\u16bc\u16bd\u16be\u16bf\u16c0",
"\u16c1\u16c2\u16c3\u16c4\u16c5\u16c6\u16c7\u16c8\u16c9\u16ca\u16cb\u16cc\u16cd\u16ce\u16cf\u16d0",
"\u16d1\u16d2\u16d3\u16d4\u16d5\u16d6\u16d7\u16d8\u16d9\u16da\u16db\u16dc\u16dd\u16de\u16df\u16e0",
"\u16e1\u16e2\u16e3\u16e4\u16e5\u16e6\u16e7\u16e8\u16e9\u16ea\u16eb\u16ec\u16ed\u16ee\u16ef\u16f0",
"\u16f1\u16f2\u16f3\u16f4\u16f5\u16f6\u16f7\u16f8\u263a\u263b\u00a6\u2639\u05da\u05f3\u05f4\u05f0",
"\u05f1\u05f2\u05be\u05c3\u05c6\u00b4\u00a8\u1d00\u0299\u1d04\u1d05\u1d07\ua730\u0262\u029c\u1d0a",
"\u1d0b\u029f\u1d0d\u0274\u1d0f\u1d18\ua7af\u0280\ua731\u1d1b\u1d1c\u1d20\u1d21\u028f\u1d22\u00a7",
"\u0271\u0273\u0272\u0288\u0256\u0261\u02a1\u0255\u0291\u0278\u029d\u02a2\u027b\u0281\u0266\u028b",
"\u0270\u026c\u026e\u0298\u01c0\u01c3\u01c2\u01c1\u0253\u0257\u1d91\u0284\u0260\u029b\u0267\u026b",
"\u0268\u0289\u028a\u0258\u0275\u0264\u025c\u025e\u0251\u0252\u025a\u025d\u0181\u0189\u0191\u01a9",
"\u01b2\u10a0\u10a1\u10a2\u10a3\u10a4\u10a5\u10a6\u10a7\u10a8\u10a9\u10aa\u10ab\u10ac\u10ad\u10ae",
"\u10af\u10b0\u10b1\u10b2\u10b3\u10b4\u10b5\u10b6\u10b7\u10b8\u10b9\u10ba\u10bb\u10bc\u10bd\u10be",
"\u10bf\u10c0\u10c1\u10c2\u10c3\u10c4\u10c5\u10c7\u10cd\u10d0\u10d1\u10d2\u10d3\u10d4\u10d5\u10d6",
"\u10d7\u10d8\u10d9\u10da\u10db\u10dc\u10dd\u10de\u10df\u10e0\u10e1\u10e2\u10e3\u10e4\u10e5\u10e6",
"\u10e7\u10e8\u10e9\u10ea\u10eb\u10ec\u10ed\u10ee\u10ef\u10f0\u10f1\u10f2\u10f3\u10f4\u10f5\u10f6",
"\u10f7\u10f8\u10f9\u10fa\u10fb\u10fc\u10fd\u10fe\u10ff\ufb4a\ufb2b\ufb4e\ufb44\ufb3b\ufb1f\ufb1d",
"\ufb4b\ufb35\ufb4c\ufb31\ua727\ua726\u027a\u2c71\u02a0\u0297\u0296\u026d\u0277\u027f\u0285\u0286",
"\u0293\u029a\u20aa\u20be\u058a\u2d00\u2d01\u2d02\u2d03\u2d04\u2d05\u2d06\u2d21\u2d07\u2d08\u2d09",
"\u2d0a\u2d0b\u2d0c\u2d22\u2d0d\u2d0e\u2d0f\u2d10\u2d11\u2d12\u2d23\u2d13\u2d14\u2d15\u2d16\u2d17",
"\u2d18\u2d19\u2d1a\u2d1b\u2d1c\u2d1d\u2d1e\u2d24\u2d1f\u2d20\u2d25\u215b\u215c\u215d\u215e\u2153",
"\u2154\u2709\u2602\u2614\u2604\u26c4\u2603\u231b\u231a\u2690\u270e\u2763\u2664\u2667\u2661\u2662",
"\u26c8\u2630\u2631\u2633\u2634\u2636\u2637\u2194\u21d2\u21cf\u21d4\u21f5\u2200\u2203\u2204\u2209",
"\u220b\u220c\u2282\u2283\u2284\u2285\u2227\u2228\u22bb\u22bc\u22bd\u2225\u2262\u22c6\u2211\u22a4",
"\u22a5\u22a2\u22a8\u2254\u2201\u2234\u2235\u221b\u221c\u2202\u22c3\u2286\u2287\u25a1\u25b3\u25b7",
"\u25bd\u25c1\u25c6\u25c7\u25cb\u25ce\u2606\u2605\u2718\u2080\u2081\u2082\u2083\u2084\u2085\u2086",
"\u2087\u2088\u2089\u208a\u208b\u208c\u208d\u208e\u222b\u222e\u221d\u2300\u2302\u2318\u3012\u027c",
"\u0184\u0185\u1e9f\u023d\u019a\u019b\u0220\u019e\u019f\u01a7\u01a8\u01aa\u01b8\u01b9\u01bb\u01bc",
"\u01bd\u01be\u0221\u0234\u0235\u0236\u023a\u2c65\u023b\u023c\u0246\u0247\u023e\u2c66\u0241\u0242",
"\u0243\u0244\u0248\u0249\u024a\u024b\u024c\u024d\u024e\u024f\u1e9c\u1e9d\u1efc\u1efd\u1efe\u1eff",
"\ua7a8\ua7a9\ud800\udf30\ud800\udf31\ud800\udf32\ud800\udf33\ud800\udf34\ud800\udf35\ud800\udf36\ud800\udf37\ud800\udf38\ud800\udf39\ud800\udf3a\ud800\udf3b\ud800\udf3c\ud800\udf3d",
"\ud800\udf3e\ud800\udf3f\ud800\udf40\ud800\udf41\ud800\udf42\ud800\udf43\ud800\udf44\ud800\udf45\ud800\udf46\ud800\udf47\ud800\udf48\ud800\udf49\ud800\udf4a\ud83c\udf27\ud83d\udd25\ud83c\udf0a",
"\u2150\u2151\u2155\u2156\u2157\u2159\u215a\u215f\u2189\ud83d\udde1\ud83c\udff9\ud83e\ude93\ud83d\udd31\ud83c\udfa3\ud83e\uddea\u2697",
"\u2bea\u2beb\u2c6d\ud83d\udee1\u2702\ud83c\udf56\ud83e\udea3\ud83d\udd14\u23f3\u2691\u20a0\u20a1\u20a2\u20a3\u20a4\u20a5",
"\u20a6\u20a9\u20ab\u20ad\u20ae\u20b0\u20b1\u20b2\u20b3\u20b5\u20b6\u20b7\u20b8\u20b9\u20ba\u20bb",
"\u20bc\u20bf\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000"
]
},
{
"type": "bitmap",
"file": "minecraft:font/accented.png",
"height": 12,
"ascent": 10,
"chars": [
"\u00c0\u00c1\u00c2\u00c3\u00c4\u00c5\u00c6\u00c7\u00c8\u00c9\u00ca\u00cb\u00cc\u00cd\u00ce\u00cf",
"\u00d0\u00d1\u00d2\u00d3\u00d4\u00d5\u00d6\u00d9\u00da\u00db\u00dc\u00dd\u00e0\u00e1\u00e2\u00e3",
"\u00e4\u00e5\u00e6\u00e7\u00ec\u00ed\u00ee\u00ef\u00f1\u00f2\u00f3\u00f4\u00f5\u00f6\u00f9\u00fa",
"\u00fb\u00fc\u00fd\u00ff\u0100\u0101\u0102\u0103\u0104\u0105\u0106\u0107\u0108\u0109\u010a\u010b",
"\u010c\u010d\u010e\u010f\u0110\u0111\u0112\u0113\u0114\u0115\u0116\u0117\u0118\u0119\u011a\u011b",
"\u011c\u011d\u1e20\u1e21\u011e\u011f\u0120\u0121\u0122\u0123\u0124\u0125\u0126\u0127\u0128\u0129",
"\u012a\u012b\u012c\u012d\u012e\u012f\u0130\u0131\u0134\u0135\u0136\u0137\u0139\u013a\u013b\u013c",
"\u013d\u013e\u013f\u0140\u0141\u0142\u0143\u0144\u0145\u0146\u0147\u0148\u014a\u014b\u014c\u014d",
"\u014e\u014f\u0150\u0151\u0152\u0153\u0154\u0155\u0156\u0157\u0158\u0159\u015a\u015b\u015c\u015d",
"\u015e\u015f\u0160\u0161\u0162\u0163\u0164\u0165\u0166\u0167\u0168\u0169\u016a\u016b\u016c\u016d",
"\u016e\u016f\u0170\u0171\u0172\u0173\u0174\u0175\u0176\u0177\u0178\u0179\u017a\u017b\u017c\u017d",
"\u017e\u01fc\u01fd\u01fe\u01ff\u0218\u0219\u021a\u021b\u0386\u0388\u0389\u038a\u038c\u038e\u038f",
"\u0390\u03aa\u03ab\u03ac\u03ad\u03ae\u03af\u03b0\u03ca\u03cb\u03cc\u03cd\u03ce\u0400\u0401\u0403",
"\u0407\u040c\u040d\u040e\u0419\u0439\u0450\u0451\u0452\u0453\u0457\u045b\u045c\u045d\u045e\u045f",
"\u0490\u0491\u1e02\u1e03\u1e0a\u1e0b\u1e1e\u1e1f\u1e22\u1e23\u1e30\u1e31\u1e40\u1e41\u1e56\u1e57",
"\u1e60\u1e61\u1e6a\u1e6b\u1e80\u1e81\u1e82\u1e83\u1e84\u1e85\u1ef2\u1ef3\u00e8\u00e9\u00ea\u00eb",
"\u0149\u01e7\u01eb\u040f\u1e0d\u1e25\u1e5b\u1e6d\u1e92\u1eca\u1ecb\u1ecc\u1ecd\u1ee4\u1ee5\u2116",
"\u0207\u0194\u0263\u0283\u2047\u01f1\u01f2\u01f3\u01c4\u01c5\u01c6\u01c7\u01c8\u01ca\u01cb\u01cc",
"\u2139\u1d6b\ua732\ua733\ua734\ua735\ua736\ua737\ua738\ua73a\ua73c\ua73d\ua74e\ua74f\ua760\ua761",
"\ufb04\ufb06\u16a1\u16b5\u01a0\u01a1\u01af\u01b0\u1eae\u1eaf\u1ea4\u1ea5\u1ebe\u1ebf\u1ed1\u1eda",
"\u1edb\u1ee8\u1ee9\u1eb0\u1eb1\u1ea6\u1ea7\u1ec0\u1ec1\u1ed3\u1edc\u1edd\u1eea\u1eeb\u1ea2\u1ea3",
"\u1eb2\u1eb3\u1ea8\u1ea9\u1eba\u1ebb\u1ed5\u1ede\u1ec2\u1ec3\u1ec8\u1ec9\u1ece\u1ecf\u1ed4\u1edf",
"\u1ee6\u1ee7\u1eec\u1eed\u1ef6\u1ef7\u1ea0\u1ea1\u1eb6\u1eb7\u1eac\u1ead\u1eb8\u1eb9\u1ec6\u1ec7",
"\u1ed8\u1ed9\u1ee2\u1ee3\u1ef0\u1ef1\u1ef4\u1ef5\u1ed0\u0195\u1eaa\u1eab\u1ed6\u1ed7\u1eef\u261e",
"\u261c\u262e\u1eb4\u1eb5\u1ebc\u1ebd\u1ec4\u1ec5\u1ed2\u1ee0\u1ee1\u1eee\u1ef8\u1ef9\u0498\u0499",
"\u04a0\u04a1\u04aa\u04ab\u01f6\u26a0\u24ea\u2460\u2461\u2462\u2463\u2464\u2465\u2466\u2467\u2468",
"\u2469\u246a\u246b\u246c\u246d\u246e\u246f\u2470\u2471\u2472\u2473\u24b6\u24b7\u24b8\u24b9\u24ba",
"\u24bb\u24bc\u24bd\u24be\u24bf\u24c0\u24c1\u24c2\u24c3\u24c4\u24c5\u24c6\u24c7\u24c8\u24c9\u24ca",
"\u24cb\u24cc\u24cd\u24ce\u24cf\u24d0\u24d1\u24d2\u24d3\u24d4\u24d5\u24d6\u24d7\u24d8\u24d9\u24da",
"\u24db\u24dc\u24dd\u24de\u24df\u24e0\u24e1\u24e2\u24e3\u24e4\u24e5\u24e6\u24e7\u24e8\u24e9\u0327",
"\u0282\u0290\u0276\u01cd\u01ce\u01de\u01df\u01fa\u01fb\u0202\u0203\u0226\u0227\u01e0\u01e1\u1e00",
"\u1e01\u0200\u0201\u1e06\u1e07\u1e04\u1e05\u1d6c\u1e08\u1e09\u1e10\u1e11\u1e12\u1e13\u1e0e\u1e0f",
"\u1e0c\u1d6d\u1e14\u1e15\u1e16\u1e17\u1e18\u1e19\u1e1c\u1e1d\u0228\u0229\u1e1a\u1e1b\u0204\u0205",
"\u0206\u1d6e\u01f4\u01f5\u01e6\u1e26\u1e27\u1e28\u1e29\u1e2a\u1e2b\u021e\u021f\u1e24\u1e96\u1e2e",
"\u1e2f\u020a\u020b\u01cf\u01d0\u0208\u0209\u1e2c\u1e2d\u01f0\u0237\u01e8\u01e9\u1e32\u1e33\u1e34",
"\u1e35\u1e3a\u1e3b\u1e3c\u1e3d\u1e36\u1e37\u1e38\u1e39\u2c62\u1e3e\u1e3f\u1e42\u1e43\u1d6f\u1e44",
"\u1e45\u1e46\u1e47\u1e4a\u1e4b\u01f8\u01f9\u1e48\u1e49\u1d70\u01ec\u01ed\u022c\u022d\u1e4c\u1e4d",
"\u1e4e\u1e4f\u1e50\u1e51\u1e52\u1e53\u020e\u020f\u022a\u022b\u01d1\u01d2\u022e\u022f\u0230\u0231",
"\u020c\u020d\u01ea\u1e54\u1e55\u1d71\u0212\u0213\u1e58\u1e59\u1e5c\u1e5d\u1e5e\u1e5f\u0210\u0211",
"\u1e5a\u1d73\u1d72\u1e64\u1e65\u1e66\u1e67\u1e62\u1e63\u1e68\u1e69\u1d74\u1e70\u1e71\u1e6e\u1e6f",
"\u1e6c\u1e97\u1d75\u1e72\u1e73\u1e76\u1e77\u1e78\u1e79\u1e7a\u1e7b\u01d3\u01d4\u01d5\u01d6\u01d7",
"\u01d8\u01d9\u01da\u01db\u01dc\u1e74\u1e75\u0214\u0215\u0216\u1e7e\u1e7f\u1e7c\u1e7d\u1e86\u1e87",
"\u1e88\u1e89\u1e98\u1e8c\u1e8d\u1e8a\u1e8b\u0232\u0233\u1e8e\u1e8f\u1e99\u1e94\u1e95\u1e90\u1e91",
"\u1e93\u1d76\u01ee\u01ef\u1e9b\ua73e\ua73f\u01e2\u01e3\u1d7a\u1efb\u1d02\u1d14\uab63\u0238\u02a3",
"\u02a5\u02a4\u02a9\u02aa\u02ab\u0239\u02a8\u02a6\u02a7\uab50\uab51\u20a7\u1efa\ufb2e\ufb2f\u0180",
"\u0182\u0183\u0187\u0188\u018a\u018b\u018c\u0193\u01e4\u01e5\u0197\u0196\u0269\u0198\u0199\u019d",
"\u01a4\u01a5\u027d\u01a6\u01ac\u01ad\u01ab\u01ae\u0217\u01b1\u019c\u01b3\u01b4\u01b5\u01b6\u01a2",
"\u01a3\u0222\u0223\u02ad\u02ae\u02af\ufb14\ufb15\ufb17\ufb16\ufb13\u04d0\u04d1\u04d2\u04d3\u04f6",
"\u04f7\u0494\u0495\u04d6\u04d7\u04bc\u04bd\u04be\u04bf\u04da\u04db\u04dc\u04dd\u04c1\u04c2\u04de",
"\u04df\u04e2\u04e3\u04e4\u04e5\u04e6\u04e7\u04ea\u04eb\u04f0\u04f1\u04ee\u04ef\u04f2\u04f3\u04f4",
"\u04f5\u04f8\u04f9\u04ec\u04ed\u0476\u0477\u04d4\u04fa\u0502\ua682\ua680\ua688\u052a\u052c\ua684",
"\u0504\u0510\u04e0\u0506\u048a\u04c3\u049e\u049c\u051e\u051a\u04c5\u052e\u0512\u0520\u0508\u0514",
"\u04cd\u04c9\u0528\u04c7\u04a4\u0522\u050a\u04a8\u0524\u04a6\u048e\u0516\u050c\ua690\u04ac\ua68a",
"\ua68c\u050e\u04b2\u04fc\u04fe\u0526\ua694\u04b4\ua68e\u04b6\u04cb\u04b8\ua692\ua696\ua686\u048c",
"\u0518\u051c\u04d5\u04fb\u0503\ua683\ua681\ua689\u052b\u052d\ua685\u0505\u0511\u04e1\u0507\u048b",
"\u04c4\u049f\u049d\u051f\u051b\u04c6\u052f\u0513\u0521\u0509\u0515\u04ce\u04ca\u0529\u04c8\u04a5",
"\u0523\u050b\u04a9\u0525\u04a7\u048f\u0517\u050d\ua691\u04ad\ua68b\ua68d\u050f\u04b3\u04fd\u04ff",
"\u0527\ua695\u04b5\ua68f\u04b7\u04cc\u04b9\ua693\ua697\ua687\u048d\u0519\u051d\u1f08\u1f00\u1f09",
"\u1f01\u1f0a\u1f02\u1f0b\u1f03\u1f0c\u1f04\u1f0d\u1f05\u1f0e\u1f06\u1f0f\u1f07\u1fba\u1f70\u1fb8",
"\u1fb0\u1fb9\u1fb1\u1fbb\u1f71\u1f88\u1f80\u1f89\u1f81\u1f8a\u1f82\u1f8b\u1f83\u1f8c\u1f84\u1f8d",
"\u1f85\u1f8e\u1f86\u1f8f\u1f87\u1fbc\u1fb4\u1fb6\u1fb7\u1fb2\u1fb3\u1f18\u1f10\u1f19\u1f11\u1f1a",
"\u1f12\u1f1b\u1f13\u1f1c\u1f14\u1f1d\u1f15\u1fc8\u1fc9\u1f72\u1f73\u1f28\u1f20\u1fca\u1f74\u1f29",
"\u1f21\u1f2a\u1f22\u1f2b\u1f23\u1f2c\u1f24\u1f2d\u1f25\u1f2e\u1f26\u1f2f\u1f27\u1f98\u1f90\u1f99",
"\u1f91\u1f9a\u1f92\u1f9b\u1f93\u1f9c\u1f94\u1f9d\u1f95\u1f9e\u1f96\u1f9f\u1f97\u1fcb\u1f75\u1fcc",
"\u1fc3\u1fc2\u1fc4\u1fc6\u1fc7\u1fda\u1f76\u1fdb\u1f77\u1f38\u1f30\u1f39\u1f31\u1f3a\u1f32\u1f3b",
"\u1f33\u1f3c\u1f34\u1f3d\u1f35\u1f3e\u1f36\u1f3f\u1f37\u1fd8\u1fd0\u1fd9\u1fd1\u1fd2\u1fd3\u1fd6",
"\u1fd7\u1ff8\u1f78\u1ff9\u1f79\u1f48\u1f40\u1f49\u1f41\u1f4a\u1f42\u1f4b\u1f43\u1f4c\u1f44\u1f4d",
"\u1f45\u1fec\u1fe4\u1fe5\u1fea\u1f7a\u1feb\u1f7b\u1f59\u1f51\u1f5b\u1f53\u1f5d\u1f55\u1f5f\u1f57",
"\u1fe8\u1fe0\u1fe9\u1fe1\u03d3\u03d4\u1fe2\u1fe3\u1fe7\u1f50\u1f52\u1f54\u1fe6\u1f56\u1ffa\u1f7c",
"\u1ffb\u1f7d\u1f68\u1f60\u1f69\u1f61\u1f6a\u1f62\u1f6b\u1f63\u1f6c\u1f64\u1f6d\u1f65\u1f6e\u1f66",
"\u1f6f\u1f67\u1fa8\u1fa0\u1fa9\u1fa1\u1faa\u1fa2\u1fab\u1fa3\u1fac\u1fa4\u1fad\u1fa5\u1fae\u1fa6",
"\u1faf\u1fa7\u1ffc\u1ff3\u1ff2\u1ff4\u1ff6\u1ff7\u262f\u2610\u2611\u2612\u018d\u01ba\u2c7e\u023f",
"\u2c7f\u0240\u1d80\ua7c4\ua794\u1d81\u1d82\u1d83\ua795\u1d84\u1d85\u1d86\u1d87\u1d88\u1d89\u1d8a",
"\u1d8b\u1d8c\u1d8d\ua7c6\u1d8e\u1d8f\u1d90\u1d92\u1d93\u1d94\u1d95\u1d96\u1d97\u1d98\u1d99\u1d9a",
"\u1e9a\u2152\u2158\u20a8\u20af\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000"
]
},
{
"type": "bitmap",
"file": "minecraft:font/ascii.png",
"ascent": 7,
"chars": [
"\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000",
"\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000",
"\u0020\u0021\u0022\u0023\u0024\u0025\u0026\u0027\u0028\u0029\u002a\u002b\u002c\u002d\u002e\u002f",
"\u0030\u0031\u0032\u0033\u0034\u0035\u0036\u0037\u0038\u0039\u003a\u003b\u003c\u003d\u003e\u003f",
"\u0040\u0041\u0042\u0043\u0044\u0045\u0046\u0047\u0048\u0049\u004a\u004b\u004c\u004d\u004e\u004f",
"\u0050\u0051\u0052\u0053\u0054\u0055\u0056\u0057\u0058\u0059\u005a\u005b\u005c\u005d\u005e\u005f",
"\u0060\u0061\u0062\u0063\u0064\u0065\u0066\u0067\u0068\u0069\u006a\u006b\u006c\u006d\u006e\u006f",
"\u0070\u0071\u0072\u0073\u0074\u0075\u0076\u0077\u0078\u0079\u007a\u007b\u007c\u007d\u007e\u0000",
"\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000",
"\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u00a3\u0000\u0000\u0192",
"\u0000\u0000\u0000\u0000\u0000\u0000\u00aa\u00ba\u0000\u0000\u00ac\u0000\u0000\u0000\u00ab\u00bb",
"\u2591\u2592\u2593\u2502\u2524\u2561\u2562\u2556\u2555\u2563\u2551\u2557\u255d\u255c\u255b\u2510",
"\u2514\u2534\u252c\u251c\u2500\u253c\u255e\u255f\u255a\u2554\u2569\u2566\u2560\u2550\u256c\u2567",
"\u2568\u2564\u2565\u2559\u2558\u2552\u2553\u256b\u256a\u2518\u250c\u2588\u2584\u258c\u2590\u2580",
"\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u2205\u2208\u0000",
"\u2261\u00b1\u2265\u2264\u2320\u2321\u00f7\u2248\u00b0\u2219\u0000\u221a\u207f\u00b2\u25a0\u0000"
]
}
]
}
@@ -0,0 +1,11 @@
{
"providers": [
{
"type": "space",
"advances": {
" ": 4,
"\u200c": 0
}
}
]
}
+2 -1
View File
@@ -57,7 +57,8 @@ public class BlockEntityTestRender {
BlockModelRegistry registry = new BlockModelRegistry(reader, textures);
BiomeTintProvider tint = new BiomeTintProvider(textures);
CemModelLoader geo = new CemModelLoader();
geo.load(new java.io.FileInputStream("/tmp/cem_models.json"), log);
geo.load(new java.io.FileInputStream("src/main/resources/cem/cem_template_models.json"), log);
geo.load(new java.io.FileInputStream("src/main/resources/cem/vanilla_entity_models.json"), log);
SkinCache skins = new SkinCache();
eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont font =
eu.mhsl.minecraft.pixelpics.assets.font.FontLoader.load(pack, textures, log);
+19 -10
View File
@@ -39,7 +39,9 @@ public class EntityTestRender {
"slime","sniffer","snow_golem","spider","squid","stray","strider","tadpole","trader_llama","tropical_fish",
"turtle","vex","villager","vindicator","wandering_trader","warden","witch","wither","wither_skeleton","wolf",
"zoglin","zombie","zombie_horse","zombie_villager","zombified_piglin","ender_dragon","player","mannequin",
"nautilus","zombie_nautilus_coral","parched","camel_husk","oak_boat"
"nautilus","zombie_nautilus","parched","camel_husk","oak_boat","sulfur_cube","oak_chest_boat","bamboo_raft",
"baby:zombie","baby:cow","baby:villager","baby:wolf","baby:sheep","baby:chicken","baby:horse","baby:pig",
"minecart","end_crystal","arrow","trident","leash_knot","evoker_fangs","wither_skull","shulker_bullet","cushion"
};
// Representative variants (the game always supplies these; null base textures wouldn't exist otherwise).
@@ -49,7 +51,8 @@ public class EntityTestRender {
Map.entry("llama", "creamy"), Map.entry("trader_llama", "creamy"), Map.entry("fox", "red"),
Map.entry("mooshroom", "red"), Map.entry("frog", "temperate"), Map.entry("panda", "normal"),
Map.entry("cow", "temperate"), Map.entry("pig", "temperate"), Map.entry("chicken", "temperate"),
Map.entry("villager", "taiga"), Map.entry("zombie_villager", "swamp")
Map.entry("villager", "taiga"), Map.entry("zombie_villager", "swamp"),
Map.entry("tropical_fish", "betty"), Map.entry("pufferfish", "big"), Map.entry("zombie_nautilus", "warm")
);
// Villager profession / level for the standalone render (biome type comes from VAR above).
@@ -62,10 +65,11 @@ public class EntityTestRender {
// Horse/llama/donkey equipment for the standalone render.
static final Map<String, String> MARK = Map.of("horse", "blackdots"); // coat markings
static final java.util.Set<String> SADDLE = java.util.Set.of("horse", "donkey", "mule", "nautilus");
static final java.util.Set<String> SADDLE = java.util.Set.of("horse", "donkey", "mule", "nautilus", "pig", "strider", "camel");
static final java.util.Set<String> CHEST = java.util.Set.of("llama", "donkey");
static final Map<String, String> EQUIP = Map.ofEntries( // armor / carpet
Map.entry("horse", "diamond"), Map.entry("llama", "red"), Map.entry("trader_llama", "trader_llama"),
Map.entry("horse", "diamond"), Map.entry("llama", "red"), Map.entry("happy_ghast", "blue_harness"),
Map.entry("wolf", "armadillo_scute"), Map.entry("trader_llama", "trader_llama"),
Map.entry("nautilus", "diamond")
);
@@ -105,7 +109,8 @@ public class EntityTestRender {
BlockModelRegistry registry = new BlockModelRegistry(reader, textures);
BiomeTintProvider tint = new BiomeTintProvider(textures);
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader geo = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader();
int n = geo.load(new java.io.FileInputStream("/tmp/cem_models.json"), log);
int n = geo.load(new java.io.FileInputStream("src/main/resources/cem/cem_template_models.json"), log);
n = geo.load(new java.io.FileInputStream("src/main/resources/cem/vanilla_entity_models.json"), log);
log.info("Loaded " + n + " geometries");
SkinCache skins = new SkinCache();
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker baker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(geo, textures, skins);
@@ -145,7 +150,7 @@ public class EntityTestRender {
File single = new File(outDir, "einzeln");
single.mkdirs();
for (int i = 0; i < list.length; i++) {
ImageIO.write(cells.get(i), "png", new File(single, list[i] + ".png"));
ImageIO.write(cells.get(i), "png", new File(single, list[i].replace(':', '_') + ".png"));
}
// Compose contact-sheet pages: 2 columns.
@@ -172,11 +177,15 @@ public class EntityTestRender {
static BufferedImage renderEntity(DefaultScreenRenderer renderer, eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker baker, WorldSnapshot world,
SkyContext sky, String key, float yaw) {
boolean baby = key.startsWith("baby:");
if (baby) key = key.substring(5);
boolean isPlayer = key.equals("player");
EntityState s = new EntityState(key, 0, 0, 0, yaw, false, 0.8, 1.0,
isPlayer, null, false, VAR.get(key), 0, 1.0, PROF.get(key), LVL.getOrDefault(key, 0),
MARK.get(key), SADDLE.contains(key), CHEST.contains(key), EQUIP.get(key), ARMOR.get(key),
INVISIBLE.contains(key));
int tint = key.equals("tropical_fish") ? 0xFFF9801D : (key.equals("sheep") ? 0xFFF9FFFE : 0);
int tint2 = key.equals("tropical_fish") ? 0xFF3AB3DA : 0;
EntityState s = new EntityState(key, 0, 0, 0, yaw, baby, 0.8, 1.0,
isPlayer, null, false, VAR.get(key), tint, 1.0, PROF.get(key), LVL.getOrDefault(key, 0),
MARK.get(key), SADDLE.contains(key), CHEST.contains(key), EQUIP.get(key), baby ? null : ARMOR.get(key),
!baby && INVISIBLE.contains(key), 0, 0, tint2, false, null, null, null);
RenderedEntity re = baker.bake(s);
if (re == null || re.cubes.isEmpty()) { // invisible with no equipment -> nothing renders
return new BufferedImage(TW, TH, BufferedImage.TYPE_INT_RGB);
+106
View File
@@ -0,0 +1,106 @@
import eu.mhsl.minecraft.pixelpics.assets.*;
import eu.mhsl.minecraft.pixelpics.render.entity.*;
import eu.mhsl.minecraft.pixelpics.render.render.*;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
import org.bukkit.util.Vector;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.lang.reflect.Proxy;
import java.util.*;
import java.util.List;
import java.util.logging.Logger;
/**
* Standalone (no server) render of {@link ObjectState}s — block/item/text displays, dropped items,
* falling blocks — in one scene, to check the ObjectBaker geometry, UV orientation and transforms.
* Output: ~/Dokumente/PixelPics-Entity-Renders/objects.png
*/
public class ObjectTestRender {
static final String ROOT = "/home/elias/Dokumente/mcTestServer/plugins/PixelPics";
/** Server-free BlockData stand-in: material + blockstate string are all the model registry needs. */
static BlockData block(Material m, String state) {
String s = "minecraft:" + m.getKey().getKey() + (state == null ? "" : "[" + state + "]");
return (BlockData) Proxy.newProxyInstance(ObjectTestRender.class.getClassLoader(),
new Class<?>[]{BlockData.class}, (p, meth, a) -> switch (meth.getName()) {
case "getMaterial" -> m;
case "getAsString" -> s;
case "equals" -> p == a[0];
case "hashCode" -> System.identityHashCode(p);
case "toString" -> s;
default -> {
Class<?> rt = meth.getReturnType();
yield rt == boolean.class ? false : (rt.isPrimitive() ? 0 : null);
}
});
}
static ObjectState obj(ObjectState.Kind k, Affine a, BlockData b, String item, List<String> lines, int bg) {
return switch (k) {
case BLOCK -> ObjectState.block(a, b, null);
case ITEM -> ObjectState.item(a, new ItemSpec(item, b, null, false), "none", false);
case TEXT -> ObjectState.text(a, lines, 200, 0xFFFFFFFF, bg, true);
case LEASH -> ObjectState.leash(new double[6]);
};
}
public static void main(String[] args) throws Exception {
Logger log = Logger.getLogger("test");
ResourcePack pack = ResourcePackLoader.load(new File(ROOT, "resourcepack"), log).orElseThrow();
AssetReader reader = new AssetReader(pack);
TextureCache textures = new TextureCache(pack);
BlockModelRegistry registry = new BlockModelRegistry(reader, textures);
BiomeTintProvider tint = new BiomeTintProvider(textures);
var geo = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader();
var font = eu.mhsl.minecraft.pixelpics.assets.font.FontLoader.load(pack, textures, log);
SkinCache skins = new SkinCache();
var baker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(geo, textures, skins);
var beBaker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(geo, textures, skins, font);
geo.load(new java.io.FileInputStream("src/main/resources/cem/cem_template_models.json"), log);
geo.load(new java.io.FileInputStream("src/main/resources/cem/vanilla_entity_models.json"), log);
DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log);
List<ObjectState> objs = new ArrayList<>();
// Block displays: plain grass block (tinted), rotated + scaled stairs.
objs.add(obj(ObjectState.Kind.BLOCK, Affine.translation(0, 0, 0), block(Material.GRASS_BLOCK, "snowy=false"), null, List.of(), 0));
objs.add(obj(ObjectState.Kind.BLOCK,
Affine.translation(2, 0, 0).mul(Affine.rotY(Math.toRadians(30))).mul(Affine.scale(0.75)),
block(Material.OAK_STAIRS, "facing=north,half=bottom,shape=straight,waterlogged=false"), null, List.of(), 0));
// Item displays: a flat item and a block item (centred).
objs.add(obj(ObjectState.Kind.ITEM, Affine.translation(0.5, 2.0, 0.5), null, "diamond_sword", List.of(), 0));
objs.add(obj(ObjectState.Kind.ITEM, Affine.translation(2.5, 2.0, 0.5).mul(Affine.scale(0.6)),
block(Material.OAK_LOG, "axis=y"), "oak_log", List.of(), 0));
// Text display (facing the camera side), opaque background.
objs.add(obj(ObjectState.Kind.TEXT,
Affine.translation(1.5, 3.0, 0).mul(Affine.rotY(Math.PI)).mul(Affine.scale(0.025)), null, null,
List.of("Hallo PixelPics!", "Zeile zwei äöü"), 0xFF203040));
// Text seen from the BACK (front faces +Z, camera is on -Z) and a shield item.
objs.add(obj(ObjectState.Kind.TEXT, Affine.translation(-0.5, 3.0, 0).mul(Affine.scale(0.025)), null, null,
List.of("Rueckseite"), 0));
objs.add(obj(ObjectState.Kind.ITEM, Affine.translation(4, 1.0, 0.5), null, "shield", List.of(), 0));
// Dropped item (flat) on the ground.
objs.add(obj(ObjectState.Kind.ITEM, Affine.translation(-1, 0.3, 0.5).mul(Affine.scale(0.5)), null, "apple", List.of(), 0));
BlockData air = block(Material.AIR, null);
WorldSnapshot empty = new WorldSnapshot(Map.of(), 0, 1, air);
SkyContext sky = new SkyContext(6000, 0, 6000);
int W = 480, H = 360;
Vector cam = new Vector(1.5, 3.2, -6.5);
Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ());
loc.setDirection(new Vector(1.2, 1.3, 0.5).subtract(cam));
List<Vector> rayMap = EntityTestRender.buildRayMap(loc, W * 3, H * 3);
RenderJob job = new RenderJob(empty, rayMap, cam, W, H, sky, List.of(), List.of(), List.of(), objs);
BufferedImage img = renderer.execute(job);
File out = new File("/home/elias/Dokumente/PixelPics-Entity-Renders/objects.png");
ImageIO.write(img, "png", out);
System.out.println("WROTE " + out);
}
}
+725
View File
@@ -0,0 +1,725 @@
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.io.FileWriter;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import net.minecraft.SharedConstants;
import net.minecraft.client.model.Model;
import net.minecraft.client.model.geom.EntityModelSet;
import net.minecraft.client.model.geom.LayerDefinitions;
import net.minecraft.client.model.geom.ModelLayerLocation;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.client.model.geom.builders.CubeDefinition;
import net.minecraft.client.model.geom.builders.LayerDefinition;
import net.minecraft.client.model.geom.builders.PartDefinition;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.entity.EntityType;
/**
* Dumps the vanilla Java entity models straight from an (unobfuscated, 26.x+) Minecraft CLIENT jar into
* the CEM-template JSON layout PixelPics loads ({@code {"models": {name: {"model": "<jem json>"}}}}).
*
* <p>Unlike the third-party CEM template set (rest geometry only), every entity model is instantiated
* through its real renderer and posed by its real {@code setupAnim} with a default (idle, adult, not
* aggressive) render state — so zombie arms are raised, spider legs splayed, alternate-state parts
* (rolled-up armadillo, crossed/straight illager arms, chests) carry their vanilla visibility, and new
* mobs of the jar's version are included automatically.
*
* <p>Output per entity type key: {@code <type>} (main model) plus {@code <type>/<extra>} for the
* renderer's additional models and render-layer models (outer layers, armor sets, saddles, …).
* Part coordinates are ABSOLUTE in the CEM frame (x negated, y up with the ground at 24, z kept);
* every part carries its absolute pivot ({@code "pivot"}), so the jem is flagged {@code "absolute": true}.
*
* <p>Run (classpath = client jar + its libraries from the version json):
* {@code java -cp <mc-classpath> tools/VanillaModelDump.java <out.json>}
*/
public class VanillaModelDump {
static final Map<Object, ModelLayerLocation> BAKED = new IdentityHashMap<>();
static Map<ModelLayerLocation, LayerDefinition> roots;
public static void main(String[] args) throws Exception {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
roots = LayerDefinitions.createRoots();
EntityModelSet set = new EntityModelSet(roots) {
@Override
public ModelPart bakeLayer(ModelLayerLocation loc) {
ModelPart p = super.bakeLayer(loc);
BAKED.put(p, loc);
return p;
}
};
EntityRendererProvider.Context ctx =
new EntityRendererProvider.Context(null, null, null, null, null, set, null, null, null, null, null);
Field provField = Class.forName("net.minecraft.client.renderer.entity.EntityRenderers")
.getDeclaredField("PROVIDERS");
provField.setAccessible(true);
@SuppressWarnings("unchecked")
Map<EntityType<?>, EntityRendererProvider<?>> providers =
(Map<EntityType<?>, EntityRendererProvider<?>>) provField.get(null);
Map<String, JsonObject> out = new TreeMap<>();
Map<String, JsonObject> entities = new TreeMap<>();
List<String> failed = new ArrayList<>();
for (Map.Entry<EntityType<?>, EntityRendererProvider<?>> e : providers.entrySet()) {
String key = BuiltInRegistries.ENTITY_TYPE.getKey(e.getKey()).getPath();
EntityRenderer<?, ?> r;
try {
r = e.getValue().create(ctx);
} catch (Throwable t) {
failed.add(key + " (renderer: " + t + ")");
continue;
}
Object state;
try {
state = r.createRenderState();
} catch (Throwable t) {
state = null;
}
idleTweaks(state);
Map<String, Model<?>> found = new LinkedHashMap<>();
Map<String, String> textures = new LinkedHashMap<>();
// The renderer's current model is the main one, whatever field else also holds it (bigModel, …).
if (fieldValue(r, "model") instanceof Model<?> current) found.put("model", current);
collectModels(r, "", found, 0);
Object layers = fieldValue(r, "layers");
if (layers instanceof List<?> ls) {
for (Object layer : ls) collectLayer(layer, found, textures);
}
JsonObject info = entityInfo(r, state);
addTransform(info, r);
Model<?> main = found.containsKey("model") ? found.get("model") : found.get("adultModel");
if (main != null) addPoseVariants(info, main, r);
entities.put(key, info);
emit(key, found, textures, state, out, failed);
}
// Players & mannequins use the avatar renderers (one per skin model type: wide/slim).
Map<?, ?> avatars = (Map<?, ?>) Class.forName("net.minecraft.client.renderer.entity.EntityRenderers")
.getMethod("createAvatarRenderers", EntityRendererProvider.Context.class)
.invoke(null, ctx);
for (Map.Entry<?, ?> a : avatars.entrySet()) {
EntityRenderer<?, ?> r = (EntityRenderer<?, ?>) a.getValue();
String key = String.valueOf(a.getKey()).toLowerCase().equals("slim") ? "player_slim" : "player";
Map<String, Model<?>> found = new LinkedHashMap<>();
Map<String, String> textures = new LinkedHashMap<>();
collectModels(r, "", found, 0);
Object layers = fieldValue(r, "layers");
if (layers instanceof List<?> ls) {
for (Object layer : ls) collectLayer(layer, found, textures);
}
JsonObject info = new JsonObject();
addTransform(info, r);
Model<?> main = found.get("model");
if (main != null) addPoseVariants(info, main, r);
entities.put(key, info);
emit(key, found, textures, r.createRenderState(), out, failed);
}
// Every layer definition in its rest pose as "layer/<model>#<layer>" (special item models such as the
// shield, block-entity models, …) — same absolute CEM frame as the entity models.
for (Map.Entry<ModelLayerLocation, LayerDefinition> le : roots.entrySet()) {
String name = "layer/" + le.getKey().model().getPath() + "#" + le.getKey().layer();
try {
ModelPart baked = le.getValue().bakeRoot();
PartDefinition rootDef = ((net.minecraft.client.model.geom.builders.MeshDefinition)
fieldValue(le.getValue(), "mesh"))
.getRoot();
Object mat = fieldValue(le.getValue(), "material");
JsonObject jem = new JsonObject();
JsonArray ts = new JsonArray();
ts.add((int) fieldValue(mat, "xTexSize"));
ts.add((int) fieldValue(mat, "yTexSize"));
jem.add("textureSize", ts);
jem.addProperty("absolute", true);
JsonArray parts = new JsonArray();
parts.add(part("root", rootDef, baked, 0, 0, 0));
jem.add("models", parts);
JsonObject entry = new JsonObject();
entry.addProperty("model", new Gson().toJson(jem));
out.putIfAbsent(name, entry);
} catch (Throwable t) {
failed.add(name + " (layer: " + t + ")");
}
}
JsonObject root = new JsonObject();
root.addProperty("version", SharedConstants.getCurrentVersion().name());
JsonObject models = new JsonObject();
out.forEach(models::add);
root.add("models", models);
JsonObject ents = new JsonObject();
entities.forEach(ents::add);
root.add("entities", ents);
try (FileWriter w = new FileWriter(args.length > 0 ? args[0] : "vanilla_entity_models.json")) {
new GsonBuilder().create().toJson(root, w);
}
System.out.println("models: " + out.size());
for (String f : failed) System.out.println("FAILED " + f);
}
/**
* Small, name-based tweaks so the default render state shows the typical idle look: illagers stand
* with crossed arms (their peaceful pose) instead of the empty-handed "neutral" pose.
*/
static void idleTweaks(Object state) {
if (state == null) return;
for (String sizeField : new String[] {"size"}) {
try {
Field f = state.getClass().getField(sizeField);
if (f.getType() == int.class && f.getInt(state) == 0) f.setInt(state, 1);
if (f.getType() == float.class && f.getFloat(state) == 0) f.setFloat(state, 1);
} catch (Throwable ignored) {
}
}
try {
Field f = state.getClass().getField("armPose");
if (f.getType().isEnum()) {
for (Object c : f.getType().getEnumConstants())
if (((Enum<?>) c).name().equals("CROSSED")) f.set(state, c);
}
} catch (Throwable ignored) {
}
}
/** Writes every collected model of one entity type; adult/normal/plain "model" fields become the main model. */
static void emit(
String key,
Map<String, Model<?>> found,
Map<String, String> textures,
Object state,
Map<String, JsonObject> out,
List<String> failed) {
for (Map.Entry<String, Model<?>> m : found.entrySet()) {
String f = m.getKey();
String name =
switch (f) {
case "model", "adultModel", "normalModel" -> key;
case "babyModel" -> key + "/baby";
default -> key + "/" + f;
};
if (out.containsKey(name)) continue;
JsonObject jem = dump(m.getValue(), state, name, failed);
if (jem != null) {
String tex = textures.get(f);
if (tex != null) jem.addProperty("texture", tex);
JsonObject entry = new JsonObject();
entry.addProperty("model", new Gson().toJson(jem));
out.put(name, entry);
}
}
}
/**
* A render layer's models (prefixed by the layer class; equipment layers by their equipment layer type,
* e.g. {@code equip.horse_saddle}) plus the texture the layer draws them with, when it holds one.
*/
static void collectLayer(Object layer, Map<String, Model<?>> found, Map<String, String> textures) {
String prefix = layer.getClass().getSimpleName() + ".";
Object type = fieldValue(layer, "layer");
if (type instanceof Enum<?> en) prefix = "equip." + en.name().toLowerCase() + ".";
Map<String, Model<?>> mine = new LinkedHashMap<>();
collectModels(layer, prefix, mine, 0);
// Texture identifiers held by the layer (static constants or instance fields): baby vs adult.
String adult = null, baby = null;
for (Class<?> c = layer.getClass(); c != null && c != Object.class; c = c.getSuperclass()) {
for (Field fl : c.getDeclaredFields()) {
if (!fl.getType().getSimpleName().equals("Identifier")) continue;
try {
fl.setAccessible(true);
Object v = fl.get(Modifier.isStatic(fl.getModifiers()) ? null : layer);
if (v == null) continue;
String path = v.toString().replaceFirst("^minecraft:", "")
.replaceFirst("^textures/", "")
.replaceFirst("\\.png$", "");
if (fl.getName().toLowerCase().contains("baby")) {
if (baby == null) baby = path;
} else if (adult == null) adult = path;
} catch (Throwable ignored) {
}
}
}
for (Map.Entry<String, Model<?>> m : mine.entrySet()) {
if (found.containsValue(m.getValue())) continue;
found.put(m.getKey(), m.getValue());
String tex = m.getKey().toLowerCase().contains("baby") ? baby : adult;
if (tex != null) textures.put(m.getKey(), tex);
}
}
/**
* Runs the renderer's real {@code submit} (yaw 0, pitch 0) against a recording collector and returns the
* pose matrix (column-major 4x4, Java model units -> entity-local blocks) of its first model submission:
* the renderer's own translate/rotate/scale (end-crystal float, minecart offsets, wither x2, …), so no
* per-entity placement table is needed.
*/
/**
* Stores the renderer transform at yaw=pitch=0 ({@code transform}, column-major 4x4) plus how yaw and
* pitch enter it, measured by re-rendering at yaw 90 / pitch 90: {@code yawSign} (+1/-1, 0 = ignores yaw)
* and {@code pitchAxis} (world-frame axis for +pitch; absent = ignores pitch).
*/
static void addTransform(JsonObject info, EntityRenderer<?, ?> r) {
float[] t0 = captureTransform(r, 0, 0);
if (t0 == null) return;
JsonArray ta = new JsonArray();
for (float v : t0) ta.add(Math.round(v * 100000f) / 100000f);
info.add("transform", ta);
org.joml.Matrix3f inv0 = new org.joml.Matrix4f().set(t0).get3x3(new org.joml.Matrix3f()).invert();
float[] ty = captureTransform(r, 90, 0);
if (ty != null) {
org.joml.Matrix3f rel = new org.joml.Matrix4f().set(ty).get3x3(new org.joml.Matrix3f()).mul(inv0);
// rel ≈ RotY(±90): m20 (column 0, row 2) = -sin(angle)
float sinA = -rel.m02;
info.addProperty("yawSign", Math.abs(sinA) < 0.5f ? 0 : (sinA > 0 ? 1 : -1));
}
float[] tp = captureTransform(r, 0, 90);
if (tp != null) {
org.joml.Matrix3f rel = new org.joml.Matrix4f().set(tp).get3x3(new org.joml.Matrix3f()).mul(inv0);
// axis of a 90° rotation: the antisymmetric part (R - R^T)/2
float ax = (rel.m12 - rel.m21) / 2, ay = (rel.m20 - rel.m02) / 2, az = (rel.m01 - rel.m10) / 2;
float len = (float) Math.sqrt(ax * ax + ay * ay + az * az);
if (len > 0.5f) info.add("pitchAxis", arr(ax / len, ay / len, az / len));
}
}
/**
* State-dependent poses as per-part overrides (only parts that differ from the idle pose): sitting as a
* passenger ({@code isPassenger}/{@code isRiding}), holding an item in the right/left hand
* ({@code right/leftArmPose = ITEM}) and their combinations, plus illager arm poses. Stored per entity
* type as {@code poses: {variant: {part: {pivot, rotate, visible}}}}; the baker applies them by part
* name to the body AND its layer models (armor follows the arms, like vanilla's copyPropertiesTo).
*/
@SuppressWarnings({"unchecked", "rawtypes"})
static void addPoseVariants(JsonObject info, Model<?> model, EntityRenderer<?, ?> r) {
Object probe;
try {
probe = r.createRenderState();
} catch (Throwable t) {
return;
}
// Toggle groups: {name, field, value} entries sharing a name are applied together.
List<List<String[]>> toggles = new ArrayList<>();
List<String[]> passenger = new ArrayList<>();
for (String f : new String[] {"isPassenger", "isRiding"})
if (hasField(probe, f)) passenger.add(new String[] {"passenger", f, "true"});
if (!passenger.isEmpty()) toggles.add(passenger);
if (hasField(probe, "rightArmPose")) toggles.add(java.util.Collections.singletonList(new String[] {"item_r", "rightArmPose", "ITEM"}));
if (hasField(probe, "leftArmPose")) toggles.add(java.util.Collections.singletonList(new String[] {"item_l", "leftArmPose", "ITEM"}));
List<List<String[]>> combos = new ArrayList<>();
for (int mask = 1; mask < (1 << toggles.size()); mask++) {
List<String[]> c = new ArrayList<>();
for (int i = 0; i < toggles.size(); i++) if ((mask & (1 << i)) != 0) c.addAll(toggles.get(i));
combos.add(c);
}
// Illagers: the arm pose enum replaces the crossed arms (crossbow hold, attacking, …).
if (hasField(probe, "armPose")) {
for (String v : new String[] {"CROSSBOW_HOLD", "ATTACKING", "NEUTRAL", "BOW_AND_ARROW"}) {
List<String[]> c = new ArrayList<>();
c.add(new String[] {"arms_" + v.toLowerCase(), "armPose", v});
combos.add(c);
if (!passenger.isEmpty()) {
List<String[]> cr = new ArrayList<>(c);
cr.addAll(passenger);
combos.add(cr);
}
}
}
if (combos.isEmpty()) return;
Map<String, float[]> base = poseOf(model, freshState(r, List.of()));
if (base == null) return;
JsonObject poses = new JsonObject();
for (List<String[]> c : combos) {
Map<String, float[]> v = poseOf(model, freshState(r, c));
if (v == null) continue;
JsonObject diff = new JsonObject();
for (Map.Entry<String, float[]> e : v.entrySet()) {
float[] b = base.get(e.getKey());
float[] x = e.getValue();
boolean same = b != null;
for (int i = 0; same && i < x.length; i++) same = Math.abs(b[i] - x[i]) < 1e-4f;
if (same) continue;
JsonObject po = new JsonObject();
po.add("pivot", arr(x[0], x[1], x[2]));
po.add("rotate", arr(x[3], x[4], x[5]));
po.addProperty("visible", x[6] > 0.5f);
diff.add(e.getKey(), po);
}
if (diff.isEmpty()) continue;
StringBuilder name = new StringBuilder();
c.stream().map(t -> t[0]).distinct().sorted().forEach(n -> name.append(name.isEmpty() ? "" : "+").append(n));
poses.add(name.toString(), diff);
}
if (!poses.isEmpty()) info.add("poses", poses);
// leave the model in its idle pose for the dump
poseOf(model, freshState(r, List.of()));
}
static boolean hasField(Object o, String f) {
try {
o.getClass().getField(f);
return true;
} catch (NoSuchFieldException e) {
return false;
}
}
@SuppressWarnings({"unchecked", "rawtypes"})
static Object freshState(EntityRenderer<?, ?> r, List<String[]> sets) {
try {
Object st = r.createRenderState();
idleTweaks(st);
for (String[] t : sets) {
Field f = st.getClass().getField(t[1]);
if (f.getType() == boolean.class) f.setBoolean(st, Boolean.parseBoolean(t[2]));
else if (f.getType().isEnum()) f.set(st, Enum.valueOf((Class) f.getType(), t[2]));
}
return st;
} catch (Throwable t) {
return null;
}
}
/** setupAnim with the state, then every part's absolute CEM pivot, CEM rotation and visibility, by name. */
@SuppressWarnings({"unchecked", "rawtypes"})
static Map<String, float[]> poseOf(Model<?> model, Object state) {
if (state == null) return null;
try {
((Model) model).setupAnim(state);
} catch (Throwable t) {
return null;
}
Map<String, float[]> out = new LinkedHashMap<>();
collectPose(model.root(), "root", 0, 0, 0, out);
return out;
}
@SuppressWarnings("unchecked")
static void collectPose(ModelPart p, String name, float px, float py, float pz, Map<String, float[]> out) {
float ax = px + p.x, ay = py + p.y, az = pz + p.z;
out.put(name, new float[] {
-ax, 24 - ay, az,
(float) Math.toDegrees(-p.xRot), (float) Math.toDegrees(-p.yRot), (float) Math.toDegrees(p.zRot),
p.visible ? 1 : 0
});
Map<String, ModelPart> children = (Map<String, ModelPart>) fieldValue(p, "children");
if (children != null) children.forEach((n, c) -> collectPose(c, n, ax, ay, az, out));
}
static void setFloat(Object o, String field, float v) {
try {
Field f = o.getClass().getField(field);
if (f.getType() == float.class) f.setFloat(o, v);
} catch (Throwable ignored) {
}
}
static float[] captureTransform(EntityRenderer<?, ?> r, float yaw, float pitch) {
Object state;
try {
state = r.createRenderState();
} catch (Throwable t) {
return null;
}
idleTweaks(state);
setFloat(state, "bodyRot", yaw);
setFloat(state, "yRot", yaw);
setFloat(state, "xRot", pitch);
float[][] first = new float[1][];
Class<?> collector;
try {
collector = Class.forName("net.minecraft.client.renderer.SubmitNodeCollector");
} catch (ClassNotFoundException e) {
return null;
}
Object proxy = java.lang.reflect.Proxy.newProxyInstance(
VanillaModelDump.class.getClassLoader(), new Class<?>[] {collector}, (px, m, a) -> {
if (m.getName().equals("order")) return px;
if (a != null && first[0] == null && m.getName().startsWith("submitModel")) {
for (Object arg : a) {
if (arg instanceof com.mojang.blaze3d.vertex.PoseStack ps) {
first[0] = ps.last().pose().get(new float[16]);
break;
}
}
}
Class<?> rt = m.getReturnType();
if (rt == boolean.class) return false;
if (rt.isPrimitive() && rt != void.class) return 0;
return null;
});
try {
Method submit = null;
for (Method m : r.getClass().getMethods()) {
if (m.getName().equals("submit") && m.getParameterCount() == 4 && !m.isBridge()) submit = m;
}
if (submit == null) return null;
submit.invoke(r, state, new com.mojang.blaze3d.vertex.PoseStack(), proxy, null);
} catch (Throwable ignored) {
// partial submissions still count (layers may fail on the null context)
}
return first[0];
}
/** Render scale (the renderer's own {@code scale(state, poseStack)}) and default adult/baby textures. */
static JsonObject entityInfo(EntityRenderer<?, ?> r, Object state) {
JsonObject o = new JsonObject();
if (state == null) return o;
try {
Method m = null;
for (Class<?> c = r.getClass(); c != null && m == null; c = c.getSuperclass()) {
for (Method dm : c.getDeclaredMethods()) {
if (dm.getName().equals("scale")
&& dm.getParameterCount() == 2
&& dm.getParameterTypes()[1].getSimpleName().equals("PoseStack")
&& !dm.isBridge()) {
m = dm;
break;
}
}
}
if (m != null) {
m.setAccessible(true);
com.mojang.blaze3d.vertex.PoseStack ps = new com.mojang.blaze3d.vertex.PoseStack();
m.invoke(r, state, ps);
float sc = ps.last().pose().getScale(new org.joml.Vector3f()).y;
if (sc > 0.05f && Math.abs(sc - 1) > 1e-3) o.addProperty("scale", Math.round(sc * 1000f) / 1000f);
}
} catch (Throwable ignored) {
}
for (boolean baby : new boolean[] {false, true}) {
try {
Object st = r.createRenderState();
try {
st.getClass().getField("isBaby").setBoolean(st, baby);
} catch (NoSuchFieldException e) {
if (baby) continue;
}
Method gt = null;
for (Class<?> c = r.getClass(); c != null && gt == null; c = c.getSuperclass()) {
for (Method dm : c.getDeclaredMethods())
if (dm.getName().equals("getTextureLocation") && dm.getParameterCount() == 1 && !dm.isBridge())
gt = dm;
}
if (gt == null) break;
gt.setAccessible(true);
Object id = gt.invoke(r, st);
if (id != null) {
String path = id.toString().replaceFirst("^minecraft:", "")
.replaceFirst("^textures/", "")
.replaceFirst("\\.png$", "");
o.addProperty(baby ? "babyTexture" : "texture", path);
}
} catch (Throwable ignored) {
}
}
return o;
}
/** Finds Model instances in the object's fields (also inside records / maps / ArmorModelSet-like holders). */
static void collectModels(Object o, String prefix, Map<String, Model<?>> found, int depth) {
if (o == null || depth > 2) return;
for (Class<?> c = o.getClass(); c != null && c != Object.class; c = c.getSuperclass()) {
for (Field f : c.getDeclaredFields()) {
if (Modifier.isStatic(f.getModifiers())) continue;
Object v;
try {
f.setAccessible(true);
v = f.get(o);
} catch (Throwable t) {
continue;
}
if (v == null) continue;
String n = prefix + f.getName();
if (v instanceof Model<?> m) {
if (!found.containsValue(m)) found.put(n, m);
} else if (v instanceof Map<?, ?> map) {
for (Map.Entry<?, ?> me : map.entrySet()) {
String mk = n + "." + String.valueOf(me.getKey()).toLowerCase();
if (me.getValue() instanceof Model<?> m) {
if (!found.containsValue(m)) found.put(mk, m);
} else if (me.getValue() != null) {
collectModels(me.getValue(), mk + ".", found, depth + 1); // e.g. adult/baby pairs
}
}
} else if (v.getClass().isRecord() || v.getClass().getSimpleName().endsWith("ModelSet")) {
collectModels(v, n + ".", found, depth + 1);
}
}
}
}
/** Path of child names from {@code from} down to the identical part {@code target}, or null. */
@SuppressWarnings("unchecked")
static List<String> findPath(ModelPart from, ModelPart target, List<String> path) {
if (from == target) return path;
Map<String, ModelPart> children = (Map<String, ModelPart>) fieldValue(from, "children");
if (children == null) return null;
for (Map.Entry<String, ModelPart> c : children.entrySet()) {
List<String> p = new ArrayList<>(path);
p.add(c.getKey());
List<String> r = findPath(c.getValue(), target, p);
if (r != null) return r;
}
return null;
}
static Object fieldValue(Object o, String name) {
for (Class<?> c = o.getClass(); c != null; c = c.getSuperclass()) {
try {
Field f = c.getDeclaredField(name);
f.setAccessible(true);
return f.get(o);
} catch (NoSuchFieldException ignored) {
} catch (Throwable t) {
return null;
}
}
return null;
}
@SuppressWarnings({"unchecked", "rawtypes"})
static JsonObject dump(Model<?> model, Object state, String name, List<String> failed) {
ModelPart root = model.root();
ModelLayerLocation loc = BAKED.get(root);
List<String> subPath = List.of();
if (loc == null) {
// Some models keep a CHILD of the baked layer root as their root (e.g. allay, vex, frog).
for (Map.Entry<Object, ModelLayerLocation> b : BAKED.entrySet()) {
List<String> path = findPath((ModelPart) b.getKey(), root, new ArrayList<>());
if (path != null) {
loc = b.getValue();
subPath = path;
break;
}
}
}
if (loc == null) {
failed.add(name + " (no layer)");
return null;
}
// Pose with the renderer's default state; fall back to a fresh state of the model's own type.
boolean posed = false;
if (state != null) {
try {
((Model) model).setupAnim(state);
posed = true;
} catch (Throwable t) {
// layer model expecting another state type
}
}
if (!posed) {
for (Method m : model.getClass().getMethods()) {
if (!m.getName().equals("setupAnim") || m.getParameterCount() != 1) continue;
try {
Object st = m.getParameterTypes()[0].getDeclaredConstructor().newInstance();
m.invoke(model, st);
posed = true;
break;
} catch (Throwable ignored) {
}
}
}
if (!posed) failed.add(name + " (rest pose: setupAnim failed)");
LayerDefinition def = roots.get(loc);
PartDefinition rootDef = ((net.minecraft.client.model.geom.builders.MeshDefinition)
fieldValue(def, "mesh"))
.getRoot();
for (String seg : subPath) rootDef = rootDef.getChild(seg);
Object mat = fieldValue(def, "material");
int tw = (int) fieldValue(mat, "xTexSize"), th = (int) fieldValue(mat, "yTexSize");
JsonObject jem = new JsonObject();
JsonArray ts = new JsonArray();
ts.add(tw);
ts.add(th);
jem.add("textureSize", ts);
jem.addProperty("absolute", true);
jem.addProperty("layer", loc.model().getPath() + "#" + loc.layer());
JsonArray parts = new JsonArray();
parts.add(part(subPath.isEmpty() ? "root" : subPath.getLast(), rootDef, root, 0, 0, 0));
jem.add("models", parts);
return jem;
}
/** One part: absolute pivot = parent pivot + this part's (posed) offset; boxes absolute. */
@SuppressWarnings("unchecked")
static JsonObject part(String name, PartDefinition def, ModelPart p, float px, float py, float pz) {
float ax = px + p.x, ay = py + p.y, az = pz + p.z;
JsonObject o = new JsonObject();
o.addProperty("part", name);
o.add("pivot", arr(-ax, 24 - ay, az));
// CEM frame = Java rotated 180° about Z: x/y rotations flip sign, z keeps it.
o.add("rotate", arr(
(float) Math.toDegrees(-p.xRot), (float) Math.toDegrees(-p.yRot), (float) Math.toDegrees(p.zRot)));
if (p.xScale != 1 || p.yScale != 1 || p.zScale != 1) o.add("scale", arr(p.xScale, p.yScale, p.zScale));
if (!p.visible) o.addProperty("invisible", true);
if (p.skipDraw) o.addProperty("skipDraw", true);
JsonArray boxes = new JsonArray();
for (CubeDefinition c : (List<CubeDefinition>) fieldValue(def, "cubes")) {
org.joml.Vector3fc org = (org.joml.Vector3fc) fieldValue(c, "origin");
org.joml.Vector3fc dim = (org.joml.Vector3fc) fieldValue(c, "dimensions");
Object grow = fieldValue(c, "grow");
float gx = (float) fieldValue(grow, "growX"), gy = (float) fieldValue(grow, "growY"),
gz = (float) fieldValue(grow, "growZ");
Object tex = fieldValue(c, "texCoord"), texScale = fieldValue(c, "texScale");
boolean mirror = (boolean) fieldValue(c, "mirror");
Set<Direction> faces = (Set<Direction>) fieldValue(c, "visibleFaces");
float x0 = ax + org.x(), y0 = ay + org.y(), z0 = az + org.z();
JsonObject b = new JsonObject();
b.add("coordinates", arr(-(x0 + dim.x()), 24 - (y0 + dim.y()), z0, dim.x(), dim.y(), dim.z()));
b.add("textureOffset", arr((float) fieldValue(tex, "u"), (float) fieldValue(tex, "v")));
float su = (float) fieldValue(texScale, "u"), sv = (float) fieldValue(texScale, "v");
if (su != 1 || sv != 1) b.add("textureScale", arr(su, sv));
if (gx == gy && gy == gz) {
if (gx != 0) b.addProperty("sizeAdd", gx);
} else {
b.add("sizeAddXYZ", arr(gx, gy, gz));
}
if (mirror) b.addProperty("mirrorTexture", "u");
if (faces != null && faces.size() < 6) {
JsonArray hide = new JsonArray();
// Named in the CEM frame (x and y flipped): Java east is CEM west, Java up is CEM down.
for (Direction d : Direction.values())
if (!faces.contains(d))
hide.add(
switch (d) {
case EAST -> "west";
case WEST -> "east";
case UP -> "down";
case DOWN -> "up";
default -> d.getName();
});
b.add("hiddenFaces", hide);
}
boxes.add(b);
}
if (!boxes.isEmpty()) o.add("boxes", boxes);
JsonArray subs = new JsonArray();
for (Map.Entry<String, PartDefinition> ce : def.getChildren()) {
ModelPart child = p.getChild(ce.getKey());
subs.add(part(ce.getKey(), ce.getValue(), child, ax, ay, az));
}
if (!subs.isEmpty()) o.add("submodels", subs);
return o;
}
static JsonArray arr(float... v) {
JsonArray a = new JsonArray();
for (float f : v) a.add(Math.round(f * 10000f) / 10000f);
return a;
}
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Builds the edge-case test plugin (PPTest.jar) against the compiled PixelPics classes.
# Usage: ./gradlew compileJava && tools/edgecase-plugin/build.sh <compile-classpath> [out.jar]
# (classpath = paper-api + its deps, e.g. the Gradle compileClasspath joined with ":")
# Then drop the jar into the TEST server's plugins/ (never production), start the server and run
# `pptest all` (or `pptest <scene>`) in its console; renders land in plugins/PPTest/<scene>.png.
# Scenes are built at world (5000 + i*64, 200, 5000) on stone platforms; see PPTest#onEnable.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
API="${1:?compile classpath (paper-api + deps)}"
OUT="${2:-$ROOT/build/PPTest.jar}"
JAVAC="${JAVAC:-javac}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
"$JAVAC" -nowarn -d "$TMP" -cp "$ROOT/build/classes/java/main:$API" "$ROOT/tools/edgecase-plugin/pptest/PPTest.java"
cp "$ROOT/tools/edgecase-plugin/plugin.yml" "$TMP/"
(cd "$TMP" && zip -q -r "$OUT" .)
echo "Wrote $OUT"
+8
View File
@@ -0,0 +1,8 @@
name: PPTest
version: '1'
main: pptest.PPTest
api-version: '1.21'
depend: [PixelPics]
commands:
pptest:
usage: /pptest [scene...|all]
+395
View File
@@ -0,0 +1,395 @@
package pptest;
import eu.mhsl.minecraft.pixelpics.Main;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.render.RenderJob;
import eu.mhsl.minecraft.pixelpics.render.render.Resolution;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.*;
import java.util.function.Consumer;
import javax.imageio.ImageIO;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.*;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.Bed;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.*;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.util.Transformation;
import org.bukkit.util.Vector;
import org.joml.AxisAngle4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/** Test-only plugin: builds edge-case scenes and renders them through PixelPics into PNGs. */
public class PPTest extends JavaPlugin {
record Scene(String name, Consumer<Location> build, double camX, double camY, double camZ, double lookX, double lookY, double lookZ) {}
private final Map<String, Scene> scenes = new LinkedHashMap<>();
private World w;
@Override
public void onEnable() {
// Each scene sits on its own stone platform; the camera is placed relative to the platform origin.
scenes.put("held", new Scene("held", this::held, 4.4, 2.6, -8.5, 4.4, 1.0, 1));
scenes.put("equip", new Scene("equip", this::equip, 4, 3, -8.5, 4, 1.0, 1.5));
scenes.put("riding", new Scene("riding", this::riding, 5, 3, -7.5, 5, 1.2, 1));
scenes.put("leash", new Scene("leash", this::leash, 5, 4, -9, 5, 2.5, 2));
scenes.put("displays", new Scene("displays", this::displays, 5, 3, -9, 5, 1.0, 1.5));
scenes.put("pixelblock", new Scene("pixelblock", this::pixelblock, 1.5, 1.9, -1.3, 0.5, 0.5, 0.5));
scenes.put("misc", new Scene("misc", this::misc, 5, 3, -7, 5, 0.8, 1.5));
scenes.put("dbg", new Scene("dbg", this::dbg, 1, 1.6, -3, 1, 0.8, 0.5));
scenes.put("vehicles", new Scene("vehicles", this::vehicles, 5, 3, -7, 5, 0.6, 1.5));
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
w = Bukkit.getWorlds().getFirst();
w.setTime(6000);
w.setStorm(false);
List<String> todo = args.length == 0 || args[0].equals("all") ? new ArrayList<>(scenes.keySet()) : List.of(args);
int i = 0;
for (String n : todo) {
Scene sc = scenes.get(n);
if (sc == null) { getLogger().warning("unknown scene " + n); continue; }
int idx = new ArrayList<>(scenes.keySet()).indexOf(n);
Location origin = new Location(w, 5000 + idx * 64, 200, 5000);
Bukkit.getScheduler().runTaskLater(this, () -> run(sc, origin), 1 + i * 60L);
i++;
}
return true;
}
private void run(Scene sc, Location o) {
try {
prepareArea(o);
sc.build.accept(o.clone());
} catch (Throwable t) {
getLogger().severe("build " + sc.name + " failed: " + t);
t.printStackTrace();
}
// Let one tick pass (passengers/leashes settle), then render.
Bukkit.getScheduler().runTaskLater(this, () -> render(sc, o), 10);
}
private void render(Scene sc, Location o) {
DefaultScreenRenderer r = Main.getInstance().getScreenRenderer();
Location eye = o.clone().add(sc.camX, sc.camY, sc.camZ);
Vector look = o.clone().add(sc.lookX, sc.lookY, sc.lookZ).toVector().subtract(eye.toVector());
eye.setDirection(look);
RenderJob job = r.prepare(eye, new Resolution(480, 320), null);
if (sc.name.equals("dbg")) {
try {
java.lang.reflect.Field f = DefaultScreenRenderer.class.getDeclaredField("objectBaker");
f.setAccessible(true);
var ob = (eu.mhsl.minecraft.pixelpics.render.entity.ObjectBaker) f.get(r);
for (var st : job.objects()) {
double[] pos = st.toWorld().t;
if (Math.abs(pos[0] - o.getX()) > 5 || Math.abs(pos[2] - o.getZ()) > 5) continue;
try {
var re = ob.bake(st);
getLogger().info("dbg obj " + st.kind() + " item=" + st.item() + " ctx=" + st.context() + " -> "
+ (re == null ? "null" : re.cubes.size() + " cubes " + java.util.Arrays.toString(re.aabbMin)));
} catch (Throwable t) {
getLogger().warning("dbg bake failed: " + t);
t.printStackTrace();
}
}
} catch (Throwable t) {
getLogger().warning("dbg: " + t);
}
}
getLogger().info("scene " + sc.name + ": " + job.entities().size() + " entities, " + job.objects().size()
+ " objects, " + job.blockEntities().size() + " block entities");
Bukkit.getScheduler().runTaskAsynchronously(this, () -> {
try {
BufferedImage img = r.execute(job);
File f = new File(getDataFolder(), sc.name + ".png");
f.getParentFile().mkdirs();
ImageIO.write(img, "png", f);
getLogger().info("WROTE " + f);
} catch (Throwable t) {
getLogger().severe("render " + sc.name + " failed: " + t);
t.printStackTrace();
}
});
}
/** Clears the area (entities + blocks) and lays a 14x14 stone floor at the origin's y-1. */
private void prepareArea(Location o) {
for (int cx = -1; cx <= 2; cx++)
for (int cz = -2; cz <= 2; cz++) {
Chunk c = w.getChunkAt((o.getBlockX() >> 4) + cx, (o.getBlockZ() >> 4) + cz);
c.load(true);
c.addPluginChunkTicket(this);
}
for (Entity e : w.getNearbyEntities(o.clone().add(6, 5, 2), 20, 20, 20)) if (!(e instanceof Player)) e.remove();
for (int x = -6; x < 18; x++)
for (int z = -12; z < 12; z++)
for (int y = -1; y < 12; y++)
w.getBlockAt(o.getBlockX() + x, o.getBlockY() + y, o.getBlockZ() + z)
.setType(y == -1 ? Material.SMOOTH_STONE : Material.AIR, false);
}
// --- helpers ---------------------------------------------------------------------------------
private <T extends Entity> T spawn(Location o, double x, double y, double z, float yaw, Class<T> type) {
Location l = o.clone().add(x, y, z);
l.setYaw(yaw);
T e = w.spawn(l, type, en -> {
if (en instanceof Mob m) m.setAI(false);
en.setPersistent(false);
});
if (e instanceof LivingEntity le) le.setRemoveWhenFarAway(false);
return e;
}
private Entity spawn(Location o, double x, double y, double z, float yaw, EntityType type) {
Location l = o.clone().add(x, y, z);
l.setYaw(yaw);
Entity e = w.spawnEntity(l, type);
if (e instanceof Mob m) m.setAI(false);
return e;
}
private static ItemStack it(Material m) {
return new ItemStack(m);
}
private static void hands(Entity e, Material main, Material off) {
if (!(e instanceof LivingEntity le) || le.getEquipment() == null) return;
if (main != null) le.getEquipment().setItemInMainHand(it(main));
if (off != null) le.getEquipment().setItemInOffHand(it(off));
}
// --- scenes ----------------------------------------------------------------------------------
private void held(Location o) {
hands(spawn(o, 0, 0, 0, 180, Zombie.class), Material.IRON_SWORD, Material.SHIELD);
hands(spawn(o, 1.6, 0, 0, 180, Skeleton.class), Material.BOW, null);
hands(spawn(o, 3.2, 0, 0, 180, Piglin.class), Material.GOLDEN_SWORD, null);
hands(spawn(o, 4.8, 0, 0, 180, EntityType.MANNEQUIN), Material.DIAMOND_PICKAXE, Material.TORCH);
ArmorStand as = spawn(o, 6.4, 0, 0, 180, ArmorStand.class);
as.setArms(true);
hands(as, Material.TRIDENT, Material.GRASS_BLOCK);
hands(spawn(o, 8.0, 0, 0, 180, Drowned.class), Material.TRIDENT, null);
hands(spawn(o, 0.8, 0, 2, 180, Pillager.class), Material.CROSSBOW, null);
hands(spawn(o, 2.4, 0, 2, 180, Vindicator.class), Material.IRON_AXE, null);
hands(spawn(o, 4.0, 0, 2, 180, Fox.class), Material.SWEET_BERRIES, null);
hands(spawn(o, 5.6, 0.5, 2, 180, Allay.class), Material.DIAMOND, null);
hands(spawn(o, 7.2, 0, 2, 180, Witch.class), Material.POTION, null);
hands(spawn(o, 8.8, 0, 2, 180, Villager.class), Material.EMERALD, null);
}
private void equip(Location o) {
LivingEntity m = (LivingEntity) spawn(o, 0, 0, 0, 180, EntityType.MANNEQUIN);
m.getEquipment().setChestplate(it(Material.ELYTRA));
m.getEquipment().setHelmet(it(Material.IRON_HELMET));
LivingEntity m2 = (LivingEntity) spawn(o, 1.6, 0, 0, 0, EntityType.MANNEQUIN); // back view: elytra
m2.getEquipment().setChestplate(it(Material.ELYTRA));
ArmorStand pumpkin = spawn(o, 3.2, 0, 0, 180, ArmorStand.class);
pumpkin.getEquipment().setHelmet(it(Material.CARVED_PUMPKIN));
pumpkin.getEquipment().setChestplate(it(Material.CHAINMAIL_CHESTPLATE));
ArmorStand head = spawn(o, 4.8, 0, 0, 180, ArmorStand.class);
head.getEquipment().setHelmet(it(Material.CREEPER_HEAD));
head.setInvisible(true);
Zombie z = spawn(o, 6.4, 0, 0, 180, Zombie.class);
z.getEquipment().setHelmet(it(Material.GOLDEN_HELMET));
z.getEquipment().setChestplate(it(Material.COPPER_CHESTPLATE));
z.getEquipment().setLeggings(it(Material.NETHERITE_LEGGINGS));
z.getEquipment().setBoots(it(Material.LEATHER_BOOTS));
spawn(o, 8.0, 0, 0, 180, Snowman.class);
Wolf wolf = spawn(o, 1, 0, 2.5, 180, Wolf.class);
wolf.getEquipment().setItem(EquipmentSlot.BODY, it(Material.WOLF_ARMOR));
Horse h = spawn(o, 4, 0, 3, 90, Horse.class);
h.getInventory().setSaddle(it(Material.SADDLE));
h.getInventory().setArmor(it(Material.GOLDEN_HORSE_ARMOR));
Zombie baby = spawn(o, 7.5, 0, 2.5, 180, Zombie.class);
baby.setBaby();
baby.getEquipment().setHelmet(it(Material.DIAMOND_HELMET));
hands(baby, Material.STONE_SWORD, null);
}
private void riding(Location o) {
Spider sp = spawn(o, 0, 0, 0, 180, Spider.class);
sp.addPassenger(spawn(o, 0, 1, 0, 180, Skeleton.class));
Chicken ch = spawn(o, 2, 0, 0, 180, Chicken.class);
Zombie bz = spawn(o, 2, 1, 0, 180, Zombie.class);
bz.setBaby();
ch.addPassenger(bz);
Horse h = spawn(o, 4.5, 0, 0.5, 180, Horse.class);
h.getInventory().setSaddle(it(Material.SADDLE));
h.addPassenger(spawn(o, 4.5, 2, 0.5, 180, EntityType.MANNEQUIN));
Minecart mc = spawn(o, 7, 0, 0, 0, Minecart.class);
mc.addPassenger(spawn(o, 7, 1, 0, 180, Villager.class));
Boat b = (Boat) spawn(o, 9.5, 0, 0, 180, EntityType.OAK_BOAT);
b.addPassenger(spawn(o, 9.5, 1, 0, 180, Zombie.class));
b.addPassenger(spawn(o, 9.5, 1, 0, 180, Pig.class));
Camel c = spawn(o, 2, 0, 3.5, 90, Camel.class);
c.getInventory().setSaddle(it(Material.SADDLE));
c.addPassenger(spawn(o, 2, 2, 3.5, 90, EntityType.MANNEQUIN));
c.addPassenger(spawn(o, 2, 2, 3.5, 90, Villager.class));
Strider st = spawn(o, 6, 0, 3.5, 180, Strider.class);
st.setSaddle(true);
st.addPassenger(spawn(o, 6, 2, 3.5, 180, Piglin.class));
Boat cb = (Boat) spawn(o, 9, 0, 3.5, 180, EntityType.BIRCH_CHEST_BOAT);
cb.addPassenger(spawn(o, 9, 1, 3.5, 180, EntityType.MANNEQUIN));
}
private void leash(Location o) {
Block fence = o.clone().add(0, 0, 0).getBlock();
fence.setType(Material.OAK_FENCE);
LeashHitch knot = w.spawn(fence.getLocation(), LeashHitch.class);
Cow cow = spawn(o, 2.5, 0, 0.5, 180, Cow.class);
cow.setLeashHolder(knot);
LivingEntity holder = (LivingEntity) spawn(o, 5, 0, 0, 180, EntityType.MANNEQUIN);
Wolf wolf = spawn(o, 7, 0, 0.5, 180, Wolf.class);
wolf.setLeashHolder(holder);
HappyGhast g = spawn(o, 4, 4, 5, 180, HappyGhast.class);
g.getEquipment().setItem(EquipmentSlot.BODY, it(Material.BLUE_HARNESS));
Boat boat = (Boat) spawn(o, 4, 0, 4, 180, EntityType.OAK_BOAT);
try {
boat.getClass().getMethod("setLeashHolder", Entity.class).invoke(boat, g);
} catch (Throwable t) {
getLogger().warning("boat not leashable: " + t);
}
Llama l1 = spawn(o, 9, 0, 2, 180, Llama.class);
Llama l2 = spawn(o, 10.5, 0, 3.5, 180, Llama.class);
l2.setLeashHolder(l1);
l1.getInventory().setDecor(it(Material.RED_CARPET));
l1.setCarryingChest(true);
}
private void displays(Location o) {
// Text displays: default background, coloured + shadowed + billboard center, multi-line wrapped.
TextDisplay t1 = spawn(o, 0.5, 1.5, 0, 0, TextDisplay.class);
t1.text(Component.text("Default Text"));
TextDisplay t2 = spawn(o, 2.5, 1.5, 0, 0, TextDisplay.class);
t2.text(Component.text("Billboard CENTER", NamedTextColor.GOLD));
t2.setBillboard(Display.Billboard.CENTER);
t2.setShadowed(true);
t2.setBackgroundColor(Color.fromARGB(255, 30, 30, 90));
TextDisplay t3 = spawn(o, 5, 1.5, 0, 180, TextDisplay.class);
t3.text(Component.text("Dies ist ein langer Text, der umgebrochen werden sollte weil er breit ist"));
t3.setLineWidth(80);
// Item displays.
ItemDisplay i1 = spawn(o, 7, 1, 0, 0, ItemDisplay.class);
i1.setItemStack(it(Material.DIAMOND_SWORD));
i1.setTransformation(new Transformation(new Vector3f(), new AxisAngle4f((float) Math.toRadians(45), 0, 0, 1),
new Vector3f(1, 1, 1), new AxisAngle4f()));
ItemDisplay i2 = spawn(o, 8.5, 0.5, 0, 30, ItemDisplay.class);
i2.setItemStack(it(Material.CHEST));
ItemDisplay i3 = spawn(o, 0.5, 0.5, 2.5, 0, ItemDisplay.class);
i3.setItemStack(it(Material.PLAYER_HEAD));
ItemDisplay i4 = spawn(o, 2, 0.5, 2.5, 0, ItemDisplay.class);
i4.setItemStack(it(Material.SHIELD));
// Block displays: rotated stairs, scaled chest (block entity!), banner, bed, glass.
BlockDisplay b1 = spawn(o, 3.5, 0, 2.5, 0, BlockDisplay.class);
b1.setBlock(Bukkit.createBlockData("minecraft:oak_stairs[facing=east]"));
b1.setTransformation(new Transformation(new Vector3f(), new Quaternionf().rotateY(0.6f), new Vector3f(0.8f), new Quaternionf()));
BlockDisplay b2 = spawn(o, 5, 0, 2.5, 0, BlockDisplay.class);
b2.setBlock(Bukkit.createBlockData("minecraft:chest[facing=north]"));
BlockDisplay b3 = spawn(o, 6.5, 0, 2.5, 0, BlockDisplay.class);
b3.setBlock(Bukkit.createBlockData("minecraft:red_banner"));
BlockDisplay b4 = spawn(o, 8, 0, 2.5, 0, BlockDisplay.class);
b4.setBlock(Bukkit.createBlockData("minecraft:light_blue_stained_glass"));
BlockDisplay b5 = spawn(o, 9.5, 0, 2.5, 0, BlockDisplay.class);
b5.setBlock(Bukkit.createBlockData("minecraft:oak_sign"));
}
/** Mimics PixelBlocks: 1/16-scaled block displays forming a mini build + the placeholder displays. */
private void pixelblock(Location o) {
float s = 1 / 16f;
String[] palette = {"minecraft:oak_planks", "minecraft:stone_bricks", "minecraft:glass", "minecraft:red_wool",
"minecraft:chest[facing=north]", "minecraft:red_bed[part=head,facing=north]", "minecraft:oak_stairs[facing=south]",
"minecraft:torch", "minecraft:grass_block"};
Random rnd = new Random(1);
for (int x = 0; x < 16; x++)
for (int z = 0; z < 16; z++) {
int h = 1 + (int) (3 * Math.abs(Math.sin(x * 0.4) * Math.cos(z * 0.3)));
for (int y = 0; y < h; y++) {
String b = y == h - 1 ? palette[rnd.nextInt(palette.length)] : "minecraft:stone_bricks";
pixel(o, x, y, z, s, Bukkit.createBlockData(b));
}
}
// Placeholder look (small pixel blocks): end-crystal item cores + glass container, next to it.
Location p = o.clone().add(-1.5, 0.5, 0.5);
for (int i = 0; i <= 90; i += 90) {
ItemDisplay core = w.spawn(p, ItemDisplay.class);
core.setRotation(i, 0);
core.setItemStack(it(Material.END_CRYSTAL));
Transformation t = core.getTransformation();
t.getScale().set(0.5);
core.setTransformation(t);
}
ItemDisplay glass = w.spawn(p, ItemDisplay.class);
glass.setItemStack(it(Material.WHITE_STAINED_GLASS));
Interaction hit = w.spawn(o.clone().add(0.5, 0, 0.5), Interaction.class);
hit.setInteractionWidth(1);
hit.setInteractionHeight(1);
}
private void pixel(Location o, int x, int y, int z, float s, BlockData data) {
Location l = o.clone().add(x * s + s / 2, y * s + s / 2, z * s + s / 2);
BlockDisplay d = w.spawn(l, BlockDisplay.class);
d.setBlock(data);
Transformation t = d.getTransformation();
t.getScale().set(s);
t.getTranslation().set(-s / 2, -s / 2, -s / 2);
d.setTransformation(t);
}
private void misc(Location o) {
w.dropItem(o.clone().add(0, 0.2, 0), it(Material.DIAMOND_SWORD), i -> i.setVelocity(new Vector()));
w.dropItem(o.clone().add(1, 0.2, 0), it(Material.OAK_LOG), i -> i.setVelocity(new Vector()));
w.dropItem(o.clone().add(2, 0.2, 0), new ItemStack(Material.COBBLESTONE, 32), i -> i.setVelocity(new Vector()));
w.dropItem(o.clone().add(3, 0.2, 0), it(Material.APPLE), i -> i.setVelocity(new Vector()));
FallingBlock fb = w.spawn(o.clone().add(4.5, 0.5, 0.5), FallingBlock.class, f -> f.setGravity(false));
fb.setBlockData(Bukkit.createBlockData("minecraft:sand"));
TNTPrimed tnt = w.spawn(o.clone().add(6, 0, 0.5), TNTPrimed.class);
tnt.setFuseTicks(10000);
tnt.setGravity(false);
w.spawn(o.clone().add(8, 0, 0.5), EnderCrystal.class);
Sheep sh = spawn(o, 0.5, 0, 3, 180, Sheep.class);
sh.setSheared(true);
Sheep sh2 = spawn(o, 2.5, 0, 3, 180, Sheep.class);
sh2.setColor(DyeColor.LIME);
ArmorStand small = spawn(o, 4.5, 0, 3, 180, ArmorStand.class);
small.setSmall(true);
small.getEquipment().setHelmet(it(Material.LEATHER_HELMET));
spawn(o, 6, 0, 3, 180, EntityType.EXPERIENCE_ORB);
Arrow a = w.spawn(o.clone().add(7, 0.5, 3), Arrow.class, ar -> ar.setGravity(false));
a.setVelocity(new Vector(1, -0.4, 0));
Trident tr = w.spawn(o.clone().add(9, 0.5, 3), Trident.class, ar -> ar.setGravity(false));
tr.setVelocity(new Vector(0, -1, 0.2));
spawn(o, 10, 0, 0, 180, TropicalFish.class);
}
private void dbg(Location o) {
TextDisplay t1 = spawn(o, 0, 1.2, 0, 0, TextDisplay.class);
t1.text(Component.text("Default Text"));
ItemDisplay i4 = spawn(o, 2, 0.5, 0.5, 0, ItemDisplay.class);
i4.setItemStack(it(Material.SHIELD));
getLogger().info("dbg text=" + t1.isValid() + " opacity=" + t1.getTextOpacity() + " shield=" + i4.getItemStack());
}
private void vehicles(Location o) {
spawn(o, 0, 0, 0, 0, EntityType.CHEST_MINECART);
spawn(o, 1.8, 0, 0, 90, EntityType.FURNACE_MINECART);
spawn(o, 3.6, 0, 0, 0, EntityType.HOPPER_MINECART);
spawn(o, 5.4, 0, 0, 0, EntityType.TNT_MINECART);
spawn(o, 7.2, 0, 0, 0, EntityType.SPAWNER_MINECART);
spawn(o, 9, 0, 0, 0, EntityType.COMMAND_BLOCK_MINECART);
spawn(o, 0.5, 0, 3, 30, EntityType.PALE_OAK_BOAT);
spawn(o, 3, 0, 3, 60, EntityType.BAMBOO_CHEST_RAFT);
spawn(o, 6, 0, 3, 90, EntityType.CHERRY_CHEST_BOAT);
spawn(o, 9, 0, 3, 120, EntityType.MANGROVE_BOAT);
}
}
+3 -4
View File
@@ -4,10 +4,9 @@
# Source: the CEM Template Loader data (Ewan Howell / wynem) — vanilla Java entity models as JSON,
# already correctly posed. These ultimately mirror Mojang's hardcoded Java EntityModel classes.
#
# Most future-proof alternative for a NEW Minecraft version (sourced straight from the game):
# 1. Install the JsonEM mod (Fabric), set dump_models=true in .minecraft/config/jsonem.properties
# 2. Launch the game once -> models dumped to .minecraft/jsonem_dump
# (a small format-adapter would be needed; the CEM source below covers vanilla until then.)
# NOTE: this set is now only the FALLBACK (rest-pose geometry) + the block-entity models (chests, signs,
# heads, banners, armor_layer_*). Mob models come from the vanilla client dump — regenerate that with
# tools/update-vanilla-models.sh <version> on every Minecraft update.
#
# Usage: tools/update-cem-models.sh # update if newer
# tools/update-cem-models.sh --force # always overwrite
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Regenerates the vanilla entity-model dump straight from a Minecraft CLIENT jar (26.x+ ships
# unobfuscated, so no mappings are needed). This is the primary entity-model source of PixelPics:
# every mob is posed by its own renderer's setupAnim, and new entities/layers/baby/variant models of
# the new version are picked up automatically. Run it once per Minecraft update.
#
# Needs the version installed in the launcher (~/.minecraft/versions/<ver>/<ver>.json + jar + libraries).
#
# Usage: tools/update-vanilla-models.sh 26.3 # -> src/main/resources/cem/ (bundled; rebuild jar)
# tools/update-vanilla-models.sh 26.4 <plugin-data-dir> # -> <dir>/vanilla_entity_models.json
# # (overrides the bundled copy, no rebuild)
set -euo pipefail
VER="${1:?usage: $0 <minecraft-version> [output-dir]}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT_DIR="${2:-$ROOT/src/main/resources/cem}"
MC="${MINECRAFT_DIR:-$HOME/.minecraft}"
JSON="$MC/versions/$VER/$VER.json"
JAR="$MC/versions/$VER/$VER.jar"
JAVA="${JAVA:-$(command -v java)}"
[[ -f "$JSON" && -f "$JAR" ]] || { echo "Version $VER not found under $MC/versions (start it once in the launcher)"; exit 1; }
# Classpath: client jar + all libraries listed in the version json.
CP="$(python3 - "$JSON" "$MC" "$JAR" <<'EOF'
import json, os, sys
j = json.load(open(sys.argv[1])); mc = sys.argv[2]; cp = []
for lib in j.get("libraries", []):
a = lib.get("downloads", {}).get("artifact")
if a:
p = os.path.join(mc, "libraries", a["path"])
if os.path.exists(p): cp.append(p)
cp.append(sys.argv[3])
print(":".join(cp))
EOF
)"
TMP="$(mktemp --suffix=.json)"
trap 'rm -f "$TMP"; rm -rf "${WORK:-}"' EXIT
# Run from a scratch dir: the game's logger writes a logs/ folder into the working directory.
WORK="$(mktemp -d)"
(cd "$WORK" && "$JAVA" -cp "$CP" "$ROOT/tools/VanillaModelDump.java" "$TMP" 2>&1) | grep -E "STDOUT|Exception" | sed 's/.*STDOUT\]: //'
python3 -c "
import json; d = json.load(open('$TMP'))
assert len(d['models']) > 100, 'dump looks incomplete'
print('version', d.get('version'), '-', len(d['models']), 'models,', len(d['entities']), 'entity types')"
mkdir -p "$OUT_DIR"
mv "$TMP" "$OUT_DIR/vanilla_entity_models.json"
chmod 644 "$OUT_DIR/vanilla_entity_models.json"
rm -rf "$WORK"
trap - EXIT
echo "Wrote $OUT_DIR/vanilla_entity_models.json"
echo "Note: the runtime resource pack must come from the same version (incl. assets/minecraft/font for text)."