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:
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(
|
||||
|
||||
+28
-3
@@ -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));
|
||||
|
||||
+2
-5
@@ -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 {
|
||||
|
||||
+106
-15
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user