primitive chunk table in world snapshot instead of colliding long-keyed hashmap, bounded cache for tinted/composited mob and banner textures, vanilla dump with nested model json, village benchmark and perf test scenes

This commit is contained in:
MineTec committed 2026-11-04 16:59:42 +01:00
1 parent b5baffebbd
commit 88bae41a03
10 files changed
+306 -38

No files matched your search

@@ -116,10 +116,11 @@ public final class Main extends JavaPlugin {
try (InputStream in =
dump.isFile() ? new java.io.FileInputStream(dump) : getResource("cem/vanilla_entity_models.json")) {
if (in != null) {
long t0 = System.nanoTime();
int n = cemLoader.load(in, getLogger());
getLogger()
.info("Loaded vanilla entity models" + (dump.isFile() ? " from " + dump.getName() : "") + " ("
+ n + " models total).");
+ n + " models total, " + (System.nanoTime() - t0) / 1_000_000 + " ms).");
}
} catch (Exception e) {
getLogger().severe("Failed to load vanilla entity models: " + e.getMessage());
@@ -0,0 +1,56 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
/**
* Bounded, thread-safe LRU cache of DERIVED textures — tinted / composited copies the bakers build per entity
* (sheep wool colours, villager type + profession + level overlays, dyed leather armor, collars, banner
* flags, …) — so identical mobs share one texture instead of re-tinting it on every bake.
*
* <p>Keys are built with {@link #key}: a value list of the inputs that determine the result. Source pixel
* grids may be part of the key; arrays compare by identity, which is exact because no grid is mutated once
* it is built (texture-cache grids live for the plugin's lifetime). Cached grids are shared and must not be
* mutated by callers.
* The computation runs outside the lock; concurrent misses on the same key may compute twice, the first
* stored result wins.
*/
public final class DerivedTextureCache {
private static final int[][] NONE = new int[0][0]; // cached "no texture"
private final Map<List<Object>, int[][]> map;
public DerivedTextureCache(int capacity) {
this.map = new LinkedHashMap<>(64, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<List<Object>, int[][]> eldest) {
return size() > capacity;
}
};
}
/** A cache key from the inputs (nulls allowed; arrays compare by identity). */
public static List<Object> key(Object... parts) {
return Arrays.asList(parts);
}
/** The cached texture for {@code key}, computing (and caching) it on a miss; null results are cached too. */
public int[][] get(List<Object> key, Supplier<int[][]> compute) {
int[][] v;
synchronized (map) {
v = map.get(key);
}
if (v == null) {
int[][] computed = compute.get();
synchronized (map) {
v = map.putIfAbsent(key, computed == null ? NONE : computed);
}
if (v == null) return computed;
}
return v == NONE ? null : v;
}
}
@@ -9,6 +9,7 @@ import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityModels;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.DerivedTextureCache;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
@@ -37,6 +38,8 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
private final TextureCache textures;
private final SkinCache skins;
private final BitmapFont font;
// Composited banner flags (base + dyed pattern masks), shared by identical banners.
private final DerivedTextureCache derived = new DerivedTextureCache(256);
/** The sign/text font (shared with text-display rendering). */
public BitmapFont font() {
@@ -104,9 +107,17 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
* (one geometry, no coplanar layers).
*/
private int[][] bannerFlag(int[][] base, BlockEntityState s) {
int baseColor = s.baseColorArgb();
List<BlockEntityState.BannerPattern> patterns = List.copyOf(s.patterns());
return derived.get(
DerivedTextureCache.key("banner", base, baseColor, patterns),
() -> buildBannerFlag(base, baseColor, patterns));
}
private int[][] buildBannerFlag(int[][] base, int baseColor, List<BlockEntityState.BannerPattern> patterns) {
int[][] out = TextureOps.deepCopy(base);
if (s.baseColorArgb() != 0) overlayMask(out, "entity/banner/base", s.baseColorArgb());
for (BlockEntityState.BannerPattern pat : s.patterns()) {
if (baseColor != 0) overlayMask(out, "entity/banner/base", baseColor);
for (BlockEntityState.BannerPattern pat : patterns) {
overlayMask(out, "entity/banner/" + pat.patternKey(), pat.colorArgb());
}
return out;
@@ -7,6 +7,7 @@ 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.DerivedTextureCache;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityModels;
@@ -48,6 +49,8 @@ public final class CemBaker implements EntityBaker<EntityState> {
private final TextureCache textures;
private final SkinCache skins;
private final Map<String, List<String>> subModelCache = new ConcurrentHashMap<>();
// Tinted / composited per-entity textures (wool, villager overlays, armor, collars, …), shared by identical mobs.
private final DerivedTextureCache derived = new DerivedTextureCache(512);
private volatile ItemGeometry items; // held/head items; null = not drawn
@@ -84,17 +87,21 @@ public final class CemBaker implements EntityBaker<EntityState> {
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);
String m = "entity/horse/horse_markings_" + s.markings(); // horse coat markings
String path = ch != null && ch.babyModel ? m + "_baby" : m;
int[][] src = tex;
tex = derived.get(DerivedTextureCache.key("markings", src, path), () -> {
int[][] out = TextureOps.deepCopy(src);
overlayIfPresent(out, path);
return out;
});
}
if (s.collar() != 0 && tex != null && layerModels(base).stream().noneMatch(n -> n.contains("CollarLayer."))) {
// Collar drawn on the body model itself (WolfCollarLayer: parent model, same UV) -> composite it.
tex = withCollar(tex, base, ch != null && ch.babyModel, s.collar());
}
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
tex = tinted(tex, s.tint()); // white base body tinted with the body colour
}
boolean invisible = s.invisible();
@@ -401,9 +408,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
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));
baked.addAll(pose.bake(lm, tinted(t, s.tint())));
}
case "TropicalFishPatternLayer" -> addTropicalPattern(s, n, lm, pose, baked);
case "CatCollarLayer" -> {
@@ -411,9 +416,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (s.collar() == 0 || lm.texture() == null) continue;
int[][] t = texture(lm.texture());
if (t == null) continue;
t = TextureOps.deepCopy(t);
TextureOps.tint(t, s.collar());
baked.addAll(pose.bake(lm, t));
baked.addAll(pose.bake(lm, tinted(t, s.collar())));
}
case "CreeperPowerLayer", "WitherArmorLayer" -> {
// EnergySwirlLayer: charged creeper / wither below half health; drawn additively.
@@ -477,13 +480,17 @@ public final class CemBaker implements EntityBaker<EntityState> {
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));
baked.addAll(pose.bake(lm, tinted(t, s.tint2())));
}
/** entity/equipment/<layerType>/<asset> (+ a "_carpet"-stripped name; leather dye + overlay). */
/** entity/equipment/<layerType>/<asset> (+ a "_carpet"-stripped name; leather dye + overlay); cached. */
private int[][] equipmentTexture(String layerType, String asset, boolean baby, int dye) {
return derived.get(
DerivedTextureCache.key("equip", layerType, asset, baby, dye),
() -> buildEquipmentTexture(layerType, asset, baby, dye));
}
private int[][] buildEquipmentTexture(String layerType, String asset, boolean baby, int dye) {
String folder = "entity/equipment/" + layerType + "/";
int[][] t = null;
List<String> names = new ArrayList<>();
@@ -520,6 +527,10 @@ public final class CemBaker implements EntityBaker<EntityState> {
* xOffset: creeper {@code age·0.01}, wither {@code cos(age·0.02)·3}.
*/
private int[][] energySwirl(String path, boolean wither) {
return derived.get(DerivedTextureCache.key("swirl", path, wither), () -> buildEnergySwirl(path, wither));
}
private int[][] buildEnergySwirl(String path, boolean wither) {
int[][] src = texture(path);
if (src == null) return null;
double u = wither ? Math.cos(SWIRL_AGE * 0.02) * 3 : SWIRL_AGE * 0.01;
@@ -539,16 +550,29 @@ public final class CemBaker implements EntityBaker<EntityState> {
/** Body texture with the dye-tinted {@code entity/<base>/<base>_collar[_baby]} composited over it. */
private int[][] withCollar(int[][] tex, String base, boolean baby, int dye) {
return derived.get(
DerivedTextureCache.key("collar", tex, base, baby, dye), () -> buildWithCollar(tex, base, baby, dye));
}
private int[][] buildWithCollar(int[][] tex, String base, boolean baby, int dye) {
String path = "entity/" + base + "/" + base + "_collar" + (baby ? "_baby" : "");
int[][] collar = texture(path);
if (collar == null || collar.length != tex.length || collar[0].length != tex[0].length) return tex;
int[][] tinted = TextureOps.deepCopy(collar);
TextureOps.tint(tinted, dye);
int[][] out = TextureOps.deepCopy(tex);
TextureOps.overlay(out, tinted);
TextureOps.overlay(out, tinted(collar, dye));
return out;
}
/** {@code src} multiplied by an ARGB tint (cached; {@code src} itself for no tint). */
private int[][] tinted(int[][] src, int argb) {
if (argb == 0) return src;
return derived.get(DerivedTextureCache.key("tint", src, argb), () -> {
int[][] out = TextureOps.deepCopy(src);
TextureOps.tint(out, argb);
return out;
});
}
// --- texture resolution --------------------------------------------------------------------------
private int[][] texture(String path) {
@@ -602,6 +626,15 @@ public final class CemBaker implements EntityBaker<EntityState> {
private int[][] compositeVillager(EntityState s, int[][] base, String folder, boolean babyModel) {
if (base == null) return null;
// Only the inputs the composite reads: babies ignore profession and level.
String prof = babyModel ? null : s.profession();
int level = babyModel ? 0 : s.villagerLevel();
return derived.get(
DerivedTextureCache.key("villager", base, folder, babyModel, s.variant(), prof, level),
() -> buildVillager(s, base, folder, babyModel));
}
private int[][] buildVillager(EntityState s, int[][] base, String folder, boolean babyModel) {
int[][] out = TextureOps.deepCopy(base);
if (babyModel) {
// Baby villagers: their own biome-type clothing set; no profession.
@@ -658,7 +691,8 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (piece == null) return;
CemModelLoader.CemModel model = models.vanilla(modelName);
if (model == null) return;
int[][] tex = buildArmorTexture(piece, layerFolder);
int[][] tex = derived.get(
DerivedTextureCache.key("armor", piece, layerFolder), () -> buildArmorTexture(piece, layerFolder));
if (tex != null) baked.addAll(pose.bake(model, tex));
}
@@ -713,9 +747,14 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (model == null) return; // renderer without a WingsLayer
int[][] tex = texture("entity/equipment/wings/elytra");
if (tex == null) return;
int[][] out = TextureOps.deepCopy(tex);
if (piece.glint()) applyGlint(out);
baked.addAll(pose.bake(model, out));
if (piece.glint()) {
tex = derived.get(DerivedTextureCache.key("elytra_glint", tex), () -> {
int[][] out = TextureOps.deepCopy(texture("entity/equipment/wings/elytra"));
applyGlint(out);
return out;
});
}
baked.addAll(pose.bake(model, tex));
}
private RenderedEntity fallbackBox(EntityState s, int[][] tex) {
@@ -211,8 +211,12 @@ public final class CemModelLoader {
try {
JsonObject entry = e.getValue().getAsJsonObject();
if (!entry.has("model")) continue;
JsonObject model =
JsonParser.parseString(entry.get("model").getAsString()).getAsJsonObject();
// The vanilla dump nests the jem as an object; the CEM template set (and dumps written before
// 2026-11) embed it as a JSON string that needs a second parse.
JsonElement m = entry.get("model");
JsonObject model = m.isJsonObject()
? m.getAsJsonObject()
: JsonParser.parseString(m.getAsString()).getAsJsonObject();
int tw = model.getAsJsonArray("textureSize").get(0).getAsInt();
int th = model.getAsJsonArray("textureSize").get(1).getAsInt();
List<CemPart> parts = new ArrayList<>();
@@ -8,35 +8,75 @@ import org.bukkit.block.data.BlockData;
/**
* An immutable, thread-safe view of a bounded region of the world, backed by {@link ChunkSnapshot}s.
* Block/biome lookups outside the captured region return air/null so rays simply terminate there.
*
* <p>The chunks live in a primitive open-addressing table (linear probing, Fibonacci hashing of the full
* {@link #chunkKey}), not a {@code HashMap<Long, …>}: {@code Long.hashCode} of the key is {@code cx ^ cz}, so
* every diagonal of the captured rectangle collided into treeified bins and each block lookup paid for
* boxing, {@code TreeNode.find} and its reflective {@code comparableClassFor}. Keys are compared in full,
* so lookups stay exact.
*/
public final class WorldSnapshot {
private final Map<Long, ChunkSnapshot> chunks;
private static final long GOLDEN = 0x9E3779B97F4A7C15L;
private final long[] keys;
private final ChunkSnapshot[] values; // null = empty slot
private final int mask;
private final int shift;
private final int minY;
private final int maxY; // exclusive
private final BlockData air;
public WorldSnapshot(Map<Long, ChunkSnapshot> chunks, int minY, int maxY, BlockData air) {
this.chunks = chunks;
// Power-of-two capacity >= 2·size (load factor <= 0.5), at least 2 so the shift stays below 64.
int cap = Math.max(2, Integer.highestOneBit(Math.max(1, chunks.size()) * 2 - 1) << 1);
this.keys = new long[cap];
this.values = new ChunkSnapshot[cap];
this.mask = cap - 1;
this.shift = 64 - Integer.numberOfTrailingZeros(cap);
for (Map.Entry<Long, ChunkSnapshot> e : chunks.entrySet()) {
if (e.getValue() == null) continue;
long key = e.getKey();
int i = slot(key);
while (values[i] != null) i = (i + 1) & mask;
keys[i] = key;
values[i] = e.getValue();
}
this.minY = minY;
this.maxY = maxY;
this.air = air;
}
/** Unique key of a chunk position (x in the high, z in the low 32 bits). */
public static long chunkKey(int chunkX, int chunkZ) {
return ((long) chunkX << 32) ^ (chunkZ & 0xFFFFFFFFL);
}
private int slot(long key) {
return (int) ((key * GOLDEN) >>> shift);
}
private ChunkSnapshot chunk(int chunkX, int chunkZ) {
long key = chunkKey(chunkX, chunkZ);
int i = slot(key);
ChunkSnapshot cs;
while ((cs = values[i]) != null) {
if (keys[i] == key) return cs;
i = (i + 1) & mask;
}
return null;
}
public BlockData getBlockData(int x, int y, int z) {
if (y < minY || y >= maxY) return air;
ChunkSnapshot cs = chunks.get(chunkKey(x >> 4, z >> 4));
ChunkSnapshot cs = chunk(x >> 4, z >> 4);
if (cs == null) return air;
return cs.getBlockData(x & 15, y, z & 15);
}
public Biome getBiome(int x, int y, int z) {
if (y < minY || y >= maxY) return null;
ChunkSnapshot cs = chunks.get(chunkKey(x >> 4, z >> 4));
ChunkSnapshot cs = chunk(x >> 4, z >> 4);
if (cs == null) return null;
return cs.getBiome(x & 15, y, z & 15);
}