simplify asset code: unread average colour and constant model fields dropped (bake returns the element list, shared empty model), baked elements cached per blockstate variant and parent models cached while flattening, one element constructor plus withFaces, blockstate box/face/axis rotation in one pass instead of per-step x/y copies, face rotation normalised once, block state parsing inlined into the resolver, shared json read and image-to-grid helpers, path concatenation instead of format, dead zip directory probe and unused dto/font members removed, comments trimmed
This commit is contained in:
1 parent
7f2520a52d
commit
44f2b3c59c
27 files changed
+356
-701
No files matched your search
@@ -1,36 +1,34 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets;
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
/**
|
/** Pack-relative asset paths in the vanilla layout {@code assets/<namespace>/<category>/<path>.<ext>}. */
|
||||||
* Builds pack-relative asset paths from namespaced ids, following the vanilla layout
|
|
||||||
* {@code assets/<namespace>/<category>/<path>.<ext>}.
|
|
||||||
*/
|
|
||||||
public final class AssetPaths {
|
public final class AssetPaths {
|
||||||
|
|
||||||
private AssetPaths() {}
|
private AssetPaths() {}
|
||||||
|
|
||||||
/** {@code assets/<ns>/blockstates/<name>.json} for a plain block name (no {@code block/} prefix). */
|
/** For a plain block name such as {@code oak_stairs}. */
|
||||||
public static String blockState(String blockName) {
|
public static String blockState(String blockName) {
|
||||||
return String.format(
|
return "assets/" + ResourceLocation.DEFAULT_NAMESPACE + "/blockstates/" + blockName.toLowerCase() + ".json";
|
||||||
"assets/%s/blockstates/%s.json", ResourceLocation.DEFAULT_NAMESPACE, blockName.toLowerCase());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@code assets/<ns>/models/<path>.json}. The id path already contains e.g. {@code block/stone}. */
|
/** The id path includes the folder, e.g. {@code block/stone}. */
|
||||||
public static String model(ResourceLocation id) {
|
public static String model(ResourceLocation id) {
|
||||||
return String.format("assets/%s/models/%s.json", id.namespace(), id.path());
|
return path(id, "models", ".json");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@code assets/<ns>/textures/<path>.png}. The id path already contains e.g. {@code block/stone}. */
|
|
||||||
public static String texture(ResourceLocation id) {
|
public static String texture(ResourceLocation id) {
|
||||||
return String.format("assets/%s/textures/%s.png", id.namespace(), id.path());
|
return path(id, "textures", ".png");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@code assets/<ns>/textures/<path>.png.mcmeta} animation metadata, if present. */
|
/** Animation metadata of a texture. */
|
||||||
public static String textureMeta(ResourceLocation id) {
|
public static String textureMeta(ResourceLocation id) {
|
||||||
return texture(id) + ".mcmeta";
|
return texture(id) + ".mcmeta";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@code assets/<ns>/font/<path>.json}, e.g. for {@code minecraft:default} or {@code minecraft:include/space}. */
|
|
||||||
public static String font(ResourceLocation id) {
|
public static String font(ResourceLocation id) {
|
||||||
return String.format("assets/%s/font/%s.json", id.namespace(), id.path());
|
return path(id, "font", ".json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String path(ResourceLocation id, String category, String extension) {
|
||||||
|
return "assets/" + id.namespace() + "/" + category + "/" + id.path() + extension;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,9 @@ import com.google.gson.JsonObject;
|
|||||||
import com.google.gson.JsonParser;
|
import com.google.gson.JsonParser;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
/**
|
/** Reads JSON assets from a {@link ResourcePack}; unreadable or malformed files are empty. */
|
||||||
* Thin convenience layer over a {@link ResourcePack} for reading JSON assets.
|
|
||||||
*/
|
|
||||||
public final class AssetReader {
|
public final class AssetReader {
|
||||||
|
|
||||||
private final ResourcePack pack;
|
private final ResourcePack pack;
|
||||||
@@ -19,20 +18,17 @@ public final class AssetReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public <T> Optional<T> readJson(String path, Class<T> type) {
|
public <T> Optional<T> readJson(String path, Class<T> type) {
|
||||||
return pack.read(path).flatMap(bytes -> {
|
return read(path, text -> gson.fromJson(text, type));
|
||||||
try {
|
|
||||||
return Optional.ofNullable(gson.fromJson(new String(bytes, StandardCharsets.UTF_8), type));
|
|
||||||
} catch (Exception e) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<JsonObject> readJsonObject(String path) {
|
public Optional<JsonObject> readJsonObject(String path) {
|
||||||
|
return read(path, text -> JsonParser.parseString(text).getAsJsonObject());
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> Optional<T> read(String path, Function<String, T> parser) {
|
||||||
return pack.read(path).flatMap(bytes -> {
|
return pack.read(path).flatMap(bytes -> {
|
||||||
try {
|
try {
|
||||||
return Optional.of(JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8))
|
return Optional.ofNullable(parser.apply(new String(bytes, StandardCharsets.UTF_8)));
|
||||||
.getAsJsonObject());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets;
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.model.AverageColor;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
|
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
|
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
|
||||||
|
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -14,12 +14,51 @@ import org.bukkit.Material;
|
|||||||
import org.bukkit.block.data.BlockData;
|
import org.bukkit.block.data.BlockData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-level entry point that turns a {@link BlockData} into a baked {@link ResolvedModel}, combining
|
* Turns a {@link BlockData} into a baked {@link ResolvedModel} (blockstate -> variants -> flattened models ->
|
||||||
* blockstate resolution, model resolution and geometry baking. Results are cached per BlockData and
|
* elements). Thread-safe; models are cached per BlockData and the baked elements per {@link Variant}.
|
||||||
* the cache is safe for concurrent access by the parallel renderer.
|
|
||||||
*/
|
*/
|
||||||
public final class BlockModelRegistry {
|
public final class BlockModelRegistry {
|
||||||
|
|
||||||
|
private static final int FALLBACK_GREY = 0xFF7F7F7F;
|
||||||
|
|
||||||
|
// Render nothing in the world: hand-only technical blocks, and liquids (vanilla RenderShape.INVISIBLE; drawn by
|
||||||
|
// render.raytrace.FluidRenderer, block displays of them show nothing like vanilla).
|
||||||
|
private static final EnumSet<Material> INVISIBLE_MATERIALS = EnumSet.of(
|
||||||
|
Material.BARRIER,
|
||||||
|
Material.LIGHT,
|
||||||
|
Material.STRUCTURE_VOID,
|
||||||
|
Material.WATER,
|
||||||
|
Material.LAVA,
|
||||||
|
Material.BUBBLE_COLUMN);
|
||||||
|
|
||||||
|
// Drawn by the block-entity renderer (entity scene) and without block-model geometry. Matched by name, not
|
||||||
|
// Material#isBlock (needs a live registry); only queried with placed block materials. Keep in sync with
|
||||||
|
// render.snapshot.BlockEntitySnapshotBuilder#classify (which also covers kinds that keep their block model).
|
||||||
|
private static final EnumSet<Material> BLOCK_ENTITY_MATERIALS = EnumSet.noneOf(Material.class);
|
||||||
|
|
||||||
|
static {
|
||||||
|
for (Material m : Material.values()) {
|
||||||
|
String n = m.name();
|
||||||
|
if (n.endsWith("_SIGN")
|
||||||
|
|| n.endsWith("_BANNER")
|
||||||
|
|| n.endsWith("SHULKER_BOX")
|
||||||
|
|| ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD)
|
||||||
|
|| m == Material.CHEST
|
||||||
|
|| m == Material.TRAPPED_CHEST
|
||||||
|
|| m == Material.ENDER_CHEST
|
||||||
|
|| n.endsWith("COPPER_CHEST")
|
||||||
|
|| m == Material.CONDUIT
|
||||||
|
|| m == Material.DECORATED_POT
|
||||||
|
|| m == Material.BELL
|
||||||
|
|| n.endsWith("COPPER_GOLEM_STATUE")
|
||||||
|
|| m == Material.END_PORTAL
|
||||||
|
|| m == Material.END_GATEWAY) {
|
||||||
|
BLOCK_ENTITY_MATERIALS.add(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final AssetReader reader;
|
||||||
private final TextureCache textures;
|
private final TextureCache textures;
|
||||||
private final BlockStateResolver blockStateResolver;
|
private final BlockStateResolver blockStateResolver;
|
||||||
private final ModelResolver modelResolver;
|
private final ModelResolver modelResolver;
|
||||||
@@ -27,18 +66,7 @@ public final class BlockModelRegistry {
|
|||||||
|
|
||||||
private final Map<BlockData, ResolvedModel> cache = new ConcurrentHashMap<>();
|
private final Map<BlockData, ResolvedModel> cache = new ConcurrentHashMap<>();
|
||||||
private final Map<ResourceLocation, ResolvedModel> modelCache = new ConcurrentHashMap<>();
|
private final Map<ResourceLocation, ResolvedModel> modelCache = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Variant, List<Element>> variantCache = new ConcurrentHashMap<>();
|
||||||
private final AssetReader reader;
|
|
||||||
|
|
||||||
/** The texture cache the models are baked from (also used for the fluid sprites). */
|
|
||||||
public TextureCache textures() {
|
|
||||||
return textures;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The asset reader this registry resolves models with (shared by item-model resolution). */
|
|
||||||
public AssetReader reader() {
|
|
||||||
return reader;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BlockModelRegistry(AssetReader reader, TextureCache textures) {
|
public BlockModelRegistry(AssetReader reader, TextureCache textures) {
|
||||||
this.reader = reader;
|
this.reader = reader;
|
||||||
@@ -48,128 +76,82 @@ public final class BlockModelRegistry {
|
|||||||
this.baker = new ModelBaker(textures);
|
this.baker = new ModelBaker(textures);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public TextureCache textures() {
|
||||||
|
return textures;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AssetReader reader() {
|
||||||
|
return reader;
|
||||||
|
}
|
||||||
|
|
||||||
public ResolvedModel get(BlockData data) {
|
public ResolvedModel get(BlockData data) {
|
||||||
return cache.computeIfAbsent(data, this::resolve);
|
ResolvedModel model = cache.get(data);
|
||||||
|
return model != null ? model : cache.computeIfAbsent(data, this::resolve);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A block model by id (e.g. {@code block/item_frame_map}) without a blockstate, unrotated: for models
|
* A block model by id (e.g. {@code block/item_frame_map}) without a blockstate, unrotated: for models vanilla
|
||||||
* vanilla draws from a fake block state (item frames). Empty geometry when the model is missing.
|
* draws from a fake block state (item frames). Empty when the model is missing.
|
||||||
*/
|
*/
|
||||||
public ResolvedModel model(ResourceLocation modelId) {
|
public ResolvedModel model(ResourceLocation modelId) {
|
||||||
return modelCache.computeIfAbsent(modelId, id -> {
|
return modelCache.computeIfAbsent(modelId, id -> {
|
||||||
ModelBaker.BakedGeometry baked = baker.bake(modelResolver.resolve(id), new Variant(id, 0, 0, false));
|
List<Element> elements = bake(new Variant(id, 0, 0, false));
|
||||||
return new ResolvedModel(baked.elements(), baked.averageColor(), 0, 0, false, baked.hasGeometry());
|
return new ResolvedModel(elements, !elements.isEmpty());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<Element> bake(Variant variant) {
|
||||||
|
return variantCache.computeIfAbsent(variant, v -> baker.bake(modelResolver.resolve(v.model()), v));
|
||||||
|
}
|
||||||
|
|
||||||
private ResolvedModel resolve(BlockData data) {
|
private ResolvedModel resolve(BlockData data) {
|
||||||
Material material = data.getMaterial();
|
Material material = data.getMaterial();
|
||||||
// Technical blocks that are invisible in the world (their textures only show held in hand), and liquid
|
if (INVISIBLE_MATERIALS.contains(material)) return ResolvedModel.EMPTY;
|
||||||
// blocks (vanilla RenderShape.INVISIBLE: water, lava and bubble columns are drawn by the fluid renderer,
|
|
||||||
// see render.raytrace.FluidRenderer; block displays of them show nothing, like vanilla).
|
|
||||||
if (INVISIBLE_MATERIALS.contains(material)) {
|
|
||||||
return new ResolvedModel(List.of(), 0, 0, 0, false, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Variant> variants = blockStateResolver.resolve(data);
|
List<Variant> variants = blockStateResolver.resolve(data);
|
||||||
|
List<Element> elements;
|
||||||
List<Element> elements = new ArrayList<>();
|
if (variants.size() == 1) {
|
||||||
AverageColor.Accumulator avgColor = new AverageColor.Accumulator();
|
elements = bake(variants.getFirst()); // shared with other states of the same variant; never mutated
|
||||||
FlatModel lastFlat = null;
|
} else {
|
||||||
|
elements = new ArrayList<>();
|
||||||
for (Variant variant : variants) {
|
for (Variant variant : variants) elements.addAll(bake(variant));
|
||||||
FlatModel flat = modelResolver.resolve(variant.model());
|
|
||||||
lastFlat = flat;
|
|
||||||
ModelBaker.BakedGeometry baked = baker.bake(flat, variant);
|
|
||||||
elements.addAll(baked.elements());
|
|
||||||
if (baked.hasGeometry()) {
|
|
||||||
avgColor.add(baked.averageColor());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!elements.isEmpty()) return new ResolvedModel(elements, true);
|
||||||
|
|
||||||
if (!elements.isEmpty()) {
|
// Block entities (builtin/entity models) are drawn by the entity scene: no grey cube in front of them.
|
||||||
return new ResolvedModel(elements, avgColor.average(0xFF7F7F7F), 0, 0, false, true);
|
if (BLOCK_ENTITY_MATERIALS.contains(material)) return ResolvedModel.EMPTY;
|
||||||
}
|
FlatModel last = variants.isEmpty()
|
||||||
|
? null
|
||||||
int avg = fallbackColor(lastFlat);
|
: modelResolver.resolve(variants.getLast().model());
|
||||||
|
return new ResolvedModel(List.of(solidCube(particleColor(last))), false);
|
||||||
// Block-entities (chests, banners, heads, shulker boxes, …) use builtin/entity models with no
|
|
||||||
// geometry; their real shape is rendered separately through the entity scene. Return empty
|
|
||||||
// geometry so the ray passes through to that geometry instead of drawing a grey fallback cube.
|
|
||||||
if (isBlockEntityRendered(material)) {
|
|
||||||
return new ResolvedModel(List.of(), avg, 0, 0, false, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No geometry: render a flat full cube using a fallback average color.
|
|
||||||
return new ResolvedModel(List.of(solidCube(avg)), avg, 0, 0, false, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Technical blocks that render nothing in the world (barrier/light/structure void are hand-only). */
|
/** A full cube whose faces sample one solid colour. */
|
||||||
private static final EnumSet<Material> INVISIBLE_MATERIALS = buildInvisibleMaterials();
|
private static Element solidCube(int color) {
|
||||||
|
|
||||||
private static EnumSet<Material> buildInvisibleMaterials() {
|
|
||||||
EnumSet<Material> set = EnumSet.noneOf(Material.class);
|
|
||||||
for (String n : new String[] {"BARRIER", "LIGHT", "STRUCTURE_VOID", "WATER", "LAVA", "BUBBLE_COLUMN"}) {
|
|
||||||
try {
|
|
||||||
set.add(Material.valueOf(n));
|
|
||||||
} catch (IllegalArgumentException ignored) {
|
|
||||||
/* older/newer server */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return set;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Block materials whose shape is drawn by the block-entity renderer, not the block-model pipeline. */
|
|
||||||
private static final EnumSet<Material> BLOCK_ENTITY_MATERIALS = buildBlockEntityMaterials();
|
|
||||||
|
|
||||||
private static boolean isBlockEntityRendered(Material material) {
|
|
||||||
return BLOCK_ENTITY_MATERIALS.contains(material);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static EnumSet<Material> buildBlockEntityMaterials() {
|
|
||||||
// Matched purely by name/identity (no Material#isBlock, which needs a live registry): the set is
|
|
||||||
// only ever queried with placed block materials, so any item-only matches are harmless.
|
|
||||||
EnumSet<Material> set = EnumSet.noneOf(Material.class);
|
|
||||||
for (Material m : Material.values()) {
|
|
||||||
String n = m.name();
|
|
||||||
boolean match = n.endsWith("_SIGN") // signs: standing/wall/(wall_)hanging
|
|
||||||
|| n.endsWith("_BANNER") // banners: standing/wall
|
|
||||||
|| n.endsWith("SHULKER_BOX") // SHULKER_BOX + <color>_SHULKER_BOX
|
|
||||||
|| ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD)
|
|
||||||
|| m == Material.CHEST
|
|
||||||
|| m == Material.TRAPPED_CHEST
|
|
||||||
|| m == Material.ENDER_CHEST
|
|
||||||
|| n.endsWith("COPPER_CHEST") // copper chests incl. weathered/waxed stages
|
|
||||||
|| m == Material.CONDUIT
|
|
||||||
|| m == Material.DECORATED_POT
|
|
||||||
|| m == Material.BELL
|
|
||||||
|| n.endsWith("COPPER_GOLEM_STATUE") // statue incl. weathered/waxed stages
|
|
||||||
|| m == Material.END_PORTAL
|
|
||||||
|| m == Material.END_GATEWAY;
|
|
||||||
if (match) set.add(m);
|
|
||||||
}
|
|
||||||
return set;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A full-block cube whose six faces sample a single solid color (1x1 texture). */
|
|
||||||
private Element solidCube(int color) {
|
|
||||||
int[][] tex = {{color}};
|
int[][] tex = {{color}};
|
||||||
Face[] faces = new Face[6];
|
Face[] faces = new Face[6];
|
||||||
for (Direction d : Direction.values()) {
|
for (Direction d : Direction.values()) faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
|
||||||
faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
|
return new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces);
|
||||||
}
|
|
||||||
return new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces, null, -1, 0, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private int fallbackColor(FlatModel flat) {
|
/** Average colour of the model's particle texture (texels with alpha > 16), else grey. */
|
||||||
if (flat != null && flat.textures() != null) {
|
private int particleColor(FlatModel flat) {
|
||||||
String particle = flat.textures().get("particle");
|
String particle = flat == null ? null : flat.textures().get("particle");
|
||||||
if (particle != null && !particle.startsWith("#")) {
|
if (particle == null || particle.startsWith("#")) return FALLBACK_GREY;
|
||||||
int[][] tex = textures.get(ResourceLocation.parse(particle)).orElse(null);
|
int[][] tex = textures.get(ResourceLocation.parse(particle)).orElse(null);
|
||||||
if (tex != null) return AverageColor.of(tex);
|
if (tex == null) return FALLBACK_GREY;
|
||||||
|
long r = 0, g = 0, b = 0;
|
||||||
|
int count = 0;
|
||||||
|
for (int[] row : tex) {
|
||||||
|
for (int argb : row) {
|
||||||
|
if (!ColorUtil.opaque(argb)) continue;
|
||||||
|
r += ColorUtil.red(argb);
|
||||||
|
g += ColorUtil.green(argb);
|
||||||
|
b += ColorUtil.blue(argb);
|
||||||
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0xFF7F7F7F;
|
if (count == 0) return FALLBACK_GREY;
|
||||||
|
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets;
|
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import org.bukkit.block.data.BlockData;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the property map and plain block name out of a {@link BlockData} string such as
|
|
||||||
* {@code minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]}.
|
|
||||||
*/
|
|
||||||
public final class BlockStateProperties {
|
|
||||||
|
|
||||||
private BlockStateProperties() {}
|
|
||||||
|
|
||||||
/** The block name without namespace, e.g. {@code oak_stairs}. */
|
|
||||||
public static String blockName(BlockData data) {
|
|
||||||
String s = data.getAsString(false);
|
|
||||||
int bracket = s.indexOf('[');
|
|
||||||
String id = bracket < 0 ? s : s.substring(0, bracket);
|
|
||||||
int colon = id.indexOf(':');
|
|
||||||
return (colon < 0 ? id : id.substring(colon + 1)).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The {@code prop -> value} map (empty when the block has no properties). */
|
|
||||||
public static Map<String, String> properties(BlockData data) {
|
|
||||||
Map<String, String> props = new LinkedHashMap<>();
|
|
||||||
String s = data.getAsString(false);
|
|
||||||
int open = s.indexOf('[');
|
|
||||||
int close = s.lastIndexOf(']');
|
|
||||||
if (open < 0 || close < 0 || close <= open) return props;
|
|
||||||
|
|
||||||
String body = s.substring(open + 1, close);
|
|
||||||
for (String pair : body.split(",")) {
|
|
||||||
int eq = pair.indexOf('=');
|
|
||||||
if (eq < 0) continue;
|
|
||||||
props.put(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
|
|
||||||
}
|
|
||||||
return props;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,12 @@ import com.google.gson.JsonArray;
|
|||||||
import com.google.gson.JsonElement;
|
import com.google.gson.JsonElement;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import org.bukkit.block.data.BlockData;
|
import org.bukkit.block.data.BlockData;
|
||||||
|
|
||||||
/**
|
/** Resolves a {@link BlockData} to the model variants of its {@code blockstates/<name>.json} (variants or multipart). */
|
||||||
* Resolves a {@link BlockData} to the list of model variants vanilla would render, by reading the
|
|
||||||
* block's {@code blockstates/<name>.json} (either {@code variants} or {@code multipart}).
|
|
||||||
*/
|
|
||||||
public final class BlockStateResolver {
|
public final class BlockStateResolver {
|
||||||
|
|
||||||
private final AssetReader reader;
|
private final AssetReader reader;
|
||||||
@@ -21,8 +19,21 @@ public final class BlockStateResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<Variant> resolve(BlockData data) {
|
public List<Variant> resolve(BlockData data) {
|
||||||
String name = BlockStateProperties.blockName(data);
|
// e.g. minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]
|
||||||
Map<String, String> props = BlockStateProperties.properties(data);
|
String state = data.getAsString(false);
|
||||||
|
int open = state.indexOf('[');
|
||||||
|
String id = open < 0 ? state : state.substring(0, open);
|
||||||
|
String name = id.substring(id.indexOf(':') + 1).trim();
|
||||||
|
Map<String, String> props = new HashMap<>();
|
||||||
|
int close = state.lastIndexOf(']');
|
||||||
|
if (open >= 0 && close > open) {
|
||||||
|
for (String pair : state.substring(open + 1, close).split(",")) {
|
||||||
|
int eq = pair.indexOf('=');
|
||||||
|
if (eq >= 0)
|
||||||
|
props.put(
|
||||||
|
pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
JsonObject root = reader.readJsonObject(AssetPaths.blockState(name)).orElse(null);
|
JsonObject root = reader.readJsonObject(AssetPaths.blockState(name)).orElse(null);
|
||||||
if (root == null) return List.of();
|
if (root == null) return List.of();
|
||||||
@@ -36,7 +47,7 @@ public final class BlockStateResolver {
|
|||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Variant> resolveVariants(JsonObject variants, Map<String, String> props) {
|
private static List<Variant> resolveVariants(JsonObject variants, Map<String, String> props) {
|
||||||
for (Map.Entry<String, JsonElement> entry : variants.entrySet()) {
|
for (Map.Entry<String, JsonElement> entry : variants.entrySet()) {
|
||||||
if (variantKeyMatches(entry.getKey(), props)) {
|
if (variantKeyMatches(entry.getKey(), props)) {
|
||||||
return List.of(parseVariant(firstOf(entry.getValue())));
|
return List.of(parseVariant(firstOf(entry.getValue())));
|
||||||
@@ -45,7 +56,7 @@ public final class BlockStateResolver {
|
|||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Variant> resolveMultipart(JsonArray multipart, Map<String, String> props) {
|
private static List<Variant> resolveMultipart(JsonArray multipart, Map<String, String> props) {
|
||||||
List<Variant> result = new ArrayList<>();
|
List<Variant> result = new ArrayList<>();
|
||||||
for (JsonElement caseEl : multipart) {
|
for (JsonElement caseEl : multipart) {
|
||||||
JsonObject caseObj = caseEl.getAsJsonObject();
|
JsonObject caseObj = caseEl.getAsJsonObject();
|
||||||
@@ -55,8 +66,8 @@ public final class BlockStateResolver {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A variant key like {@code facing=east,half=bottom} matches when every pair holds for the block. */
|
/** A variant key like {@code facing=east,half=bottom} matches when every pair holds. */
|
||||||
private boolean variantKeyMatches(String key, Map<String, String> props) {
|
private static boolean variantKeyMatches(String key, Map<String, String> props) {
|
||||||
if (key.isEmpty()) return true;
|
if (key.isEmpty()) return true;
|
||||||
for (String pair : key.split(",")) {
|
for (String pair : key.split(",")) {
|
||||||
int eq = pair.indexOf('=');
|
int eq = pair.indexOf('=');
|
||||||
@@ -68,7 +79,7 @@ public final class BlockStateResolver {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean whenMatches(JsonElement when, Map<String, String> props) {
|
private static boolean whenMatches(JsonElement when, Map<String, String> props) {
|
||||||
JsonObject obj = when.getAsJsonObject();
|
JsonObject obj = when.getAsJsonObject();
|
||||||
if (obj.has("OR")) {
|
if (obj.has("OR")) {
|
||||||
for (JsonElement sub : obj.getAsJsonArray("OR")) {
|
for (JsonElement sub : obj.getAsJsonArray("OR")) {
|
||||||
@@ -82,7 +93,6 @@ public final class BlockStateResolver {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Implicit AND over property conditions.
|
|
||||||
for (Map.Entry<String, JsonElement> e : obj.entrySet()) {
|
for (Map.Entry<String, JsonElement> e : obj.entrySet()) {
|
||||||
if (!valueMatches(props.get(e.getKey()), e.getValue().getAsString())) return false;
|
if (!valueMatches(props.get(e.getKey()), e.getValue().getAsString())) return false;
|
||||||
}
|
}
|
||||||
@@ -90,7 +100,7 @@ public final class BlockStateResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A condition value may be an OR list like {@code "north|south"}. */
|
/** A condition value may be an OR list like {@code "north|south"}. */
|
||||||
private boolean valueMatches(String actual, String expected) {
|
private static boolean valueMatches(String actual, String expected) {
|
||||||
if (actual == null) return false;
|
if (actual == null) return false;
|
||||||
for (String option : expected.split("\\|")) {
|
for (String option : expected.split("\\|")) {
|
||||||
if (option.equals(actual)) return true;
|
if (option.equals(actual)) return true;
|
||||||
@@ -98,14 +108,15 @@ public final class BlockStateResolver {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private JsonObject firstOf(JsonElement element) {
|
/** Weighted variant lists: the first entry. */
|
||||||
|
private static JsonObject firstOf(JsonElement element) {
|
||||||
if (element.isJsonArray()) {
|
if (element.isJsonArray()) {
|
||||||
return element.getAsJsonArray().get(0).getAsJsonObject();
|
return element.getAsJsonArray().get(0).getAsJsonObject();
|
||||||
}
|
}
|
||||||
return element.getAsJsonObject();
|
return element.getAsJsonObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Variant parseVariant(JsonObject obj) {
|
private static Variant parseVariant(JsonObject obj) {
|
||||||
ResourceLocation model = ResourceLocation.parse(obj.get("model").getAsString());
|
ResourceLocation model = ResourceLocation.parse(obj.get("model").getAsString());
|
||||||
int x = obj.has("x") ? obj.get("x").getAsInt() : 0;
|
int x = obj.has("x") ? obj.get("x").getAsInt() : 0;
|
||||||
int y = obj.has("y") ? obj.get("y").getAsInt() : 0;
|
int y = obj.has("y") ? obj.get("y").getAsInt() : 0;
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ package eu.mhsl.minecraft.pixelpics.assets;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/** Several packs as one; the first pack containing an asset wins. */
|
||||||
* Combines several packs into one; the first pack that contains an asset wins. Lets an admin drop
|
|
||||||
* both an unpacked directory and one or more {@code .zip} packs.
|
|
||||||
*/
|
|
||||||
public final class CompositeResourcePack implements ResourcePack {
|
public final class CompositeResourcePack implements ResourcePack {
|
||||||
|
|
||||||
private final List<ResourcePack> packs;
|
private final List<ResourcePack> packs;
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/** A {@link ResourcePack} backed by a directory (the one containing {@code assets/}). */
|
||||||
* A {@link ResourcePack} backed by a directory on disk. The root typically contains the
|
|
||||||
* {@code assets/} folder.
|
|
||||||
*/
|
|
||||||
public final class DirectoryResourcePack implements ResourcePack {
|
public final class DirectoryResourcePack implements ResourcePack {
|
||||||
|
|
||||||
private final Path root;
|
private final Path root;
|
||||||
@@ -19,7 +16,7 @@ public final class DirectoryResourcePack implements ResourcePack {
|
|||||||
|
|
||||||
private Optional<Path> resolve(String path) {
|
private Optional<Path> resolve(String path) {
|
||||||
Path resolved = root.resolve(path).normalize();
|
Path resolved = root.resolve(path).normalize();
|
||||||
// Guard against path traversal outside the pack root.
|
// No path traversal outside the pack root.
|
||||||
if (!resolved.startsWith(root)) return Optional.empty();
|
if (!resolved.startsWith(root)) return Optional.empty();
|
||||||
return Optional.of(resolved);
|
return Optional.of(resolved);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A model with its parent chain flattened: textures merged (child wins) and the nearest non-empty
|
* A model with its parent chain flattened: textures merged (child wins), elements from the nearest model that has
|
||||||
* {@code elements} list selected (vanilla does not merge elements across parents).
|
* any (vanilla does not merge elements across parents).
|
||||||
*/
|
*/
|
||||||
public record FlatModel(Map<String, String> textures, List<ModelFileDto.ElementDto> elements) {
|
public record FlatModel(Map<String, String> textures, List<ModelFileDto.ElementDto> elements) {
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets;
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thread-safe cache of ARGB pixel grids keyed by {@code K}. Subclasses implement {@link #load} to
|
* Thread-safe cache of ARGB pixel grids ({@code [y][x]}, top-left origin). Failed loads ({@link #load} returns null)
|
||||||
* produce the grid (or {@code null} on failure); a failure is cached as a sentinel so it is not
|
* are cached too and never retried.
|
||||||
* retried, and {@link #get} returns empty for it. The grid is indexed {@code [y][x]}, top-left origin.
|
|
||||||
*/
|
*/
|
||||||
public abstract class ImageCache<K> {
|
public abstract class ImageCache<K> {
|
||||||
|
|
||||||
@@ -15,7 +15,6 @@ public abstract class ImageCache<K> {
|
|||||||
|
|
||||||
private final Map<K, int[][]> cache = new ConcurrentHashMap<>();
|
private final Map<K, int[][]> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
/** Loads the pixel grid for {@code key}, or returns {@code null} if it cannot be loaded. */
|
|
||||||
protected abstract int[][] load(K key);
|
protected abstract int[][] load(K key);
|
||||||
|
|
||||||
public Optional<int[][]> get(K key) {
|
public Optional<int[][]> get(K key) {
|
||||||
@@ -23,6 +22,13 @@ public abstract class ImageCache<K> {
|
|||||||
return result == MISSING ? Optional.empty() : Optional.of(result);
|
return result == MISSING ? Optional.empty() : Optional.of(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The top-left {@code w×h} pixels of an image as ARGB rows. */
|
||||||
|
protected static int[][] pixels(BufferedImage img, int w, int h) {
|
||||||
|
int[][] out = new int[h][];
|
||||||
|
for (int y = 0; y < h; y++) out[y] = img.getRGB(0, y, w, 1, null, 0, w);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
private int[][] loadOrSentinel(K key) {
|
private int[][] loadOrSentinel(K key) {
|
||||||
int[][] loaded = load(key);
|
int[][] loaded = load(key);
|
||||||
return loaded == null ? MISSING : loaded;
|
return loaded == null ? MISSING : loaded;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets;
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
|
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.model.AverageColor;
|
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
|
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||||
@@ -10,11 +9,8 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bakes a {@link FlatModel} plus a blockstate {@link Variant} into intersectable {@link Element}
|
* Bakes a {@link FlatModel} plus a blockstate {@link Variant} into {@link Element} boxes. Blockstate x/y rotations
|
||||||
* boxes. Texture variables are resolved against the model's merged texture map; default UVs follow
|
* (90° steps) are applied to box, faces and the element's own rotation; rotated elements keep their model-space copy.
|
||||||
* the vanilla element extents; blockstate {@code x}/{@code y} rotations (90-degree steps) are baked
|
|
||||||
* into geometry and face directions, while a model element's own rotation is preserved for OBB
|
|
||||||
* intersection.
|
|
||||||
*/
|
*/
|
||||||
public final class ModelBaker {
|
public final class ModelBaker {
|
||||||
|
|
||||||
@@ -24,26 +20,19 @@ public final class ModelBaker {
|
|||||||
this.textures = textures;
|
this.textures = textures;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record BakedGeometry(List<Element> elements, int averageColor, boolean hasGeometry) {}
|
public List<Element> bake(FlatModel model, Variant variant) {
|
||||||
|
if (!model.hasElements()) return List.of();
|
||||||
|
|
||||||
public BakedGeometry bake(FlatModel model, Variant variant) {
|
int xSteps = Math.floorMod(variant.x() / 90, 4);
|
||||||
if (!model.hasElements()) {
|
int ySteps = Math.floorMod(variant.y() / 90, 4);
|
||||||
return new BakedGeometry(List.of(), 0, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
int xSteps = ((variant.x() / 90) % 4 + 4) % 4;
|
|
||||||
int ySteps = ((variant.y() / 90) % 4 + 4) % 4;
|
|
||||||
|
|
||||||
List<Element> baked = new ArrayList<>();
|
List<Element> baked = new ArrayList<>();
|
||||||
AverageColor.Accumulator avgColor = new AverageColor.Accumulator();
|
|
||||||
|
|
||||||
for (ModelFileDto.ElementDto dto : model.elements()) {
|
for (ModelFileDto.ElementDto dto : model.elements()) {
|
||||||
if (dto.from == null || dto.to == null) continue;
|
if (dto.from == null || dto.to == null) continue;
|
||||||
|
|
||||||
double[] from = {dto.from[0] / 16.0, dto.from[1] / 16.0, dto.from[2] / 16.0};
|
double[] from = {dto.from[0] / 16.0, dto.from[1] / 16.0, dto.from[2] / 16.0};
|
||||||
double[] to = {dto.to[0] / 16.0, dto.to[1] / 16.0, dto.to[2] / 16.0};
|
double[] to = {dto.to[0] / 16.0, dto.to[1] / 16.0, dto.to[2] / 16.0};
|
||||||
|
|
||||||
// Build faces (pre variant rotation).
|
|
||||||
Face[] faces = new Face[6];
|
Face[] faces = new Face[6];
|
||||||
if (dto.faces != null) {
|
if (dto.faces != null) {
|
||||||
for (Map.Entry<String, ModelFileDto.FaceDto> e : dto.faces.entrySet()) {
|
for (Map.Entry<String, ModelFileDto.FaceDto> e : dto.faces.entrySet()) {
|
||||||
@@ -54,7 +43,6 @@ public final class ModelBaker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Element's own rotation (origin in 0..1, axis index, radians).
|
|
||||||
double[] rotOrigin = null;
|
double[] rotOrigin = null;
|
||||||
int rotAxis = -1;
|
int rotAxis = -1;
|
||||||
double rotAngle = 0;
|
double rotAngle = 0;
|
||||||
@@ -75,97 +63,61 @@ public final class ModelBaker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Element modelElement = rotMatrix != null
|
Element modelElement =
|
||||||
? new Element(from, to, faces, rotOrigin, rotMatrix, rescale, null, 0, 0)
|
new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rotMatrix, rescale, null, 0, 0);
|
||||||
: new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale);
|
|
||||||
|
|
||||||
// Apply blockstate variant rotation (90-degree steps) to box + faces + element rotation.
|
// Rotated variants keep the model-space element; intersectors hit it with the inverse-rotated ray (as
|
||||||
for (int i = 0; i < xSteps; i++) {
|
// vanilla's FaceBakery, textures turn with the model). Under uvlock its faces get a UV transform instead.
|
||||||
double[][] r = rotateBoxX(from, to);
|
// The world box is min/max-normalised, so only the model-space copy of an inverted element stays inverted.
|
||||||
from = r[0];
|
|
||||||
to = r[1];
|
|
||||||
faces = rotateFacesX(faces);
|
|
||||||
if (rotAxis >= 0) rotOrigin = rotatePointX(rotOrigin);
|
|
||||||
if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) {
|
|
||||||
AxisRotation na = rotateAxisX(rotAxis);
|
|
||||||
rotAxis = na.axis();
|
|
||||||
if (na.flip()) rotAngle = -rotAngle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (int i = 0; i < ySteps; i++) {
|
|
||||||
double[][] r = rotateBoxY(from, to);
|
|
||||||
from = r[0];
|
|
||||||
to = r[1];
|
|
||||||
faces = rotateFacesY(faces);
|
|
||||||
if (rotAxis >= 0) rotOrigin = rotatePointY(rotOrigin);
|
|
||||||
if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) {
|
|
||||||
AxisRotation na = rotateAxisY(rotAxis);
|
|
||||||
rotAxis = na.axis();
|
|
||||||
if (na.flip()) rotAngle = -rotAngle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotated variants keep the model-space element; intersectors hit it with the inverse-rotated ray
|
|
||||||
// (vanilla bakes the same way). Without uvlock the textures thus turn with the model; with uvlock
|
|
||||||
// each face's final UVs are additionally transformed so the texture stays world-aligned.
|
|
||||||
Element unrotated = null;
|
Element unrotated = null;
|
||||||
if (xSteps != 0 || ySteps != 0) {
|
if (xSteps != 0 || ySteps != 0) {
|
||||||
unrotated = variant.uvlock() ? uvLocked(modelElement, xSteps, ySteps) : modelElement;
|
unrotated = variant.uvlock() ? uvLocked(modelElement, xSteps, ySteps) : modelElement;
|
||||||
|
double[][] box = minMax(rotatePoint(from, xSteps, ySteps), rotatePoint(to, xSteps, ySteps));
|
||||||
|
from = box[0];
|
||||||
|
to = box[1];
|
||||||
|
Face[] rotated = new Face[6];
|
||||||
|
for (Direction d : Direction.values()) {
|
||||||
|
rotated[d.rotateX(xSteps).rotateY(ySteps).ordinal()] = faces[d.ordinal()];
|
||||||
|
}
|
||||||
|
faces = rotated;
|
||||||
|
if (rotAxis >= 0) rotOrigin = rotatePoint(rotOrigin, xSteps, ySteps);
|
||||||
|
if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) {
|
||||||
|
Direction axis =
|
||||||
|
Direction.of(rotAxis, false).rotateX(xSteps).rotateY(ySteps);
|
||||||
|
rotAxis = axis.axis;
|
||||||
|
if (axis.nx + axis.ny + axis.nz < 0) rotAngle = -rotAngle;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
baked.add(
|
baked.add(new Element(
|
||||||
rotMatrix != null
|
from,
|
||||||
? new Element(
|
to,
|
||||||
from,
|
faces,
|
||||||
to,
|
rotOrigin,
|
||||||
faces,
|
rotAxis,
|
||||||
rotOrigin,
|
rotAngle,
|
||||||
conjugate(rotMatrix, xSteps, ySteps),
|
rotMatrix == null ? null : conjugate(rotMatrix, xSteps, ySteps),
|
||||||
rescale,
|
rescale,
|
||||||
unrotated,
|
unrotated,
|
||||||
xSteps,
|
xSteps,
|
||||||
ySteps)
|
ySteps));
|
||||||
: new Element(
|
|
||||||
from, to, faces, rotOrigin, rotAxis, rotAngle, rescale, unrotated, xSteps, ySteps));
|
|
||||||
|
|
||||||
// Accumulate average color from the element's face textures.
|
|
||||||
for (Face f : faces) {
|
|
||||||
if (f == null) continue;
|
|
||||||
avgColor.add(AverageColor.of(f.texture));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return baked;
|
||||||
return new BakedGeometry(baked, avgColor.average(0xFF7F7F7F), !baked.isEmpty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Face buildFace(
|
private Face buildFace(
|
||||||
Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to, Map<String, String> textureVars) {
|
Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to, Map<String, String> textureVars) {
|
||||||
int[][] tex = resolveTexture(dto.texture, textureVars);
|
int[][] tex = resolveTexture(dto.texture, textureVars);
|
||||||
if (tex == null) return null;
|
if (tex == null) return null;
|
||||||
|
double[] uv = dto.uv != null && dto.uv.length == 4
|
||||||
double u1, v1, u2, v2;
|
? new double[] {dto.uv[0] / 16.0, dto.uv[1] / 16.0, dto.uv[2] / 16.0, dto.uv[3] / 16.0}
|
||||||
if (dto.uv != null && dto.uv.length == 4) {
|
: defaultUv(dir, from, to);
|
||||||
u1 = dto.uv[0] / 16.0;
|
|
||||||
v1 = dto.uv[1] / 16.0;
|
|
||||||
u2 = dto.uv[2] / 16.0;
|
|
||||||
v2 = dto.uv[3] / 16.0;
|
|
||||||
} else {
|
|
||||||
double[] d = defaultUv(dir, from, to);
|
|
||||||
u1 = d[0];
|
|
||||||
v1 = d[1];
|
|
||||||
u2 = d[2];
|
|
||||||
v2 = d[3];
|
|
||||||
}
|
|
||||||
int tint = dto.tintindex != null ? dto.tintindex : -1;
|
int tint = dto.tintindex != null ? dto.tintindex : -1;
|
||||||
return new Face(tex, u1, v1, u2, v2, dto.rotation, tint);
|
return new Face(tex, uv[0], uv[1], uv[2], uv[3], dto.rotation, tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Vanilla {@code BlockElement.uvsByFace}, normalised (texture v is top-down). */
|
||||||
* Default UV (normalized 0..1) from the element extents, matching vanilla. Texture V is top-down,
|
private static double[] defaultUv(Direction dir, double[] f, double[] t) {
|
||||||
* so for side faces v1 (texture top) corresponds to the element's high-Y edge: v = [1-to.y, 1-from.y].
|
|
||||||
*/
|
|
||||||
private double[] defaultUv(Direction dir, double[] f, double[] t) {
|
|
||||||
return switch (dir) {
|
return switch (dir) {
|
||||||
// Vanilla BlockElement.uvsByFace (u/v in 0..1).
|
|
||||||
case DOWN -> new double[] {f[0], 1 - t[2], t[0], 1 - f[2]};
|
case DOWN -> new double[] {f[0], 1 - t[2], t[0], 1 - f[2]};
|
||||||
case UP -> new double[] {f[0], f[2], t[0], t[2]};
|
case UP -> new double[] {f[0], f[2], t[0], t[2]};
|
||||||
case NORTH -> new double[] {1 - t[0], 1 - t[1], 1 - f[0], 1 - f[1]};
|
case NORTH -> new double[] {1 - t[0], 1 - t[1], 1 - f[0], 1 - f[1]};
|
||||||
@@ -217,25 +169,21 @@ public final class ModelBaker {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The model-space element with every face's UVs locked to the world under the given rotation. */
|
|
||||||
private static Element uvLocked(Element e, int xSteps, int ySteps) {
|
private static Element uvLocked(Element e, int xSteps, int ySteps) {
|
||||||
Face[] locked = new Face[6];
|
Face[] locked = new Face[6];
|
||||||
for (Direction d : Direction.values()) {
|
for (Direction d : Direction.values()) {
|
||||||
Face f = e.faces[d.ordinal()];
|
Face f = e.faces[d.ordinal()];
|
||||||
if (f != null) locked[d.ordinal()] = f.withUvLock(uvLockTransform(d, xSteps, ySteps));
|
if (f != null) locked[d.ordinal()] = f.withUvLock(uvLockTransform(d, xSteps, ySteps));
|
||||||
}
|
}
|
||||||
if (e.rotMatrix != null)
|
return e.withFaces(locked);
|
||||||
return new Element(e.from, e.to, locked, e.rotOrigin, e.rotMatrix, e.rescale, null, 0, 0);
|
|
||||||
return new Element(e.from, e.to, locked, e.rotOrigin, e.rotAxis, e.rotAngleRad, e.rescale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vanilla uvlock ({@code BlockMath.getFaceTransformation}, inverted as in {@code FaceBakery}): the final
|
* Vanilla uvlock ({@code BlockMath.getFaceTransformation}, inverted as in {@code FaceBakery}): the final
|
||||||
* texture coordinates of model face {@code d}, centred on the texture, are mapped by
|
* texture coordinates of model face {@code d}, centred on the texture, are mapped by
|
||||||
* {@code M = G2L(d) · R⁻¹ · L2G(D)} where R is the blockstate rotation, D the world face {@code d} turns into,
|
* {@code M = G2L(d) · R⁻¹ · L2G(D)} where R is the blockstate rotation, D the world face {@code d} turns into,
|
||||||
* and L2G the per-face UV frames (south = identity). For side faces this keeps the texture world-aligned;
|
* and L2G the per-face UV frames (south = identity). Up/down under x rotations follow vanilla's frames (not
|
||||||
* up/down under x rotations follow vanilla's frames (not strictly world-aligned). M is one of the 8 symmetries
|
* strictly world-aligned). M is one of the 8 symmetries of the unit UV square, as the affine of {@link Face#uvLock}.
|
||||||
* of the unit UV square, returned as the affine of {@link Face#uvLock}.
|
|
||||||
*/
|
*/
|
||||||
static double[] uvLockTransform(Direction d, int xSteps, int ySteps) {
|
static double[] uvLockTransform(Direction d, int xSteps, int ySteps) {
|
||||||
Direction world = d.rotateX(xSteps).rotateY(ySteps);
|
Direction world = d.rotateX(xSteps).rotateY(ySteps);
|
||||||
@@ -275,7 +223,7 @@ public final class ModelBaker {
|
|||||||
return textures.get(ResourceLocation.parse(current)).orElse(null);
|
return textures.get(ResourceLocation.parse(current)).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int axisIndex(String axis) {
|
private static int axisIndex(String axis) {
|
||||||
if (axis == null) return -1;
|
if (axis == null) return -1;
|
||||||
return switch (axis.toLowerCase()) {
|
return switch (axis.toLowerCase()) {
|
||||||
case "x" -> 0;
|
case "x" -> 0;
|
||||||
@@ -285,74 +233,27 @@ public final class ModelBaker {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 90-degree box rotations around the block center (0.5, 0.5, 0.5) ---
|
/**
|
||||||
|
* Blockstate rotation of a point about the block centre: x steps, then y steps, both clockwise like vanilla
|
||||||
private double[][] rotateBoxY(double[] from, double[] to) {
|
* (y: north -> east). Re-centred after every step instead of using {@link Element#rotate}: that one adds
|
||||||
// 90 deg clockwise viewed from above (vanilla), see rotatePointY.
|
* {@code (p - 0.5) + 0.5} on the fixed axis, which is not exact for every coordinate.
|
||||||
double[] c1 = rotatePointY(from);
|
*/
|
||||||
double[] c2 = rotatePointY(to);
|
private static double[] rotatePoint(double[] p, int xSteps, int ySteps) {
|
||||||
return minMax(c1, c2);
|
double[] r = p;
|
||||||
|
for (int i = 0; i < xSteps; i++) {
|
||||||
|
double y = r[1] - 0.5, z = r[2] - 0.5;
|
||||||
|
r = new double[] {r[0], 0.5 + z, 0.5 - y};
|
||||||
|
}
|
||||||
|
for (int i = 0; i < ySteps; i++) {
|
||||||
|
double x = r[0] - 0.5, z = r[2] - 0.5;
|
||||||
|
r = new double[] {0.5 - z, r[1], 0.5 + x};
|
||||||
|
}
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
private double[][] rotateBoxX(double[] from, double[] to) {
|
private static double[][] minMax(double[] a, double[] b) {
|
||||||
double[] c1 = rotatePointX(from);
|
|
||||||
double[] c2 = rotatePointX(to);
|
|
||||||
return minMax(c1, c2);
|
|
||||||
}
|
|
||||||
|
|
||||||
private double[] rotatePointY(double[] p) {
|
|
||||||
// Vanilla blockstate "y": clockwise seen from above (north -> east, south -> west), i.e. a rotation
|
|
||||||
// by -90° about +Y: (dx, dz) -> (-dz, dx). Must match Direction.rotateY used for the faces.
|
|
||||||
double x = p[0] - 0.5, z = p[2] - 0.5;
|
|
||||||
return new double[] {0.5 - z, p[1], 0.5 + x};
|
|
||||||
}
|
|
||||||
|
|
||||||
private double[] rotatePointX(double[] p) {
|
|
||||||
double y = p[1] - 0.5, z = p[2] - 0.5;
|
|
||||||
return new double[] {p[0], 0.5 + z, 0.5 - y};
|
|
||||||
}
|
|
||||||
|
|
||||||
private double[][] minMax(double[] a, double[] b) {
|
|
||||||
double[] from = {Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2])};
|
double[] from = {Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(a[2], b[2])};
|
||||||
double[] to = {Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(a[2], b[2])};
|
double[] to = {Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(a[2], b[2])};
|
||||||
return new double[][] {from, to};
|
return new double[][] {from, to};
|
||||||
}
|
}
|
||||||
|
|
||||||
private Face[] rotateFacesY(Face[] faces) {
|
|
||||||
Face[] out = new Face[6];
|
|
||||||
for (Direction d : Direction.values()) {
|
|
||||||
out[d.rotateY(1).ordinal()] = faces[d.ordinal()];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Face[] rotateFacesX(Face[] faces) {
|
|
||||||
Face[] out = new Face[6];
|
|
||||||
for (Direction d : Direction.values()) {
|
|
||||||
out[d.rotateX(1).ordinal()] = faces[d.ordinal()];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotating an element's own rotation axis under a 90-degree block rotation.
|
|
||||||
// {@code flip} indicates the angle sign should invert.
|
|
||||||
private record AxisRotation(int axis, boolean flip) {}
|
|
||||||
|
|
||||||
private AxisRotation rotateAxisY(int axis) {
|
|
||||||
// Clockwise Y rotation maps +x -> +z and +z -> -x; the y axis is unchanged.
|
|
||||||
return switch (axis) {
|
|
||||||
case 0 -> new AxisRotation(2, false); // x -> z
|
|
||||||
case 2 -> new AxisRotation(0, true); // z -> -x
|
|
||||||
default -> new AxisRotation(axis, false);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private AxisRotation rotateAxisX(int axis) {
|
|
||||||
// X rotation maps y<->z; the x axis is unchanged.
|
|
||||||
return switch (axis) {
|
|
||||||
case 1 -> new AxisRotation(2, true); // y -> z
|
|
||||||
case 2 -> new AxisRotation(1, false); // z -> y
|
|
||||||
default -> new AxisRotation(axis, false);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets;
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
|
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/** Loads block models and flattens their parent chains into {@link FlatModel}s, cached per model id. */
|
||||||
* Loads block models and flattens their parent chains into {@link FlatModel}s. Results are cached by
|
|
||||||
* model id.
|
|
||||||
*/
|
|
||||||
public final class ModelResolver {
|
public final class ModelResolver {
|
||||||
|
|
||||||
private static final int MAX_DEPTH = 16;
|
private static final int MAX_DEPTH = 16;
|
||||||
@@ -22,15 +20,11 @@ public final class ModelResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public FlatModel resolve(ResourceLocation modelId) {
|
public FlatModel resolve(ResourceLocation modelId) {
|
||||||
FlatModel cached = cache.get(modelId);
|
return resolve(modelId, 0);
|
||||||
if (cached != null) return cached;
|
|
||||||
FlatModel resolved = resolve(modelId, 0);
|
|
||||||
cache.put(modelId, resolved);
|
|
||||||
return resolved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A texture value: a plain reference, or the {@code sprite} of a 26.x texture object; null otherwise. */
|
/** A texture value: a plain reference, or the {@code sprite} of a 26.x texture object; null otherwise. */
|
||||||
public static String textureRef(com.google.gson.JsonElement v) {
|
public static String textureRef(JsonElement v) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
if (v.isJsonPrimitive()) return v.getAsString();
|
if (v.isJsonPrimitive()) return v.getAsString();
|
||||||
if (v.isJsonObject() && v.getAsJsonObject().has("sprite")) {
|
if (v.isJsonObject() && v.getAsJsonObject().has("sprite")) {
|
||||||
@@ -39,30 +33,34 @@ public final class ModelResolver {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parents go through the cache too (shared chains like block/cube_all are parsed once). No computeIfAbsent:
|
||||||
|
// the recursion would modify the map from inside its own compute. depth only guards against parent cycles.
|
||||||
private FlatModel resolve(ResourceLocation modelId, int depth) {
|
private FlatModel resolve(ResourceLocation modelId, int depth) {
|
||||||
|
FlatModel cached = cache.get(modelId);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
ModelFileDto dto =
|
ModelFileDto dto =
|
||||||
reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null);
|
reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null);
|
||||||
|
FlatModel resolved;
|
||||||
if (dto == null) {
|
if (dto == null) {
|
||||||
return new FlatModel(new HashMap<>(), null);
|
resolved = new FlatModel(new HashMap<>(), null);
|
||||||
}
|
} else {
|
||||||
|
Map<String, String> textures = new HashMap<>();
|
||||||
Map<String, String> textures = new HashMap<>();
|
List<ModelFileDto.ElementDto> elements = dto.elements;
|
||||||
List<ModelFileDto.ElementDto> elements = dto.elements;
|
if (dto.parent != null && depth < MAX_DEPTH && !dto.parent.startsWith("builtin/")) {
|
||||||
|
FlatModel parent = resolve(ResourceLocation.parse(dto.parent), depth + 1);
|
||||||
if (dto.parent != null && depth < MAX_DEPTH && !dto.parent.startsWith("builtin/")) {
|
textures.putAll(parent.textures());
|
||||||
FlatModel parent = resolve(ResourceLocation.parse(dto.parent), depth + 1);
|
if (elements == null || elements.isEmpty()) elements = parent.elements();
|
||||||
textures.putAll(parent.textures());
|
|
||||||
if (elements == null || elements.isEmpty()) {
|
|
||||||
elements = parent.elements();
|
|
||||||
}
|
}
|
||||||
|
if (dto.textures != null) {
|
||||||
|
dto.textures.forEach((key, value) -> {
|
||||||
|
String ref = textureRef(value);
|
||||||
|
if (ref != null) textures.put(key, ref);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolved = new FlatModel(textures, elements);
|
||||||
}
|
}
|
||||||
if (dto.textures != null) {
|
FlatModel raced = cache.putIfAbsent(modelId, resolved);
|
||||||
dto.textures.forEach((key, value) -> {
|
return raced != null ? raced : resolved;
|
||||||
String ref = textureRef(value);
|
|
||||||
if (ref != null) textures.put(key, ref);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FlatModel(textures, elements);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets;
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
/**
|
/** A namespaced asset id such as {@code minecraft:block/stone}; lower-cased, namespace defaults to minecraft. */
|
||||||
* A namespaced identifier as used throughout Minecraft assets, e.g. {@code minecraft:block/stone}.
|
|
||||||
* When no namespace is present the default {@code minecraft} is assumed.
|
|
||||||
*/
|
|
||||||
public record ResourceLocation(String namespace, String path) {
|
public record ResourceLocation(String namespace, String path) {
|
||||||
|
|
||||||
public static final String DEFAULT_NAMESPACE = "minecraft";
|
public static final String DEFAULT_NAMESPACE = "minecraft";
|
||||||
@@ -13,9 +10,6 @@ public record ResourceLocation(String namespace, String path) {
|
|||||||
path = path.toLowerCase();
|
path = path.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a string like {@code minecraft:block/stone} or {@code block/stone}.
|
|
||||||
*/
|
|
||||||
public static ResourceLocation parse(String raw) {
|
public static ResourceLocation parse(String raw) {
|
||||||
int colon = raw.indexOf(':');
|
int colon = raw.indexOf(':');
|
||||||
if (colon < 0) {
|
if (colon < 0) {
|
||||||
|
|||||||
@@ -4,22 +4,13 @@ import java.io.Closeable;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A read-only source of Minecraft assets. Paths are pack-relative and always use {@code /} as a
|
* A read-only source of assets by pack-relative {@code /}-separated path, e.g.
|
||||||
* separator, e.g. {@code assets/minecraft/blockstates/oak_fence.json}.
|
* {@code assets/minecraft/blockstates/oak_fence.json}. Must be safe for concurrent reads (parallel renderer).
|
||||||
*
|
|
||||||
* <p>Implementations must be safe for concurrent {@link #read} calls, since the renderer accesses
|
|
||||||
* assets from multiple threads.
|
|
||||||
*/
|
*/
|
||||||
public interface ResourcePack extends Closeable {
|
public interface ResourcePack extends Closeable {
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads the raw bytes of an asset, or an empty optional if it does not exist.
|
|
||||||
*/
|
|
||||||
Optional<byte[]> read(String path);
|
Optional<byte[]> read(String path);
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the given asset path exists in this pack.
|
|
||||||
*/
|
|
||||||
boolean exists(String path);
|
boolean exists(String path);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ import java.util.logging.Logger;
|
|||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} data folder. The folder may
|
* Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} folder: unpacked packs (the folder itself or
|
||||||
* contain an unpacked pack (a directory with {@code assets/minecraft/...}) and/or one or more
|
* a direct sub-folder with {@code assets/minecraft}) and {@code .zip} packs anywhere below it, in that priority.
|
||||||
* {@code .zip} packs. Returns an empty optional when nothing usable is found.
|
|
||||||
*/
|
*/
|
||||||
public final class ResourcePackLoader {
|
public final class ResourcePackLoader {
|
||||||
|
|
||||||
/** The marker that identifies a valid pack root: {@code <root>/assets/minecraft}. */
|
|
||||||
private static final String MARKER = "assets/minecraft";
|
private static final String MARKER = "assets/minecraft";
|
||||||
|
|
||||||
private ResourcePackLoader() {}
|
private ResourcePackLoader() {}
|
||||||
@@ -29,7 +27,6 @@ public final class ResourcePackLoader {
|
|||||||
|
|
||||||
List<ResourcePack> packs = new ArrayList<>();
|
List<ResourcePack> packs = new ArrayList<>();
|
||||||
|
|
||||||
// Directory packs: probe the folder itself and any direct sub-folder for the assets marker.
|
|
||||||
List<Path> dirCandidates = new ArrayList<>();
|
List<Path> dirCandidates = new ArrayList<>();
|
||||||
dirCandidates.add(resourcePackDir.toPath());
|
dirCandidates.add(resourcePackDir.toPath());
|
||||||
File[] children = resourcePackDir.listFiles(File::isDirectory);
|
File[] children = resourcePackDir.listFiles(File::isDirectory);
|
||||||
@@ -43,7 +40,6 @@ public final class ResourcePackLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zip packs anywhere under the resourcepack folder.
|
|
||||||
try (Stream<Path> walk = Files.walk(resourcePackDir.toPath())) {
|
try (Stream<Path> walk = Files.walk(resourcePackDir.toPath())) {
|
||||||
List<Path> zips = walk.filter(Files::isRegularFile)
|
List<Path> zips = walk.filter(Files::isRegularFile)
|
||||||
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
|
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
|
||||||
@@ -51,7 +47,10 @@ public final class ResourcePackLoader {
|
|||||||
for (Path zip : zips) {
|
for (Path zip : zips) {
|
||||||
try {
|
try {
|
||||||
ZipResourcePack pack = new ZipResourcePack(zip);
|
ZipResourcePack pack = new ZipResourcePack(zip);
|
||||||
if (pack.exists(MARKER + "/blockstates") || pack.exists("pack.mcmeta") || hasAnyBlockstate(pack)) {
|
// Zip entries are files only; the client jar has no pack.mcmeta, so probe two vanilla files.
|
||||||
|
if (pack.exists("pack.mcmeta")
|
||||||
|
|| pack.exists(AssetPaths.blockState("stone"))
|
||||||
|
|| pack.exists(AssetPaths.blockState("dirt"))) {
|
||||||
packs.add(pack);
|
packs.add(pack);
|
||||||
logger.info("Loaded resource pack zip: " + zip);
|
logger.info("Loaded resource pack zip: " + zip);
|
||||||
} else {
|
} else {
|
||||||
@@ -69,9 +68,4 @@ public final class ResourcePackLoader {
|
|||||||
if (packs.size() == 1) return Optional.of(packs.getFirst());
|
if (packs.size() == 1) return Optional.of(packs.getFirst());
|
||||||
return Optional.of(new CompositeResourcePack(packs));
|
return Optional.of(new CompositeResourcePack(packs));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasAnyBlockstate(ResourcePack pack) {
|
|
||||||
// Cheap sanity probe for a couple of guaranteed-present vanilla blockstates.
|
|
||||||
return pack.exists(AssetPaths.blockState("stone")) || pack.exists(AssetPaths.blockState("dirt"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -9,9 +9,8 @@ import java.util.Optional;
|
|||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads and caches player skin textures (by URL) as ARGB pixel grids. Legacy 64x32 skins are
|
* Player skins by URL as 64x64 ARGB grids (legacy 64x32 skins converted). Downloaded off the main thread, from
|
||||||
* converted to the modern 64x64 layout (so the model's overlay/second-layer bones map correctly).
|
* the baking step.
|
||||||
* Downloads happen off the main thread (from the entity baking step) and are cached.
|
|
||||||
*/
|
*/
|
||||||
public final class SkinCache extends ImageCache<String> {
|
public final class SkinCache extends ImageCache<String> {
|
||||||
|
|
||||||
@@ -42,14 +41,11 @@ public final class SkinCache extends ImageCache<String> {
|
|||||||
|
|
||||||
/** Reads the image to a 64x64 grid, converting the legacy 64x32 layout (mirrored arm/leg) if needed. */
|
/** Reads the image to a 64x64 grid, converting the legacy 64x32 layout (mirrored arm/leg) if needed. */
|
||||||
private int[][] toModern(BufferedImage img) {
|
private int[][] toModern(BufferedImage img) {
|
||||||
int w = img.getWidth();
|
int w = Math.min(img.getWidth(), 64);
|
||||||
int h = img.getHeight();
|
int h = Math.min(img.getHeight(), 64);
|
||||||
int[][] out = new int[64][64];
|
int[][] out = new int[64][64];
|
||||||
for (int y = 0; y < Math.min(h, 64); y++) {
|
int[][] src = pixels(img, w, h);
|
||||||
for (int x = 0; x < Math.min(w, 64); x++) {
|
for (int y = 0; y < h; y++) System.arraycopy(src[y], 0, out[y], 0, w);
|
||||||
out[y][x] = img.getRGB(x, y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (h <= 32) {
|
if (h <= 32) {
|
||||||
// Legacy skin: copy the right arm/leg regions into the modern left arm/leg slots (mirrored).
|
// Legacy skin: copy the right arm/leg regions into the modern left arm/leg slots (mirrored).
|
||||||
copyMirrored(img, out, 44, 16, 36, 48); // right leg -> left leg (top/quads handled by mirror)
|
copyMirrored(img, out, 44, 16, 36, 48); // right leg -> left leg (top/quads handled by mirror)
|
||||||
|
|||||||
@@ -4,15 +4,7 @@ import java.awt.image.BufferedImage;
|
|||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
|
|
||||||
/**
|
/** Pack textures as ARGB grids by id (unflipped; the UV origin is top-left like vanilla). */
|
||||||
* Loads and caches block textures as raw ARGB pixel grids. Textures are stored unflipped (vanilla
|
|
||||||
* UV origin is top-left); all orientation is handled by the UV math in the intersector.
|
|
||||||
*
|
|
||||||
* <p>Animated textures (e.g. {@code water_still.png}, a vertical strip of frames) are reduced to their
|
|
||||||
* first frame — but ONLY when an actual {@code .png.mcmeta} animation file is present. A tall sprite
|
|
||||||
* without one (e.g. a 64×128 entity texture like the witch/strider) is a real texture, not an animation,
|
|
||||||
* and must be loaded in full.
|
|
||||||
*/
|
|
||||||
public final class TextureCache extends ImageCache<ResourceLocation> {
|
public final class TextureCache extends ImageCache<ResourceLocation> {
|
||||||
|
|
||||||
private final ResourcePack pack;
|
private final ResourcePack pack;
|
||||||
@@ -35,17 +27,9 @@ public final class TextureCache extends ImageCache<ResourceLocation> {
|
|||||||
|
|
||||||
int width = img.getWidth();
|
int width = img.getWidth();
|
||||||
int height = img.getHeight();
|
int height = img.getHeight();
|
||||||
// Reduce animated strips to the first frame — but only a REAL animation (has a .mcmeta); a tall
|
// Animated strips keep their first frame, but only real animations (with a .mcmeta like vanilla): tall
|
||||||
// sprite without one (e.g. a 64×128 entity texture) is a full texture, not a frame strip.
|
// entity textures (64×128 witch/strider) are full textures.
|
||||||
boolean animated = height > width && height % width == 0 && pack.exists(AssetPaths.textureMeta(id));
|
boolean animated = height > width && height % width == 0 && pack.exists(AssetPaths.textureMeta(id));
|
||||||
int frameHeight = animated ? width : height;
|
return pixels(img, width, animated ? width : height);
|
||||||
|
|
||||||
int[][] pixels = new int[frameHeight][width];
|
|
||||||
for (int y = 0; y < frameHeight; y++) {
|
|
||||||
for (int x = 0; x < width; x++) {
|
|
||||||
pixels[y][x] = img.getRGB(x, y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pixels;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets;
|
package eu.mhsl.minecraft.pixelpics.assets;
|
||||||
|
|
||||||
/**
|
/** A blockstate variant: model, {@code x}/{@code y} rotation in degrees (multiples of 90) and {@code uvlock}. */
|
||||||
* A resolved blockstate variant: which model to use plus its {@code x}/{@code y} rotation (in
|
|
||||||
* degrees, multiples of 90) and {@code uvlock}.
|
|
||||||
*/
|
|
||||||
public record Variant(ResourceLocation model, int x, int y, boolean uvlock) {}
|
public record Variant(ResourceLocation model, int x, int y, boolean uvlock) {}
|
||||||
@@ -9,12 +9,7 @@ import java.util.Optional;
|
|||||||
import java.util.zip.ZipEntry;
|
import java.util.zip.ZipEntry;
|
||||||
import java.util.zip.ZipFile;
|
import java.util.zip.ZipFile;
|
||||||
|
|
||||||
/**
|
/** A {@link ResourcePack} backed by a {@code .zip}; {@link ZipFile} allows concurrent reads. */
|
||||||
* A {@link ResourcePack} backed by a {@code .zip} archive (e.g. a vanilla/custom resource pack).
|
|
||||||
*
|
|
||||||
* <p>{@link ZipFile} allows concurrent {@link ZipFile#getInputStream} calls, so reads are thread
|
|
||||||
* safe. The entry lookup map is built once at construction.
|
|
||||||
*/
|
|
||||||
public final class ZipResourcePack implements ResourcePack {
|
public final class ZipResourcePack implements ResourcePack {
|
||||||
|
|
||||||
private final ZipFile zipFile;
|
private final ZipFile zipFile;
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import com.google.gson.JsonElement;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/** Gson binding of a block model JSON; coordinates and UVs in 0..16. */
|
||||||
* Gson-bound representation of a vanilla block model JSON
|
|
||||||
* ({@code assets/minecraft/models/block/*.json}).
|
|
||||||
*/
|
|
||||||
public class ModelFileDto {
|
public class ModelFileDto {
|
||||||
public String parent;
|
public String parent;
|
||||||
// Values are texture refs ("block/stone", "#side") or, since 26.x, objects {"sprite": …, "force_translucent": …}.
|
// Values are texture refs ("block/stone", "#side") or, since 26.x, objects {"sprite": …, "force_translucent": …}.
|
||||||
@@ -15,26 +12,25 @@ public class ModelFileDto {
|
|||||||
public List<ElementDto> elements;
|
public List<ElementDto> elements;
|
||||||
|
|
||||||
public static class ElementDto {
|
public static class ElementDto {
|
||||||
public double[] from; // 0..16
|
public double[] from;
|
||||||
public double[] to; // 0..16
|
public double[] to;
|
||||||
public RotationDto rotation; // optional
|
public RotationDto rotation;
|
||||||
public Map<String, FaceDto> faces; // keys: down/up/north/south/west/east
|
public Map<String, FaceDto> faces; // keyed by direction name
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class FaceDto {
|
public static class FaceDto {
|
||||||
public double[] uv; // optional, 0..16 (x1,y1,x2,y2)
|
public double[] uv; // u1, v1, u2, v2; default from the element extents
|
||||||
public String texture; // e.g. "#side" or "minecraft:block/oak_planks"
|
public String texture; // e.g. "#side" or "minecraft:block/oak_planks"
|
||||||
public Integer tintindex;
|
public Integer tintindex;
|
||||||
public String cullface; // ignored by the renderer
|
|
||||||
public int rotation; // 0/90/180/270
|
public int rotation; // 0/90/180/270
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class RotationDto {
|
public static class RotationDto {
|
||||||
public double[] origin; // 0..16
|
public double[] origin;
|
||||||
public String axis; // "x" | "y" | "z"
|
public String axis;
|
||||||
public double angle; // degrees (vanilla models: 22.5 steps)
|
public double angle; // degrees
|
||||||
// Since 26.x alternatively Euler angles in degrees instead of axis/angle (CuboidRotation.EulerXYZRotation,
|
// Since 26.x alternatively Euler angles in degrees (CuboidRotation.EulerXYZRotation, Rz·Ry·Rx), e.g. the
|
||||||
// matrix Rz·Ry·Rx), e.g. the chains of template_hanging_sign_rot_3.
|
// chains of template_hanging_sign_rot_3.
|
||||||
public Double x, y, z;
|
public Double x, y, z;
|
||||||
public boolean rescale;
|
public boolean rescale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,19 @@ package eu.mhsl.minecraft.pixelpics.assets.font;
|
|||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/** Glyphs and advances of the pack's bitmap font (see {@link FontLoader}); immutable. */
|
||||||
* A Minecraft bitmap font assembled from the resource pack's {@code font/default.json} providers
|
|
||||||
* (the {@code bitmap} and {@code space} providers; {@code unihex}/{@code legacy_unicode}/{@code ttf}
|
|
||||||
* are out of scope). Maps codepoints to {@link Glyph}s and supplies advance widths, enough to rasterize
|
|
||||||
* a line of text. Immutable after construction.
|
|
||||||
*/
|
|
||||||
public final class BitmapFont {
|
public final class BitmapFont {
|
||||||
|
|
||||||
private final Map<Integer, Glyph> glyphs;
|
private final Map<Integer, Glyph> glyphs;
|
||||||
private final Map<Integer, Integer> spaceAdvances;
|
private final Map<Integer, Integer> spaceAdvances;
|
||||||
private final int maxAscent;
|
private final int maxAscent;
|
||||||
private final int maxDescent;
|
private final int lineHeight;
|
||||||
|
|
||||||
public BitmapFont(Map<Integer, Glyph> glyphs, Map<Integer, Integer> spaceAdvances, int maxAscent, int maxDescent) {
|
public BitmapFont(Map<Integer, Glyph> glyphs, Map<Integer, Integer> spaceAdvances, int maxAscent, int maxDescent) {
|
||||||
this.glyphs = glyphs;
|
this.glyphs = glyphs;
|
||||||
this.spaceAdvances = spaceAdvances;
|
this.spaceAdvances = spaceAdvances;
|
||||||
this.maxAscent = maxAscent;
|
this.maxAscent = maxAscent;
|
||||||
this.maxDescent = maxDescent;
|
this.lineHeight = maxAscent + maxDescent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The glyph for a codepoint, or null when no bitmap provider supplies it (e.g. CJK via unihex). */
|
/** The glyph for a codepoint, or null when no bitmap provider supplies it (e.g. CJK via unihex). */
|
||||||
@@ -35,19 +30,14 @@ public final class BitmapFont {
|
|||||||
return g != null ? g.advance() : 0;
|
return g != null ? g.advance() : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Largest baseline offset across all glyphs (font px) — the common baseline for a rendered line. */
|
/** Largest ascent of all glyphs (font px): the common baseline of a line. */
|
||||||
public int maxAscent() {
|
public int maxAscent() {
|
||||||
return maxAscent;
|
return maxAscent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Largest below-baseline extent across all glyphs (font px). */
|
/** Largest ascent + largest descent (font px). */
|
||||||
public int maxDescent() {
|
|
||||||
return maxDescent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Total vertical extent of one line (ascent + descent, font px). */
|
|
||||||
public int lineHeight() {
|
public int lineHeight() {
|
||||||
return maxAscent + maxDescent;
|
return lineHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isEmpty() {
|
public boolean isEmpty() {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import eu.mhsl.minecraft.pixelpics.assets.AssetPaths;
|
|||||||
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.ResourcePack;
|
import eu.mhsl.minecraft.pixelpics.assets.ResourcePack;
|
||||||
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -17,19 +19,15 @@ import java.util.Set;
|
|||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a {@link BitmapFont} from a resource pack's {@code font/default.json}. Resolves {@code reference}
|
* Builds a {@link BitmapFont} from the pack's {@code font/default.json}: {@code reference}, {@code bitmap} and
|
||||||
* providers recursively, parses {@code bitmap} providers into per-codepoint {@link Glyph}s (cell size and
|
* {@code space} providers; {@code unihex}/{@code legacy_unicode}/{@code ttf} are skipped (CJK etc. render as
|
||||||
* per-glyph pixel width derived from the PNG), and {@code space} providers into advance overrides.
|
* gaps). Earlier providers win, like vanilla.
|
||||||
* {@code unihex}/{@code legacy_unicode}/{@code ttf} providers are skipped (CJK etc. render as gaps).
|
|
||||||
*
|
|
||||||
* <p>Provider order is priority order (first match wins, matching vanilla): an earlier provider's glyph is
|
|
||||||
* never overwritten by a later one.
|
|
||||||
*/
|
*/
|
||||||
public final class FontLoader {
|
public final class FontLoader {
|
||||||
|
|
||||||
private FontLoader() {}
|
private FontLoader() {}
|
||||||
|
|
||||||
/** Loads {@code minecraft:default}. Returns an empty font (no glyphs) when the pack has no font assets. */
|
/** {@code minecraft:default}; a font without glyphs when the pack has no font assets. */
|
||||||
public static BitmapFont load(ResourcePack pack, TextureCache textures, Logger log) {
|
public static BitmapFont load(ResourcePack pack, TextureCache textures, Logger log) {
|
||||||
Builder b = new Builder(pack, textures, log);
|
Builder b = new Builder(pack, textures, log);
|
||||||
b.loadFont(ResourceLocation.parse("minecraft:default"), new HashSet<>());
|
b.loadFont(ResourceLocation.parse("minecraft:default"), new HashSet<>());
|
||||||
@@ -57,10 +55,9 @@ public final class FontLoader {
|
|||||||
|
|
||||||
void loadFont(ResourceLocation id, Set<String> visited) {
|
void loadFont(ResourceLocation id, Set<String> visited) {
|
||||||
String path = AssetPaths.font(id);
|
String path = AssetPaths.font(id);
|
||||||
if (!visited.add(path)) return; // cycle guard
|
if (!visited.add(path)) return;
|
||||||
Optional<byte[]> bytes = pack.read(path);
|
Optional<byte[]> bytes = pack.read(path);
|
||||||
// Packs built from just the textures/models lack font/*.json — use the bundled vanilla font
|
// Packs without font/*.json: the bundled vanilla definitions (glyph sheets still come from the pack).
|
||||||
// definitions (tiny JSON; the glyph sheets still come from the pack's textures/font/).
|
|
||||||
if (bytes.isEmpty()) bytes = bundled(path);
|
if (bytes.isEmpty()) bytes = bundled(path);
|
||||||
if (bytes.isEmpty()) return;
|
if (bytes.isEmpty()) return;
|
||||||
JsonObject root;
|
JsonObject root;
|
||||||
@@ -68,7 +65,7 @@ public final class FontLoader {
|
|||||||
root = JsonParser.parseString(new String(bytes.get(), StandardCharsets.UTF_8))
|
root = JsonParser.parseString(new String(bytes.get(), StandardCharsets.UTF_8))
|
||||||
.getAsJsonObject();
|
.getAsJsonObject();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (log != null) log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage());
|
log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
JsonArray providers = root.getAsJsonArray("providers");
|
JsonArray providers = root.getAsJsonArray("providers");
|
||||||
@@ -80,11 +77,11 @@ public final class FontLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Optional<byte[]> bundled(String path) {
|
private Optional<byte[]> bundled(String path) {
|
||||||
try (java.io.InputStream in = FontLoader.class.getResourceAsStream("/fallback/" + path)) {
|
try (InputStream in = FontLoader.class.getResourceAsStream("/fallback/" + path)) {
|
||||||
if (in == null) return Optional.empty();
|
if (in == null) return Optional.empty();
|
||||||
if (log != null) log.info("PixelPics: resource pack has no " + path + ", using the bundled copy.");
|
log.info("PixelPics: resource pack has no " + path + ", using the bundled copy.");
|
||||||
return Optional.of(in.readAllBytes());
|
return Optional.of(in.readAllBytes());
|
||||||
} catch (java.io.IOException e) {
|
} catch (IOException e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,9 +94,7 @@ public final class FontLoader {
|
|||||||
}
|
}
|
||||||
case "space" -> space(p);
|
case "space" -> space(p);
|
||||||
case "bitmap" -> bitmap(p);
|
case "bitmap" -> bitmap(p);
|
||||||
default -> {
|
default -> {}
|
||||||
/* unihex, legacy_unicode, ttf, … — out of scope */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +118,7 @@ public final class FontLoader {
|
|||||||
if (file.endsWith(".png")) file = file.substring(0, file.length() - 4);
|
if (file.endsWith(".png")) file = file.substring(0, file.length() - 4);
|
||||||
int[][] tex = textures.get(ResourceLocation.parse(file)).orElse(null);
|
int[][] tex = textures.get(ResourceLocation.parse(file)).orElse(null);
|
||||||
if (tex == null || tex.length == 0) {
|
if (tex == null || tex.length == 0) {
|
||||||
if (log != null) log.warning("PixelPics: font bitmap missing: " + file);
|
log.warning("PixelPics: font bitmap missing: " + file);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
int imgH = tex.length;
|
int imgH = tex.length;
|
||||||
@@ -132,9 +127,8 @@ public final class FontLoader {
|
|||||||
JsonArray rows = p.getAsJsonArray("chars");
|
JsonArray rows = p.getAsJsonArray("chars");
|
||||||
int nRows = rows.size();
|
int nRows = rows.size();
|
||||||
if (nRows == 0) return;
|
if (nRows == 0) return;
|
||||||
int nCols = rows.get(0)
|
String firstRow = rows.get(0).getAsString();
|
||||||
.getAsString()
|
int nCols = firstRow.codePointCount(0, firstRow.length());
|
||||||
.codePointCount(0, rows.get(0).getAsString().length());
|
|
||||||
if (nCols == 0) return;
|
if (nCols == 0) return;
|
||||||
int cellW = imgW / nCols;
|
int cellW = imgW / nCols;
|
||||||
int cellH = imgH / nRows;
|
int cellH = imgH / nRows;
|
||||||
@@ -153,7 +147,7 @@ public final class FontLoader {
|
|||||||
int srcX = thisCol * cellW;
|
int srcX = thisCol * cellW;
|
||||||
int srcY = r * cellH;
|
int srcY = r * cellH;
|
||||||
int glyphPx = rightmostOpaqueColumn(tex, srcX, srcY, cellW, cellH) + 1;
|
int glyphPx = rightmostOpaqueColumn(tex, srcX, srcY, cellW, cellH) + 1;
|
||||||
if (glyphPx <= 0) continue; // blank glyph (handled by space provider)
|
if (glyphPx <= 0) continue; // blank: spaces come from the space provider
|
||||||
int advance = (int) (glyphPx * scale + 0.5) + 1;
|
int advance = (int) (glyphPx * scale + 0.5) + 1;
|
||||||
glyphs.put(cp, new Glyph(tex, srcX, srcY, cellW, cellH, glyphPx, height, ascent, advance));
|
glyphs.put(cp, new Glyph(tex, srcX, srcY, cellW, cellH, glyphPx, height, ascent, advance));
|
||||||
maxAscent = Math.max(maxAscent, ascent);
|
maxAscent = Math.max(maxAscent, ascent);
|
||||||
@@ -162,13 +156,13 @@ public final class FontLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rightmost column index (0-based, relative to the cell) containing an opaque pixel, or -1 if blank. */
|
/** Rightmost cell column with a non-transparent pixel, -1 when blank. */
|
||||||
private static int rightmostOpaqueColumn(int[][] tex, int srcX, int srcY, int cellW, int cellH) {
|
private static int rightmostOpaqueColumn(int[][] tex, int srcX, int srcY, int cellW, int cellH) {
|
||||||
int last = -1;
|
int last = -1;
|
||||||
for (int x = 0; x < cellW; x++) {
|
for (int x = 0; x < cellW; x++) {
|
||||||
for (int y = 0; y < cellH; y++) {
|
for (int y = 0; y < cellH; y++) {
|
||||||
int argb = tex[srcY + y][srcX + x];
|
int argb = tex[srcY + y][srcX + x];
|
||||||
if (((argb >>> 24) & 0xFF) != 0) {
|
if (argb >>> 24 != 0) {
|
||||||
last = x;
|
last = x;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets.font;
|
package eu.mhsl.minecraft.pixelpics.assets.font;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single bitmap-font glyph: a rectangular cell {@code [srcX, srcX+cellW) × [srcY, srcY+cellH)} inside
|
* A bitmap-font glyph: its cell in the provider sheet {@code tex}, the drawn width {@code glyphPx} (up to the
|
||||||
* its provider PNG ({@code tex}, ARGB top-left), the number of opaque pixel columns to actually draw
|
* rightmost non-transparent column), and {@code height}, {@code ascent} and {@code advance} in font px. Providers
|
||||||
* ({@code glyphPx}, measured to the rightmost non-transparent column), the rendered {@code height} and
|
* differ in cell size and ascent (ascii 8/7, accented 12/10), so lines align glyphs on a common baseline.
|
||||||
* {@code ascent} (baseline offset from the top, font px) and the horizontal {@code advance} (font px).
|
|
||||||
*
|
|
||||||
* <p>Glyphs from different providers may declare different cell sizes and ascents (e.g. ascii 8px/ascent
|
|
||||||
* 7 vs accented 12px/ascent 10); rendering aligns them on a common baseline so umlauts and Latin mix.
|
|
||||||
*/
|
*/
|
||||||
public record Glyph(
|
public record Glyph(
|
||||||
int[][] tex, int srcX, int srcY, int cellW, int cellH, int glyphPx, int height, int ascent, int advance) {
|
int[][] tex, int srcX, int srcY, int cellW, int cellH, int glyphPx, int height, int ascent, int advance) {}
|
||||||
|
|
||||||
/** Vertical pixels below the baseline (font px). */
|
|
||||||
public int descent() {
|
|
||||||
return height - ascent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
|
||||||
|
|
||||||
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes the average opaque color of a texture, used as the 100% coverage fallback.
|
|
||||||
*/
|
|
||||||
public final class AverageColor {
|
|
||||||
|
|
||||||
private AverageColor() {}
|
|
||||||
|
|
||||||
/** Average ARGB over pixels with alpha > 16; returns opaque gray when fully transparent/empty. */
|
|
||||||
public static int of(int[][] texture) {
|
|
||||||
long r = 0, g = 0, b = 0;
|
|
||||||
int count = 0;
|
|
||||||
for (int[] row : texture) {
|
|
||||||
for (int argb : row) {
|
|
||||||
if (ColorUtil.alpha(argb) <= 16) continue;
|
|
||||||
r += ColorUtil.red(argb);
|
|
||||||
g += ColorUtil.green(argb);
|
|
||||||
b += ColorUtil.blue(argb);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (count == 0) return 0xFF7F7F7F;
|
|
||||||
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mutable accumulator that averages a set of already-opaque ARGB colors (e.g. per-face/per-variant). */
|
|
||||||
public static final class Accumulator {
|
|
||||||
private long r, g, b;
|
|
||||||
private int count;
|
|
||||||
|
|
||||||
public void add(int argb) {
|
|
||||||
r += ColorUtil.red(argb);
|
|
||||||
g += ColorUtil.green(argb);
|
|
||||||
b += ColorUtil.blue(argb);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Opaque average of the added colors, or {@code fallback} if none were added. */
|
|
||||||
public int average(int fallback) {
|
|
||||||
if (count == 0) return fallback;
|
|
||||||
return ColorUtil.argb(0xFF, (int) (r / count), (int) (g / count), (int) (b / count));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||||
|
|
||||||
/**
|
/** The six block face directions with unit normals and the blockstate 90° rotations. */
|
||||||
* The six block face directions, with unit normals and 90-degree rotation helpers used when baking
|
|
||||||
* blockstate {@code x}/{@code y} rotations.
|
|
||||||
*/
|
|
||||||
public enum Direction {
|
public enum Direction {
|
||||||
DOWN(0, -1, 0),
|
DOWN(0, -1, 0),
|
||||||
UP(0, 1, 0),
|
UP(0, 1, 0),
|
||||||
@@ -54,9 +51,9 @@ public enum Direction {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rotate this direction by {@code steps * 90} degrees around the Y axis (clockwise from above). */
|
/** Rotated by {@code steps}·90° about Y, clockwise seen from above (vanilla blockstate {@code y}). */
|
||||||
public Direction rotateY(int steps) {
|
public Direction rotateY(int steps) {
|
||||||
steps = ((steps % 4) + 4) % 4;
|
steps = Math.floorMod(steps, 4);
|
||||||
Direction d = this;
|
Direction d = this;
|
||||||
for (int i = 0; i < steps; i++) {
|
for (int i = 0; i < steps; i++) {
|
||||||
d = switch (d) {
|
d = switch (d) {
|
||||||
@@ -64,15 +61,14 @@ public enum Direction {
|
|||||||
case EAST -> SOUTH;
|
case EAST -> SOUTH;
|
||||||
case SOUTH -> WEST;
|
case SOUTH -> WEST;
|
||||||
case WEST -> NORTH;
|
case WEST -> NORTH;
|
||||||
// up/down unchanged
|
|
||||||
default -> d;};
|
default -> d;};
|
||||||
}
|
}
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rotate this direction by {@code steps * 90} degrees around the X axis. */
|
/** Rotated by {@code steps}·90° about X (vanilla blockstate {@code x}). */
|
||||||
public Direction rotateX(int steps) {
|
public Direction rotateX(int steps) {
|
||||||
steps = ((steps % 4) + 4) % 4;
|
steps = Math.floorMod(steps, 4);
|
||||||
Direction d = this;
|
Direction d = this;
|
||||||
for (int i = 0; i < steps; i++) {
|
for (int i = 0; i < steps; i++) {
|
||||||
d = switch (d) {
|
d = switch (d) {
|
||||||
@@ -80,7 +76,6 @@ public enum Direction {
|
|||||||
case NORTH -> DOWN;
|
case NORTH -> DOWN;
|
||||||
case DOWN -> SOUTH;
|
case DOWN -> SOUTH;
|
||||||
case SOUTH -> UP;
|
case SOUTH -> UP;
|
||||||
// east/west unchanged
|
|
||||||
default -> d;};
|
default -> d;};
|
||||||
}
|
}
|
||||||
return d;
|
return d;
|
||||||
|
|||||||
@@ -1,48 +1,45 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single box of a baked block model. Coordinates are normalized to the 0..1 block cube. Faces are
|
* A box of a baked block model in block units (0..1 inside the cell); faces indexed by {@link Direction#ordinal()},
|
||||||
* indexed by {@link Direction#ordinal()} and may be {@code null} when absent.
|
* null when absent. {@code from > to} on an axis marks an inverted element (inward-facing faces, like vanilla).
|
||||||
*
|
|
||||||
* <p>An optional element rotation (from the model JSON) is kept as origin/axis/angle so the
|
|
||||||
* intersector can treat the box as oriented (OBB) when {@code angle != 0}.
|
|
||||||
*/
|
*/
|
||||||
public final class Element {
|
public final class Element {
|
||||||
|
|
||||||
public final double[] from; // length 3, 0..1
|
public final double[] from;
|
||||||
public final double[] to; // length 3, 0..1
|
public final double[] to;
|
||||||
public final Face[] faces; // length 6, indexed by Direction.ordinal()
|
public final Face[] faces;
|
||||||
|
|
||||||
// Element rotation (0..1 origin), null/zero when axis-aligned.
|
// Own element rotation about rotOrigin: single axis (0=x, 1=y, 2=z) by rotAngleRad, or AXIS_MATRIX for a
|
||||||
public final double[] rotOrigin; // length 3, 0..1, may be null
|
// general (Euler x/y/z) rotation in rotMatrix; rotAxis -1 and rotOrigin null when unrotated.
|
||||||
public final int rotAxis; // 0=x,1=y,2=z, AXIS_MATRIX (Euler x/y/z rotation in rotMatrix), -1 when none
|
public final double[] rotOrigin;
|
||||||
public final double rotAngleRad; // radians (single-axis rotations only)
|
public final int rotAxis;
|
||||||
|
public final double rotAngleRad;
|
||||||
public final boolean rescale;
|
public final boolean rescale;
|
||||||
|
|
||||||
/** {@link #rotAxis} of an element rotated by a general (Euler x/y/z) rotation held in {@link #rotMatrix}. */
|
|
||||||
public static final int AXIS_MATRIX = 3;
|
public static final int AXIS_MATRIX = 3;
|
||||||
|
|
||||||
// cos/sin of the element rotation (+angle: local -> block) and of its inverse (-angle: block -> local),
|
// Precomputed for the intersector's hot path: cos/sin of +angle (local -> block) and -angle (block -> local).
|
||||||
// precomputed for the intersector's hot path.
|
|
||||||
public final double rotCos, rotSin, invRotCos, invRotSin;
|
public final double rotCos, rotSin, invRotCos, invRotSin;
|
||||||
|
|
||||||
// General rotation (rotAxis == AXIS_MATRIX): row-major 3x3 local -> block and its inverse (transpose); null
|
// Row-major 3x3 local -> block and its transpose, only for AXIS_MATRIX.
|
||||||
// otherwise.
|
|
||||||
public final double[] rotMatrix, invRotMatrix;
|
public final double[] rotMatrix, invRotMatrix;
|
||||||
|
|
||||||
// Vanilla rescale (CuboidRotation.computeRescale): the element is scaled per LOCAL axis by
|
// Vanilla rescale (CuboidRotation.computeRescale): scale per LOCAL axis by 1 / max|component of R·axis| before
|
||||||
// 1 / max|component of R·axis| before the rotation (1/cos 22.5° or 1/cos 45° on the axes the rotation tilts),
|
// rotating, so a tilted cross plane still spans the block. Stored as reciprocals (block -> local); null without.
|
||||||
// so a rotated cross plane still spans the whole block. Reciprocals (block -> local); null without rescale.
|
|
||||||
public final double[] invScale;
|
public final double[] invScale;
|
||||||
|
|
||||||
// Blockstate rotation: the model-space element and the 90° steps (x first, then y) that turn it into this
|
// Blockstate rotation: the model-space element and the 90° steps (x first, then y) that turn it into this
|
||||||
// world-space one. Intersectors hit the unrotated element with the inverse-rotated ray, so textures stay
|
// world-space one; null/0 when unrotated. Intersectors hit the unrotated element with the inverse-rotated ray.
|
||||||
// bound to the model (vanilla); under uvlock its faces carry a UV transform keeping them world-aligned.
|
|
||||||
// Null/0 when unrotated.
|
|
||||||
public final Element unrotated;
|
public final Element unrotated;
|
||||||
public final int xSteps;
|
public final int xSteps;
|
||||||
public final int ySteps;
|
public final int ySteps;
|
||||||
|
|
||||||
|
/** An unrotated box. */
|
||||||
|
public Element(double[] from, double[] to, Face[] faces) {
|
||||||
|
this(from, to, faces, null, -1, 0, null, false, null, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
public Element(
|
public Element(
|
||||||
double[] from,
|
double[] from,
|
||||||
double[] to,
|
double[] to,
|
||||||
@@ -50,50 +47,11 @@ public final class Element {
|
|||||||
double[] rotOrigin,
|
double[] rotOrigin,
|
||||||
int rotAxis,
|
int rotAxis,
|
||||||
double rotAngleRad,
|
double rotAngleRad,
|
||||||
boolean rescale) {
|
|
||||||
this(from, to, faces, rotOrigin, rotAxis, rotAngleRad, rescale, null, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An element rotated by a general rotation matrix (row-major 3x3, local -> block, e.g. Euler x/y/z). */
|
|
||||||
public Element(
|
|
||||||
double[] from,
|
|
||||||
double[] to,
|
|
||||||
Face[] faces,
|
|
||||||
double[] rotOrigin,
|
|
||||||
double[] rotMatrix,
|
double[] rotMatrix,
|
||||||
boolean rescale,
|
boolean rescale,
|
||||||
Element unrotated,
|
Element unrotated,
|
||||||
int xSteps,
|
int xSteps,
|
||||||
int ySteps) {
|
int ySteps) {
|
||||||
this(from, to, faces, rotOrigin, AXIS_MATRIX, 0, rescale, unrotated, xSteps, ySteps, rotMatrix);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Element(
|
|
||||||
double[] from,
|
|
||||||
double[] to,
|
|
||||||
Face[] faces,
|
|
||||||
double[] rotOrigin,
|
|
||||||
int rotAxis,
|
|
||||||
double rotAngleRad,
|
|
||||||
boolean rescale,
|
|
||||||
Element unrotated,
|
|
||||||
int xSteps,
|
|
||||||
int ySteps) {
|
|
||||||
this(from, to, faces, rotOrigin, rotAxis, rotAngleRad, rescale, unrotated, xSteps, ySteps, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Element(
|
|
||||||
double[] from,
|
|
||||||
double[] to,
|
|
||||||
Face[] faces,
|
|
||||||
double[] rotOrigin,
|
|
||||||
int rotAxis,
|
|
||||||
double rotAngleRad,
|
|
||||||
boolean rescale,
|
|
||||||
Element unrotated,
|
|
||||||
int xSteps,
|
|
||||||
int ySteps,
|
|
||||||
double[] rotMatrix) {
|
|
||||||
this.unrotated = unrotated;
|
this.unrotated = unrotated;
|
||||||
this.xSteps = xSteps;
|
this.xSteps = xSteps;
|
||||||
this.ySteps = ySteps;
|
this.ySteps = ySteps;
|
||||||
@@ -113,6 +71,12 @@ public final class Element {
|
|||||||
this.invScale = rescale && !isAxisAligned() ? inverseRescale(linearRotation()) : null;
|
this.invScale = rescale && !isAxisAligned() ? inverseRescale(linearRotation()) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** This element with other faces (rotation and blockstate rotation kept). */
|
||||||
|
public Element withFaces(Face[] faces) {
|
||||||
|
return new Element(
|
||||||
|
from, to, faces, rotOrigin, rotAxis, rotAngleRad, rotMatrix, rescale, unrotated, xSteps, ySteps);
|
||||||
|
}
|
||||||
|
|
||||||
/** The rotation as a row-major 3x3 (local -> block), identity when axis-aligned. */
|
/** The rotation as a row-major 3x3 (local -> block), identity when axis-aligned. */
|
||||||
public double[] linearRotation() {
|
public double[] linearRotation() {
|
||||||
if (rotMatrix != null) return rotMatrix.clone();
|
if (rotMatrix != null) return rotMatrix.clone();
|
||||||
@@ -125,12 +89,11 @@ public final class Element {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vanilla rescale factors per local axis: 1 / max|component of R·axis| (column of R); as reciprocals. */
|
/** Reciprocal rescale per local axis: max|component of R·axis| (column a of R). */
|
||||||
private static double[] inverseRescale(double[] r) {
|
private static double[] inverseRescale(double[] r) {
|
||||||
double[] inv = new double[3];
|
double[] inv = new double[3];
|
||||||
for (int a = 0; a < 3; a++) {
|
for (int a = 0; a < 3; a++) {
|
||||||
double max = Math.max(Math.max(Math.abs(r[a]), Math.abs(r[3 + a])), Math.abs(r[6 + a]));
|
inv[a] = Math.max(Math.max(Math.abs(r[a]), Math.abs(r[3 + a])), Math.abs(r[6 + a]));
|
||||||
inv[a] = max; // 1 / (1 / max)
|
|
||||||
}
|
}
|
||||||
return inv;
|
return inv;
|
||||||
}
|
}
|
||||||
@@ -152,7 +115,7 @@ public final class Element {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@link #rotate} into {@code out} (allocation-free, for the intersectors). */
|
/** {@link #rotate} into {@code out} (allocation-free). */
|
||||||
public static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
|
public static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
|
||||||
double c = point ? 0.5 : 0;
|
double c = point ? 0.5 : 0;
|
||||||
double dx = x - c, dy = y - c, dz = z - c;
|
double dx = x - c, dy = y - c, dz = z - c;
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||||
|
|
||||||
/**
|
/** A textured face of an {@link Element}; UVs in 0..1 with a top-left origin (v down), like vanilla. */
|
||||||
* A textured face of an {@link Element}. UV coordinates are normalized to 0..1 in texture space with
|
|
||||||
* the origin at the top-left ({@code v} increasing downwards), matching the vanilla convention.
|
|
||||||
*/
|
|
||||||
public final class Face {
|
public final class Face {
|
||||||
|
|
||||||
public final int[][] texture; // [y][x] ARGB, top-left origin
|
public final int[][] texture; // [y][x] ARGB, top-left origin
|
||||||
public final double u1, v1, u2, v2;
|
public final double u1, v1, u2, v2;
|
||||||
public final int rotation; // 0/90/180/270, applied to the sampled UV
|
public final int rotation; // 0/90/180/270 (normalised), applied to the sampled UV
|
||||||
public final int tintIndex; // -1 = no tint
|
public final int tintIndex; // -1 = no tint
|
||||||
|
|
||||||
// uvlock: affine applied to the final texture (u, v) as {a, b, c, d, e, f} with u' = a + b*u + c*v and
|
// uvlock: affine applied to the final texture (u, v) as {a, b, c, d, e, f} with u' = a + b*u + c*v and
|
||||||
@@ -27,7 +24,7 @@ public final class Face {
|
|||||||
this.v1 = v1;
|
this.v1 = v1;
|
||||||
this.u2 = u2;
|
this.u2 = u2;
|
||||||
this.v2 = v2;
|
this.v2 = v2;
|
||||||
this.rotation = rotation;
|
this.rotation = Math.floorMod(rotation, 360);
|
||||||
this.tintIndex = tintIndex;
|
this.tintIndex = tintIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,14 +38,10 @@ public final class Face {
|
|||||||
return new Face(texture, u1, v1, u2, v2, rotation, tintIndex, lock);
|
return new Face(texture, u1, v1, u2, v2, rotation, tintIndex, lock);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The ARGB texel at {@code (s, t)} in 0..1 across the face (s horizontal, t vertical, top-left origin). */
|
||||||
* Samples the ARGB pixel for a position within the face, where {@code (s, t)} are in 0..1 across
|
|
||||||
* the face's two in-plane axes (s = horizontal, t = vertical, top-left origin).
|
|
||||||
*/
|
|
||||||
public int sample(double s, double t) {
|
public int sample(double s, double t) {
|
||||||
// Apply face rotation by rotating the (s,t) lookup.
|
|
||||||
double rs = s, rt = t;
|
double rs = s, rt = t;
|
||||||
switch (((rotation % 360) + 360) % 360) {
|
switch (rotation) {
|
||||||
case 90 -> {
|
case 90 -> {
|
||||||
rs = t;
|
rs = t;
|
||||||
rt = 1.0 - s;
|
rt = 1.0 - s;
|
||||||
|
|||||||
@@ -3,34 +3,18 @@ package eu.mhsl.minecraft.pixelpics.assets.model;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fully baked, intersectable block model: a list of {@link Element} boxes plus rendering hints.
|
* A baked block model. {@code hasGeometry} is false for empty models and for the solid fallback cube of blocks
|
||||||
*
|
* without model elements. Deliberately not a record: callers key caches on identity (one instance per BlockData).
|
||||||
* <p>{@code averageColor} is the 100% coverage fallback used for blocks without geometry
|
|
||||||
* (builtin/generated models, unresolved blocks) and as a backstop. {@code hasGeometry} is false when
|
|
||||||
* the model has no usable elements; the renderer then draws a flat shaded cube using
|
|
||||||
* {@code averageColor}.
|
|
||||||
*/
|
*/
|
||||||
public final class ResolvedModel {
|
public final class ResolvedModel {
|
||||||
|
|
||||||
|
public static final ResolvedModel EMPTY = new ResolvedModel(List.of(), false);
|
||||||
|
|
||||||
public final List<Element> elements;
|
public final List<Element> elements;
|
||||||
public final int averageColor; // ARGB
|
|
||||||
public final double transparency; // 0..1
|
|
||||||
public final double reflection; // 0..1
|
|
||||||
public final boolean occluding;
|
|
||||||
public final boolean hasGeometry;
|
public final boolean hasGeometry;
|
||||||
|
|
||||||
public ResolvedModel(
|
public ResolvedModel(List<Element> elements, boolean hasGeometry) {
|
||||||
List<Element> elements,
|
|
||||||
int averageColor,
|
|
||||||
double transparency,
|
|
||||||
double reflection,
|
|
||||||
boolean occluding,
|
|
||||||
boolean hasGeometry) {
|
|
||||||
this.elements = elements;
|
this.elements = elements;
|
||||||
this.averageColor = averageColor;
|
|
||||||
this.transparency = transparency;
|
|
||||||
this.reflection = reflection;
|
|
||||||
this.occluding = occluding;
|
|
||||||
this.hasGeometry = hasGeometry;
|
this.hasGeometry = hasGeometry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user