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:
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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user