enhance entity rendering: add support for villager levels, horse armor, llama decor, and saddle/chest states

This commit is contained in:
2026-06-21 15:32:53 +02:00
parent 094aa463c5
commit 5330948dbd
7 changed files with 170 additions and 11 deletions
@@ -11,7 +11,13 @@ public record EntityState(
boolean baby,
double width, double height,
boolean player, String skinUrl, boolean slim,
String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"), or null
String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"); for villagers the biome type, or null
int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none
double sizeScale // extra model scale (slime/magma-cube size); 1.0 = default
double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default
String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null
int villagerLevel, // villager profession level 1-5 (badge tier); 0 = none/unknown
String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null
boolean saddle, // horse/donkey/mule is saddled
boolean chest, // donkey/mule/llama is carrying a chest
String bodyEquip // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama"; null = none
) {}
@@ -46,6 +46,14 @@ public final class CemBaker implements EntityBaker<EntityState> {
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
}
CemModelLoader.CemModel model = models.get(cem);
if (model == null || tex == null) return fallbackBox(s, tex);
@@ -54,7 +62,12 @@ public final class CemBaker implements EntityBaker<EntityState> {
// rotations and handedness; only px->block scaling is applied.
Affine pre = Affine.scale(sc / 16.0);
java.util.Set<String> hidden = HIDDEN_PARTS.getOrDefault(cem, java.util.Set.of());
java.util.Set<String> hidden = new java.util.HashSet<>(HIDDEN_PARTS.getOrDefault(cem, java.util.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"); }
}
List<CemGeometry.Baked> baked = new ArrayList<>(CemGeometry.bakeModel(model, tex, pre, hidden));
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
if (s.typeKey().equals("sheep")) {
@@ -74,6 +87,8 @@ public final class CemBaker implements EntityBaker<EntityState> {
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 (baked.isEmpty()) return fallbackBox(s, tex);
double minY = Double.MAX_VALUE;
@@ -104,6 +119,65 @@ public final class CemBaker implements EntityBaker<EntityState> {
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) {
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 (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]);
}
}
return out;
}
/** 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);
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;
}
/** 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.
java.util.Set<String> hideBase = new java.util.HashSet<>();
for (CemModelLoader.CemPart p : base.parts()) hideBase.add(p.name());
baked.addAll(CemGeometry.bakeModel(sm, saddleTex, pre, hideBase));
}
private RenderedEntity fallbackBox(EntityState s, int[][] tex) {
double w = Math.max(0.3, s.width()) * 16 * s.sizeScale(), h = Math.max(0.3, s.height()) * 16 * s.sizeScale();
double[] from = {-w / 2, 0, -w / 2};
@@ -110,8 +110,7 @@ public final class BlockEntitySnapshotBuilder {
b.baseColorArgb(ColorUtil.dyeArgb(base, 0xFFFFFFFF));
List<BannerPattern> pats = new ArrayList<>();
for (Pattern p : banner.getPatterns()) {
String key = p.getPattern().key().value();
pats.add(new BannerPattern(key, ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF)));
pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF)));
}
b.patterns(pats);
}
@@ -267,6 +266,13 @@ public final class BlockEntitySnapshotBuilder {
return null;
}
/** The banner pattern's path key (e.g. "stripe_bottom"); every PatternType key accessor is marked
* for removal in this API with no stable replacement, so the warning is suppressed here. */
@SuppressWarnings("removal")
private static String patternKey(Pattern p) {
return p.getPattern().getKey().getKey();
}
// --- small fluent builder to keep the 15-field record construction readable ---
private static Builder base(Kind kind, int bx, int by, int bz, float yaw) {
@@ -75,6 +75,12 @@ public final class EntitySnapshotBuilder {
String variant = null;
int tint = 0;
double sizeScale = 1.0;
String profession = null;
int villagerLevel = 0;
String markings = null;
boolean saddle = false;
boolean chest = false;
String bodyEquip = null;
try {
// Slime & magma cube (MagmaCube extends Slime) scale their model by size (1/2/4).
if (e instanceof org.bukkit.entity.Slime sl) sizeScale = sl.getSize();
@@ -93,8 +99,21 @@ public final class EntitySnapshotBuilder {
variant = keyOf(r.getRabbitType());
} else if (e instanceof org.bukkit.entity.Horse h) {
variant = keyOf(h.getColor());
markings = markingsKey(h.getStyle());
saddle = isSaddled(h);
bodyEquip = horseArmorKey(h);
} else if (e instanceof org.bukkit.entity.Llama l) {
variant = keyOf(l.getColor());
chest = l.isCarryingChest();
// Trader llamas wear a fixed decor; normal llamas carry a dyed carpet in the decor slot.
bodyEquip = (e instanceof org.bukkit.entity.TraderLlama) ? "trader_llama" : carpetKey(l);
} else if (e instanceof org.bukkit.entity.ChestedHorse ch) {
// Donkey & mule (llama already handled above).
chest = ch.isCarryingChest();
saddle = isSaddled(ch);
} else if (e instanceof org.bukkit.entity.AbstractHorse ah) {
// Skeleton/zombie horse: only saddle (no colour/markings/armor variants).
saddle = isSaddled(ah);
} else if (e instanceof org.bukkit.entity.Fox f) {
variant = keyOf(f.getFoxType());
} else if (e instanceof org.bukkit.entity.MushroomCow mc) {
@@ -107,8 +126,12 @@ public final class EntitySnapshotBuilder {
variant = s.getColor() == null ? null : keyOf(s.getColor());
} else if (e instanceof org.bukkit.entity.ZombieVillager zv) {
variant = keyOf(zv.getVillagerType());
profession = keyOf(zv.getVillagerProfession());
// ZombieVillager exposes no level via Bukkit -> no profession-level badge (matches vanilla).
} else if (e instanceof org.bukkit.entity.Villager vi) {
variant = keyOf(vi.getVillagerType());
profession = keyOf(vi.getProfession());
villagerLevel = vi.getVillagerLevel();
} else if (e instanceof org.bukkit.entity.Cow co) {
variant = keyOf(co.getVariant());
} else if (e instanceof org.bukkit.entity.Pig pg) {
@@ -122,7 +145,36 @@ public final class EntitySnapshotBuilder {
return new EntityState(type, loc.getX(), loc.getY(), loc.getZ(),
bodyYaw, baby, width, height,
player, skinUrl, slim, variant, tint, sizeScale);
player, skinUrl, slim, variant, tint, sizeScale, profession, villagerLevel,
markings, saddle, chest, bodyEquip);
}
/** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */
private static String markingsKey(org.bukkit.entity.Horse.Style style) {
if (style == null || style == org.bukkit.entity.Horse.Style.NONE) return null;
return style.name().toLowerCase(java.util.Locale.ROOT).replace("_", ""); // WHITE_DOTS -> whitedots
}
/** Horse armor material -> equipment/horse_body texture key (golden uses the "gold" file); null if none. */
private static String horseArmorKey(org.bukkit.entity.Horse h) {
org.bukkit.inventory.ItemStack a = h.getInventory().getArmor();
if (a == null || a.getType().isAir()) return null;
String k = a.getType().getKey().getKey().replace("_horse_armor", "");
return k.equals("golden") ? "gold" : k;
}
/** Llama carpet decor -> equipment/llama_body colour key; null if none. */
private static String carpetKey(org.bukkit.entity.Llama l) {
org.bukkit.inventory.ItemStack d = l.getInventory().getDecor();
if (d == null || d.getType().isAir()) return null;
String k = d.getType().getKey().getKey();
return k.endsWith("_carpet") ? k.substring(0, k.length() - "_carpet".length()) : null;
}
/** Whether a horse-like mount carries a saddle in its dedicated saddle slot. */
private static boolean isSaddled(org.bukkit.entity.AbstractHorse h) {
org.bukkit.inventory.ItemStack st = h.getInventory().getSaddle();
return st != null && !st.getType().isAir();
}
/** Registry/Keyed values yield their key path; plain enums yield their lower-case name. */
@@ -21,6 +21,9 @@ public final class MapColorPalette {
private MapColorPalette() {}
// MapPalette.getColor(byte) is deprecated for removal with no replacement for enumerating the
// index->colour palette; suppress until the API offers an alternative.
@SuppressWarnings("removal")
private static synchronized void ensure() {
if (initialized) return;
List<Byte> idx = new ArrayList<>();
@@ -33,7 +36,7 @@ public final class MapColorPalette {
} catch (Throwable t) {
continue;
}
if (c == null || c.getAlpha() < 255) continue; // skip transparent slots
if (c.getAlpha() < 255) continue; // skip transparent slots
idx.add((byte) i);
rgbs.add((c.getRed() << 16) | (c.getGreen() << 8) | c.getBlue());
labs.add(rgbToLab(c.getRed(), c.getGreen(), c.getBlue()));
+1 -1
View File
@@ -54,7 +54,7 @@ public class BlockEntityTestRender {
DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log);
BlockData air = (BlockData) Proxy.newProxyInstance(BlockEntityTestRender.class.getClassLoader(),
new Class[]{BlockData.class}, (p, m, a) -> {
new Class<?>[]{BlockData.class}, (p, m, a) -> {
switch (m.getName()) {
case "getMaterial": return Material.AIR;
case "equals": return p == a[0];
+21 -3
View File
@@ -48,7 +48,24 @@ public class EntityTestRender {
Map.entry("parrot", "red"), Map.entry("rabbit", "brown"), Map.entry("horse", "white"),
Map.entry("llama", "creamy"), Map.entry("trader_llama", "creamy"), Map.entry("fox", "red"),
Map.entry("mooshroom", "red"), Map.entry("frog", "temperate"), Map.entry("panda", "normal"),
Map.entry("cow", "temperate"), Map.entry("pig", "temperate"), Map.entry("chicken", "temperate")
Map.entry("cow", "temperate"), Map.entry("pig", "temperate"), Map.entry("chicken", "temperate"),
Map.entry("villager", "taiga"), Map.entry("zombie_villager", "swamp")
);
// Villager profession / level for the standalone render (biome type comes from VAR above).
static final Map<String, String> PROF = Map.ofEntries(
Map.entry("villager", "librarian"), Map.entry("zombie_villager", "farmer")
);
static final Map<String, Integer> LVL = Map.ofEntries(
Map.entry("villager", 5)
);
// Horse/llama/donkey equipment for the standalone render.
static final Map<String, String> MARK = Map.of("horse", "blackdots"); // coat markings
static final java.util.Set<String> SADDLE = java.util.Set.of("horse", "donkey", "mule");
static final java.util.Set<String> CHEST = java.util.Set.of("llama", "donkey");
static final Map<String, String> EQUIP = Map.ofEntries( // armor / carpet
Map.entry("horse", "diamond"), Map.entry("llama", "red"), Map.entry("trader_llama", "trader_llama")
);
public static void main(String[] args) throws Exception {
@@ -67,7 +84,7 @@ public class EntityTestRender {
DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log);
BlockData air = (BlockData) Proxy.newProxyInstance(EntityTestRender.class.getClassLoader(),
new Class[]{BlockData.class}, (p, m, a) -> {
new Class<?>[]{BlockData.class}, (p, m, a) -> {
switch (m.getName()) {
case "getMaterial": return Material.AIR;
case "equals": return p == a[0];
@@ -126,7 +143,8 @@ public class EntityTestRender {
SkyContext sky, String key, float yaw) {
boolean isPlayer = key.equals("player");
EntityState s = new EntityState(key, 0, 0, 0, yaw, false, 0.8, 1.0,
isPlayer, null, false, VAR.get(key), 0, 1.0);
isPlayer, null, false, VAR.get(key), 0, 1.0, PROF.get(key), LVL.getOrDefault(key, 0),
MARK.get(key), SADDLE.contains(key), CHEST.contains(key), EQUIP.get(key));
RenderedEntity re = baker.bake(s);
double cx = (re.aabbMin[0] + re.aabbMax[0]) / 2;
double cy = (re.aabbMin[1] + re.aabbMax[1]) / 2;