resource-pack render engine
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
/**
|
||||
* Builds pack-relative asset paths from namespaced ids, following the vanilla layout
|
||||
* {@code assets/<namespace>/<category>/<path>.<ext>}.
|
||||
*/
|
||||
public final class AssetPaths {
|
||||
|
||||
private AssetPaths() {}
|
||||
|
||||
/** {@code assets/<ns>/blockstates/<name>.json} for a plain block name (no {@code block/} prefix). */
|
||||
public static String blockState(String blockName) {
|
||||
return String.format("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}. */
|
||||
public static String model(ResourceLocation id) {
|
||||
return String.format("assets/%s/models/%s.json", id.namespace(), id.path());
|
||||
}
|
||||
|
||||
/** {@code assets/<ns>/textures/<path>.png}. The id path already contains e.g. {@code block/stone}. */
|
||||
public static String texture(ResourceLocation id) {
|
||||
return String.format("assets/%s/textures/%s.png", id.namespace(), id.path());
|
||||
}
|
||||
|
||||
/** {@code assets/<ns>/textures/<path>.png.mcmeta} animation metadata, if present. */
|
||||
public static String textureMeta(ResourceLocation id) {
|
||||
return texture(id) + ".mcmeta";
|
||||
}
|
||||
|
||||
/** {@code assets/minecraft/textures/colormap/<name>.png}. */
|
||||
public static String colormap(String name) {
|
||||
return String.format("assets/%s/textures/colormap/%s.png", ResourceLocation.DEFAULT_NAMESPACE, name.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Thin convenience layer over a {@link ResourcePack} for reading JSON assets.
|
||||
*/
|
||||
public final class AssetReader {
|
||||
|
||||
private final ResourcePack pack;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
public AssetReader(ResourcePack pack) {
|
||||
this.pack = pack;
|
||||
}
|
||||
|
||||
public ResourcePack pack() {
|
||||
return pack;
|
||||
}
|
||||
|
||||
public <T> Optional<T> readJson(String path, Class<T> type) {
|
||||
return pack.read(path).flatMap(bytes -> {
|
||||
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) {
|
||||
return pack.read(path).flatMap(bytes -> {
|
||||
try {
|
||||
return Optional.of(JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject());
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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.Element;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Top-level entry point that turns a {@link BlockData} into a baked {@link ResolvedModel}, combining
|
||||
* blockstate resolution, model resolution and geometry baking. Results are cached per BlockData and
|
||||
* the cache is safe for concurrent access by the parallel renderer.
|
||||
*/
|
||||
public final class BlockModelRegistry {
|
||||
|
||||
private final TextureCache textures;
|
||||
private final BlockStateResolver blockStateResolver;
|
||||
private final ModelResolver modelResolver;
|
||||
private final ModelBaker baker;
|
||||
|
||||
private final Map<BlockData, ResolvedModel> cache = new ConcurrentHashMap<>();
|
||||
private volatile ResolvedModel waterModel;
|
||||
private volatile ResolvedModel lavaModel;
|
||||
|
||||
public BlockModelRegistry(AssetReader reader, TextureCache textures) {
|
||||
this.textures = textures;
|
||||
this.blockStateResolver = new BlockStateResolver(reader);
|
||||
this.modelResolver = new ModelResolver(reader);
|
||||
this.baker = new ModelBaker(textures);
|
||||
}
|
||||
|
||||
public ResolvedModel get(BlockData data) {
|
||||
return cache.computeIfAbsent(data, this::resolve);
|
||||
}
|
||||
|
||||
private ResolvedModel resolve(BlockData data) {
|
||||
Material material = data.getMaterial();
|
||||
if (material == Material.WATER) return water();
|
||||
if (material == Material.LAVA) return lava();
|
||||
|
||||
List<Variant> variants = blockStateResolver.resolve(data);
|
||||
|
||||
List<Element> elements = new java.util.ArrayList<>();
|
||||
long ar = 0, ag = 0, ab = 0;
|
||||
int acount = 0;
|
||||
FlatModel lastFlat = null;
|
||||
|
||||
for (Variant variant : variants) {
|
||||
FlatModel flat = modelResolver.resolve(variant.model());
|
||||
lastFlat = flat;
|
||||
ModelBaker.BakedGeometry baked = baker.bake(flat, variant);
|
||||
elements.addAll(baked.elements());
|
||||
if (baked.hasGeometry()) {
|
||||
int c = baked.averageColor();
|
||||
ar += (c >> 16) & 0xFF;
|
||||
ag += (c >> 8) & 0xFF;
|
||||
ab += c & 0xFF;
|
||||
acount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!elements.isEmpty()) {
|
||||
int avg = 0xFF000000 | (((int) (ar / acount)) << 16) | (((int) (ag / acount)) << 8) | ((int) (ab / acount));
|
||||
return new ResolvedModel(elements, avg, 0, 0, false, true);
|
||||
}
|
||||
|
||||
// No geometry: render a flat full cube using a fallback average color.
|
||||
int avg = fallbackColor(lastFlat);
|
||||
return new ResolvedModel(List.of(solidCube(avg)), avg, 0, 0, false, false);
|
||||
}
|
||||
|
||||
/** A full-block cube whose six faces sample a single solid color (1x1 texture). */
|
||||
private Element solidCube(int color) {
|
||||
int[][] tex = {{color}};
|
||||
Face[] faces = new Face[6];
|
||||
for (Direction d : Direction.values()) {
|
||||
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, null, -1, 0, false);
|
||||
}
|
||||
|
||||
private int fallbackColor(FlatModel flat) {
|
||||
if (flat != null && flat.textures() != null) {
|
||||
String particle = flat.textures().get("particle");
|
||||
if (particle != null && !particle.startsWith("#")) {
|
||||
int[][] tex = textures.get(ResourceLocation.parse(particle)).orElse(null);
|
||||
if (tex != null) return AverageColor.of(tex);
|
||||
}
|
||||
}
|
||||
return 0xFF7F7F7F;
|
||||
}
|
||||
|
||||
private ResolvedModel water() {
|
||||
ResolvedModel m = waterModel;
|
||||
if (m == null) {
|
||||
m = liquid("block/water_still", 0, 0.60, 0.10);
|
||||
waterModel = m;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private ResolvedModel lava() {
|
||||
ResolvedModel m = lavaModel;
|
||||
if (m == null) {
|
||||
m = liquid("block/lava_still", -1, 0.15, 0.05);
|
||||
lavaModel = m;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Builds a simple full-cube model for a liquid texture with the given tint/transparency/reflection. */
|
||||
private ResolvedModel liquid(String texturePath, int tintIndex, double transparency, double reflection) {
|
||||
int[][] tex = textures.get(ResourceLocation.parse(texturePath)).orElse(null);
|
||||
if (tex == null) {
|
||||
return new ResolvedModel(List.of(solidCube(0xFF3F76E4)), 0xFF3F76E4, transparency, reflection, true, true);
|
||||
}
|
||||
Face[] faces = new Face[6];
|
||||
for (Direction d : Direction.values()) {
|
||||
double[] uv = switch (d) {
|
||||
case UP, DOWN -> new double[]{0, 0, 1, 1};
|
||||
default -> new double[]{0, 0, 1, 1};
|
||||
};
|
||||
faces[d.ordinal()] = new Face(tex, uv[0], uv[1], uv[2], uv[3], 0, tintIndex);
|
||||
}
|
||||
Element cube = new Element(new double[]{0, 0, 0}, new double[]{1, 1, 1}, faces, null, -1, 0, false);
|
||||
int avg = AverageColor.of(tex);
|
||||
return new ResolvedModel(List.of(cube), avg, transparency, reflection, true, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
private final AssetReader reader;
|
||||
|
||||
public BlockStateResolver(AssetReader reader) {
|
||||
this.reader = reader;
|
||||
}
|
||||
|
||||
public List<Variant> resolve(BlockData data) {
|
||||
String name = BlockStateProperties.blockName(data);
|
||||
Map<String, String> props = BlockStateProperties.properties(data);
|
||||
|
||||
JsonObject root = reader.readJsonObject(AssetPaths.blockState(name)).orElse(null);
|
||||
if (root == null) return List.of();
|
||||
|
||||
if (root.has("variants")) {
|
||||
return resolveVariants(root.getAsJsonObject("variants"), props);
|
||||
}
|
||||
if (root.has("multipart")) {
|
||||
return resolveMultipart(root.getAsJsonArray("multipart"), props);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<Variant> resolveVariants(JsonObject variants, Map<String, String> props) {
|
||||
for (Map.Entry<String, JsonElement> entry : variants.entrySet()) {
|
||||
if (variantKeyMatches(entry.getKey(), props)) {
|
||||
return List.of(parseVariant(firstOf(entry.getValue())));
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<Variant> resolveMultipart(JsonArray multipart, Map<String, String> props) {
|
||||
List<Variant> result = new ArrayList<>();
|
||||
for (JsonElement caseEl : multipart) {
|
||||
JsonObject caseObj = caseEl.getAsJsonObject();
|
||||
if (caseObj.has("when") && !whenMatches(caseObj.get("when"), props)) continue;
|
||||
result.add(parseVariant(firstOf(caseObj.get("apply"))));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A variant key like {@code facing=east,half=bottom} matches when every pair holds for the block. */
|
||||
private boolean variantKeyMatches(String key, Map<String, String> props) {
|
||||
if (key.isEmpty()) return true;
|
||||
for (String pair : key.split(",")) {
|
||||
int eq = pair.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
String prop = pair.substring(0, eq).trim();
|
||||
String value = pair.substring(eq + 1).trim();
|
||||
if (!valueMatches(props.get(prop), value)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean whenMatches(JsonElement when, Map<String, String> props) {
|
||||
JsonObject obj = when.getAsJsonObject();
|
||||
if (obj.has("OR")) {
|
||||
for (JsonElement sub : obj.getAsJsonArray("OR")) {
|
||||
if (whenMatches(sub, props)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (obj.has("AND")) {
|
||||
for (JsonElement sub : obj.getAsJsonArray("AND")) {
|
||||
if (!whenMatches(sub, props)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Implicit AND over property conditions.
|
||||
for (Map.Entry<String, JsonElement> e : obj.entrySet()) {
|
||||
if (!valueMatches(props.get(e.getKey()), e.getValue().getAsString())) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A condition value may be an OR list like {@code "north|south"}. */
|
||||
private boolean valueMatches(String actual, String expected) {
|
||||
if (actual == null) return false;
|
||||
for (String option : expected.split("\\|")) {
|
||||
if (option.equals(actual)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private JsonObject firstOf(JsonElement element) {
|
||||
if (element.isJsonArray()) {
|
||||
return element.getAsJsonArray().get(0).getAsJsonObject();
|
||||
}
|
||||
return element.getAsJsonObject();
|
||||
}
|
||||
|
||||
private Variant parseVariant(JsonObject obj) {
|
||||
ResourceLocation model = ResourceLocation.parse(obj.get("model").getAsString());
|
||||
int x = obj.has("x") ? obj.get("x").getAsInt() : 0;
|
||||
int y = obj.has("y") ? obj.get("y").getAsInt() : 0;
|
||||
boolean uvlock = obj.has("uvlock") && obj.get("uvlock").getAsBoolean();
|
||||
return new Variant(model, x, y, uvlock);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
private final List<ResourcePack> packs;
|
||||
|
||||
public CompositeResourcePack(List<ResourcePack> packs) {
|
||||
this.packs = List.copyOf(packs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<byte[]> read(String path) {
|
||||
for (ResourcePack pack : packs) {
|
||||
Optional<byte[]> result = pack.read(path);
|
||||
if (result.isPresent()) return result;
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String path) {
|
||||
return packs.stream().anyMatch(pack -> pack.exists(path));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
packs.forEach(ResourcePack::close);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A {@link ResourcePack} backed by a directory on disk. The root typically contains the
|
||||
* {@code assets/} folder.
|
||||
*/
|
||||
public final class DirectoryResourcePack implements ResourcePack {
|
||||
|
||||
private final Path root;
|
||||
|
||||
public DirectoryResourcePack(Path root) {
|
||||
this.root = root.toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private Optional<Path> resolve(String path) {
|
||||
Path resolved = root.resolve(path).normalize();
|
||||
// Guard against path traversal outside the pack root.
|
||||
if (!resolved.startsWith(root)) return Optional.empty();
|
||||
return Optional.of(resolved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<byte[]> read(String path) {
|
||||
return resolve(path).flatMap(p -> {
|
||||
if (!Files.isRegularFile(p)) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(Files.readAllBytes(p));
|
||||
} catch (IOException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String path) {
|
||||
return resolve(path).map(Files::isRegularFile).orElse(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// nothing to release
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A model with its parent chain flattened: textures merged (child wins) and the nearest non-empty
|
||||
* {@code elements} list selected (vanilla does not merge elements across parents).
|
||||
*/
|
||||
public record FlatModel(Map<String, String> textures, List<ModelFileDto.ElementDto> elements) {
|
||||
|
||||
public boolean hasElements() {
|
||||
return elements != null && !elements.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
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.Element;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Bakes a {@link FlatModel} plus a blockstate {@link Variant} into intersectable {@link Element}
|
||||
* boxes. Texture variables are resolved against the model's merged texture map; default UVs follow
|
||||
* 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 {
|
||||
|
||||
private final TextureCache textures;
|
||||
|
||||
public ModelBaker(TextureCache textures) {
|
||||
this.textures = textures;
|
||||
}
|
||||
|
||||
public record BakedGeometry(List<Element> elements, int averageColor, boolean hasGeometry) {}
|
||||
|
||||
public BakedGeometry bake(FlatModel model, Variant variant) {
|
||||
if (!model.hasElements()) {
|
||||
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<>();
|
||||
long ar = 0, ag = 0, ab = 0, acount = 0;
|
||||
|
||||
for (ModelFileDto.ElementDto dto : model.elements()) {
|
||||
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[] 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];
|
||||
if (dto.faces != null) {
|
||||
for (Map.Entry<String, ModelFileDto.FaceDto> e : dto.faces.entrySet()) {
|
||||
Direction dir = Direction.fromName(e.getKey());
|
||||
if (dir == null) continue;
|
||||
Face face = buildFace(dir, e.getValue(), from, to, model.textures());
|
||||
if (face != null) faces[dir.ordinal()] = face;
|
||||
}
|
||||
}
|
||||
|
||||
// Element's own rotation (origin in 0..1, axis index, radians).
|
||||
double[] rotOrigin = null;
|
||||
int rotAxis = -1;
|
||||
double rotAngle = 0;
|
||||
boolean rescale = false;
|
||||
if (dto.rotation != null && dto.rotation.angle != 0 && dto.rotation.origin != null) {
|
||||
rotOrigin = new double[]{
|
||||
dto.rotation.origin[0] / 16.0, dto.rotation.origin[1] / 16.0, dto.rotation.origin[2] / 16.0};
|
||||
rotAxis = axisIndex(dto.rotation.axis);
|
||||
rotAngle = Math.toRadians(dto.rotation.angle);
|
||||
rescale = dto.rotation.rescale;
|
||||
}
|
||||
|
||||
// Apply blockstate variant rotation (90-degree steps) to box + faces + element rotation.
|
||||
for (int i = 0; i < xSteps; i++) {
|
||||
double[][] r = rotateBoxX(from, to);
|
||||
from = r[0];
|
||||
to = r[1];
|
||||
faces = rotateFacesX(faces);
|
||||
if (rotAxis >= 0) {
|
||||
rotOrigin = rotatePointX(rotOrigin);
|
||||
int[] na = rotateAxisX(rotAxis, rotAngle);
|
||||
rotAxis = na[0];
|
||||
rotAngle = na[1] == 0 ? 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);
|
||||
int[] na = rotateAxisY(rotAxis, rotAngle);
|
||||
rotAxis = na[0];
|
||||
rotAngle = na[1] == 0 ? rotAngle : -rotAngle;
|
||||
}
|
||||
}
|
||||
|
||||
baked.add(new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale));
|
||||
|
||||
// Accumulate average color from the element's face textures.
|
||||
for (Face f : faces) {
|
||||
if (f == null) continue;
|
||||
int c = AverageColor.of(f.texture);
|
||||
ar += (c >> 16) & 0xFF;
|
||||
ag += (c >> 8) & 0xFF;
|
||||
ab += c & 0xFF;
|
||||
acount++;
|
||||
}
|
||||
}
|
||||
|
||||
int avg = acount == 0 ? 0xFF7F7F7F
|
||||
: 0xFF000000 | (((int) (ar / acount)) << 16) | (((int) (ag / acount)) << 8) | ((int) (ab / acount));
|
||||
return new BakedGeometry(baked, avg, !baked.isEmpty());
|
||||
}
|
||||
|
||||
private Face buildFace(Direction dir, ModelFileDto.FaceDto dto, double[] from, double[] to,
|
||||
Map<String, String> textureVars) {
|
||||
int[][] tex = resolveTexture(dto.texture, textureVars);
|
||||
if (tex == null) return null;
|
||||
|
||||
double u1, v1, u2, v2;
|
||||
if (dto.uv != null && dto.uv.length == 4) {
|
||||
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;
|
||||
return new Face(tex, u1, v1, u2, v2, dto.rotation, tint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default UV (normalized 0..1) from the element extents, matching vanilla. Texture V is top-down,
|
||||
* 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) {
|
||||
case UP, DOWN -> new double[]{f[0], f[2], t[0], t[2]};
|
||||
case NORTH, SOUTH -> new double[]{f[0], 1 - t[1], t[0], 1 - f[1]};
|
||||
case WEST, EAST -> new double[]{f[2], 1 - t[1], t[2], 1 - f[1]};
|
||||
};
|
||||
}
|
||||
|
||||
private int[][] resolveTexture(String ref, Map<String, String> vars) {
|
||||
if (ref == null) return null;
|
||||
String current = ref;
|
||||
int guard = 0;
|
||||
while (current.startsWith("#") && guard++ < 16) {
|
||||
current = vars.get(current.substring(1));
|
||||
if (current == null) return null;
|
||||
}
|
||||
if (current.startsWith("#")) return null;
|
||||
return textures.get(ResourceLocation.parse(current)).orElse(null);
|
||||
}
|
||||
|
||||
private int axisIndex(String axis) {
|
||||
if (axis == null) return -1;
|
||||
return switch (axis.toLowerCase()) {
|
||||
case "x" -> 0;
|
||||
case "y" -> 1;
|
||||
case "z" -> 2;
|
||||
default -> -1;
|
||||
};
|
||||
}
|
||||
|
||||
// --- 90-degree box rotations around the block center (0.5, 0.5, 0.5) ---
|
||||
|
||||
private double[][] rotateBoxY(double[] from, double[] to) {
|
||||
// (x,z) -> (z, 1-x): 90 deg clockwise viewed from above.
|
||||
double[] c1 = rotatePointY(from);
|
||||
double[] c2 = rotatePointY(to);
|
||||
return minMax(c1, c2);
|
||||
}
|
||||
|
||||
private double[][] rotateBoxX(double[] from, double[] to) {
|
||||
double[] c1 = rotatePointX(from);
|
||||
double[] c2 = rotatePointX(to);
|
||||
return minMax(c1, c2);
|
||||
}
|
||||
|
||||
private double[] rotatePointY(double[] p) {
|
||||
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[] to = {Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(a[2], b[2])};
|
||||
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.
|
||||
// Returns {newAxisIndex, flipFlag(0/1)}; flip indicates the angle sign should invert.
|
||||
private int[] rotateAxisY(int axis, double angle) {
|
||||
// Y rotation maps x<->z; the y axis is unchanged.
|
||||
return switch (axis) {
|
||||
case 0 -> new int[]{2, 1}; // x -> z
|
||||
case 2 -> new int[]{0, 0}; // z -> x
|
||||
default -> new int[]{axis, 0};
|
||||
};
|
||||
}
|
||||
|
||||
private int[] rotateAxisX(int axis, double angle) {
|
||||
// X rotation maps y<->z; the x axis is unchanged.
|
||||
return switch (axis) {
|
||||
case 1 -> new int[]{2, 1}; // y -> z
|
||||
case 2 -> new int[]{1, 0}; // z -> y
|
||||
default -> new int[]{axis, 0};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.dto.ModelFileDto;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Loads block models and flattens their parent chains into {@link FlatModel}s. Results are cached by
|
||||
* model id.
|
||||
*/
|
||||
public final class ModelResolver {
|
||||
|
||||
private static final int MAX_DEPTH = 16;
|
||||
|
||||
private final AssetReader reader;
|
||||
private final Map<ResourceLocation, FlatModel> cache = new ConcurrentHashMap<>();
|
||||
|
||||
public ModelResolver(AssetReader reader) {
|
||||
this.reader = reader;
|
||||
}
|
||||
|
||||
public FlatModel resolve(ResourceLocation modelId) {
|
||||
FlatModel cached = cache.get(modelId);
|
||||
if (cached != null) return cached;
|
||||
FlatModel resolved = resolve(modelId, 0);
|
||||
cache.put(modelId, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private FlatModel resolve(ResourceLocation modelId, int depth) {
|
||||
ModelFileDto dto = reader.readJson(AssetPaths.model(modelId), ModelFileDto.class).orElse(null);
|
||||
if (dto == null) {
|
||||
return new FlatModel(new HashMap<>(), null);
|
||||
}
|
||||
|
||||
Map<String, String> textures = new HashMap<>();
|
||||
java.util.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);
|
||||
textures.putAll(parent.textures());
|
||||
if (elements == null || elements.isEmpty()) {
|
||||
elements = parent.elements();
|
||||
}
|
||||
}
|
||||
if (dto.textures != null) {
|
||||
textures.putAll(dto.textures);
|
||||
}
|
||||
|
||||
return new FlatModel(textures, elements);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
/**
|
||||
* 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 static final String DEFAULT_NAMESPACE = "minecraft";
|
||||
|
||||
public ResourceLocation {
|
||||
namespace = namespace.toLowerCase();
|
||||
path = path.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a string like {@code minecraft:block/stone} or {@code block/stone}.
|
||||
*/
|
||||
public static ResourceLocation parse(String raw) {
|
||||
int colon = raw.indexOf(':');
|
||||
if (colon < 0) {
|
||||
return new ResourceLocation(DEFAULT_NAMESPACE, raw);
|
||||
}
|
||||
return new ResourceLocation(raw.substring(0, colon), raw.substring(colon + 1));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return namespace + ":" + path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A read-only source of Minecraft assets. Paths are pack-relative and always use {@code /} as a
|
||||
* separator, e.g. {@code assets/minecraft/blockstates/oak_fence.json}.
|
||||
*
|
||||
* <p>Implementations must be safe for concurrent {@link #read} calls, since the renderer accesses
|
||||
* assets from multiple threads.
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* Whether the given asset path exists in this pack.
|
||||
*/
|
||||
boolean exists(String path);
|
||||
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Builds a {@link ResourcePack} from the plugin's {@code resourcepack/} data folder. The folder may
|
||||
* contain an unpacked pack (a directory with {@code assets/minecraft/...}) and/or one or more
|
||||
* {@code .zip} packs. Returns an empty optional when nothing usable is found.
|
||||
*/
|
||||
public final class ResourcePackLoader {
|
||||
|
||||
/** The marker that identifies a valid pack root: {@code <root>/assets/minecraft}. */
|
||||
private static final String MARKER = "assets/minecraft";
|
||||
|
||||
private ResourcePackLoader() {}
|
||||
|
||||
public static Optional<ResourcePack> load(File resourcePackDir, Logger logger) {
|
||||
if (!resourcePackDir.isDirectory()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
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<>();
|
||||
dirCandidates.add(resourcePackDir.toPath());
|
||||
File[] children = resourcePackDir.listFiles(File::isDirectory);
|
||||
if (children != null) {
|
||||
for (File child : children) dirCandidates.add(child.toPath());
|
||||
}
|
||||
for (Path candidate : dirCandidates) {
|
||||
if (Files.isDirectory(candidate.resolve(MARKER))) {
|
||||
packs.add(new DirectoryResourcePack(candidate));
|
||||
logger.info("Loaded resource pack directory: " + candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// Zip packs anywhere under the resourcepack folder.
|
||||
try (Stream<Path> walk = Files.walk(resourcePackDir.toPath())) {
|
||||
List<Path> zips = walk
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(p -> p.getFileName().toString().toLowerCase().endsWith(".zip"))
|
||||
.toList();
|
||||
for (Path zip : zips) {
|
||||
try {
|
||||
ZipResourcePack pack = new ZipResourcePack(zip);
|
||||
if (pack.exists(MARKER + "/blockstates") || pack.exists("pack.mcmeta")
|
||||
|| hasAnyBlockstate(pack)) {
|
||||
packs.add(pack);
|
||||
logger.info("Loaded resource pack zip: " + zip);
|
||||
} else {
|
||||
pack.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warning("Failed to open resource pack zip " + zip + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warning("Failed to scan resource pack directory: " + e.getMessage());
|
||||
}
|
||||
|
||||
if (packs.isEmpty()) return Optional.empty();
|
||||
if (packs.size() == 1) return Optional.of(packs.getFirst());
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Downloads and caches player skin textures (by URL) as ARGB pixel grids. Legacy 64x32 skins are
|
||||
* converted to the modern 64x64 layout (so the model's overlay/second-layer bones map correctly).
|
||||
* Downloads happen off the main thread (from the entity baking step) and are cached.
|
||||
*/
|
||||
public final class SkinCache {
|
||||
|
||||
private final Map<String, int[][]> cache = new ConcurrentHashMap<>();
|
||||
private static final int[][] FAILED = new int[0][0];
|
||||
|
||||
public Optional<int[][]> get(String url) {
|
||||
if (url == null || url.isEmpty()) return Optional.empty();
|
||||
int[][] result = cache.computeIfAbsent(url, this::download);
|
||||
return result == FAILED ? Optional.empty() : Optional.of(result);
|
||||
}
|
||||
|
||||
private int[][] download(String url) {
|
||||
try {
|
||||
URL u = URI.create(url).toURL();
|
||||
URLConnection conn = u.openConnection();
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.setReadTimeout(5000);
|
||||
conn.setRequestProperty("User-Agent", "PixelPics");
|
||||
BufferedImage img;
|
||||
try (InputStream in = conn.getInputStream()) {
|
||||
img = ImageIO.read(in);
|
||||
}
|
||||
if (img == null) return FAILED;
|
||||
return toModern(img);
|
||||
} catch (Exception e) {
|
||||
return FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the image to a 64x64 grid, converting the legacy 64x32 layout (mirrored arm/leg) if needed. */
|
||||
private int[][] toModern(BufferedImage img) {
|
||||
int w = img.getWidth();
|
||||
int h = img.getHeight();
|
||||
int[][] out = new int[64][64];
|
||||
for (int y = 0; y < Math.min(h, 64); y++) {
|
||||
for (int x = 0; x < Math.min(w, 64); x++) {
|
||||
out[y][x] = img.getRGB(x, y);
|
||||
}
|
||||
}
|
||||
if (h <= 32) {
|
||||
// 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, 52);
|
||||
copyMirrored(img, out, 40, 16, 32, 48); // right arm -> left arm
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Mirror-copies a 16x12 limb block (legacy) into a modern slot; coarse but visually adequate. */
|
||||
private void copyMirrored(BufferedImage img, int[][] out, int srcX, int srcY, int dstX, int dstY) {
|
||||
for (int y = 0; y < 12 && srcY + y < img.getHeight(); y++) {
|
||||
for (int x = 0; x < 16 && srcX + x < img.getWidth(); x++) {
|
||||
int px = img.getRGB(srcX + (15 - x), srcY + y);
|
||||
int ox = dstX + x, oy = dstY + y;
|
||||
if (ox < 64 && oy < 64) out[oy][ox] = px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
private final ResourcePack pack;
|
||||
private final Map<ResourceLocation, int[][]> cache = new ConcurrentHashMap<>();
|
||||
private static final int[][] MISSING = new int[0][0];
|
||||
|
||||
public TextureCache(ResourcePack pack) {
|
||||
this.pack = pack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the texture pixels for the given id, or empty if it cannot be loaded.
|
||||
* The grid is indexed {@code [y][x]} with {@code [0][0]} at the top-left.
|
||||
*/
|
||||
public Optional<int[][]> get(ResourceLocation textureId) {
|
||||
int[][] result = cache.computeIfAbsent(textureId, this::load);
|
||||
return result == MISSING ? Optional.empty() : Optional.of(result);
|
||||
}
|
||||
|
||||
private int[][] load(ResourceLocation id) {
|
||||
Optional<byte[]> bytes = pack.read(AssetPaths.texture(id));
|
||||
if (bytes.isEmpty()) return MISSING;
|
||||
BufferedImage img;
|
||||
try {
|
||||
img = ImageIO.read(new ByteArrayInputStream(bytes.get()));
|
||||
} catch (Exception e) {
|
||||
return MISSING;
|
||||
}
|
||||
if (img == null) return MISSING;
|
||||
|
||||
int width = img.getWidth();
|
||||
int height = img.getHeight();
|
||||
// Reduce animated strips to the first frame — but only a REAL animation (has a .mcmeta); a tall
|
||||
// sprite without one (e.g. a 64×128 entity texture) is a full texture, not a frame strip.
|
||||
boolean animated = height > width && height % width == 0 && pack.exists(AssetPaths.textureMeta(id));
|
||||
int frameHeight = 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
private final ZipFile zipFile;
|
||||
private final Map<String, ZipEntry> entries = new HashMap<>();
|
||||
|
||||
public ZipResourcePack(Path zipPath) throws IOException {
|
||||
this.zipFile = new ZipFile(zipPath.toFile());
|
||||
zipFile.stream()
|
||||
.filter(entry -> !entry.isDirectory())
|
||||
.forEach(entry -> entries.put(entry.getName(), entry));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<byte[]> read(String path) {
|
||||
ZipEntry entry = entries.get(path);
|
||||
if (entry == null) return Optional.empty();
|
||||
try (InputStream input = zipFile.getInputStream(entry)) {
|
||||
return Optional.of(input.readAllBytes());
|
||||
} catch (IOException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String path) {
|
||||
return entries.containsKey(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
zipFile.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Gson-bound representation of a vanilla block model JSON
|
||||
* ({@code assets/minecraft/models/block/*.json}).
|
||||
*/
|
||||
public class ModelFileDto {
|
||||
public String parent;
|
||||
public Map<String, String> textures;
|
||||
public List<ElementDto> elements;
|
||||
|
||||
public static class ElementDto {
|
||||
public double[] from; // 0..16
|
||||
public double[] to; // 0..16
|
||||
public RotationDto rotation; // optional
|
||||
public Map<String, FaceDto> faces; // keys: down/up/north/south/west/east
|
||||
}
|
||||
|
||||
public static class FaceDto {
|
||||
public double[] uv; // optional, 0..16 (x1,y1,x2,y2)
|
||||
public String texture; // e.g. "#side" or "minecraft:block/oak_planks"
|
||||
public Integer tintindex;
|
||||
public String cullface; // ignored by the renderer
|
||||
public int rotation; // 0/90/180/270
|
||||
}
|
||||
|
||||
public static class RotationDto {
|
||||
public double[] origin; // 0..16
|
||||
public String axis; // "x" | "y" | "z"
|
||||
public double angle; // -45..45 in 22.5 steps
|
||||
public boolean rescale;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
DOWN(0, -1, 0),
|
||||
UP(0, 1, 0),
|
||||
NORTH(0, 0, -1),
|
||||
SOUTH(0, 0, 1),
|
||||
WEST(-1, 0, 0),
|
||||
EAST(1, 0, 0);
|
||||
|
||||
public final int nx, ny, nz;
|
||||
|
||||
Direction(int nx, int ny, int nz) {
|
||||
this.nx = nx;
|
||||
this.ny = ny;
|
||||
this.nz = nz;
|
||||
}
|
||||
|
||||
public static Direction fromName(String name) {
|
||||
return switch (name.toLowerCase()) {
|
||||
case "down" -> DOWN;
|
||||
case "up" -> UP;
|
||||
case "north" -> NORTH;
|
||||
case "south" -> SOUTH;
|
||||
case "west" -> WEST;
|
||||
case "east" -> EAST;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Rotate this direction by {@code steps * 90} degrees around the Y axis (clockwise from above). */
|
||||
public Direction rotateY(int steps) {
|
||||
steps = ((steps % 4) + 4) % 4;
|
||||
Direction d = this;
|
||||
for (int i = 0; i < steps; i++) {
|
||||
d = switch (d) {
|
||||
case NORTH -> EAST;
|
||||
case EAST -> SOUTH;
|
||||
case SOUTH -> WEST;
|
||||
case WEST -> NORTH;
|
||||
default -> d; // up/down unchanged
|
||||
};
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Rotate this direction by {@code steps * 90} degrees around the X axis. */
|
||||
public Direction rotateX(int steps) {
|
||||
steps = ((steps % 4) + 4) % 4;
|
||||
Direction d = this;
|
||||
for (int i = 0; i < steps; i++) {
|
||||
d = switch (d) {
|
||||
case UP -> NORTH;
|
||||
case NORTH -> DOWN;
|
||||
case DOWN -> SOUTH;
|
||||
case SOUTH -> UP;
|
||||
default -> d; // east/west unchanged
|
||||
};
|
||||
}
|
||||
return d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
* indexed by {@link Direction#ordinal()} and may be {@code null} when absent.
|
||||
*
|
||||
* <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 double[] from; // length 3, 0..1
|
||||
public final double[] to; // length 3, 0..1
|
||||
public final Face[] faces; // length 6, indexed by Direction.ordinal()
|
||||
|
||||
// Element rotation (0..1 origin), null/zero when axis-aligned.
|
||||
public final double[] rotOrigin; // length 3, 0..1, may be null
|
||||
public final int rotAxis; // 0=x,1=y,2=z, -1 when none
|
||||
public final double rotAngleRad; // radians
|
||||
public final boolean rescale;
|
||||
|
||||
public Element(double[] from, double[] to, Face[] faces,
|
||||
double[] rotOrigin, int rotAxis, double rotAngleRad, boolean rescale) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.faces = faces;
|
||||
this.rotOrigin = rotOrigin;
|
||||
this.rotAxis = rotAxis;
|
||||
this.rotAngleRad = rotAngleRad;
|
||||
this.rescale = rescale;
|
||||
}
|
||||
|
||||
public boolean isAxisAligned() {
|
||||
return rotAxis < 0 || rotAngleRad == 0.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||
|
||||
/**
|
||||
* 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 int[][] texture; // [y][x] ARGB, top-left origin
|
||||
public final double u1, v1, u2, v2;
|
||||
public final int rotation; // 0/90/180/270, applied to the sampled UV
|
||||
public final int tintIndex; // -1 = no tint
|
||||
|
||||
public Face(int[][] texture, double u1, double v1, double u2, double v2, int rotation, int tintIndex) {
|
||||
this.texture = texture;
|
||||
this.u1 = u1;
|
||||
this.v1 = v1;
|
||||
this.u2 = u2;
|
||||
this.v2 = v2;
|
||||
this.rotation = rotation;
|
||||
this.tintIndex = tintIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// Apply face rotation by rotating the (s,t) lookup.
|
||||
double rs = s, rt = t;
|
||||
switch (((rotation % 360) + 360) % 360) {
|
||||
case 90 -> { rs = t; rt = 1.0 - s; }
|
||||
case 180 -> { rs = 1.0 - s; rt = 1.0 - t; }
|
||||
case 270 -> { rs = 1.0 - t; rt = s; }
|
||||
default -> { /* 0 */ }
|
||||
}
|
||||
|
||||
double u = u1 + (u2 - u1) * rs;
|
||||
double v = v1 + (v2 - v1) * rt;
|
||||
|
||||
int h = texture.length;
|
||||
if (h == 0) return 0;
|
||||
int w = texture[0].length;
|
||||
|
||||
int px = (int) Math.floor(u * w);
|
||||
int py = (int) Math.floor(v * h);
|
||||
px = Math.max(0, Math.min(w - 1, px));
|
||||
py = Math.max(0, Math.min(h - 1, py));
|
||||
return texture[py][px];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package eu.mhsl.minecraft.pixelpics.assets.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A fully baked, intersectable block model: a list of {@link Element} boxes plus rendering hints.
|
||||
*
|
||||
* <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 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 ResolvedModel(List<Element> elements, int averageColor,
|
||||
double transparency, double reflection, boolean occluding, boolean hasGeometry) {
|
||||
this.elements = elements;
|
||||
this.averageColor = averageColor;
|
||||
this.transparency = transparency;
|
||||
this.reflection = reflection;
|
||||
this.occluding = occluding;
|
||||
this.hasGeometry = hasGeometry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
/**
|
||||
* A 3x3 linear transform plus translation (affine), used to compose entity bone hierarchies and to
|
||||
* map a cube's local space to world space. The {@code apply} parameter {@code t} of a ray is
|
||||
* preserved under affine maps, so ray distances stay consistent between world and local space.
|
||||
*/
|
||||
public final class Affine {
|
||||
|
||||
// row-major 3x3
|
||||
public final double[] r;
|
||||
public final double[] t;
|
||||
|
||||
public Affine(double[] r, double[] t) {
|
||||
this.r = r;
|
||||
this.t = t;
|
||||
}
|
||||
|
||||
public static Affine identity() {
|
||||
return new Affine(new double[]{1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[]{0, 0, 0});
|
||||
}
|
||||
|
||||
public static Affine translation(double x, double y, double z) {
|
||||
return new Affine(new double[]{1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[]{x, y, z});
|
||||
}
|
||||
|
||||
public static Affine scale(double s) {
|
||||
return new Affine(new double[]{s, 0, 0, 0, s, 0, 0, 0, s}, new double[]{0, 0, 0});
|
||||
}
|
||||
|
||||
public static Affine rotX(double a) {
|
||||
double c = Math.cos(a), s = Math.sin(a);
|
||||
return new Affine(new double[]{1, 0, 0, 0, c, -s, 0, s, c}, new double[]{0, 0, 0});
|
||||
}
|
||||
|
||||
public static Affine rotY(double a) {
|
||||
double c = Math.cos(a), s = Math.sin(a);
|
||||
return new Affine(new double[]{c, 0, s, 0, 1, 0, -s, 0, c}, new double[]{0, 0, 0});
|
||||
}
|
||||
|
||||
public static Affine rotZ(double a) {
|
||||
double c = Math.cos(a), s = Math.sin(a);
|
||||
return new Affine(new double[]{c, -s, 0, s, c, 0, 0, 0, 1}, new double[]{0, 0, 0});
|
||||
}
|
||||
|
||||
/** this ∘ o (apply o first, then this). */
|
||||
public Affine mul(Affine o) {
|
||||
double[] a = this.r, b = o.r;
|
||||
double[] nr = new double[9];
|
||||
for (int i = 0; i < 3; i++) {
|
||||
for (int j = 0; j < 3; j++) {
|
||||
nr[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
|
||||
}
|
||||
}
|
||||
double[] ot = o.t;
|
||||
double[] nt = new double[]{
|
||||
a[0] * ot[0] + a[1] * ot[1] + a[2] * ot[2] + this.t[0],
|
||||
a[3] * ot[0] + a[4] * ot[1] + a[5] * ot[2] + this.t[1],
|
||||
a[6] * ot[0] + a[7] * ot[1] + a[8] * ot[2] + this.t[2]
|
||||
};
|
||||
return new Affine(nr, nt);
|
||||
}
|
||||
|
||||
public double[] apply(double x, double y, double z) {
|
||||
return new double[]{
|
||||
r[0] * x + r[1] * y + r[2] * z + t[0],
|
||||
r[3] * x + r[4] * y + r[5] * z + t[1],
|
||||
r[6] * x + r[7] * y + r[8] * z + t[2]
|
||||
};
|
||||
}
|
||||
|
||||
/** Linear part only (for directions). */
|
||||
public double[] applyLinear(double x, double y, double z) {
|
||||
return new double[]{
|
||||
r[0] * x + r[1] * y + r[2] * z,
|
||||
r[3] * x + r[4] * y + r[5] * z,
|
||||
r[6] * x + r[7] * y + r[8] * z
|
||||
};
|
||||
}
|
||||
|
||||
/** General affine inverse (3x3 inverse + translation). */
|
||||
public Affine inverse() {
|
||||
double a = r[0], b = r[1], c = r[2], d = r[3], e = r[4], f = r[5], g = r[6], h = r[7], i = r[8];
|
||||
double det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
|
||||
double inv = Math.abs(det) < 1e-12 ? 0 : 1.0 / det;
|
||||
double[] ir = new double[]{
|
||||
(e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv,
|
||||
(f * g - d * i) * inv, (a * i - c * g) * inv, (c * d - a * f) * inv,
|
||||
(d * h - e * g) * inv, (b * g - a * h) * inv, (a * e - b * d) * inv
|
||||
};
|
||||
double[] it = new double[]{
|
||||
-(ir[0] * t[0] + ir[1] * t[1] + ir[2] * t[2]),
|
||||
-(ir[3] * t[0] + ir[4] * t[1] + ir[5] * t[2]),
|
||||
-(ir[6] * t[0] + ir[7] * t[1] + ir[8] * t[2])
|
||||
};
|
||||
return new Affine(ir, it);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||
|
||||
/**
|
||||
* Unwraps a {@link ModelCube} into six {@link Face}s using the standard Minecraft/Bedrock box-UV
|
||||
* layout (faces packed in the canonical cross around the {@code uv} offset). Mirror swaps the
|
||||
* left/right faces and flips each face horizontally.
|
||||
*/
|
||||
public final class BoxUv {
|
||||
|
||||
private BoxUv() {}
|
||||
|
||||
/**
|
||||
* Returns Faces indexed by {@link Direction#ordinal()}. UVs are normalized by the model's DECLARED
|
||||
* texel size (so a higher-res pack texture — e.g. a 128x128 sheet for a model authored at 64x64 —
|
||||
* still maps proportionally, same layout). Falls back to the actual texture size if undeclared.
|
||||
*/
|
||||
public static Face[] build(ModelCube cube, int[][] texture, int declaredW, int declaredH) {
|
||||
int texW = texture.length > 0 ? texture[0].length : 64;
|
||||
int texH = texture.length > 0 ? texture.length : 64;
|
||||
int nW = declaredW > 0 ? declaredW : texW;
|
||||
int nH = declaredH > 0 ? declaredH : texH;
|
||||
|
||||
// Modern per-face UV: each face carries its own {u, v, w, h} rect directly.
|
||||
if (cube.faceUv != null) {
|
||||
Face[] faces = new Face[6];
|
||||
for (int i = 0; i < 6; i++) {
|
||||
if (cube.faceUv[i] != null) faces[i] = face(cube.faceUv[i], texture, nW, nH);
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
double dx = cube.size[0], dy = cube.size[1], dz = cube.size[2];
|
||||
double u = cube.uv[0], v = cube.uv[1];
|
||||
|
||||
// rect = {x, y, w, h} in texels, SIGNED — a negative width/height flips that axis. These match
|
||||
// the OptiFine/Blockbench box-UV layout EXACTLY (up/down are flipped), paired with the (s,t) the
|
||||
// EntityIntersector feeds in, so every face's texture orientation matches vanilla Java.
|
||||
double[] east = {u, v + dz, dz, dy};
|
||||
double[] west = {u + dz + dx, v + dz, dz, dy};
|
||||
double[] north = {u + dz, v + dz, dx, dy};
|
||||
double[] south = {u + 2 * dz + dx, v + dz, dx, dy};
|
||||
double[] up = {u + dz + dx, v + dz, -dx, -dz};
|
||||
double[] down = {u + dz + 2 * dx, v, -dx, dz};
|
||||
|
||||
if (cube.mirror) {
|
||||
for (double[] f : new double[][]{east, west, up, down, south, north}) { f[0] += f[2]; f[2] = -f[2]; }
|
||||
double[] tmp = east; east = west; west = tmp; // mirror swaps the left/right faces
|
||||
}
|
||||
|
||||
Face[] faces = new Face[6];
|
||||
faces[Direction.EAST.ordinal()] = face(east, texture, nW, nH);
|
||||
faces[Direction.WEST.ordinal()] = face(west, texture, nW, nH);
|
||||
faces[Direction.NORTH.ordinal()] = face(north, texture, nW, nH);
|
||||
faces[Direction.SOUTH.ordinal()] = face(south, texture, nW, nH);
|
||||
faces[Direction.UP.ordinal()] = face(up, texture, nW, nH);
|
||||
faces[Direction.DOWN.ordinal()] = face(down, texture, nW, nH);
|
||||
return faces;
|
||||
}
|
||||
|
||||
private static Face face(double[] rect, int[][] texture, int texW, int texH) {
|
||||
double u1 = rect[0] / texW;
|
||||
double v1 = rect[1] / texH;
|
||||
double u2 = (rect[0] + rect[2]) / texW;
|
||||
double v2 = (rect[1] + rect[3]) / texH;
|
||||
return new Face(texture, u1, v1, u2, v2, 0, -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||
|
||||
/**
|
||||
* A baked entity cube in world space: a local box (model pixels) plus the affine transform mapping it
|
||||
* into the world, its six faces, the precomputed inverse transform and a world-space AABB for
|
||||
* broad-phase culling.
|
||||
*/
|
||||
public final class EntityCube {
|
||||
public final double[] from; // local min (px, inflated)
|
||||
public final double[] to; // local max
|
||||
public final Face[] faces; // by Direction.ordinal()
|
||||
public final Affine toWorld;
|
||||
public final Affine toLocal; // inverse
|
||||
public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
|
||||
public final double[] aabbMax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
|
||||
|
||||
public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.faces = faces;
|
||||
this.toWorld = toWorld;
|
||||
this.toLocal = toWorld.inverse();
|
||||
for (int i = 0; i < 8; i++) {
|
||||
double x = (i & 1) == 0 ? from[0] : to[0];
|
||||
double y = (i & 2) == 0 ? from[1] : to[1];
|
||||
double z = (i & 4) == 0 ? from[2] : to[2];
|
||||
double[] w = toWorld.apply(x, y, z);
|
||||
for (int a = 0; a < 3; a++) {
|
||||
if (w[a] < aabbMin[a]) aabbMin[a] = w[a];
|
||||
if (w[a] > aabbMax[a]) aabbMax[a] = w[a];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/**
|
||||
* Intersects a world-space ray with a single {@link EntityCube} (oriented box). The ray is mapped
|
||||
* into the cube's local frame, slab-tested, and the entry face is sampled. Fully transparent texels
|
||||
* are treated as holes (alpha cutout). The returned {@code t} is a world-space distance.
|
||||
*/
|
||||
public final class EntityIntersector {
|
||||
|
||||
private static final double EPS = 1e-7;
|
||||
private static final int ALPHA_THRESHOLD = 16;
|
||||
|
||||
private EntityIntersector() {}
|
||||
|
||||
public static FaceHit intersect(EntityCube cube, double ox, double oy, double oz,
|
||||
double dx, double dy, double dz) {
|
||||
double[] o = cube.toLocal.apply(ox, oy, oz);
|
||||
double[] d = cube.toLocal.applyLinear(dx, dy, dz);
|
||||
|
||||
double tmin = Double.NEGATIVE_INFINITY, tmax = Double.POSITIVE_INFINITY;
|
||||
int axis = -1;
|
||||
boolean neg = false;
|
||||
for (int a = 0; a < 3; a++) {
|
||||
if (Math.abs(d[a]) < EPS) {
|
||||
if (o[a] < cube.from[a] - EPS || o[a] > cube.to[a] + EPS) return null;
|
||||
continue;
|
||||
}
|
||||
double inv = 1.0 / d[a];
|
||||
double t1 = (cube.from[a] - o[a]) * inv;
|
||||
double t2 = (cube.to[a] - o[a]) * inv;
|
||||
boolean n = true;
|
||||
if (t1 > t2) {
|
||||
double tmp = t1; t1 = t2; t2 = tmp;
|
||||
n = false;
|
||||
}
|
||||
if (t1 > tmin) { tmin = t1; axis = a; neg = n; }
|
||||
if (t2 < tmax) tmax = t2;
|
||||
if (tmin > tmax) return null;
|
||||
}
|
||||
if (axis < 0) return null;
|
||||
double t = tmin;
|
||||
if (t < EPS) { t = tmax; if (t < EPS) return null; }
|
||||
|
||||
double px = o[0] + d[0] * t, py = o[1] + d[1] * t, pz = o[2] + d[2] * t;
|
||||
Direction dir = switch (axis) {
|
||||
case 0 -> neg ? Direction.WEST : Direction.EAST;
|
||||
case 1 -> neg ? Direction.DOWN : Direction.UP;
|
||||
default -> neg ? Direction.NORTH : Direction.SOUTH;
|
||||
};
|
||||
Face face = cube.faces[dir.ordinal()];
|
||||
if (face == null) return null;
|
||||
|
||||
double fx = frac(px, cube.from[0], cube.to[0]);
|
||||
double fy = frac(py, cube.from[1], cube.to[1]);
|
||||
double fz = frac(pz, cube.from[2], cube.to[2]);
|
||||
// (s,t) = Blockbench/Java box-UV (lerp_x, lerp_y) for this face (see BoxUv). Front/right faces
|
||||
// run their horizontal axis opposite to back/left (they're viewed from the other side).
|
||||
double s, tt;
|
||||
switch (dir) {
|
||||
case UP -> { s = fx; tt = fz; }
|
||||
case DOWN -> { s = fx; tt = 1 - fz; }
|
||||
case NORTH -> { s = 1 - fx; tt = 1 - fy; }
|
||||
case SOUTH -> { s = fx; tt = 1 - fy; }
|
||||
case EAST -> { s = 1 - fz; tt = 1 - fy; }
|
||||
default -> { s = fz; tt = 1 - fy; } // WEST
|
||||
}
|
||||
int color = face.sample(s, tt);
|
||||
if (ColorUtil.alpha(color) <= ALPHA_THRESHOLD) return null;
|
||||
|
||||
Vector world = new Vector(ox + dx * t, oy + dy * t, oz + dz * t);
|
||||
double[] n = cube.toWorld.applyLinear(dir.nx, dir.ny, dir.nz);
|
||||
Vector normal = new Vector(n[0], n[1], n[2]).normalize();
|
||||
return new FaceHit(t, world, normal, color, -1);
|
||||
}
|
||||
|
||||
private static double frac(double v, double lo, double hi) {
|
||||
double span = hi - lo;
|
||||
if (span < 1e-6) return 0;
|
||||
double f = (v - lo) / span;
|
||||
return f < 0 ? 0 : Math.min(f, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Maps an entity type to its CEM ({@code .jem}) Java model name and candidate texture paths. Most types
|
||||
* use the type key directly for both; small override maps handle the exceptions. The models are vanilla
|
||||
* Java models (already posed); variant-specific textures (cow/sheep colour, etc.) are handled here.
|
||||
*/
|
||||
public final class EntityModels {
|
||||
|
||||
private EntityModels() {}
|
||||
|
||||
// Type key -> CEM (.jem) model name. Default is the type key itself; these are the exceptions
|
||||
// (mob reuses another mob's model, or the CEM set only ships a version-suffixed/renamed name).
|
||||
private static final Map<String, String> CEM_OVERRIDE = Map.ofEntries(
|
||||
Map.entry("husk", "zombie"),
|
||||
Map.entry("giant", "zombie"),
|
||||
Map.entry("mooshroom", "cow"),
|
||||
Map.entry("ocelot", "cat"),
|
||||
Map.entry("cave_spider", "spider"),
|
||||
Map.entry("elder_guardian", "guardian"),
|
||||
Map.entry("glow_squid", "squid"),
|
||||
Map.entry("mule", "donkey"),
|
||||
Map.entry("skeleton_horse", "horse"),
|
||||
Map.entry("zombie_horse", "horse"),
|
||||
Map.entry("trader_llama", "llama"),
|
||||
Map.entry("stray", "skeleton"),
|
||||
Map.entry("wither_skeleton", "skeleton"),
|
||||
Map.entry("zoglin", "hoglin"),
|
||||
Map.entry("piglin_brute", "piglin"),
|
||||
Map.entry("zombified_piglin", "piglin"),
|
||||
Map.entry("evoker", "illager"),
|
||||
Map.entry("vindicator", "illager"),
|
||||
Map.entry("illusioner", "illager"),
|
||||
Map.entry("wandering_trader", "villager"),
|
||||
Map.entry("ender_dragon", "dragon"),
|
||||
Map.entry("mannequin", "player"),
|
||||
Map.entry("camel_husk", "camel"),
|
||||
Map.entry("rabbit", "rabbit_21.11"),
|
||||
Map.entry("pufferfish", "puffer_fish_big"),
|
||||
Map.entry("tropical_fish", "tropical_fish_a")
|
||||
);
|
||||
|
||||
/** The CEM model name for an entity type (boats/rafts share the boat hull). */
|
||||
public static String cemModel(String typeKey) {
|
||||
if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) return "boat";
|
||||
return CEM_OVERRIDE.getOrDefault(typeKey, typeKey);
|
||||
}
|
||||
|
||||
// Type key -> texture path override (where the first derived candidate is wrong).
|
||||
private static final Map<String, String> TEX_OVERRIDE = Map.ofEntries(
|
||||
Map.entry("cow", "entity/cow/cow_temperate"),
|
||||
Map.entry("mooshroom", "entity/cow/mooshroom_red"),
|
||||
Map.entry("zombie", "entity/zombie/zombie"),
|
||||
Map.entry("husk", "entity/zombie/husk"),
|
||||
Map.entry("drowned", "entity/zombie/drowned"),
|
||||
Map.entry("zombified_piglin", "entity/piglin/zombified_piglin"),
|
||||
Map.entry("skeleton", "entity/skeleton/skeleton"),
|
||||
Map.entry("stray", "entity/skeleton/stray"),
|
||||
Map.entry("wither_skeleton", "entity/skeleton/wither_skeleton"),
|
||||
Map.entry("creeper", "entity/creeper/creeper"),
|
||||
Map.entry("spider", "entity/spider/spider"),
|
||||
Map.entry("enderman", "entity/enderman/enderman"),
|
||||
Map.entry("player", "entity/player/wide/steve"),
|
||||
// Textures whose folder/name doesn't follow the "entity/<key>/<key>" pattern.
|
||||
Map.entry("iron_golem", "entity/iron_golem/iron_golem"),
|
||||
Map.entry("polar_bear", "entity/bear/polarbear"),
|
||||
Map.entry("ender_dragon", "entity/enderdragon/dragon"),
|
||||
Map.entry("magma_cube", "entity/slime/magmacube"),
|
||||
Map.entry("tropical_fish", "entity/fish/tropical_a"),
|
||||
Map.entry("bogged", "entity/skeleton/bogged"),
|
||||
Map.entry("donkey", "entity/horse/donkey"),
|
||||
Map.entry("mule", "entity/horse/mule"),
|
||||
Map.entry("skeleton_horse", "entity/horse/horse_skeleton"),
|
||||
Map.entry("zombie_horse", "entity/horse/horse_zombie"),
|
||||
Map.entry("trader_llama", "entity/llama/llama_creamy"),
|
||||
Map.entry("cave_spider", "entity/spider/cave_spider"),
|
||||
Map.entry("guardian", "entity/guardian/guardian"),
|
||||
Map.entry("elder_guardian", "entity/guardian/guardian_elder"),
|
||||
Map.entry("piglin_brute", "entity/piglin/piglin_brute"),
|
||||
Map.entry("zoglin", "entity/hoglin/zoglin"),
|
||||
Map.entry("illusioner", "entity/illager/illusioner"),
|
||||
Map.entry("giant", "entity/zombie/zombie"),
|
||||
// Illagers share one texture folder; none follow the entity/<key>/<key> pattern.
|
||||
Map.entry("pillager", "entity/illager/pillager"),
|
||||
Map.entry("vindicator", "entity/illager/vindicator"),
|
||||
Map.entry("evoker", "entity/illager/evoker"),
|
||||
Map.entry("ravager", "entity/illager/ravager"),
|
||||
Map.entry("vex", "entity/illager/vex"),
|
||||
// Fish share entity/fish/; squids share entity/squid/.
|
||||
Map.entry("cod", "entity/fish/cod"),
|
||||
Map.entry("salmon", "entity/fish/salmon"),
|
||||
Map.entry("pufferfish", "entity/fish/pufferfish"),
|
||||
Map.entry("glow_squid", "entity/squid/glow_squid"),
|
||||
// Variant-only textures with no plain base file — pick a sensible default variant.
|
||||
Map.entry("cat", "entity/cat/cat_tabby"),
|
||||
Map.entry("ocelot", "entity/cat/ocelot"), // ocelot texture lives in the cat folder now
|
||||
Map.entry("axolotl", "entity/axolotl/axolotl_wild"),
|
||||
Map.entry("parrot", "entity/parrot/parrot_red_blue"),
|
||||
Map.entry("turtle", "entity/turtle/turtle"),
|
||||
Map.entry("wind_charge", "entity/projectiles/wind_charge"),
|
||||
Map.entry("camel_husk", "entity/camel/camel_husk"),
|
||||
Map.entry("armor_stand", "entity/armorstand/armorstand"), // texture folder is "armorstand"
|
||||
Map.entry("happy_ghast", "entity/ghast/happy_ghast"),
|
||||
Map.entry("parched", "entity/skeleton/parched"), // husk-style skeleton, texture in skeleton/
|
||||
Map.entry("zombie_nautilus_coral", "entity/nautilus/zombie_nautilus_coral"),
|
||||
Map.entry("mannequin", "entity/player/wide/steve")
|
||||
);
|
||||
|
||||
/** Ordered texture-path candidates; the baker uses the first that loads. */
|
||||
public static List<ResourceLocation> textureCandidates(String typeKey, String variant) {
|
||||
List<ResourceLocation> list = new ArrayList<>();
|
||||
if (typeKey.endsWith("_boat")) {
|
||||
String wood = typeKey.substring(0, typeKey.length() - "_boat".length());
|
||||
if (wood.endsWith("_chest")) wood = wood.substring(0, wood.length() - "_chest".length());
|
||||
list.add(ResourceLocation.parse("entity/boat/" + wood));
|
||||
return list;
|
||||
}
|
||||
if (variant != null) {
|
||||
for (String p : variantPaths(typeKey, variant)) list.add(ResourceLocation.parse(p));
|
||||
}
|
||||
String override = TEX_OVERRIDE.get(typeKey);
|
||||
if (override != null) list.add(ResourceLocation.parse(override));
|
||||
list.add(ResourceLocation.parse("entity/" + typeKey + "/temperate_" + typeKey)); // legacy 1.21 default
|
||||
list.add(ResourceLocation.parse("entity/" + typeKey + "/" + typeKey));
|
||||
list.add(ResourceLocation.parse("entity/" + typeKey));
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant-specific texture paths (modern pack naming is "entity/<folder>/<entity>_<variant>", with a
|
||||
* handful of mismatches the small maps below normalise). Returned paths are tried before the generic
|
||||
* fallbacks, so an unknown variant still degrades to the base texture.
|
||||
*/
|
||||
private static List<String> variantPaths(String typeKey, String v) {
|
||||
switch (typeKey) {
|
||||
case "cat": return List.of("entity/cat/cat_" + v);
|
||||
case "axolotl": return List.of("entity/axolotl/axolotl_" + v);
|
||||
case "wolf": return List.of("entity/wolf/wolf_" + v, "entity/wolf/wolf");
|
||||
case "horse": return List.of("entity/horse/horse_" + HORSE_COLOR.getOrDefault(v, v));
|
||||
case "llama": return List.of("entity/llama/llama_" + v);
|
||||
case "cow": return List.of("entity/cow/cow_" + v);
|
||||
case "pig": return List.of("entity/pig/pig_" + v);
|
||||
case "chicken": return List.of("entity/chicken/chicken_" + v);
|
||||
case "frog": return List.of("entity/frog/frog_" + v);
|
||||
case "panda": return List.of(v.equals("normal") ? "entity/panda/panda" : "entity/panda/panda_" + v);
|
||||
case "fox": return List.of(v.equals("snow") ? "entity/fox/fox_snow" : "entity/fox/fox");
|
||||
case "parrot": return List.of("entity/parrot/parrot_" + PARROT_COLOR.getOrDefault(v, v));
|
||||
case "rabbit": return List.of("entity/rabbit/rabbit_" + RABBIT_TYPE.getOrDefault(v, v));
|
||||
case "mooshroom": return List.of("entity/cow/mooshroom_" + v);
|
||||
case "shulker": return List.of("entity/shulker/shulker_" + v);
|
||||
// villager/zombie_villager: type/<biome> and profession are transparent OVERLAYS (clothing
|
||||
// only); the opaque base body is entity/<folder>/<folder> — handled by the generic candidates.
|
||||
default: return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static final Map<String, String> HORSE_COLOR = Map.of("dark_brown", "darkbrown");
|
||||
private static final Map<String, String> PARROT_COLOR = Map.of(
|
||||
"red", "red_blue", "cyan", "yellow_blue", "gray", "grey");
|
||||
private static final Map<String, String> RABBIT_TYPE = Map.of(
|
||||
"black_and_white", "white_splotched", "salt_and_pepper", "salt", "the_killer_bunny", "caerbannog");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
|
||||
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* All baked entities for one render. Provides the nearest entity hit along a ray, using per-entity
|
||||
* and per-cube AABB broad-phase culling. Immutable after construction → safe for the parallel tracer.
|
||||
*/
|
||||
public final class EntityScene {
|
||||
|
||||
private static final double EPS = 1e-7;
|
||||
private final List<RenderedEntity> entities;
|
||||
|
||||
public EntityScene(List<EntityState> states, CemBaker baker) {
|
||||
this.entities = new ArrayList<>(states.size());
|
||||
for (EntityState s : states) {
|
||||
RenderedEntity e = baker.bake(s);
|
||||
if (e != null && !e.cubes.isEmpty()) entities.add(e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return entities.isEmpty();
|
||||
}
|
||||
|
||||
/** Nearest entity hit with {@code t < maxT}, or null. */
|
||||
public FaceHit nearestHit(double ox, double oy, double oz, double dx, double dy, double dz, double maxT) {
|
||||
FaceHit best = null;
|
||||
double bestT = maxT;
|
||||
for (RenderedEntity e : entities) {
|
||||
if (!rayAabb(e.aabbMin, e.aabbMax, ox, oy, oz, dx, dy, dz, bestT)) continue;
|
||||
for (EntityCube cube : e.cubes) {
|
||||
if (!rayAabb(cube.aabbMin, cube.aabbMax, ox, oy, oz, dx, dy, dz, bestT)) continue;
|
||||
FaceHit hit = EntityIntersector.intersect(cube, ox, oy, oz, dx, dy, dz);
|
||||
if (hit != null && hit.t() > EPS && hit.t() < bestT) {
|
||||
best = hit;
|
||||
bestT = hit.t();
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static boolean rayAabb(double[] min, double[] max, double ox, double oy, double oz,
|
||||
double dx, double dy, double dz, double maxT) {
|
||||
double tmin = 0, tmax = maxT;
|
||||
double[] o = {ox, oy, oz}, d = {dx, dy, dz};
|
||||
for (int a = 0; a < 3; a++) {
|
||||
if (Math.abs(d[a]) < EPS) {
|
||||
if (o[a] < min[a] || o[a] > max[a]) return false;
|
||||
} else {
|
||||
double inv = 1.0 / d[a];
|
||||
double t1 = (min[a] - o[a]) * inv;
|
||||
double t2 = (max[a] - o[a]) * inv;
|
||||
if (t1 > t2) { double tmp = t1; t1 = t2; t2 = tmp; }
|
||||
if (t1 > tmin) tmin = t1;
|
||||
if (t2 < tmax) tmax = t2;
|
||||
if (tmin > tmax) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
/**
|
||||
* Immutable snapshot of one entity captured on the main thread, sufficient to bake and pose it
|
||||
* off-thread. Angles are in degrees (Minecraft convention).
|
||||
*/
|
||||
public record EntityState(
|
||||
String typeKey, // e.g. "cow", "zombie", "player"
|
||||
double x, double y, double z,
|
||||
float bodyYaw, float headYaw, float pitch,
|
||||
double vx, double vy, double vz,
|
||||
boolean baby,
|
||||
double width, double height,
|
||||
boolean player, String skinUrl, boolean slim,
|
||||
String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"), or null
|
||||
int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none
|
||||
double sizeScale // extra model scale (slime/magma-cube size); 1.0 = default
|
||||
) {
|
||||
public double horizontalSpeed() {
|
||||
return Math.sqrt(vx * vx + vz * vz);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
/**
|
||||
* A single box of an entity model, in Bedrock model-pixel coordinates (16 px = 1 block).
|
||||
* {@code origin} is the minimum corner, {@code uv} is the box-UV texture offset.
|
||||
*/
|
||||
public final class ModelCube {
|
||||
public final double[] origin; // 3, min corner (px)
|
||||
public final double[] size; // 3 (px)
|
||||
public final double inflate; // px, expands the box on all sides (overlay layers)
|
||||
public final double[] uv; // 2, box-UV offset (texels)
|
||||
public final boolean mirror;
|
||||
public final double[] rotation; // 3 (degrees), per-cube rotation around pivot
|
||||
public final double[] pivot; // 3 (px), per-cube rotation pivot
|
||||
/** Optional modern per-face UV, indexed by {@link Direction#ordinal()}: {u, v, w, h} texels (h/w may be negative for flips). Null = use box-UV. */
|
||||
public final double[][] faceUv;
|
||||
|
||||
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror,
|
||||
double[] rotation, double[] pivot) {
|
||||
this(origin, size, inflate, uv, mirror, rotation, pivot, null);
|
||||
}
|
||||
|
||||
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror,
|
||||
double[] rotation, double[] pivot, double[][] faceUv) {
|
||||
this.origin = origin;
|
||||
this.size = size;
|
||||
this.inflate = inflate;
|
||||
this.uv = uv;
|
||||
this.mirror = mirror;
|
||||
this.rotation = rotation;
|
||||
this.pivot = pivot;
|
||||
this.faceUv = faceUv;
|
||||
}
|
||||
|
||||
public boolean hasRotation() {
|
||||
return rotation[0] != 0 || rotation[1] != 0 || rotation[2] != 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** A baked entity: its world-space cubes and overall AABB (for broad-phase culling). */
|
||||
public final class RenderedEntity {
|
||||
public final List<EntityCube> cubes;
|
||||
public final double[] aabbMin;
|
||||
public final double[] aabbMax;
|
||||
|
||||
public RenderedEntity(List<EntityCube> cubes, double[] aabbMin, double[] aabbMax) {
|
||||
this.cubes = cubes;
|
||||
this.aabbMin = aabbMin;
|
||||
this.aabbMax = aabbMax;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.SkinCache;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityModels;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Bakes an {@link EntityState} into world-space cubes using a vanilla Java {@link CemModelLoader.CemModel}
|
||||
* (OptiFine-CEM format). These models are already correctly posed (standing), so no animation/lay-down
|
||||
* logic is needed. The CEM model space (px, invertAxis "xy") is mapped to the world by an inner X/Y flip
|
||||
* + px→block scale + an outer Y-flip (upright); the model is then dropped onto the ground and placed at
|
||||
* the entity's position/yaw. Calibrated against fox/pig/cow.
|
||||
*/
|
||||
public final class CemBaker {
|
||||
|
||||
// Parts representing an alternate state (rolled-up, sleeping, …) that must not render in the idle pose.
|
||||
private static final java.util.Map<String, java.util.Set<String>> HIDDEN_PARTS = java.util.Map.of(
|
||||
"armadillo", java.util.Set.of("cube"), // the rolled-up ball
|
||||
"illager", java.util.Set.of("left_arm", "right_arm")
|
||||
);
|
||||
|
||||
private final CemModelLoader models;
|
||||
private final TextureCache textures;
|
||||
private final SkinCache skins;
|
||||
|
||||
public CemBaker(CemModelLoader models, TextureCache textures, SkinCache skins) {
|
||||
this.models = models;
|
||||
this.textures = textures;
|
||||
this.skins = skins;
|
||||
}
|
||||
|
||||
private record Baked(double[] from, double[] to, Face[] faces, Affine world) {
|
||||
double minWorldY() {
|
||||
double m = Double.MAX_VALUE;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
double x = (i & 1) == 0 ? from[0] : to[0];
|
||||
double y = (i & 2) == 0 ? from[1] : to[1];
|
||||
double z = (i & 4) == 0 ? from[2] : to[2];
|
||||
m = Math.min(m, world.apply(x, y, z)[1]);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
public RenderedEntity bake(EntityState s) {
|
||||
int[][] tex = resolveTexture(s);
|
||||
CemModelLoader.CemModel model = models.get(EntityModels.cemModel(s.typeKey()));
|
||||
if (model == null || tex == null) return fallbackBox(s, tex);
|
||||
|
||||
double sc = (s.baby() ? 0.5 : 1.0) * s.sizeScale();
|
||||
// CEM model px -> entity-local blocks. Identity orientation (no axis flip) preserves ALL part
|
||||
// rotations and handedness; only px->block scaling is applied.
|
||||
Affine pre = Affine.scale(sc / 16.0);
|
||||
|
||||
java.util.Set<String> hidden = HIDDEN_PARTS.getOrDefault(EntityModels.cemModel(s.typeKey()), java.util.Set.of());
|
||||
List<Baked> baked = new ArrayList<>();
|
||||
bakeModel(model, tex, pre, hidden, baked);
|
||||
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
|
||||
if (s.typeKey().equals("sheep")) {
|
||||
CemModelLoader.CemModel wool = models.get("sheep_wool");
|
||||
int[][] woolTex = textures.get(ResourceLocation.parse("entity/sheep/sheep_wool")).orElse(null);
|
||||
if (wool != null && woolTex != null) {
|
||||
int[][] t = new int[woolTex.length][];
|
||||
for (int y = 0; y < woolTex.length; y++) t[y] = woolTex[y].clone();
|
||||
if (s.tint() != 0) tint(t, s.tint());
|
||||
bakeModel(wool, t, pre, hidden, baked);
|
||||
}
|
||||
}
|
||||
// Guardian: the CEM model ships a RIGHT body side-panel but no left one, and the main body box's
|
||||
// left face is transparent in the texture → a see-through hole on the left. Add the mirrored left panel.
|
||||
if (EntityModels.cemModel(s.typeKey()).equals("guardian")) {
|
||||
double[] org = {-8, 2, -6}, size = {2, 12, 12};
|
||||
ModelCube mc = new ModelCube(org, size, 0, new double[]{0, 28}, true, new double[]{0,0,0}, new double[]{0,0,0});
|
||||
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
|
||||
baked.add(new Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre));
|
||||
}
|
||||
if (baked.isEmpty()) return fallbackBox(s, tex);
|
||||
|
||||
double minY = Double.MAX_VALUE;
|
||||
for (Baked b : baked) minY = Math.min(minY, b.minWorldY());
|
||||
|
||||
Affine place = Affine.translation(s.x(), s.y(), s.z())
|
||||
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
|
||||
.mul(Affine.translation(0, -minY, 0));
|
||||
|
||||
List<EntityCube> cubes = new ArrayList<>(baked.size());
|
||||
for (Baked b : baked) cubes.add(new EntityCube(b.from, b.to, b.faces, place.mul(b.world)));
|
||||
return finish(cubes);
|
||||
}
|
||||
|
||||
private void bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre,
|
||||
java.util.Set<String> hidden, List<Baked> out) {
|
||||
for (CemModelLoader.CemPart p : model.parts()) {
|
||||
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
|
||||
bakePart(p, pre, o, 0, hidden, model, tex, out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Faithful OptiFine/Blockbench CEM transform: each part is a group whose rotation pivots around its
|
||||
* origin {@code O} (top-level: {@code -translate}; submodel: {@code translate}, accumulated with the
|
||||
* parent origin from the 2nd nesting level on). Top-level boxes are absolute; nested boxes are offset
|
||||
* by their group origin. The group transform is {@code parent · T(O) · R · T(-O)}.
|
||||
*/
|
||||
private void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth,
|
||||
java.util.Set<String> hidden, CemModelLoader.CemModel model, int[][] tex, List<Baked> out) {
|
||||
if (hidden.contains(part.name())) return;
|
||||
Affine world = parentWorld
|
||||
.mul(Affine.translation(o[0], o[1], o[2]))
|
||||
.mul(Affine.rotZ(Math.toRadians(part.rotate()[2])))
|
||||
.mul(Affine.rotY(Math.toRadians(part.rotate()[1])))
|
||||
.mul(Affine.rotX(Math.toRadians(part.rotate()[0])))
|
||||
.mul(Affine.translation(-o[0], -o[1], -o[2]));
|
||||
|
||||
double ox = depth > 0 ? o[0] : 0, oy = depth > 0 ? o[1] : 0, oz = depth > 0 ? o[2] : 0;
|
||||
for (CemModelLoader.CemBox b : part.boxes()) {
|
||||
double inf = b.inflate();
|
||||
double[] org = {b.origin()[0] + ox, b.origin()[1] + oy, b.origin()[2] + oz};
|
||||
double[] from = {org[0] - inf, org[1] - inf, org[2] - inf};
|
||||
double[] to = {org[0] + b.size()[0] + inf, org[1] + b.size()[1] + inf, org[2] + b.size()[2] + inf};
|
||||
ModelCube mc = new ModelCube(org, b.size(), inf, b.uv(), b.mirror(), new double[]{0,0,0}, new double[]{0,0,0});
|
||||
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
|
||||
out.add(new Baked(from, to, faces, world));
|
||||
}
|
||||
for (CemModelLoader.CemPart child : part.children()) {
|
||||
double[] t = child.translate();
|
||||
// submodel origin = its translate, accumulated with this group's origin from the 2nd level on.
|
||||
double[] co = depth >= 1 ? new double[]{t[0] + o[0], t[1] + o[1], t[2] + o[2]} : new double[]{t[0], t[1], t[2]};
|
||||
bakePart(child, world, co, depth + 1, hidden, model, tex, out);
|
||||
}
|
||||
}
|
||||
|
||||
// --- texture resolution (player skin, dyed sheep wool, variant candidates) ---
|
||||
private int[][] resolveTexture(EntityState s) {
|
||||
if (s.player()) {
|
||||
int[][] skin = skins.get(s.skinUrl()).orElse(null);
|
||||
if (skin != null) return skin;
|
||||
int[][] def = textures.get(ResourceLocation.parse(
|
||||
s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve")).orElse(null);
|
||||
if (def != null) return def;
|
||||
}
|
||||
for (ResourceLocation rl : EntityModels.textureCandidates(s.typeKey(), s.variant())) {
|
||||
int[][] t = textures.get(rl).orElse(null);
|
||||
if (t != null) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private RenderedEntity fallbackBox(EntityState s, int[][] tex) {
|
||||
double w = Math.max(0.3, s.width()) * 16 * s.sizeScale(), h = Math.max(0.3, s.height()) * 16 * s.sizeScale();
|
||||
double[] from = {-w / 2, 0, -w / 2};
|
||||
double[] to = {w / 2, h, w / 2};
|
||||
int[][] t = tex != null ? tex : flat(0xFF8C8C8C);
|
||||
ModelCube box = new ModelCube(new double[]{-w/2, 0, -w/2}, new double[]{w, h, w}, 0,
|
||||
new double[]{0, 0}, false, new double[]{0, 0, 0}, new double[]{0, 0, 0});
|
||||
Face[] faces = BoxUv.build(box, t, Math.max(64, (int) (2 * (w + w))), Math.max(64, (int) (2 * (w + h))));
|
||||
Affine place = Affine.translation(s.x(), s.y(), s.z())
|
||||
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
|
||||
.mul(Affine.scale(1.0 / 16.0));
|
||||
List<EntityCube> cubes = new ArrayList<>();
|
||||
cubes.add(new EntityCube(from, to, faces, place));
|
||||
return finish(cubes);
|
||||
}
|
||||
|
||||
private RenderedEntity finish(List<EntityCube> cubes) {
|
||||
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
|
||||
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
|
||||
for (EntityCube c : cubes) {
|
||||
for (int a = 0; a < 3; a++) {
|
||||
if (c.aabbMin[a] < min[a]) min[a] = c.aabbMin[a];
|
||||
if (c.aabbMax[a] > max[a]) max[a] = c.aabbMax[a];
|
||||
}
|
||||
}
|
||||
return new RenderedEntity(cubes, min, max);
|
||||
}
|
||||
|
||||
private static void tint(int[][] tex, int argb) {
|
||||
int tr = (argb >> 16) & 0xFF, tg = (argb >> 8) & 0xFF, tb = argb & 0xFF;
|
||||
for (int[] row : tex) {
|
||||
for (int x = 0; x < row.length; x++) {
|
||||
int p = row[x];
|
||||
int a = (p >>> 24) & 0xFF;
|
||||
if (a == 0) continue;
|
||||
int r = ((p >> 16) & 0xFF) * tr / 255, g = ((p >> 8) & 0xFF) * tg / 255, b = (p & 0xFF) * tb / 255;
|
||||
row[x] = (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int[][] flat(int argb) {
|
||||
int[][] t = new int[1][1];
|
||||
t[0][0] = argb;
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Loads OptiFine-CEM ({@code .jem}) entity models from the bundled {@code cem_template_models.json}
|
||||
* (CEM Template Loader data) into baked-ready {@link CemModel}s. These are the vanilla Java entity
|
||||
* models, already in the correct standing pose — no Bedrock geometry / animation needed.
|
||||
*
|
||||
* <p>Format per part: {@code coordinates} are ABSOLUTE model pixels, {@code translate} is the rotation
|
||||
* pivot (negated), {@code rotate} is degrees. {@code invertAxis "xy"} is handled by the baker's flips.
|
||||
*/
|
||||
public final class CemModelLoader {
|
||||
|
||||
/** A box: absolute min corner + size (px), inflate, box-UV offset (texels), and horizontal texture mirror. */
|
||||
public record CemBox(double[] origin, double[] size, double inflate, double[] uv, boolean mirror) {}
|
||||
|
||||
/** A model part: its (raw) translate, rotation (deg), boxes and nested submodels. The rotation pivot
|
||||
* is {@code -(sum of translates from the root to this part)} — accumulated by the baker. */
|
||||
public record CemPart(String name, double[] translate, double[] rotate, List<CemBox> boxes, List<CemPart> children) {}
|
||||
|
||||
/** A whole model: declared texture size and its top-level parts. */
|
||||
public record CemModel(int texW, int texH, List<CemPart> parts) {}
|
||||
|
||||
private final Map<String, CemModel> models = new HashMap<>();
|
||||
|
||||
public CemModel get(String name) {
|
||||
return models.get(name);
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return models.size();
|
||||
}
|
||||
|
||||
/** Parse the CEM template-models JSON stream. Returns the number of models loaded. */
|
||||
public int load(InputStream in, Logger logger) {
|
||||
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
JsonObject modelsObj = root.getAsJsonObject("models");
|
||||
for (Map.Entry<String, JsonElement> e : modelsObj.entrySet()) {
|
||||
try {
|
||||
JsonObject entry = e.getValue().getAsJsonObject();
|
||||
if (!entry.has("model")) continue;
|
||||
JsonObject model = JsonParser.parseString(entry.get("model").getAsString()).getAsJsonObject();
|
||||
int tw = model.getAsJsonArray("textureSize").get(0).getAsInt();
|
||||
int th = model.getAsJsonArray("textureSize").get(1).getAsInt();
|
||||
List<CemPart> parts = new ArrayList<>();
|
||||
for (JsonElement pe : model.getAsJsonArray("models")) parts.add(parsePart(pe.getAsJsonObject()));
|
||||
models.put(e.getKey(), new CemModel(tw, th, parts));
|
||||
} catch (Exception ex) {
|
||||
if (logger != null) logger.warning("Failed to parse CEM model " + e.getKey() + ": " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
return models.size();
|
||||
}
|
||||
|
||||
private CemPart parsePart(JsonObject p) {
|
||||
double[] translate = arr3(p, "translate");
|
||||
double[] rotate = arr3(p, "rotate");
|
||||
boolean partMirror = mirrorsU(p); // mirrorTexture "u" — applies to all of the part's boxes
|
||||
List<CemBox> boxes = new ArrayList<>();
|
||||
if (p.has("boxes")) {
|
||||
for (JsonElement be : p.getAsJsonArray("boxes")) {
|
||||
JsonObject b = be.getAsJsonObject();
|
||||
if (!b.has("coordinates")) continue;
|
||||
JsonArray c = b.getAsJsonArray("coordinates");
|
||||
double[] origin = {c.get(0).getAsDouble(), c.get(1).getAsDouble(), c.get(2).getAsDouble()};
|
||||
double[] size = {c.get(3).getAsDouble(), c.get(4).getAsDouble(), c.get(5).getAsDouble()};
|
||||
double inflate = b.has("sizeAdd") ? b.get("sizeAdd").getAsDouble() : 0;
|
||||
double[] uv = b.has("textureOffset")
|
||||
? new double[]{b.getAsJsonArray("textureOffset").get(0).getAsDouble(), b.getAsJsonArray("textureOffset").get(1).getAsDouble()}
|
||||
: new double[]{0, 0};
|
||||
boxes.add(new CemBox(origin, size, inflate, uv, partMirror || mirrorsU(b)));
|
||||
}
|
||||
}
|
||||
List<CemPart> children = new ArrayList<>();
|
||||
if (p.has("submodels")) for (JsonElement se : p.getAsJsonArray("submodels")) children.add(parsePart(se.getAsJsonObject()));
|
||||
if (p.has("submodel")) children.add(parsePart(p.getAsJsonObject("submodel")));
|
||||
String name = p.has("part") ? p.get("part").getAsString() : (p.has("id") ? p.get("id").getAsString() : "");
|
||||
return new CemPart(name, translate, rotate, boxes, children);
|
||||
}
|
||||
|
||||
private static boolean mirrorsU(JsonObject o) {
|
||||
return o.has("mirrorTexture") && o.get("mirrorTexture").getAsString().contains("u");
|
||||
}
|
||||
|
||||
private static double[] arr3(JsonObject o, String key) {
|
||||
if (!o.has(key) || !o.get(key).isJsonArray()) return new double[]{0, 0, 0};
|
||||
JsonArray a = o.getAsJsonArray(key);
|
||||
return new double[]{a.get(0).getAsDouble(), a.get(1).getAsDouble(), a.get(2).getAsDouble()};
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
||||
|
||||
import com.google.common.base.Preconditions;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.MultiModel.MultiModelBuilder;
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.CrossModel.CrossModelBuilder;
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.StaticModel.StaticModelBuilder;
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.OctahedronModel.OctahedronModelBuilder;
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.SphereModel.SphereModelBuilder;
|
||||
|
||||
|
||||
public abstract class AbstractModel implements Model {
|
||||
|
||||
final int textureSize;
|
||||
final int[][] texture;
|
||||
|
||||
private final double transparencyFactor;
|
||||
private final double reflectionFactor;
|
||||
private final boolean occluding;
|
||||
|
||||
AbstractModel(int[][] texture, double transparencyFactor, double reflectionFactor,
|
||||
boolean occluding) {
|
||||
Preconditions.checkNotNull(texture);
|
||||
Preconditions.checkArgument(texture.length > 0, "texture cannot be empty");
|
||||
Preconditions.checkArgument(texture.length == texture[0].length, "texture must be a square array");
|
||||
|
||||
this.textureSize = texture.length;
|
||||
this.texture = texture;
|
||||
|
||||
this.transparencyFactor = transparencyFactor;
|
||||
this.reflectionFactor = reflectionFactor;
|
||||
this.occluding = occluding;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getTransparencyFactor() {
|
||||
return transparencyFactor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getReflectionFactor() {
|
||||
return reflectionFactor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOccluding() {
|
||||
return occluding;
|
||||
}
|
||||
|
||||
public static abstract class Builder {
|
||||
|
||||
final int[][] texture;
|
||||
|
||||
double transparencyFactor;
|
||||
double reflectionFactor;
|
||||
boolean occluding;
|
||||
|
||||
Builder(int[][] texture) {
|
||||
this.texture = texture;
|
||||
|
||||
this.transparencyFactor = 0;
|
||||
this.reflectionFactor = 0;
|
||||
this.occluding = false;
|
||||
}
|
||||
|
||||
public static SimpleModel.SimpleModelBuilder createSimple(int[][] texture) {
|
||||
return new SimpleModel.SimpleModelBuilder(texture);
|
||||
}
|
||||
|
||||
public static MultiModelBuilder createMulti(int[][] topTexture, int[][] sideTexture,
|
||||
int[][] bottomTexture) {
|
||||
return new MultiModelBuilder(topTexture, sideTexture, bottomTexture);
|
||||
}
|
||||
|
||||
public static StaticModelBuilder createStatic(int color) {
|
||||
return new StaticModelBuilder(color);
|
||||
}
|
||||
|
||||
public static CrossModelBuilder createCross(int[][] texture) {
|
||||
return new CrossModelBuilder(texture);
|
||||
}
|
||||
|
||||
public static SphereModelBuilder createSphere(int[][] texture) {
|
||||
return new SphereModelBuilder(texture);
|
||||
}
|
||||
|
||||
public static OctahedronModelBuilder createOctahedron(int[][] texture) {
|
||||
return new OctahedronModelBuilder(texture);
|
||||
}
|
||||
|
||||
public Builder transparency(double transparencyFactor) {
|
||||
this.transparencyFactor = transparencyFactor;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder reflection(double reflectionFactor) {
|
||||
this.reflectionFactor = reflectionFactor;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder occlusion() {
|
||||
this.occluding = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public abstract Model build();
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
public class CrossModel extends AbstractModel {
|
||||
|
||||
private static final Vector NORMAL_ONE = new Vector(1, 0, 1).normalize();
|
||||
private static final Vector NORMAL_TWO = new Vector(-1, 0, 1).normalize();
|
||||
|
||||
private static final Vector POINT_ONE = new Vector(1, 0, 0);
|
||||
private static final Vector POINT_TWO = new Vector(1, 0, 1);
|
||||
|
||||
private CrossModel(int[][] texture, double transparencyFactor, double reflectionFactor,
|
||||
boolean occluding) {
|
||||
super(texture, transparencyFactor, reflectionFactor, occluding);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Intersection intersect(Block block, Intersection currentIntersection) {
|
||||
Vector linePoint = currentIntersection.getPoint();
|
||||
Vector lineDirection = currentIntersection.getDirection();
|
||||
|
||||
Vector blockPoint = block.getLocation().toVector();
|
||||
Vector planePoint = block.getLocation().add(0.5, 0, 0.5).toVector();
|
||||
|
||||
double distance = Double.POSITIVE_INFINITY;
|
||||
int color = 0;
|
||||
Vector target = null;
|
||||
|
||||
Vector intersectionOne = MathUtil.getLinePlaneIntersection(linePoint, lineDirection, planePoint, NORMAL_ONE,
|
||||
true);
|
||||
if (intersectionOne != null) {
|
||||
intersectionOne.subtract(blockPoint);
|
||||
if (isInsideBlock(intersectionOne)) {
|
||||
color = getColor(intersectionOne, POINT_ONE);
|
||||
distance = linePoint.distanceSquared(intersectionOne.add(blockPoint));
|
||||
target = intersectionOne;
|
||||
}
|
||||
}
|
||||
|
||||
Vector intersectionTwo = MathUtil.getLinePlaneIntersection(linePoint, lineDirection, planePoint, NORMAL_TWO,
|
||||
true);
|
||||
if (intersectionTwo != null) {
|
||||
intersectionTwo.subtract(blockPoint);
|
||||
if (isInsideBlock(intersectionTwo)) {
|
||||
int colorTwo = getColor(intersectionTwo, POINT_TWO);
|
||||
double distanceTwo = linePoint.distanceSquared(intersectionTwo.add(blockPoint));
|
||||
if ((distanceTwo < distance && (colorTwo >> 24) != 0) || (color >> 24) == 0) {
|
||||
target = intersectionTwo;
|
||||
color = colorTwo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (target == null) {
|
||||
target = linePoint;
|
||||
}
|
||||
|
||||
return Intersection.of(currentIntersection.getNormal(), target, lineDirection, color);
|
||||
}
|
||||
|
||||
private boolean isInsideBlock(Vector vec) {
|
||||
return vec.getX() >= 0 && vec.getZ() < 1 && vec.getY() >= 0 && vec.getY() < 1 && vec.getZ() >= 0
|
||||
&& vec.getZ() < 1;
|
||||
}
|
||||
|
||||
private int getColor(Vector vec, Vector base) {
|
||||
double xOffset = Math.sqrt(Math.pow(vec.getX() - base.getX(), 2) + Math.pow(vec.getZ() - base.getZ(), 2));
|
||||
double yOffset = vec.getY();
|
||||
|
||||
int pixelY = (int) Math.floor(yOffset * textureSize);
|
||||
int pixelX = (int) Math.floor(xOffset / Math.sqrt(2) * textureSize);
|
||||
|
||||
return texture[pixelY][pixelX];
|
||||
}
|
||||
|
||||
public static class CrossModelBuilder extends Builder {
|
||||
|
||||
CrossModelBuilder(int[][] texture) {
|
||||
super(texture);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CrossModel build() {
|
||||
return new CrossModel(texture, transparencyFactor, reflectionFactor, occluding);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
||||
import org.bukkit.block.Block;
|
||||
|
||||
public interface Model {
|
||||
|
||||
Intersection intersect(Block block, Intersection currentIntersection);
|
||||
|
||||
double getTransparencyFactor();
|
||||
|
||||
double getReflectionFactor();
|
||||
|
||||
boolean isOccluding();
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
public class MultiModel extends SimpleModel {
|
||||
|
||||
private final int[][] topTexture;
|
||||
private final int[][] bottomTexture;
|
||||
|
||||
private MultiModel(int[][] topTexture, int[][] sideTexture, int[][] bottomTexture,
|
||||
double transparencyFactor, double reflectionFactor, boolean occluding) {
|
||||
super(sideTexture, transparencyFactor, reflectionFactor, occluding);
|
||||
|
||||
this.topTexture = topTexture;
|
||||
this.bottomTexture = bottomTexture;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Intersection intersect(Block block, Intersection currentIntersection) {
|
||||
if (!currentIntersection.getNormal().equals(UP) && !currentIntersection.getNormal().equals(DOWN)) {
|
||||
return super.intersect(block, currentIntersection);
|
||||
}
|
||||
|
||||
Vector normal = currentIntersection.getNormal();
|
||||
Vector point = currentIntersection.getPoint();
|
||||
Vector direction = currentIntersection.getDirection();
|
||||
|
||||
double yOffset = point.getX() - (int) point.getX();
|
||||
double xOffset = point.getZ() - (int) point.getZ();
|
||||
|
||||
int pixelY = (int) Math.floor((yOffset < 0 ? yOffset + 1 : yOffset) * textureSize);
|
||||
int pixelX = (int) Math.floor((xOffset < 0 ? xOffset + 1 : xOffset) * textureSize);
|
||||
|
||||
if (normal.equals(UP)) {
|
||||
return Intersection.of(normal, point, direction, topTexture[pixelY][pixelX]);
|
||||
} else {
|
||||
return Intersection.of(normal, point, direction, bottomTexture[pixelY][pixelX]);
|
||||
}
|
||||
}
|
||||
|
||||
public static class MultiModelBuilder extends SimpleModelBuilder {
|
||||
|
||||
private final int[][] topTexture;
|
||||
private final int[][] bottomTexture;
|
||||
|
||||
MultiModelBuilder(int[][] topTexture, int[][] sideTexture, int[][] bottomTexture) {
|
||||
super(sideTexture);
|
||||
|
||||
this.topTexture = topTexture;
|
||||
this.bottomTexture = bottomTexture;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MultiModel build() {
|
||||
return new MultiModel(topTexture, texture, bottomTexture, transparencyFactor,
|
||||
reflectionFactor, occluding);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
public class OctahedronModel extends AbstractModel {
|
||||
|
||||
private static final double RADIUS = 0.5;
|
||||
|
||||
private static final Vector[] NORMALS = new Vector[]{new Vector(-1, -1, -1), new Vector(-1, -1, 1),
|
||||
new Vector(-1, 1, -1), new Vector(-1, 1, 1), new Vector(1, -1, -1), new Vector(1, -1, 1),
|
||||
new Vector(1, 1, -1), new Vector(1, 1, 1)};
|
||||
|
||||
private OctahedronModel(int[][] texture, double transparencyFactor, double reflectionFactor,
|
||||
boolean occluding) {
|
||||
super(texture, transparencyFactor, reflectionFactor, occluding);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Intersection intersect(Block block, Intersection currentIntersection) {
|
||||
Vector linePoint = currentIntersection.getPoint();
|
||||
Vector lineDirection = currentIntersection.getDirection();
|
||||
Vector blockPoint = block.getLocation().toVector();
|
||||
Vector centerPoint = blockPoint.clone().add(new Vector(0.5, 0.5, 0.5));
|
||||
|
||||
Vector lastIntersection = null;
|
||||
double lastDistance = Double.POSITIVE_INFINITY;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
Vector planePoint = new Vector(i < 4 ? -0.5 : 0.5, 0, 0).add(centerPoint);
|
||||
Vector planeNormal = NORMALS[i];
|
||||
|
||||
Vector intersection = MathUtil.getLinePlaneIntersection(linePoint, lineDirection, planePoint, planeNormal,
|
||||
false);
|
||||
if (intersection == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isInsideBlock(blockPoint, planeNormal, intersection)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
double distance = intersection.distance(linePoint);
|
||||
if (distance < lastDistance) {
|
||||
lastIntersection = intersection;
|
||||
lastDistance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastIntersection == null) {
|
||||
return currentIntersection;
|
||||
}
|
||||
|
||||
double dist = linePoint.distance(centerPoint);
|
||||
double minDist = dist - RADIUS;
|
||||
double maxDist = dist + RADIUS;
|
||||
double factor = (lastDistance - minDist) / (maxDist - minDist);
|
||||
|
||||
double yOffset = lastIntersection.getX() - (int) lastIntersection.getX();
|
||||
double xOffset = lastIntersection.getZ() - (int) lastIntersection.getZ();
|
||||
|
||||
int pixelY = (int) Math.floor((yOffset < 0 ? yOffset + 1 : yOffset) * textureSize);
|
||||
int pixelX = (int) Math.floor((xOffset < 0 ? xOffset + 1 : xOffset) * textureSize);
|
||||
|
||||
return Intersection.of(currentIntersection.getNormal(), lastIntersection, lineDirection,
|
||||
0xFF000000 | MathUtil.weightedColorSum(texture[pixelY][pixelX], 0, 1 - factor, factor));
|
||||
}
|
||||
|
||||
private boolean isInsideBlock(Vector blockPoint, Vector planeNormal, Vector intersection) {
|
||||
intersection = intersection.clone().subtract(blockPoint);
|
||||
|
||||
if (intersection.getX() < 0 || intersection.getX() >= 1 || intersection.getY() < 0 || intersection.getY() >= 1
|
||||
|| intersection.getZ() < 0 || intersection.getZ() >= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean posX = planeNormal.getX() >= 0;
|
||||
boolean posY = planeNormal.getY() >= 0;
|
||||
boolean posZ = planeNormal.getZ() >= 0;
|
||||
|
||||
boolean blockX = intersection.getX() >= 0.5;
|
||||
boolean blockY = intersection.getY() >= 0.5;
|
||||
boolean blockZ = intersection.getZ() >= 0.5;
|
||||
|
||||
return posX == blockX && posY == blockY && posZ == blockZ;
|
||||
}
|
||||
|
||||
public static class OctahedronModelBuilder extends Builder {
|
||||
|
||||
OctahedronModelBuilder(int[][] texture) {
|
||||
super(texture);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Model build() {
|
||||
return new OctahedronModel(texture, transparencyFactor, reflectionFactor, occluding);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
public class SimpleModel extends AbstractModel {
|
||||
|
||||
static final Vector UP = new Vector(0, 1, 0);
|
||||
static final Vector DOWN = new Vector(0, -1, 0);
|
||||
private static final Vector NORTH = new Vector(0, 0, -1);
|
||||
private static final Vector SOUTH = new Vector(0, 0, 1);
|
||||
private static final Vector EAST = new Vector(1, 0, 0);
|
||||
private static final Vector WEST = new Vector(-1, 0, 0);
|
||||
|
||||
SimpleModel(int[][] texture, double transparencyFactor, double reflectionFactor,
|
||||
boolean occluding) {
|
||||
super(texture, transparencyFactor, reflectionFactor, occluding);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Intersection intersect(Block block, Intersection currentIntersection) {
|
||||
double yOffset;
|
||||
double xOffset;
|
||||
|
||||
Vector normal = currentIntersection.getNormal();
|
||||
Vector point = currentIntersection.getPoint();
|
||||
Vector direction = currentIntersection.getDirection();
|
||||
|
||||
if (normal.equals(NORTH) || normal.equals(SOUTH)) {
|
||||
yOffset = point.getY() - (int) point.getY();
|
||||
xOffset = point.getX() - (int) point.getX();
|
||||
} else if (normal.equals(EAST) || normal.equals(WEST)) {
|
||||
yOffset = point.getY() - (int) point.getY();
|
||||
xOffset = point.getZ() - (int) point.getZ();
|
||||
} else {
|
||||
yOffset = point.getX() - (int) point.getX();
|
||||
xOffset = point.getZ() - (int) point.getZ();
|
||||
}
|
||||
|
||||
int pixelY = (int) Math.floor((yOffset < 0 ? yOffset + 1 : yOffset) * textureSize);
|
||||
int pixelX = (int) Math.floor((xOffset < 0 ? xOffset + 1 : xOffset) * textureSize);
|
||||
|
||||
return Intersection.of(normal, point, direction, texture[pixelY][pixelX]);
|
||||
}
|
||||
|
||||
public static class SimpleModelBuilder extends Builder {
|
||||
|
||||
protected SimpleModelBuilder(int[][] texture) {
|
||||
super(texture);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Model build() {
|
||||
return new SimpleModel(texture, transparencyFactor, reflectionFactor, occluding);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
public class SphereModel extends AbstractModel {
|
||||
|
||||
private final double radius;
|
||||
private final Vector offset;
|
||||
|
||||
private SphereModel(int[][] texture, double transparencyFactor, double reflectionFactor,
|
||||
boolean occluding, double radius, Vector offset) {
|
||||
super(texture, transparencyFactor, reflectionFactor, occluding);
|
||||
|
||||
this.radius = radius;
|
||||
this.offset = offset;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Intersection intersect(Block block, Intersection currentIntersection) {
|
||||
Vector linePoint = currentIntersection.getPoint();
|
||||
Vector lineDirection = currentIntersection.getDirection();
|
||||
Vector blockPoint = block.getLocation().toVector();
|
||||
Vector centerPoint = block.getLocation().add(0.5, 0.5, 0.5).add(offset).toVector();
|
||||
|
||||
double a = lineDirection.dot(lineDirection);
|
||||
double b = 2 * (linePoint.dot(lineDirection) - centerPoint.dot(lineDirection));
|
||||
double c = linePoint.dot(linePoint) - 2 * centerPoint.dot(linePoint) + centerPoint.dot(centerPoint)
|
||||
- Math.pow(radius, 2);
|
||||
|
||||
double delta = Math.pow(b, 2) - 4 * a * c;
|
||||
if (delta < 0) {
|
||||
return Intersection.of(currentIntersection.getNormal(), linePoint, lineDirection);
|
||||
}
|
||||
|
||||
double dist = linePoint.distance(centerPoint);
|
||||
double minDist = dist - radius;
|
||||
double maxDist = dist + radius;
|
||||
|
||||
if (delta == 0) {
|
||||
double t = -b / (2 * a);
|
||||
Vector intersection = lineDirection.clone().add(lineDirection.clone().multiply(t));
|
||||
if (!isInsideBlock(blockPoint, intersection)) {
|
||||
return currentIntersection;
|
||||
}
|
||||
double currentDist = intersection.distance(linePoint);
|
||||
double factor = (currentDist - minDist) / (maxDist - minDist);
|
||||
Vector normal = intersection.clone().subtract(centerPoint).normalize();
|
||||
return Intersection.of(normal, intersection, lineDirection, getColor(centerPoint, intersection, factor));
|
||||
}
|
||||
|
||||
double deltaSqrt = Math.sqrt(delta);
|
||||
|
||||
double tOne = (-b + deltaSqrt) / (2 * a);
|
||||
double tTwo = (-b - deltaSqrt) / (2 * a);
|
||||
|
||||
Vector intersectionOne = linePoint.clone().add(lineDirection.clone().multiply(tOne));
|
||||
Vector intersectionTwo = linePoint.clone().add(lineDirection.clone().multiply(tTwo));
|
||||
|
||||
boolean first = intersectionOne.distanceSquared(linePoint) < intersectionTwo.distanceSquared(linePoint);
|
||||
double currentDist = (first ? intersectionOne : intersectionTwo).distance(linePoint);
|
||||
double factor = (currentDist - minDist) / (maxDist - minDist);
|
||||
if (first && isInsideBlock(blockPoint, intersectionOne)) {
|
||||
Vector normal = intersectionOne.clone().subtract(centerPoint).normalize();
|
||||
return Intersection.of(normal, intersectionOne, lineDirection,
|
||||
getColor(centerPoint, intersectionOne, factor));
|
||||
} else if (isInsideBlock(blockPoint, intersectionTwo)) {
|
||||
Vector normal = intersectionTwo.clone().subtract(centerPoint).normalize();
|
||||
return Intersection.of(normal, intersectionTwo, lineDirection,
|
||||
getColor(centerPoint, intersectionTwo, factor));
|
||||
} else {
|
||||
return currentIntersection;
|
||||
}
|
||||
}
|
||||
|
||||
private int getColor(Vector base, Vector intersection, double factor) {
|
||||
Location loc = base.toLocation(null);
|
||||
loc.setDirection(intersection.clone().subtract(base).normalize());
|
||||
|
||||
double perimeter = Math.round(2 * Math.PI * radius);
|
||||
double yawDiv = 360 / perimeter;
|
||||
double pitchDiv = 180 / perimeter;
|
||||
|
||||
int pixelX = (int) ((loc.getYaw() % yawDiv) / (yawDiv / textureSize));
|
||||
int pixelY = (int) (((loc.getPitch() + 90) % pitchDiv) / (pitchDiv / textureSize));
|
||||
|
||||
return 0xFF000000 | MathUtil.weightedColorSum(texture[pixelY][pixelX], 0, 1 - factor, factor);
|
||||
}
|
||||
|
||||
private boolean isInsideBlock(Vector blockPoint, Vector intersection) {
|
||||
intersection = intersection.clone().subtract(blockPoint);
|
||||
|
||||
return intersection.getX() >= 0 && intersection.getX() < 1 && intersection.getY() >= 0
|
||||
&& intersection.getY() < 1 && intersection.getZ() >= 0 && intersection.getZ() < 1;
|
||||
}
|
||||
|
||||
public static class SphereModelBuilder extends Builder {
|
||||
|
||||
private double radius;
|
||||
private Vector offset;
|
||||
|
||||
SphereModelBuilder(int[][] texture) {
|
||||
super(texture);
|
||||
|
||||
this.radius = 0.5;
|
||||
this.offset = new Vector();
|
||||
}
|
||||
|
||||
public SphereModelBuilder radius(double radius) {
|
||||
this.radius = radius;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SphereModelBuilder offset(Vector offset) {
|
||||
this.offset = offset.clone();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Model build() {
|
||||
return new SphereModel(texture, transparencyFactor, reflectionFactor, occluding, radius,
|
||||
offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.model;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.AbstractModel.Builder;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
||||
import org.bukkit.block.Block;
|
||||
|
||||
public class StaticModel implements Model {
|
||||
|
||||
private final int color;
|
||||
private final double transparencyFactor;
|
||||
private final double reflectionFactor;
|
||||
private final boolean occluding;
|
||||
|
||||
private StaticModel(int color, double transparencyFactor, double reflectionFactor, boolean occluding) {
|
||||
this.color = color;
|
||||
this.transparencyFactor = transparencyFactor;
|
||||
this.reflectionFactor = reflectionFactor;
|
||||
this.occluding = occluding;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Intersection intersect(Block block, Intersection currentIntersection) {
|
||||
return Intersection.of(currentIntersection.getNormal(), currentIntersection.getPoint(),
|
||||
currentIntersection.getDirection(), color);
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getTransparencyFactor() {
|
||||
return transparencyFactor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getReflectionFactor() {
|
||||
return reflectionFactor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOccluding() {
|
||||
return occluding;
|
||||
}
|
||||
|
||||
public static class StaticModelBuilder extends Builder {
|
||||
|
||||
private final int color;
|
||||
|
||||
StaticModelBuilder(int color) {
|
||||
super(new int[1][1]);
|
||||
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StaticModel build() {
|
||||
return new StaticModel(color, transparencyFactor, reflectionFactor, occluding);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.Model;
|
||||
import eu.mhsl.minecraft.pixelpics.render.registry.AdvancedModelRegistry;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.BlockRaytracer;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
public class AdvancedRaytracer implements Raytracer {
|
||||
private final int maxDistance;
|
||||
private final int reflectionDepth;
|
||||
|
||||
private final AdvancedModelRegistry textureRegistry;
|
||||
private Block reflectedBlock;
|
||||
|
||||
public AdvancedRaytracer() {
|
||||
this(300, 10);
|
||||
}
|
||||
|
||||
public AdvancedRaytracer(int maxDistance, int reflectionDepth) {
|
||||
this.maxDistance = maxDistance;
|
||||
this.reflectionDepth = reflectionDepth;
|
||||
|
||||
this.textureRegistry = new AdvancedModelRegistry();
|
||||
this.textureRegistry.initialize();
|
||||
|
||||
this.reflectedBlock = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int trace(World world, Vector point, Vector direction) {
|
||||
return trace(world, point, direction, reflectionDepth);
|
||||
}
|
||||
|
||||
private int trace(World world, Vector point, Vector direction, int reflectionDepth) {
|
||||
Location loc = point.toLocation(world);
|
||||
loc.setDirection(direction);
|
||||
BlockRaytracer iterator = new BlockRaytracer(loc);
|
||||
int baseColor = Color.fromRGB(65, 89, 252).asRGB();
|
||||
Vector finalIntersection = null;
|
||||
|
||||
int reflectionColor = 0;
|
||||
double reflectionFactor = 0;
|
||||
boolean reflected = false;
|
||||
|
||||
Vector transparencyStart = null;
|
||||
int transparencyColor = 0;
|
||||
double transparencyFactor = 0;
|
||||
|
||||
Material occlusionMaterial = null;
|
||||
BlockData occlusionData = null;
|
||||
|
||||
for (int i = 0; i < maxDistance; i++) {
|
||||
if (!iterator.hasNext()) break;
|
||||
Block block = iterator.next();
|
||||
if (reflectedBlock != null && reflectedBlock.equals(block)) continue;
|
||||
reflectedBlock = null;
|
||||
|
||||
Material material = block.getType();
|
||||
if (material == Material.AIR) {
|
||||
occlusionMaterial = null;
|
||||
occlusionData = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
Model textureModel = textureRegistry.getModel(block.getType(), block.getBlockData(), block.getTemperature(), block.getHumidity());
|
||||
Intersection currentIntersection = Intersection.of(
|
||||
MathUtil.toVector(iterator.getIntersectionFace()),
|
||||
i == 0 ? point : iterator.getIntersectionPoint(),
|
||||
direction
|
||||
);
|
||||
Intersection newIntersection = textureModel.intersect(block, currentIntersection);
|
||||
|
||||
if (newIntersection == null) continue;
|
||||
|
||||
int color = newIntersection.getColor();
|
||||
|
||||
if (!reflected && textureModel.getReflectionFactor() > 0 && reflectionDepth > 0 && (color >> 24) != 0) {
|
||||
reflectedBlock = block;
|
||||
reflectionColor = trace(
|
||||
world,
|
||||
newIntersection.getPoint(),
|
||||
MathUtil.reflectVector(
|
||||
point,
|
||||
direction,
|
||||
newIntersection.getPoint(),
|
||||
newIntersection.getNormal()
|
||||
),
|
||||
reflectionDepth - 1
|
||||
);
|
||||
reflectionFactor = textureModel.getReflectionFactor();
|
||||
reflected = true;
|
||||
}
|
||||
|
||||
if (transparencyStart == null && textureModel.getTransparencyFactor() > 0) {
|
||||
transparencyStart = newIntersection.getPoint();
|
||||
transparencyColor = newIntersection.getColor();
|
||||
transparencyFactor = textureModel.getTransparencyFactor();
|
||||
}
|
||||
|
||||
if (textureModel.isOccluding()) {
|
||||
BlockData data = block.getBlockData();
|
||||
|
||||
if (material == occlusionMaterial && data.equals(occlusionData)) continue;
|
||||
|
||||
occlusionMaterial = material;
|
||||
occlusionData = data;
|
||||
} else {
|
||||
occlusionMaterial = null;
|
||||
occlusionData = null;
|
||||
}
|
||||
|
||||
if (transparencyStart != null && textureModel.getTransparencyFactor() > 0) continue;
|
||||
if ((color >> 24) == 0) continue;
|
||||
|
||||
baseColor = color;
|
||||
finalIntersection = newIntersection.getPoint();
|
||||
break;
|
||||
}
|
||||
|
||||
if (transparencyStart != null) {
|
||||
baseColor = MathUtil.weightedColorSum(
|
||||
baseColor,
|
||||
transparencyColor,
|
||||
transparencyFactor,
|
||||
(1
|
||||
- transparencyFactor)
|
||||
* (1 + transparencyStart.distance(finalIntersection == null ? transparencyStart : finalIntersection)
|
||||
/ 5.0));
|
||||
}
|
||||
if (reflected) {
|
||||
baseColor = MathUtil.weightedColorSum(
|
||||
baseColor,
|
||||
reflectionColor,
|
||||
1 - reflectionFactor,
|
||||
reflectionFactor
|
||||
);
|
||||
}
|
||||
|
||||
return baseColor & 0xFFFFFF;
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.Model;
|
||||
import eu.mhsl.minecraft.pixelpics.render.registry.AdvancedModelRegistry;
|
||||
import eu.mhsl.minecraft.pixelpics.render.registry.ModelRegistry;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.BlockRaytracer;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.Intersection;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Biome;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
public class DefaultRaytracer implements Raytracer {
|
||||
private final int maxDistance;
|
||||
private final int reflectionDepth;
|
||||
|
||||
private final ModelRegistry textureRegistry;
|
||||
private Block reflectedBlock;
|
||||
|
||||
public DefaultRaytracer() {
|
||||
this(300, 10);
|
||||
}
|
||||
|
||||
public DefaultRaytracer(int maxDistance, int reflectionDepth) {
|
||||
this.maxDistance = maxDistance;
|
||||
this.reflectionDepth = reflectionDepth;
|
||||
|
||||
this.textureRegistry = new AdvancedModelRegistry();
|
||||
this.textureRegistry.initialize();
|
||||
|
||||
this.reflectedBlock = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int trace(World world, Vector point, Vector direction) {
|
||||
return trace(world, point, direction, reflectionDepth);
|
||||
}
|
||||
|
||||
private int trace(World world, Vector point, Vector direction, int reflectionDepth) {
|
||||
Location loc = point.toLocation(world);
|
||||
loc.setDirection(direction);
|
||||
BlockRaytracer iterator = new BlockRaytracer(loc);
|
||||
int baseColor = Color.fromRGB(65, 89, 252).asRGB();
|
||||
Vector finalIntersection = null;
|
||||
|
||||
int reflectionColor = 0;
|
||||
double reflectionFactor = 0;
|
||||
boolean reflected = false;
|
||||
|
||||
Vector transparencyStart = null;
|
||||
int transparencyColor = 0;
|
||||
double transparencyFactor = 0;
|
||||
|
||||
Material occlusionMaterial = null;
|
||||
BlockData occlusionData = null;
|
||||
|
||||
for (int i = 0; i < maxDistance; i++) {
|
||||
if (!iterator.hasNext()) break;
|
||||
Block block = iterator.next();
|
||||
if (reflectedBlock != null && reflectedBlock.equals(block)) continue;
|
||||
reflectedBlock = null;
|
||||
|
||||
Material material = block.getType();
|
||||
if (material == Material.AIR) {
|
||||
occlusionMaterial = null;
|
||||
occlusionData = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
Biome biome = block.getBiome();
|
||||
Model textureModel = textureRegistry.getModel(block);
|
||||
Intersection currentIntersection = Intersection.of(
|
||||
MathUtil.toVector(iterator.getIntersectionFace()),
|
||||
i == 0 ? point : iterator.getIntersectionPoint(),
|
||||
direction
|
||||
);
|
||||
Intersection newIntersection = textureModel.intersect(block, currentIntersection);
|
||||
|
||||
if (newIntersection == null) continue;
|
||||
|
||||
int color = newIntersection.getColor();
|
||||
|
||||
if (!reflected && textureModel.getReflectionFactor() > 0 && reflectionDepth > 0 && (color >> 24) != 0) {
|
||||
reflectedBlock = block;
|
||||
reflectionColor = trace(
|
||||
world,
|
||||
newIntersection.getPoint(),
|
||||
MathUtil.reflectVector(
|
||||
point,
|
||||
direction,
|
||||
newIntersection.getPoint(),
|
||||
newIntersection.getNormal()
|
||||
),
|
||||
reflectionDepth - 1
|
||||
);
|
||||
reflectionFactor = textureModel.getReflectionFactor();
|
||||
reflected = true;
|
||||
}
|
||||
|
||||
if (transparencyStart == null && textureModel.getTransparencyFactor() > 0) {
|
||||
transparencyStart = newIntersection.getPoint();
|
||||
transparencyColor = newIntersection.getColor();
|
||||
transparencyFactor = textureModel.getTransparencyFactor();
|
||||
}
|
||||
|
||||
if (textureModel.isOccluding()) {
|
||||
BlockData data = block.getBlockData();
|
||||
|
||||
if (material == occlusionMaterial && data.equals(occlusionData)) continue;
|
||||
|
||||
occlusionMaterial = material;
|
||||
occlusionData = data;
|
||||
} else {
|
||||
occlusionMaterial = null;
|
||||
occlusionData = null;
|
||||
}
|
||||
|
||||
if (transparencyStart != null && textureModel.getTransparencyFactor() > 0) continue;
|
||||
if ((color >> 24) == 0) continue;
|
||||
|
||||
baseColor = color;
|
||||
finalIntersection = newIntersection.getPoint();
|
||||
break;
|
||||
}
|
||||
|
||||
if (transparencyStart != null) {
|
||||
baseColor = MathUtil.weightedColorSum(
|
||||
baseColor,
|
||||
transparencyColor,
|
||||
transparencyFactor,
|
||||
(1
|
||||
- transparencyFactor)
|
||||
* (1 + transparencyStart.distance(finalIntersection == null ? transparencyStart : finalIntersection)
|
||||
/ 5.0));
|
||||
}
|
||||
if (reflected) {
|
||||
baseColor = MathUtil.weightedColorSum(
|
||||
baseColor,
|
||||
reflectionColor,
|
||||
1 - reflectionFactor,
|
||||
reflectionFactor
|
||||
);
|
||||
}
|
||||
|
||||
return baseColor & 0xFFFFFF;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns
|
||||
* the nearest opaque face hit. Axis-aligned boxes use a slab test; rotated elements are handled as
|
||||
* oriented boxes by transforming the ray into the element's local frame. Fully transparent texels
|
||||
* (alpha ≤ threshold) are treated as holes so the ray passes through (cutout for plants, glass).
|
||||
*/
|
||||
public final class ElementIntersector {
|
||||
|
||||
private static final double EPS = 1e-7;
|
||||
private static final int ALPHA_THRESHOLD = 16;
|
||||
|
||||
private ElementIntersector() {}
|
||||
|
||||
/**
|
||||
* @param ox,oy,oz ray origin in block-local coordinates (world origin minus block min corner)
|
||||
* @param dx,dy,dz ray direction (need not be normalized)
|
||||
* @param bx,by,bz block min corner in world coordinates (for reconstructing the world hit point)
|
||||
*/
|
||||
public static FaceHit intersect(ResolvedModel model,
|
||||
double ox, double oy, double oz,
|
||||
double dx, double dy, double dz,
|
||||
int bx, int by, int bz) {
|
||||
List<Candidate> candidates = new ArrayList<>(model.elements.size());
|
||||
for (int i = 0; i < model.elements.size(); i++) {
|
||||
Element element = model.elements.get(i);
|
||||
Candidate c = element.isAxisAligned()
|
||||
? intersectAabb(element, ox, oy, oz, dx, dy, dz)
|
||||
: intersectObb(element, ox, oy, oz, dx, dy, dz);
|
||||
if (c != null) candidates.add(new Candidate(c.element, c.t, c.dir, c.s, c.t2, c.normal, i));
|
||||
}
|
||||
if (candidates.isEmpty()) return null;
|
||||
// Sort by depth; for coplanar faces (equal t) render later elements first, matching vanilla's
|
||||
// draw order so overlays (e.g. the tinted grass side overlay) sit on top of the base face.
|
||||
candidates.sort((a, b) -> {
|
||||
if (Math.abs(a.t - b.t) > 1e-4) return Double.compare(a.t, b.t);
|
||||
return Integer.compare(b.order, a.order);
|
||||
});
|
||||
|
||||
for (Candidate c : candidates) {
|
||||
Face face = c.element.faces[c.dir.ordinal()];
|
||||
if (face == null) continue;
|
||||
int color = face.sample(c.s, c.t2);
|
||||
if (ColorUtil.alpha(color) <= ALPHA_THRESHOLD) continue;
|
||||
|
||||
Vector world = new Vector(bx + ox + dx * c.t, by + oy + dy * c.t, bz + oz + dz * c.t);
|
||||
Vector normal = new Vector(c.normal[0], c.normal[1], c.normal[2]);
|
||||
return new FaceHit(c.t, world, normal, color, face.tintIndex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private record Candidate(Element element, double t, Direction dir, double s, double t2, double[] normal, int order) {}
|
||||
|
||||
private static Candidate intersectAabb(Element e, double ox, double oy, double oz,
|
||||
double dx, double dy, double dz) {
|
||||
return slab(e, ox, oy, oz, dx, dy, dz, e.from, e.to, null);
|
||||
}
|
||||
|
||||
/** Rotated element: transform the ray into the element's local (unrotated) frame, then slab-test. */
|
||||
private static Candidate intersectObb(Element e, double ox, double oy, double oz,
|
||||
double dx, double dy, double dz) {
|
||||
double[] o = rotate(ox - e.rotOrigin[0], oy - e.rotOrigin[1], oz - e.rotOrigin[2], e.rotAxis, -e.rotAngleRad);
|
||||
o[0] += e.rotOrigin[0];
|
||||
o[1] += e.rotOrigin[1];
|
||||
o[2] += e.rotOrigin[2];
|
||||
double[] d = rotate(dx, dy, dz, e.rotAxis, -e.rotAngleRad);
|
||||
return slab(e, o[0], o[1], o[2], d[0], d[1], d[2], e.from, e.to, e);
|
||||
}
|
||||
|
||||
private static Candidate slab(Element e, double ox, double oy, double oz,
|
||||
double dx, double dy, double dz,
|
||||
double[] from, double[] to, Element obb) {
|
||||
double tmin = Double.NEGATIVE_INFINITY;
|
||||
double tmax = Double.POSITIVE_INFINITY;
|
||||
int axis = -1;
|
||||
boolean negFace = false; // entered through the low-coordinate face
|
||||
|
||||
double[] o = {ox, oy, oz};
|
||||
double[] d = {dx, dy, dz};
|
||||
for (int a = 0; a < 3; a++) {
|
||||
if (Math.abs(d[a]) < EPS) {
|
||||
if (o[a] < from[a] - EPS || o[a] > to[a] + EPS) return null;
|
||||
continue;
|
||||
}
|
||||
double inv = 1.0 / d[a];
|
||||
double t1 = (from[a] - o[a]) * inv;
|
||||
double t2 = (to[a] - o[a]) * inv;
|
||||
boolean neg = true;
|
||||
if (t1 > t2) {
|
||||
double tmp = t1; t1 = t2; t2 = tmp;
|
||||
neg = false;
|
||||
}
|
||||
if (t1 > tmin) {
|
||||
tmin = t1;
|
||||
axis = a;
|
||||
negFace = neg;
|
||||
}
|
||||
if (t2 < tmax) tmax = t2;
|
||||
if (tmin > tmax) return null;
|
||||
}
|
||||
if (axis < 0) return null;
|
||||
|
||||
double tEntry = tmin;
|
||||
if (tEntry < EPS) {
|
||||
// origin inside the box (e.g. camera within a block): use exit point instead
|
||||
tEntry = tmax;
|
||||
if (tEntry < EPS) return null;
|
||||
}
|
||||
|
||||
double px = o[0] + d[0] * tEntry;
|
||||
double py = o[1] + d[1] * tEntry;
|
||||
double pz = o[2] + d[2] * tEntry;
|
||||
|
||||
Direction dir = faceFor(axis, negFace);
|
||||
double[] normal = {dir.nx, dir.ny, dir.nz};
|
||||
if (obb != null) {
|
||||
// rotate the normal back into block space
|
||||
normal = rotate(normal[0], normal[1], normal[2], obb.rotAxis, obb.rotAngleRad);
|
||||
}
|
||||
|
||||
double fracX = frac(px, from[0], to[0]);
|
||||
double fracY = frac(py, from[1], to[1]);
|
||||
double fracZ = frac(pz, from[2], to[2]);
|
||||
|
||||
double s, t;
|
||||
switch (dir) {
|
||||
// Texture V is top-down (0 = texture top). For side faces the texture top is the block
|
||||
// top (high Y), so t = 1 - fracY.
|
||||
case UP, DOWN -> { s = fracX; t = fracZ; }
|
||||
case NORTH, SOUTH -> { s = fracX; t = 1 - fracY; }
|
||||
default -> { s = fracZ; t = 1 - fracY; } // WEST, EAST
|
||||
}
|
||||
return new Candidate(e, tEntry, dir, s, t, normal, 0);
|
||||
}
|
||||
|
||||
private static Direction faceFor(int axis, boolean negFace) {
|
||||
return switch (axis) {
|
||||
case 0 -> negFace ? Direction.WEST : Direction.EAST;
|
||||
case 1 -> negFace ? Direction.DOWN : Direction.UP;
|
||||
default -> negFace ? Direction.NORTH : Direction.SOUTH;
|
||||
};
|
||||
}
|
||||
|
||||
private static double frac(double v, double lo, double hi) {
|
||||
double span = hi - lo;
|
||||
if (span < 1e-6) return 0;
|
||||
double f = (v - lo) / span;
|
||||
return f < 0 ? 0 : Math.min(f, 1);
|
||||
}
|
||||
|
||||
/** Rotate (x,y,z) around the given axis (0=x,1=y,2=z) by angle radians. */
|
||||
private static double[] rotate(double x, double y, double z, int axis, double angle) {
|
||||
double cos = Math.cos(angle);
|
||||
double sin = Math.sin(angle);
|
||||
return switch (axis) {
|
||||
case 0 -> new double[]{x, y * cos - z * sin, y * sin + z * cos};
|
||||
case 1 -> new double[]{x * cos + z * sin, y, -x * sin + z * cos};
|
||||
default -> new double[]{x * cos - y * sin, x * sin + y * cos, z};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/**
|
||||
* The result of intersecting a ray with a block's geometry: the world-space hit point and normal,
|
||||
* the sampled ARGB color (before shading/tinting) and the face's tint index ({@code -1} = none).
|
||||
*/
|
||||
public record FaceHit(double t, Vector point, Vector normal, int color, int tintIndex) {
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
public interface Raytracer {
|
||||
|
||||
int trace(World world, Vector point, Vector direction);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityScene;
|
||||
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
|
||||
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTint;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Biome;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/**
|
||||
* Traces a single ray against a {@link WorldSnapshot}, sampling block models via the
|
||||
* {@link ElementIntersector} and applying biome tint, directional face shading, transparency and
|
||||
* reflection. Contains no Bukkit world access, so it is safe to invoke from worker threads.
|
||||
*/
|
||||
public final class SnapshotRaytracer {
|
||||
|
||||
private static final int BIOME_BLEND_RADIUS = 2;
|
||||
|
||||
// Distance fog (atmospheric perspective).
|
||||
private static final double FOG_START = 128;
|
||||
private static final double FOG_END = 256;
|
||||
private static final double FOG_MAX = 0.75;
|
||||
|
||||
// Vanilla-style ambient occlusion brightness per occlusion level (0=most occluded .. 3=open). Kept subtle.
|
||||
private static final double[] AO_BRIGHTNESS = {0.55, 0.70, 0.85, 1.0};
|
||||
|
||||
private final BlockModelRegistry registry;
|
||||
private final BiomeTintProvider tintProvider;
|
||||
private final SkyRenderer skyRenderer;
|
||||
private final double maxDistance;
|
||||
private final int reflectionDepth;
|
||||
private final int maxSteps;
|
||||
private final java.util.Map<Long, BiomeTint> tintCache = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
public SnapshotRaytracer(BlockModelRegistry registry, BiomeTintProvider tintProvider,
|
||||
SkyRenderer skyRenderer, double maxDistance, int reflectionDepth) {
|
||||
this.registry = registry;
|
||||
this.tintProvider = tintProvider;
|
||||
this.skyRenderer = skyRenderer;
|
||||
this.maxDistance = maxDistance;
|
||||
this.reflectionDepth = reflectionDepth;
|
||||
this.maxSteps = (int) (maxDistance * 3) + 3;
|
||||
}
|
||||
|
||||
public int trace(WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene) {
|
||||
return trace(snapshot, origin, direction, sky, scene, reflectionDepth);
|
||||
}
|
||||
|
||||
private int trace(WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene, int depth) {
|
||||
double ox = origin.getX(), oy = origin.getY(), oz = origin.getZ();
|
||||
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
|
||||
|
||||
VoxelDDA dda = new VoxelDDA(ox, oy, oz, dx, dy, dz);
|
||||
|
||||
int skyColor = skyRenderer.colorFor(direction, origin, sky);
|
||||
int baseColor = skyColor;
|
||||
Vector finalPoint = null;
|
||||
|
||||
int reflectionColor = 0;
|
||||
double reflectionFactor = 0;
|
||||
boolean reflected = false;
|
||||
|
||||
Vector transparencyStart = null;
|
||||
int transparencyColor = 0;
|
||||
double transparencyFactor = 0;
|
||||
|
||||
BlockData occlusion = null;
|
||||
|
||||
for (int i = 0; i < maxSteps && dda.tCurrent <= maxDistance; i++) {
|
||||
int bx = dda.x, by = dda.y, bz = dda.z;
|
||||
BlockData data = snapshot.getBlockData(bx, by, bz);
|
||||
if (data.getMaterial() == Material.AIR) {
|
||||
occlusion = null;
|
||||
dda.advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
ResolvedModel model = registry.get(data);
|
||||
FaceHit hit = ElementIntersector.intersect(model, ox - bx, oy - by, oz - bz, dx, dy, dz, bx, by, bz);
|
||||
if (hit == null) {
|
||||
occlusion = null;
|
||||
dda.advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
int color = shadeAndTint(hit, data, snapshot, bx, by, bz);
|
||||
|
||||
if (!reflected && model.reflection > 0 && depth > 0) {
|
||||
Vector reflectDir = MathUtil.reflectVector(origin, direction, hit.point(), hit.normal());
|
||||
Vector reflectStart = hit.point().clone().add(hit.normal().clone().multiply(1e-3));
|
||||
reflectionColor = trace(snapshot, reflectStart, reflectDir, sky, scene, depth - 1);
|
||||
reflectionFactor = model.reflection;
|
||||
reflected = true;
|
||||
}
|
||||
|
||||
if (transparencyStart == null && model.transparency > 0) {
|
||||
transparencyStart = hit.point();
|
||||
transparencyColor = color;
|
||||
transparencyFactor = model.transparency;
|
||||
}
|
||||
|
||||
if (model.occluding) {
|
||||
if (data.equals(occlusion)) {
|
||||
dda.advance();
|
||||
continue;
|
||||
}
|
||||
occlusion = data;
|
||||
} else {
|
||||
occlusion = null;
|
||||
}
|
||||
|
||||
if (transparencyStart != null && model.transparency > 0) {
|
||||
dda.advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
baseColor = color;
|
||||
finalPoint = hit.point();
|
||||
break;
|
||||
}
|
||||
|
||||
// Entities: if one is closer than the opaque block/sky, it becomes the surface.
|
||||
if (scene != null && !scene.isEmpty()) {
|
||||
double blockDist = finalPoint != null ? origin.distance(finalPoint) : maxDistance;
|
||||
FaceHit eh = scene.nearestHit(ox, oy, oz, dx, dy, dz, blockDist);
|
||||
if (eh != null) {
|
||||
baseColor = ColorUtil.shade(eh.color(), shadeFactor(eh.normal()));
|
||||
finalPoint = eh.point();
|
||||
reflected = false;
|
||||
if (transparencyStart != null && origin.distance(transparencyStart) >= eh.t()) {
|
||||
transparencyStart = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transparencyStart != null) {
|
||||
baseColor = MathUtil.weightedColorSum(
|
||||
baseColor,
|
||||
transparencyColor,
|
||||
transparencyFactor,
|
||||
(1 - transparencyFactor)
|
||||
* (1 + transparencyStart.distance(finalPoint == null ? transparencyStart : finalPoint) / 5.0));
|
||||
}
|
||||
if (reflected) {
|
||||
baseColor = MathUtil.weightedColorSum(baseColor, reflectionColor, 1 - reflectionFactor, reflectionFactor);
|
||||
}
|
||||
|
||||
// Distance fog (atmospheric perspective): fade distant geometry toward the sky color.
|
||||
if (finalPoint != null) {
|
||||
double fog = fogFactor(origin.distance(finalPoint));
|
||||
if (fog > 0) baseColor = MathUtil.weightedColorSum(baseColor, skyColor, 1 - fog, fog);
|
||||
}
|
||||
|
||||
return baseColor & 0xFFFFFF;
|
||||
}
|
||||
|
||||
private int shadeAndTint(FaceHit hit, BlockData data, WorldSnapshot snapshot, int bx, int by, int bz) {
|
||||
int color = hit.color();
|
||||
if (hit.tintIndex() >= 0) {
|
||||
BiomeTint tint = blendedTint(snapshot, bx, by, bz);
|
||||
if (tint != null) {
|
||||
int tintColor = TintResolver.resolve(data, hit.tintIndex(), tint);
|
||||
if (tintColor != -1) color = ColorUtil.multiply(color, tintColor);
|
||||
}
|
||||
}
|
||||
double light = shadeFactor(hit.normal()) * ambientOcclusion(hit, snapshot, bx, by, bz);
|
||||
return ColorUtil.shade(color, light);
|
||||
}
|
||||
|
||||
private double fogFactor(double distance) {
|
||||
if (distance <= FOG_START) return 0;
|
||||
double f = (distance - FOG_START) / (FOG_END - FOG_START);
|
||||
return Math.clamp(f, 0, FOG_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vanilla-style smooth ambient occlusion: darkens face corners by how many of the three blocks
|
||||
* touching that corner (in the layer just outside the face) are solid, bilinearly interpolated
|
||||
* across the face. Only applied to axis-aligned faces.
|
||||
*/
|
||||
private double ambientOcclusion(FaceHit hit, WorldSnapshot snapshot, int bx, int by, int bz) {
|
||||
double nx = hit.normal().getX(), ny = hit.normal().getY(), nz = hit.normal().getZ();
|
||||
double ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
|
||||
if (Math.max(ax, Math.max(ay, az)) < 0.99) return 1.0; // skip rotated/diagonal faces
|
||||
|
||||
double lx = hit.point().getX() - bx;
|
||||
double ly = hit.point().getY() - by;
|
||||
double lz = hit.point().getZ() - bz;
|
||||
|
||||
// Offset to the layer just outside the face, plus the two in-plane unit axes and face coords.
|
||||
int ofx = (int) Math.round(nx), ofy = (int) Math.round(ny), ofz = (int) Math.round(nz);
|
||||
int ux, uy, uz, vx, vy, vz;
|
||||
double su, sv;
|
||||
if (ay > 0.5) { // up/down
|
||||
ux = 1; uy = 0; uz = 0; vx = 0; vy = 0; vz = 1; su = lx; sv = lz;
|
||||
} else if (ax > 0.5) { // east/west
|
||||
ux = 0; uy = 0; uz = 1; vx = 0; vy = 1; vz = 0; su = lz; sv = ly;
|
||||
} else { // north/south
|
||||
ux = 1; uy = 0; uz = 0; vx = 0; vy = 1; vz = 0; su = lx; sv = ly;
|
||||
}
|
||||
|
||||
double b00 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, -1);
|
||||
double b10 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, +1, -1);
|
||||
double b01 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, +1);
|
||||
double b11 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, +1, +1);
|
||||
|
||||
double top = b00 + (b10 - b00) * su;
|
||||
double bottom = b01 + (b11 - b01) * su;
|
||||
return top + (bottom - top) * sv;
|
||||
}
|
||||
|
||||
private double aoCorner(WorldSnapshot snapshot, int bx, int by, int bz,
|
||||
int ofx, int ofy, int ofz, int ux, int uy, int uz, int vx, int vy, int vz,
|
||||
int du, int dv) {
|
||||
boolean side1 = solid(snapshot, bx + ofx + du * ux, by + ofy + du * uy, bz + ofz + du * uz);
|
||||
boolean side2 = solid(snapshot, bx + ofx + dv * vx, by + ofy + dv * vy, bz + ofz + dv * vz);
|
||||
boolean corner = solid(snapshot,
|
||||
bx + ofx + du * ux + dv * vx, by + ofy + du * uy + dv * vy, bz + ofz + du * uz + dv * vz);
|
||||
int level = (side1 && side2) ? 0 : 3 - (side1 ? 1 : 0) - (side2 ? 1 : 0) - (corner ? 1 : 0);
|
||||
return AO_BRIGHTNESS[Math.clamp(level, 0, 3)];
|
||||
}
|
||||
|
||||
private boolean solid(WorldSnapshot snapshot, int x, int y, int z) {
|
||||
Material m = snapshot.getBlockData(x, y, z).getMaterial();
|
||||
return m != Material.AIR && m.isOccluding();
|
||||
}
|
||||
|
||||
/**
|
||||
* Biome-blended tint: averages the per-biome tint over a {@code (2r+1)x(2r+1)} neighbourhood in
|
||||
* X/Z (vanilla biome blend radius, default 2), giving smooth grass/foliage gradients across biome
|
||||
* borders instead of hard edges. Cached per column.
|
||||
*/
|
||||
private BiomeTint blendedTint(WorldSnapshot snapshot, int bx, int by, int bz) {
|
||||
long key = (((long) bx) & 0xFFFFFFFFL) | (((long) bz) << 32);
|
||||
BiomeTint cached = tintCache.get(key);
|
||||
if (cached != null) return cached;
|
||||
|
||||
long[] g = new long[3], f = new long[3], d = new long[3], w = new long[3];
|
||||
int n = 0;
|
||||
for (int dx = -BIOME_BLEND_RADIUS; dx <= BIOME_BLEND_RADIUS; dx++) {
|
||||
for (int dz = -BIOME_BLEND_RADIUS; dz <= BIOME_BLEND_RADIUS; dz++) {
|
||||
Biome biome = snapshot.getBiome(bx + dx, by, bz + dz);
|
||||
if (biome == null) continue;
|
||||
BiomeTint t = tintProvider.forBiome(biome);
|
||||
accumulate(g, t.grass());
|
||||
accumulate(f, t.foliage());
|
||||
accumulate(d, t.dryFoliage());
|
||||
accumulate(w, t.water());
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (n == 0) return null;
|
||||
BiomeTint result = new BiomeTint(average(g, n), average(f, n), average(d, n), average(w, n));
|
||||
tintCache.put(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void accumulate(long[] acc, int argb) {
|
||||
acc[0] += (argb >> 16) & 0xFF;
|
||||
acc[1] += (argb >> 8) & 0xFF;
|
||||
acc[2] += argb & 0xFF;
|
||||
}
|
||||
|
||||
private static int average(long[] acc, int n) {
|
||||
return 0xFF000000 | (((int) (acc[0] / n)) << 16) | (((int) (acc[1] / n)) << 8) | ((int) (acc[2] / n));
|
||||
}
|
||||
|
||||
/** Vanilla-style directional shading: top 1.0, north/south 0.8, east/west 0.6, bottom 0.5. */
|
||||
private double shadeFactor(Vector normal) {
|
||||
double ax = Math.abs(normal.getX());
|
||||
double ay = Math.abs(normal.getY());
|
||||
double az = Math.abs(normal.getZ());
|
||||
if (ay >= ax && ay >= az) return normal.getY() >= 0 ? 1.0 : 0.5;
|
||||
if (az >= ax) return 0.8;
|
||||
return 0.6;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.raytrace;
|
||||
|
||||
/**
|
||||
* Amanatides-Woo voxel traversal: walks the integer block grid a ray passes through, in order,
|
||||
* without any Bukkit world access (safe to run off the main thread).
|
||||
*/
|
||||
public final class VoxelDDA {
|
||||
|
||||
public int x, y, z;
|
||||
public double tCurrent; // ray parameter at which the current voxel was entered
|
||||
|
||||
private final int stepX, stepY, stepZ;
|
||||
private final double tDeltaX, tDeltaY, tDeltaZ;
|
||||
private double tMaxX, tMaxY, tMaxZ;
|
||||
|
||||
public VoxelDDA(double ox, double oy, double oz, double dx, double dy, double dz) {
|
||||
this.x = (int) Math.floor(ox);
|
||||
this.y = (int) Math.floor(oy);
|
||||
this.z = (int) Math.floor(oz);
|
||||
this.tCurrent = 0;
|
||||
|
||||
this.stepX = dx > 0 ? 1 : (dx < 0 ? -1 : 0);
|
||||
this.stepY = dy > 0 ? 1 : (dy < 0 ? -1 : 0);
|
||||
this.stepZ = dz > 0 ? 1 : (dz < 0 ? -1 : 0);
|
||||
|
||||
this.tDeltaX = dx == 0 ? Double.POSITIVE_INFINITY : Math.abs(1.0 / dx);
|
||||
this.tDeltaY = dy == 0 ? Double.POSITIVE_INFINITY : Math.abs(1.0 / dy);
|
||||
this.tDeltaZ = dz == 0 ? Double.POSITIVE_INFINITY : Math.abs(1.0 / dz);
|
||||
|
||||
this.tMaxX = boundary(ox, dx, x, stepX);
|
||||
this.tMaxY = boundary(oy, dy, y, stepY);
|
||||
this.tMaxZ = boundary(oz, dz, z, stepZ);
|
||||
}
|
||||
|
||||
private static double boundary(double origin, double dir, int voxel, int step) {
|
||||
if (dir == 0) return Double.POSITIVE_INFINITY;
|
||||
double next = step > 0 ? (voxel + 1) : voxel;
|
||||
return (next - origin) / dir;
|
||||
}
|
||||
|
||||
/** Advance to the next voxel along the ray, updating {@link #tCurrent}. */
|
||||
public void advance() {
|
||||
if (tMaxX < tMaxY) {
|
||||
if (tMaxX < tMaxZ) {
|
||||
x += stepX;
|
||||
tCurrent = tMaxX;
|
||||
tMaxX += tDeltaX;
|
||||
} else {
|
||||
z += stepZ;
|
||||
tCurrent = tMaxZ;
|
||||
tMaxZ += tDeltaZ;
|
||||
}
|
||||
} else {
|
||||
if (tMaxY < tMaxZ) {
|
||||
y += stepY;
|
||||
tCurrent = tMaxY;
|
||||
tMaxY += tDeltaY;
|
||||
} else {
|
||||
z += stepZ;
|
||||
tCurrent = tMaxZ;
|
||||
tMaxZ += tDeltaZ;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.registry;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import eu.mhsl.minecraft.pixelpics.Main;
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.AbstractModel;
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.Model;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.*;
|
||||
import java.net.URL;
|
||||
import java.util.*;
|
||||
|
||||
import static eu.mhsl.minecraft.pixelpics.render.registry.DefaultModelRegistry.TEXTURE_SIZE;
|
||||
|
||||
public class AdvancedModelRegistry implements ModelRegistry {
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
private final Map<Material, Map<BlockData, Model>> modelMap = new HashMap<>();
|
||||
private final Set<String> tintedBlocks = Set.of("grass", "grass_block", "leaves", "oak_leaves", "water", "vine", "sugar_cane");
|
||||
|
||||
public record BlockInfo(String parent, BlockTextures textures){}
|
||||
public record BlockTextures(
|
||||
String texture,
|
||||
String bottom,
|
||||
String top,
|
||||
String all,
|
||||
String particle,
|
||||
String end,
|
||||
String side,
|
||||
String cross,
|
||||
String rail,
|
||||
String overlay
|
||||
){}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
System.out.println(modelMap);
|
||||
|
||||
File blockDir = new File(Main.getInstance().getDataFolder(), "models/block");
|
||||
for (File file : Objects.requireNonNull(blockDir.listFiles())) {
|
||||
addModelFromFile(file);
|
||||
}
|
||||
|
||||
try {
|
||||
registerModel(Material.LAVA, AbstractModel.Builder.createSimple(getTextureArray("lava_still"))
|
||||
.transparency(0.15).reflection(0.05).occlusion().build());
|
||||
registerModel(Material.WATER, AbstractModel.Builder.createSimple(getTextureArray("water_still"))
|
||||
.transparency(0.60).reflection(0.1).occlusion().build());
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
|
||||
@Override
|
||||
public Model getModel(Block block) {
|
||||
return ModelRegistry.super.getModel(block);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Model getModel(Material material, BlockData blockData) {
|
||||
return getModel(material, blockData, 0.8, 0.4);
|
||||
}
|
||||
|
||||
public Model getModel(Material material, BlockData blockData, double temperature, double humidity) {
|
||||
return modelMap.computeIfAbsent(material, key -> new HashMap<>()).getOrDefault(blockData,
|
||||
blockData == null ? getDefaultModel()
|
||||
: modelMap.get(material).getOrDefault(null, getDefaultModel()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Model getDefaultModel() {
|
||||
return AbstractModel.Builder.createStatic(Color.PURPLE.asRGB()).build();
|
||||
}
|
||||
|
||||
private void registerModel(Material material, Model blockModel) {
|
||||
modelMap.computeIfAbsent(material, key -> new HashMap<>())
|
||||
.put(null, blockModel);
|
||||
}
|
||||
|
||||
private void addModelFromFile(File file) {
|
||||
String blockName = file.getName().substring(0, file.getName().lastIndexOf('.'));
|
||||
Material material = Material.getMaterial(blockName.toUpperCase());
|
||||
if(material == null) return;
|
||||
|
||||
Model model = getModelFromFile(file);
|
||||
if(model == null) return;
|
||||
|
||||
registerModel(material, model);
|
||||
}
|
||||
|
||||
private Model getModelFromFile(File file) {
|
||||
try (Reader reader = new FileReader(file)) {
|
||||
BlockInfo blockInfo = gson.fromJson(reader, BlockInfo.class);
|
||||
|
||||
if(blockInfo.textures.all != null) {
|
||||
return AbstractModel.Builder.createSimple(
|
||||
getTextureArray(blockInfo.textures.all.substring(blockInfo.textures.all.lastIndexOf('/') + 1))
|
||||
).build();
|
||||
}
|
||||
if(blockInfo.textures.cross != null) {
|
||||
return AbstractModel.Builder.createCross(
|
||||
getTextureArray(blockInfo.textures.cross.substring(blockInfo.textures.cross.lastIndexOf('/') + 1))
|
||||
).build();
|
||||
}
|
||||
if(blockInfo.textures.side != null && blockInfo.textures.bottom != null && blockInfo.textures.top != null) {
|
||||
return AbstractModel.Builder.createMulti(
|
||||
getTextureArray(blockInfo.textures.top.substring(blockInfo.textures.top.lastIndexOf('/') + 1)),
|
||||
getTextureArray(blockInfo.textures.side.substring(blockInfo.textures.side.lastIndexOf('/') + 1)),
|
||||
getTextureArray(blockInfo.textures.bottom.substring(blockInfo.textures.bottom.lastIndexOf('/') + 1))
|
||||
).build();
|
||||
}
|
||||
if(blockInfo.textures.side != null && blockInfo.textures.end != null) {
|
||||
return AbstractModel.Builder.createMulti(
|
||||
getTextureArray(blockInfo.textures.end.substring(blockInfo.textures.end.lastIndexOf('/') + 1)),
|
||||
getTextureArray(blockInfo.textures.side.substring(blockInfo.textures.side.lastIndexOf('/') + 1)),
|
||||
getTextureArray(blockInfo.textures.end.substring(blockInfo.textures.end.lastIndexOf('/') + 1))
|
||||
).build();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private int[][] getTextureArray(String textureName) {
|
||||
int[][] texture = new int[TEXTURE_SIZE][TEXTURE_SIZE];
|
||||
BufferedImage img;
|
||||
URL url = this.getClass().getClassLoader().getResource(String.format("textures/block/%s.png", textureName));
|
||||
if (url == null) {
|
||||
throw new RuntimeException("Block Texture Resource not found.");
|
||||
}
|
||||
try (InputStream input = url.openConnection().getInputStream()) {
|
||||
img = ImageIO.read(input);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
for (int pixelY = 0; pixelY < TEXTURE_SIZE; pixelY++) {
|
||||
for (int pixelX = 0; pixelX < TEXTURE_SIZE; pixelX++) {
|
||||
texture[TEXTURE_SIZE - 1 - pixelY][TEXTURE_SIZE - 1 - pixelX] = img.getRGB(pixelX, pixelY);
|
||||
}
|
||||
}
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
private int tintPixel(int baseColor, int tintColor) {
|
||||
int a = (baseColor >> 24) & 0xFF;
|
||||
int r = ((baseColor >> 16) & 0xFF) * ((tintColor >> 16) & 0xFF) / 255;
|
||||
int g = ((baseColor >> 8) & 0xFF) * ((tintColor >> 8) & 0xFF) / 255;
|
||||
int b = (baseColor & 0xFF) * (tintColor & 0xFF) / 255;
|
||||
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.registry;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.AbstractModel.Builder;
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.Model;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class DefaultModelRegistry implements ModelRegistry {
|
||||
|
||||
private static final String IMAGE_RESOURCE = "terrain.png";
|
||||
static final int TEXTURE_SIZE = 16;
|
||||
|
||||
private final Map<Material, Map<BlockData, Model>> modelMap;
|
||||
private BufferedImage textures;
|
||||
|
||||
public DefaultModelRegistry() {
|
||||
this.modelMap = new HashMap<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
URL url = this.getClass().getClassLoader().getResource(IMAGE_RESOURCE);
|
||||
if (url == null) {
|
||||
throw new RuntimeException("Default resource \"terrain.png\" is missing");
|
||||
}
|
||||
try (InputStream input = url.openConnection().getInputStream()) {
|
||||
this.textures = ImageIO.read(input);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
registerModel(Material.GRASS_BLOCK, Builder.createMulti(textureIndex(0, 0), textureIndex(0, 3), textureIndex(0, 2)).build());
|
||||
registerModel(Material.STONE, Builder.createSimple(textureIndex(0, 1)).build());
|
||||
registerModel(Material.DIRT, Builder.createSimple(textureIndex(0, 2)).build());
|
||||
registerModel(Material.OAK_PLANKS, Builder.createSimple(textureIndex(0, 4)).build());
|
||||
registerModel(Material.SPRUCE_PLANKS,
|
||||
Builder.createSimple(textureIndex(0, 4)).build());
|
||||
registerModel(Material.BIRCH_PLANKS,
|
||||
Builder.createSimple(textureIndex(0, 4)).build());
|
||||
registerModel(Material.JUNGLE_PLANKS,
|
||||
Builder.createSimple(textureIndex(0, 4)).build());
|
||||
registerModel(Material.ACACIA_PLANKS,
|
||||
Builder.createSimple(textureIndex(0, 4)).build());
|
||||
registerModel(Material.DARK_OAK_PLANKS,
|
||||
Builder.createSimple(textureIndex(0, 4)).build());
|
||||
registerModel(Material.BRICK, Builder.createSimple(textureIndex(0, 7)).build());
|
||||
registerModel(Material.TNT, Builder.createMulti(textureIndex(0, 9),
|
||||
textureIndex(0, 8), textureIndex(0, 10)).build());
|
||||
registerModel(Material.WATER, Builder.createStatic(0xFF000000 | Color.fromRGB(0, 5, 60).asRGB())
|
||||
.transparency(0.60).reflection(0.1).occlusion().build());
|
||||
registerModel(Material.DIAMOND_BLOCK,
|
||||
Builder.createSimple(textureIndex(3, 3)).reflection(0.75).build());
|
||||
registerModel(Material.POPPY, Builder.createCross(textureIndex(0, 12)).build());
|
||||
registerModel(Material.DANDELION, Builder.createCross(textureIndex(0, 13)).build());
|
||||
registerModel(Material.OAK_SAPLING,
|
||||
Builder.createCross(textureIndex(0, 15)).build());
|
||||
registerModel(Material.SPRUCE_SAPLING,
|
||||
Builder.createCross(textureIndex(0, 15)).build());
|
||||
registerModel(Material.BIRCH_SAPLING,
|
||||
Builder.createCross(textureIndex(0, 15)).build());
|
||||
registerModel(Material.JUNGLE_SAPLING,
|
||||
Builder.createCross(textureIndex(0, 15)).build());
|
||||
registerModel(Material.ACACIA_SAPLING,
|
||||
Builder.createCross(textureIndex(0, 15)).build());
|
||||
registerModel(Material.DARK_OAK_SAPLING,
|
||||
Builder.createCross(textureIndex(0, 15)).build());
|
||||
|
||||
registerModel(Material.COBBLESTONE,
|
||||
Builder.createSimple(textureIndex(1, 0)).build());
|
||||
registerModel(Material.BEDROCK, Builder.createSimple(textureIndex(1, 1)).build());
|
||||
registerModel(Material.SAND, Builder.createSimple(textureIndex(1, 2)).build());
|
||||
registerModel(Material.GRAVEL, Builder.createSimple(textureIndex(1, 3)).build());
|
||||
registerModel(Material.OAK_LOG, Builder.createMulti(textureIndex(1, 5),
|
||||
textureIndex(1, 4), textureIndex(1, 5)).build());
|
||||
registerModel(Material.SPRUCE_LOG, Builder.createMulti(textureIndex(1, 5),
|
||||
textureIndex(1, 4), textureIndex(1, 5)).build());
|
||||
registerModel(Material.BIRCH_LOG, Builder.createMulti(textureIndex(1, 5),
|
||||
textureIndex(1, 4), textureIndex(1, 5)).build());
|
||||
registerModel(Material.JUNGLE_LOG, Builder.createMulti(textureIndex(1, 5),
|
||||
textureIndex(1, 4), textureIndex(1, 5)).build());
|
||||
registerModel(Material.ACACIA_LOG, Builder.createMulti(textureIndex(1, 5),
|
||||
textureIndex(1, 4), textureIndex(1, 5)).build());
|
||||
registerModel(Material.DARK_OAK_LOG, Builder.createMulti(textureIndex(1, 5),
|
||||
textureIndex(1, 4), textureIndex(1, 5)).build());
|
||||
registerModel(Material.OAK_WOOD, Builder.createSimple(textureIndex(1, 4)).build());
|
||||
registerModel(Material.SPRUCE_WOOD,
|
||||
Builder.createSimple(textureIndex(1, 4)).build());
|
||||
registerModel(Material.BIRCH_WOOD, Builder.createSimple(textureIndex(1, 4)).build());
|
||||
registerModel(Material.JUNGLE_WOOD,
|
||||
Builder.createSimple(textureIndex(1, 4)).build());
|
||||
registerModel(Material.ACACIA_WOOD,
|
||||
Builder.createSimple(textureIndex(1, 4)).build());
|
||||
registerModel(Material.DARK_OAK_WOOD,
|
||||
Builder.createSimple(textureIndex(1, 4)).build());
|
||||
registerModel(Material.OAK_LEAVES, Builder.createSimple(textureIndex(1, 6)).build());
|
||||
registerModel(Material.SPRUCE_LEAVES,
|
||||
Builder.createSimple(textureIndex(1, 6)).build());
|
||||
registerModel(Material.BIRCH_LEAVES,
|
||||
Builder.createSimple(textureIndex(1, 6)).build());
|
||||
registerModel(Material.JUNGLE_LEAVES,
|
||||
Builder.createSimple(textureIndex(1, 6)).build());
|
||||
registerModel(Material.ACACIA_LEAVES,
|
||||
Builder.createSimple(textureIndex(1, 6)).build());
|
||||
registerModel(Material.DARK_OAK_LEAVES,
|
||||
Builder.createSimple(textureIndex(1, 6)).build());
|
||||
registerModel(Material.IRON_BLOCK,
|
||||
Builder.createMulti(textureIndex(1, 7),
|
||||
textureIndex(2, 7), textureIndex(3, 7)).build());
|
||||
registerModel(Material.GOLD_BLOCK, Builder.createMulti(textureIndex(1, 8),
|
||||
textureIndex(2, 8), textureIndex(3, 8)).build());
|
||||
registerModel(Material.RED_MUSHROOM,
|
||||
Builder.createCross(textureIndex(1, 12)).build());
|
||||
registerModel(Material.BROWN_MUSHROOM,
|
||||
Builder.createCross(textureIndex(1, 13)).build());
|
||||
registerModel(Material.LAVA, Builder.createSimple(textureIndex(2, 14))
|
||||
.transparency(0.15).reflection(0.05).occlusion().build());
|
||||
|
||||
registerModel(Material.GOLD_ORE, Builder.createSimple(textureIndex(2, 0)).build());
|
||||
registerModel(Material.IRON_ORE, Builder.createSimple(textureIndex(2, 1)).build());
|
||||
registerModel(Material.COAL_ORE, Builder.createSimple(textureIndex(2, 2)).build());
|
||||
|
||||
registerModel(Material.GLASS,
|
||||
Builder.createSimple(textureIndex(3, 1)).occlusion().build());
|
||||
|
||||
registerModel(Material.SHORT_GRASS, Builder.createCross(textureIndex(5, 6)).build());
|
||||
registerModel(Material.SUGAR_CANE, Builder.createCross(textureIndex(5, 5)).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Model getModel(Material material, BlockData blockData) {
|
||||
return modelMap.computeIfAbsent(material, key -> new HashMap<>()).getOrDefault(blockData,
|
||||
blockData == null ? getDefaultModel()
|
||||
: modelMap.get(material).getOrDefault(null, getDefaultModel()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Model getDefaultModel() {
|
||||
return Builder.createStatic(Color.PURPLE.asRGB()).build();
|
||||
}
|
||||
|
||||
private void registerModel(Material material, Model blockModel) {
|
||||
modelMap.computeIfAbsent(material, key -> new HashMap<>())
|
||||
.put(null, blockModel);
|
||||
}
|
||||
|
||||
private int[][] textureIndex(int verticalIndex, int horizontalIndex) {
|
||||
int[][] texture = new int[TEXTURE_SIZE][TEXTURE_SIZE];
|
||||
|
||||
int offsetY = verticalIndex * TEXTURE_SIZE + (TEXTURE_SIZE - 1);
|
||||
int offsetX = horizontalIndex * TEXTURE_SIZE;
|
||||
|
||||
for (int pixelY = 0; pixelY < TEXTURE_SIZE; pixelY++) {
|
||||
for (int pixelX = 0; pixelX < TEXTURE_SIZE; pixelX++) {
|
||||
texture[pixelY][pixelX] = textures.getRGB(offsetX + pixelX, offsetY - pixelY);
|
||||
}
|
||||
}
|
||||
|
||||
return texture;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.registry;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.model.Model;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
public interface ModelRegistry {
|
||||
|
||||
void initialize();
|
||||
|
||||
default Model getModel(Block block) {
|
||||
return getModel(block.getType(), block.getBlockData());
|
||||
}
|
||||
|
||||
default Model getModel(Material material) {
|
||||
return getModel(material, null);
|
||||
}
|
||||
|
||||
Model getModel(Material material, BlockData blockData);
|
||||
|
||||
Model getDefaultModel();
|
||||
}
|
||||
+95
-28
@@ -1,52 +1,118 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.render;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.raytrace.DefaultRaytracer;
|
||||
import eu.mhsl.minecraft.pixelpics.render.raytrace.Raytracer;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityScene;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
|
||||
import eu.mhsl.minecraft.pixelpics.render.raytrace.SnapshotRaytracer;
|
||||
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
|
||||
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.EntitySnapshotBuilder;
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder;
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
/**
|
||||
* Renders the scene by capturing a world snapshot on the main thread ({@link #prepare}) and then
|
||||
* tracing one ray per pixel in parallel against that snapshot ({@link #execute}).
|
||||
*/
|
||||
public class DefaultScreenRenderer implements Renderer {
|
||||
|
||||
private static final double FOV_YAW_DEG = 53;
|
||||
private static final double FOV_PITCH_DEG = 23;
|
||||
|
||||
private static final double FOV_YAW_RAD = Math.toRadians(FOV_YAW_DEG);
|
||||
private static final double FOV_PITCH_RAD = Math.toRadians(FOV_PITCH_DEG);
|
||||
|
||||
/** Horizontal half field-of-view; the vertical half is derived from the output aspect ratio. */
|
||||
private static final double H_FOV_HALF_RAD = Math.toRadians(35);
|
||||
private static final Vector BASE_VEC = new Vector(1, 0, 0);
|
||||
|
||||
private final Raytracer raytracer;
|
||||
private static final double MAX_DISTANCE = 256;
|
||||
private static final int REFLECTION_DEPTH = 4;
|
||||
|
||||
public DefaultScreenRenderer() {
|
||||
this.raytracer = new DefaultRaytracer();
|
||||
/** Supersampling factor: SSAA x SSAA rays per output pixel, downsampled gamma-correctly. */
|
||||
private static final int SSAA = 3;
|
||||
|
||||
private final SnapshotRaytracer raytracer;
|
||||
private final CemBaker entityBaker;
|
||||
private final Logger logger;
|
||||
|
||||
public DefaultScreenRenderer(BlockModelRegistry registry, BiomeTintProvider tintProvider,
|
||||
TextureCache textures, CemBaker entityBaker, Logger logger) {
|
||||
SkyRenderer skyRenderer = new SkyRenderer(textures);
|
||||
this.raytracer = new SnapshotRaytracer(registry, tintProvider, skyRenderer, MAX_DISTANCE, REFLECTION_DEPTH);
|
||||
this.entityBaker = entityBaker;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/** Convenience: prepare and execute in one call (must run on the main thread). */
|
||||
@Override
|
||||
public BufferedImage render(Location eyeLocation, Resolution resolution) {
|
||||
int width = resolution.getWidth();
|
||||
int height = resolution.getHeight();
|
||||
return execute(prepare(eyeLocation, resolution, null));
|
||||
}
|
||||
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
int[] imageData = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
||||
/** Builds the (supersampled) ray map and captures world + entities. MUST run on the main thread. */
|
||||
public RenderJob prepare(Location eyeLocation, Resolution resolution, UUID shooter) {
|
||||
int superW = resolution.getWidth() * SSAA;
|
||||
int superH = resolution.getHeight() * SSAA;
|
||||
List<Vector> rayMap = buildRayMap(eyeLocation, superW, superH);
|
||||
WorldSnapshot snapshot = SnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, logger);
|
||||
List<EntityState> entities = EntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, shooter);
|
||||
|
||||
World world = eyeLocation.getWorld();
|
||||
Vector linePoint = eyeLocation.toVector();
|
||||
List<Vector> rayMap = buildRayMap(eyeLocation, resolution);
|
||||
for (int i = 0; i < rayMap.size(); i++) {
|
||||
imageData[i] = raytracer.trace(world, linePoint, rayMap.get(i));
|
||||
}
|
||||
long dayTime = world.getTime();
|
||||
long fullTime = world.getFullTime();
|
||||
int moonPhase = (int) (fullTime / 24000L % 8L);
|
||||
SkyContext sky = new SkyContext(dayTime, moonPhase, fullTime);
|
||||
|
||||
return new RenderJob(snapshot, rayMap, eyeLocation.toVector(),
|
||||
resolution.getWidth(), resolution.getHeight(), sky, entities);
|
||||
}
|
||||
|
||||
/** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */
|
||||
public BufferedImage execute(RenderJob job) {
|
||||
int finalW = job.width();
|
||||
int finalH = job.height();
|
||||
int superW = finalW * SSAA;
|
||||
List<Vector> rayMap = job.rayMap();
|
||||
WorldSnapshot snapshot = job.snapshot();
|
||||
Vector origin = job.origin();
|
||||
SkyContext sky = job.sky();
|
||||
EntityScene scene = new EntityScene(job.entities(), entityBaker);
|
||||
|
||||
int[] superBuf = new int[rayMap.size()];
|
||||
IntStream.range(0, rayMap.size()).parallel().forEach(i ->
|
||||
superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene));
|
||||
|
||||
BufferedImage image = new BufferedImage(finalW, finalH, BufferedImage.TYPE_INT_RGB);
|
||||
int[] imageData = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
|
||||
IntStream.range(0, finalH).parallel().forEach(fy -> {
|
||||
int[] block = new int[SSAA * SSAA];
|
||||
for (int fx = 0; fx < finalW; fx++) {
|
||||
int n = 0;
|
||||
for (int sy = 0; sy < SSAA; sy++) {
|
||||
int srcRow = (fy * SSAA + sy) * superW + fx * SSAA;
|
||||
for (int sx = 0; sx < SSAA; sx++) {
|
||||
block[n++] = superBuf[srcRow + sx];
|
||||
}
|
||||
}
|
||||
imageData[fy * finalW + fx] = ColorUtil.averageLinear(block, 0, n);
|
||||
}
|
||||
});
|
||||
return image;
|
||||
}
|
||||
|
||||
private List<Vector> buildRayMap(Location eyeLocation, Resolution resolution) {
|
||||
private List<Vector> buildRayMap(Location eyeLocation, int width, int height) {
|
||||
Vector lineDirection = eyeLocation.getDirection();
|
||||
|
||||
double x = lineDirection.getX();
|
||||
@@ -56,20 +122,21 @@ public class DefaultScreenRenderer implements Renderer {
|
||||
double angleYaw = Math.atan2(z, x);
|
||||
double anglePitch = Math.atan2(y, Math.sqrt(x * x + z * z));
|
||||
|
||||
Vector lowerLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -FOV_YAW_RAD, -FOV_PITCH_RAD, angleYaw, anglePitch);
|
||||
Vector upperLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -FOV_YAW_RAD, FOV_PITCH_RAD, angleYaw, anglePitch);
|
||||
Vector lowerRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, FOV_YAW_RAD, -FOV_PITCH_RAD, angleYaw, anglePitch);
|
||||
Vector upperRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, FOV_YAW_RAD, FOV_PITCH_RAD, angleYaw, anglePitch);
|
||||
// Derive the vertical half-FOV from the horizontal one so square output is not distorted.
|
||||
double yawHalf = H_FOV_HALF_RAD;
|
||||
double pitchHalf = Math.atan(Math.tan(yawHalf) * ((double) height / width));
|
||||
|
||||
Vector lowerLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -yawHalf, -pitchHalf, angleYaw, anglePitch);
|
||||
Vector upperLeftCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, -yawHalf, pitchHalf, angleYaw, anglePitch);
|
||||
Vector lowerRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, yawHalf, -pitchHalf, angleYaw, anglePitch);
|
||||
Vector upperRightCorner = MathUtil.doubleYawPitchRotation(BASE_VEC, yawHalf, pitchHalf, angleYaw, anglePitch);
|
||||
|
||||
int width = resolution.getWidth();
|
||||
int height = resolution.getHeight();
|
||||
List<Vector> rayMap = new ArrayList<>(width * height);
|
||||
|
||||
Vector leftFraction = upperLeftCorner.clone().subtract(lowerLeftCorner).multiply(1.0 / (height - 1));
|
||||
Vector rightFraction = upperRightCorner.clone().subtract(lowerRightCorner).multiply(1.0 / (height - 1));
|
||||
|
||||
for (int pitch = 0; pitch < height; pitch++) {
|
||||
|
||||
Vector leftPitch = upperLeftCorner.clone().subtract(leftFraction.clone().multiply(pitch));
|
||||
Vector rightPitch = upperRightCorner.clone().subtract(rightFraction.clone().multiply(pitch));
|
||||
Vector yawFraction = rightPitch.clone().subtract(leftPitch).multiply(1.0 / (width - 1));
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.render;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
|
||||
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A prepared render: the world snapshot (captured on the main thread) plus the ray map, camera
|
||||
* origin, the sky context (time of day / moon phase) and the captured entity states.
|
||||
* {@link DefaultScreenRenderer#execute} can run this off the main thread.
|
||||
*/
|
||||
public record RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
|
||||
int width, int height, SkyContext sky, List<EntityState> entities) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.sky;
|
||||
|
||||
/**
|
||||
* Per-render sky state captured on the main thread: the world time of day (0..24000), the moon phase
|
||||
* (0..7) and the absolute world time (for continuous cloud drift). Immutable so it can be read from
|
||||
* worker threads.
|
||||
*/
|
||||
public record SkyContext(long dayTime, int moonPhase, long fullTime) {
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.sky;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/**
|
||||
* Computes a time-of-day dependent sky color for rays that escape the world: a day/night gradient
|
||||
* with twilight glow, the sun and moon (with phase), stars at night and a procedural cloud layer.
|
||||
* All inputs are immutable ({@link SkyContext} + captured textures), so it is thread safe.
|
||||
*/
|
||||
public final class SkyRenderer {
|
||||
|
||||
private static final double TICKS_PER_DAY = 24000.0;
|
||||
private static final double CLOUD_HEIGHT = 192.0;
|
||||
private static final double CLOUD_CELL = 12.0; // world blocks per cloud texel
|
||||
private static final double CLOUD_SPEED = 0.03; // blocks per tick, drift along +X
|
||||
|
||||
private static final double SUN_HALF = 0.085; // angular half-size (radians)
|
||||
private static final double MOON_HALF = 0.075;
|
||||
|
||||
// Gradient endpoints (RGB).
|
||||
private static final int DAY_ZENITH = rgb(86, 138, 252);
|
||||
private static final int DAY_HORIZON = rgb(170, 205, 255);
|
||||
private static final int NIGHT_ZENITH = rgb(2, 3, 12);
|
||||
private static final int NIGHT_HORIZON = rgb(10, 14, 40);
|
||||
private static final int SUNSET_ORANGE = rgb(255, 150, 70);
|
||||
private static final int SUNSET_RED = rgb(205, 70, 60);
|
||||
private static final int TWI_PURPLE = rgb(80, 42, 92);
|
||||
|
||||
private final int[][] sunTexture;
|
||||
private final int[][] moonTexture;
|
||||
private final int[][] cloudTexture;
|
||||
|
||||
public SkyRenderer(TextureCache textures) {
|
||||
this.sunTexture = textures.get(ResourceLocation.parse("environment/sun")).orElse(null);
|
||||
this.moonTexture = textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null);
|
||||
this.cloudTexture = textures.get(ResourceLocation.parse("environment/clouds")).orElse(null);
|
||||
}
|
||||
|
||||
public int colorFor(Vector direction, Vector origin, SkyContext ctx) {
|
||||
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
|
||||
double len = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
if (len < 1e-9) return DAY_ZENITH;
|
||||
dx /= len; dy /= len; dz /= len;
|
||||
|
||||
// Sun/moon position, derived exactly from Minecraft's sky transforms:
|
||||
// celestialAngle ca = getTimeOfDay(dayTime); the sun is rotated by ca*360deg about the X axis
|
||||
// (after a -90deg Y rotation), giving sunDir = (-sin(2*pi*ca), cos(2*pi*ca), 0) in world space.
|
||||
double ca = celestialAngle(ctx.dayTime());
|
||||
double ang = ca * 2 * Math.PI;
|
||||
double sunX = -Math.sin(ang), sunY = Math.cos(ang);
|
||||
double dayFactor = smoothstep(-0.20, 0.25, sunY);
|
||||
|
||||
// Base vertical gradient, blended day<->night.
|
||||
double up = clamp01(dy);
|
||||
int dayColor = lerp(DAY_HORIZON, DAY_ZENITH, up);
|
||||
int nightColor = lerp(NIGHT_HORIZON, NIGHT_ZENITH, up);
|
||||
int color = lerp(nightColor, dayColor, dayFactor);
|
||||
|
||||
// Sunrise/sunset: a full-sky warm wash (orange at the horizon -> red -> purple at the zenith),
|
||||
// strongest while the sun is near the horizon and warmer toward its azimuth. Matches vanilla.
|
||||
double twilight = clamp01(1 - Math.abs(sunY) / 0.45);
|
||||
if (twilight > 0) {
|
||||
double az = clamp01(dx * Math.signum(sunX) * 0.5 + 0.5); // 1 toward sun .. 0 away
|
||||
int grad = up < 0.40
|
||||
? lerp(SUNSET_ORANGE, SUNSET_RED, up / 0.40)
|
||||
: lerp(SUNSET_RED, TWI_PURPLE, (up - 0.40) / 0.60);
|
||||
int twiColor = lerp(lerp(TWI_PURPLE, grad, 0.55), grad, az); // cooler away from the sun
|
||||
color = lerp(color, twiColor, twilight * 0.85);
|
||||
}
|
||||
|
||||
// Stars: at night, faded out by daylight and twilight.
|
||||
if (dy > 0) {
|
||||
double visibility = (1 - dayFactor) * (1 - twilight);
|
||||
if (visibility > 0.05) {
|
||||
double star = starField(dx, dy, dz);
|
||||
if (star > 0) {
|
||||
int s = (int) (star * 255 * visibility);
|
||||
color = add(color, s, s, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warm bloom halo around the sun near the horizon.
|
||||
if (sunY > -0.20) {
|
||||
double cosSun = dx * sunX + dy * sunY;
|
||||
if (cosSun > 0) {
|
||||
double bloom = Math.pow(clamp01(cosSun), 16) * clamp01(sunY + 0.3);
|
||||
color = lerp(color, rgb(255, 235, 190), bloom * 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
// Sun disc (soft glowing disc, texture used only as a shape mask).
|
||||
if (sunY > -0.15) {
|
||||
color = overlayDisc(color, dx, dy, dz, sunX, sunY, 0, SUN_HALF, sunTexture, rgb(255, 244, 214), -1);
|
||||
}
|
||||
// Moon disc (phase shape from the texture's alpha).
|
||||
if (-sunY > -0.15) {
|
||||
color = overlayDisc(color, dx, dy, dz, -sunX, -sunY, 0, MOON_HALF, moonTexture, rgb(228, 228, 238), ctx.moonPhase());
|
||||
}
|
||||
|
||||
// Cloud layer: the ray crosses the cloud plane at y = CLOUD_HEIGHT; the world hit point is
|
||||
// mapped to a clouds.png texel exactly as vanilla does (see clouds()). Horizontal drift uses
|
||||
// the world time (fullTime * CLOUD_SPEED along +X).
|
||||
if (dy > 0.02 && origin.getY() < CLOUD_HEIGHT) {
|
||||
double t = (CLOUD_HEIGHT - origin.getY()) / dy;
|
||||
double cx = origin.getX() + dx * t + ctx.fullTime() * CLOUD_SPEED;
|
||||
double cz = origin.getZ() + dz * t;
|
||||
double coverage = clouds(cx, cz);
|
||||
if (coverage > 0) {
|
||||
int cloudColor = lerp(rgb(45, 48, 60), rgb(236, 240, 248), dayFactor);
|
||||
if (twilight > 0) cloudColor = lerp(cloudColor, rgb(150, 95, 85), twilight * 0.45);
|
||||
double fade = clamp01((dy - 0.02) * 4); // fade out near the horizon (single-plane sampling)
|
||||
color = lerp(color, cloudColor, coverage * fade);
|
||||
}
|
||||
}
|
||||
|
||||
return color & 0xFFFFFF;
|
||||
}
|
||||
|
||||
/** Draws a sun/moon disc, sampling a texture when available (moonPhase ≥ 0 picks the phase tile). */
|
||||
private int overlayDisc(int base, double dx, double dy, double dz,
|
||||
double cx, double cy, double cz, double half, int[][] texture, int solid, int moonPhase) {
|
||||
double cos = dx * cx + dy * cy + dz * cz;
|
||||
if (cos <= 0) return base;
|
||||
double sinHalf = Math.sin(half);
|
||||
// Local disc coordinates: project the direction onto the plane around the body axis.
|
||||
// right = normalize(body x worldUp); discUp = right x body
|
||||
double crx = cz, cry = 0, crz = -cx;
|
||||
double crl = Math.sqrt(crx * crx + crz * crz);
|
||||
if (crl < 1e-6) { crx = 1; cry = 0; crz = 0; crl = 1; }
|
||||
crx /= crl; cry /= crl; crz /= crl;
|
||||
// discUp = right cross body
|
||||
double ux = cry * cz - crz * cy;
|
||||
double uy = crz * cx - crx * cz;
|
||||
double uz = crx * cy - cry * cx;
|
||||
|
||||
double u = dx * crx + dy * cry + dz * crz;
|
||||
double v = dx * ux + dy * uy + dz * uz;
|
||||
// The sun and moon are flat SQUARE billboards in Minecraft, not round discs.
|
||||
double m = Math.max(Math.abs(u), Math.abs(v)) / sinHalf; // 0 center .. 1 square edge
|
||||
if (m > 1) return base;
|
||||
|
||||
double su = u / sinHalf * 0.5 + 0.5;
|
||||
double sv = v / sinHalf * 0.5 + 0.5;
|
||||
|
||||
// The texture is used only as a shape/phase mask; the body color is always `solid` so the
|
||||
// texture's black transparent texels never bleed in as a dark rim.
|
||||
double alpha;
|
||||
if (moonPhase >= 0 && texture != null && texture.length > 0) {
|
||||
alpha = bodyAlpha(texture, su, sv, moonPhase) > 80 ? 1.0 : 0.0; // phase shape
|
||||
} else {
|
||||
alpha = 1 - smoothstep(0.92, 1.0, m); // solid square, faint edge softening
|
||||
}
|
||||
if (alpha <= 0.02) return base;
|
||||
return lerp(base, solid, alpha);
|
||||
}
|
||||
|
||||
/** Alpha of the body texture at the disc coordinate; moonPhase≥0 selects a tile in the 4x2 grid. */
|
||||
private int bodyAlpha(int[][] texture, double su, double sv, int moonPhase) {
|
||||
int h = texture.length;
|
||||
int w = texture[0].length;
|
||||
double u = su, v = 1 - sv; // texture v is top-down
|
||||
int col = moonPhase % 4;
|
||||
int row = (moonPhase / 4) % 2;
|
||||
u = (col + u) / 4.0;
|
||||
v = (row + v) / 2.0;
|
||||
int px = clamp((int) (u * w), 0, w - 1);
|
||||
int py = clamp((int) (v * h), 0, h - 1);
|
||||
return ColorUtil.alpha(texture[py][px]);
|
||||
}
|
||||
|
||||
/** Sparse pseudo-random star field keyed on the quantized direction. */
|
||||
private double starField(double dx, double dy, double dz) {
|
||||
int gx = (int) Math.floor(dx * 320);
|
||||
int gy = (int) Math.floor(dy * 320);
|
||||
int gz = (int) Math.floor(dz * 320);
|
||||
int h = hash(gx, gy, gz);
|
||||
if ((h & 0x1FF) != 0) return 0; // ~1/512 cells contain a star
|
||||
return 0.5 + ((h >>> 9) & 0xFF) / 510.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact vanilla cloud coverage. Minecraft tiles {@code clouds.png} (256x256) over the world with
|
||||
* each texel covering a {@link #CLOUD_CELL} (=12) block square, so the pattern repeats every
|
||||
* 256*12 = 3072 blocks. A world position maps to texel
|
||||
* {@code (col = floorMod(floor(x/12), 256), row = floorMod(floor(z/12), 256))} with the texture's
|
||||
* U axis along world X and V axis along world Z; a texel is a cloud where its alpha > 0. This
|
||||
* reproduces the blocky cloud shapes and their world alignment exactly. Falls back to value noise
|
||||
* only when the texture is missing from the pack.
|
||||
*/
|
||||
private double clouds(double x, double z) {
|
||||
if (cloudTexture != null && cloudTexture.length > 0) {
|
||||
int w = cloudTexture[0].length;
|
||||
int h = cloudTexture.length;
|
||||
int tx = Math.floorMod((int) Math.floor(x / CLOUD_CELL), w);
|
||||
int tz = Math.floorMod((int) Math.floor(z / CLOUD_CELL), h);
|
||||
int alpha = ColorUtil.alpha(cloudTexture[tz][tx]);
|
||||
return alpha > 16 ? 0.85 : 0.0;
|
||||
}
|
||||
double scale = 0.012;
|
||||
double n = valueNoise(x * scale, z * scale) * 0.6
|
||||
+ valueNoise(x * scale * 2.3, z * scale * 2.3) * 0.4;
|
||||
return smoothstep(0.52, 0.72, n) * 0.8;
|
||||
}
|
||||
|
||||
private double valueNoise(double x, double z) {
|
||||
int x0 = (int) Math.floor(x), z0 = (int) Math.floor(z);
|
||||
double fx = x - x0, fz = z - z0;
|
||||
double sx = fx * fx * (3 - 2 * fx);
|
||||
double sz = fz * fz * (3 - 2 * fz);
|
||||
double n00 = rand(x0, z0), n10 = rand(x0 + 1, z0);
|
||||
double n01 = rand(x0, z0 + 1), n11 = rand(x0 + 1, z0 + 1);
|
||||
double nx0 = n00 + (n10 - n00) * sx;
|
||||
double nx1 = n01 + (n11 - n01) * sx;
|
||||
return nx0 + (nx1 - nx0) * sz;
|
||||
}
|
||||
|
||||
private double rand(int x, int z) {
|
||||
return (hash(x, z, 0) & 0xFFFF) / 65535.0;
|
||||
}
|
||||
|
||||
private int hash(int x, int y, int z) {
|
||||
int h = x * 374761393 + y * 668265263 + z * 2147483647;
|
||||
h = (h ^ (h >>> 13)) * 1274126177;
|
||||
return h ^ (h >>> 16);
|
||||
}
|
||||
|
||||
// --- small color/math helpers ---
|
||||
|
||||
private static int rgb(int r, int g, int b) { return (r << 16) | (g << 8) | b; }
|
||||
|
||||
private static int lerp(int a, int b, double t) {
|
||||
t = clamp01(t);
|
||||
int ar = (a >> 16) & 0xFF, ag = (a >> 8) & 0xFF, ab = a & 0xFF;
|
||||
int br = (b >> 16) & 0xFF, bg = (b >> 8) & 0xFF, bb = b & 0xFF;
|
||||
int r = (int) (ar + (br - ar) * t);
|
||||
int g = (int) (ag + (bg - ag) * t);
|
||||
int bl = (int) (ab + (bb - ab) * t);
|
||||
return rgb(r, g, bl);
|
||||
}
|
||||
|
||||
private static int add(int c, int r, int g, int b) {
|
||||
int cr = Math.min(255, ((c >> 16) & 0xFF) + r);
|
||||
int cg = Math.min(255, ((c >> 8) & 0xFF) + g);
|
||||
int cb = Math.min(255, (c & 0xFF) + b);
|
||||
return rgb(cr, cg, cb);
|
||||
}
|
||||
|
||||
/** Minecraft's {@code Level.getTimeOfDay}: the celestial angle as a fraction [0,1). */
|
||||
private static double celestialAngle(long dayTime) {
|
||||
double d = frac(dayTime / TICKS_PER_DAY - 0.25);
|
||||
double e = 0.5 - Math.cos(d * Math.PI) / 2.0;
|
||||
return (d * 2.0 + e) / 3.0;
|
||||
}
|
||||
|
||||
private static double frac(double v) {
|
||||
return v - Math.floor(v);
|
||||
}
|
||||
|
||||
private static double smoothstep(double edge0, double edge1, double x) {
|
||||
double t = clamp01((x - edge0) / (edge1 - edge0));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
private static double clamp01(double v) { return v < 0 ? 0 : Math.min(v, 1); }
|
||||
|
||||
private static int clamp(int v, int lo, int hi) { return v < lo ? lo : Math.min(v, hi); }
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Ageable;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Captures entities near the view frustum into immutable {@link EntityState}s. MUST run on the main
|
||||
* thread (live entity access). The camera entity is skipped.
|
||||
*/
|
||||
public final class EntitySnapshotBuilder {
|
||||
|
||||
private EntitySnapshotBuilder() {}
|
||||
|
||||
// Technical / non-mob entity types that have no meaningful geometry; rendering them would only
|
||||
// produce stray fallback boxes. Markers, displays, item frames, paintings, projectiles, drops, etc.
|
||||
private static final java.util.Set<String> NON_RENDERABLE = java.util.Set.of(
|
||||
"area_effect_cloud", "marker", "interaction",
|
||||
"item_frame", "glow_item_frame", "painting",
|
||||
"block_display", "item_display", "text_display",
|
||||
"fishing_bobber", "lightning_bolt", "eye_of_ender",
|
||||
"experience_orb", "experience_bottle", "egg", "snowball",
|
||||
"potion", "ender_pearl", "tnt", "falling_block", "item"
|
||||
);
|
||||
|
||||
public static List<EntityState> build(Location eye, List<Vector> rayMap, double maxDistance, UUID shooter) {
|
||||
Vector o = eye.toVector();
|
||||
double minX = o.getX(), minY = o.getY(), minZ = o.getZ();
|
||||
double maxX = o.getX(), maxY = o.getY(), maxZ = o.getZ();
|
||||
for (Vector ray : rayMap) {
|
||||
minX = Math.min(minX, o.getX() + ray.getX() * maxDistance);
|
||||
maxX = Math.max(maxX, o.getX() + ray.getX() * maxDistance);
|
||||
minY = Math.min(minY, o.getY() + ray.getY() * maxDistance);
|
||||
maxY = Math.max(maxY, o.getY() + ray.getY() * maxDistance);
|
||||
minZ = Math.min(minZ, o.getZ() + ray.getZ() * maxDistance);
|
||||
maxZ = Math.max(maxZ, o.getZ() + ray.getZ() * maxDistance);
|
||||
}
|
||||
Location center = new Location(eye.getWorld(), (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
|
||||
double hx = (maxX - minX) / 2 + 2, hy = (maxY - minY) / 2 + 2, hz = (maxZ - minZ) / 2 + 2;
|
||||
|
||||
Collection<Entity> nearby = eye.getWorld().getNearbyEntities(center, hx, hy, hz);
|
||||
List<EntityState> states = new ArrayList<>();
|
||||
for (Entity e : nearby) {
|
||||
if (shooter != null && e.getUniqueId().equals(shooter)) continue;
|
||||
EntityState s = toState(e);
|
||||
if (s != null) states.add(s);
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
private static EntityState toState(Entity e) {
|
||||
Location loc = e.getLocation();
|
||||
// Skip non-renderable technical entities.
|
||||
String type = e.getType().getKey().getKey();
|
||||
// Boats now have a bundled geometry.boat; rafts use a different hull we don't ship yet — skip those.
|
||||
if (NON_RENDERABLE.contains(type) || type.endsWith("_raft")) return null;
|
||||
|
||||
float bodyYaw = loc.getYaw();
|
||||
float headYaw = loc.getYaw();
|
||||
float pitch = loc.getPitch();
|
||||
if (e instanceof LivingEntity le) {
|
||||
bodyYaw = le.getBodyYaw();
|
||||
Location eyeLoc = le.getEyeLocation();
|
||||
headYaw = eyeLoc.getYaw();
|
||||
pitch = eyeLoc.getPitch();
|
||||
}
|
||||
|
||||
boolean baby = (e instanceof Ageable a && !a.isAdult())
|
||||
|| (e instanceof org.bukkit.entity.Zombie z && z.isBaby());
|
||||
|
||||
Vector v = e.getVelocity();
|
||||
double width = safeWidth(e);
|
||||
double height = safeHeight(e);
|
||||
|
||||
boolean player = e instanceof Player;
|
||||
String skinUrl = null;
|
||||
boolean slim = false;
|
||||
if (player) {
|
||||
String[] skin = resolveSkin((Player) e);
|
||||
skinUrl = skin[0];
|
||||
slim = "slim".equals(skin[1]);
|
||||
}
|
||||
|
||||
String variant = null;
|
||||
int tint = 0;
|
||||
double sizeScale = 1.0;
|
||||
try {
|
||||
// Slime & magma cube (MagmaCube extends Slime) scale their model by size (1/2/4).
|
||||
if (e instanceof org.bukkit.entity.Slime sl) sizeScale = sl.getSize();
|
||||
// MushroomCow extends Cow, ZombieVillager does not extend Villager — order matters.
|
||||
if (e instanceof org.bukkit.entity.Sheep sh) {
|
||||
tint = dyeArgb(sh.getColor());
|
||||
} else if (e instanceof org.bukkit.entity.Cat c) {
|
||||
variant = keyOf(c.getCatType());
|
||||
} else if (e instanceof org.bukkit.entity.Wolf w) {
|
||||
variant = keyOf(w.getVariant());
|
||||
} else if (e instanceof org.bukkit.entity.Axolotl a) {
|
||||
variant = keyOf(a.getVariant());
|
||||
} else if (e instanceof org.bukkit.entity.Parrot p) {
|
||||
variant = keyOf(p.getVariant());
|
||||
} else if (e instanceof org.bukkit.entity.Rabbit r) {
|
||||
variant = keyOf(r.getRabbitType());
|
||||
} else if (e instanceof org.bukkit.entity.Horse h) {
|
||||
variant = keyOf(h.getColor());
|
||||
} else if (e instanceof org.bukkit.entity.Llama l) {
|
||||
variant = keyOf(l.getColor());
|
||||
} else if (e instanceof org.bukkit.entity.Fox f) {
|
||||
variant = keyOf(f.getFoxType());
|
||||
} else if (e instanceof org.bukkit.entity.MushroomCow mc) {
|
||||
variant = keyOf(mc.getVariant());
|
||||
} else if (e instanceof org.bukkit.entity.Panda pa) {
|
||||
variant = keyOf(pa.getMainGene());
|
||||
} else if (e instanceof org.bukkit.entity.Frog fr) {
|
||||
variant = keyOf(fr.getVariant());
|
||||
} else if (e instanceof org.bukkit.entity.Shulker s) {
|
||||
variant = s.getColor() == null ? null : keyOf(s.getColor());
|
||||
} else if (e instanceof org.bukkit.entity.ZombieVillager zv) {
|
||||
variant = keyOf(zv.getVillagerType());
|
||||
} else if (e instanceof org.bukkit.entity.Villager vi) {
|
||||
variant = keyOf(vi.getVillagerType());
|
||||
} else if (e instanceof org.bukkit.entity.Cow co) {
|
||||
variant = keyOf(co.getVariant());
|
||||
} else if (e instanceof org.bukkit.entity.Pig pg) {
|
||||
variant = keyOf(pg.getVariant());
|
||||
} else if (e instanceof org.bukkit.entity.Chicken ch) {
|
||||
variant = keyOf(ch.getVariant());
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
// Unsupported on this server version — fall back to the base texture.
|
||||
}
|
||||
|
||||
return new EntityState(type, loc.getX(), loc.getY(), loc.getZ(),
|
||||
bodyYaw, headYaw, pitch, v.getX(), v.getY(), v.getZ(), baby, width, height,
|
||||
player, skinUrl, slim, variant, tint, sizeScale);
|
||||
}
|
||||
|
||||
/** Registry/Keyed values yield their key path; plain enums yield their lower-case name. */
|
||||
private static String keyOf(Object o) {
|
||||
if (o == null) return null;
|
||||
if (o instanceof org.bukkit.Keyed k) return k.getKey().getKey();
|
||||
if (o instanceof Enum<?> en) return en.name().toLowerCase(java.util.Locale.ROOT);
|
||||
return o.toString().toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
/** ARGB wool-tint multiplier for a dye colour (opaque); never returns 0 so it stays "set". */
|
||||
private static int dyeArgb(org.bukkit.DyeColor dye) {
|
||||
if (dye == null) return 0;
|
||||
org.bukkit.Color c = dye.getColor();
|
||||
return 0xFF000000 | (c.getRed() << 16) | (c.getGreen() << 8) | c.getBlue();
|
||||
}
|
||||
|
||||
/** Returns {skinUrl, model} from the player's profile texture property, or {null, null}. */
|
||||
private static String[] resolveSkin(Player player) {
|
||||
try {
|
||||
for (com.destroystokyo.paper.profile.ProfileProperty prop : player.getPlayerProfile().getProperties()) {
|
||||
if (!prop.getName().equals("textures")) continue;
|
||||
String json = new String(java.util.Base64.getDecoder().decode(prop.getValue()),
|
||||
java.nio.charset.StandardCharsets.UTF_8);
|
||||
com.google.gson.JsonObject root = com.google.gson.JsonParser.parseString(json).getAsJsonObject();
|
||||
com.google.gson.JsonObject skin = root.getAsJsonObject("textures").getAsJsonObject("SKIN");
|
||||
String url = skin.get("url").getAsString();
|
||||
String model = null;
|
||||
if (skin.has("metadata") && skin.getAsJsonObject("metadata").has("model")) {
|
||||
model = skin.getAsJsonObject("metadata").get("model").getAsString();
|
||||
}
|
||||
return new String[]{url, model};
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return new String[]{null, null};
|
||||
}
|
||||
|
||||
private static double safeWidth(Entity e) {
|
||||
try {
|
||||
return e.getWidth();
|
||||
} catch (Throwable t) {
|
||||
return e.getBoundingBox().getWidthX();
|
||||
}
|
||||
}
|
||||
|
||||
private static double safeHeight(Entity e) {
|
||||
try {
|
||||
return e.getHeight();
|
||||
} catch (Throwable t) {
|
||||
return e.getBoundingBox().getHeight();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||
|
||||
import org.bukkit.ChunkSnapshot;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Captures the world region covered by the camera frustum into a {@link WorldSnapshot}.
|
||||
*
|
||||
* <p>MUST be called on the main server thread: it reads live chunks. Only already-loaded chunks are
|
||||
* captured (no forced generation), so the call is cheap and rays into unloaded areas hit sky.
|
||||
*/
|
||||
public final class SnapshotBuilder {
|
||||
|
||||
/** Safety cap on captured chunks to avoid pathological memory/latency. */
|
||||
private static final int MAX_CHUNKS = 4096;
|
||||
|
||||
private SnapshotBuilder() {}
|
||||
|
||||
public static WorldSnapshot build(Location eye, List<Vector> rayMap, double maxDistance, Logger logger) {
|
||||
World world = eye.getWorld();
|
||||
Vector origin = eye.toVector();
|
||||
|
||||
double minX = origin.getX(), minY = origin.getY(), minZ = origin.getZ();
|
||||
double maxX = origin.getX(), maxY = origin.getY(), maxZ = origin.getZ();
|
||||
|
||||
for (Vector ray : rayMap) {
|
||||
double fx = origin.getX() + ray.getX() * maxDistance;
|
||||
double fy = origin.getY() + ray.getY() * maxDistance;
|
||||
double fz = origin.getZ() + ray.getZ() * maxDistance;
|
||||
minX = Math.min(minX, fx); maxX = Math.max(maxX, fx);
|
||||
minY = Math.min(minY, fy); maxY = Math.max(maxY, fy);
|
||||
minZ = Math.min(minZ, fz); maxZ = Math.max(maxZ, fz);
|
||||
}
|
||||
|
||||
int worldMinY = world.getMinHeight();
|
||||
int worldMaxY = world.getMaxHeight();
|
||||
int clampedMinY = Math.max(worldMinY, (int) Math.floor(minY) - 1);
|
||||
int clampedMaxY = Math.min(worldMaxY, (int) Math.ceil(maxY) + 1);
|
||||
|
||||
int minCX = (int) Math.floor(minX) >> 4;
|
||||
int maxCX = (int) Math.floor(maxX) >> 4;
|
||||
int minCZ = (int) Math.floor(minZ) >> 4;
|
||||
int maxCZ = (int) Math.floor(maxZ) >> 4;
|
||||
|
||||
Map<Long, ChunkSnapshot> chunks = new HashMap<>();
|
||||
int captured = 0;
|
||||
int skipped = 0;
|
||||
for (int cx = minCX; cx <= maxCX; cx++) {
|
||||
for (int cz = minCZ; cz <= maxCZ; cz++) {
|
||||
if (captured >= MAX_CHUNKS) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!world.isChunkLoaded(cx, cz)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
ChunkSnapshot cs = world.getChunkAt(cx, cz).getChunkSnapshot(false, true, false);
|
||||
chunks.put(WorldSnapshot.chunkKey(cx, cz), cs);
|
||||
captured++;
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped > 0) {
|
||||
logger.fine(String.format("Snapshot captured %d chunks, skipped %d (unloaded or over cap)", captured, skipped));
|
||||
}
|
||||
|
||||
return new WorldSnapshot(chunks, clampedMinY, clampedMaxY, Material.AIR.createBlockData());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.snapshot;
|
||||
|
||||
import org.bukkit.ChunkSnapshot;
|
||||
import org.bukkit.block.Biome;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* An immutable, thread-safe view of a bounded region of the world, backed by {@link ChunkSnapshot}s.
|
||||
* Block/biome lookups outside the captured region return air/null so rays simply terminate there.
|
||||
*/
|
||||
public final class WorldSnapshot {
|
||||
|
||||
private final Map<Long, ChunkSnapshot> chunks;
|
||||
private final int minY;
|
||||
private final int maxY; // exclusive
|
||||
private final BlockData air;
|
||||
|
||||
public WorldSnapshot(Map<Long, ChunkSnapshot> chunks, int minY, int maxY, BlockData air) {
|
||||
this.chunks = chunks;
|
||||
this.minY = minY;
|
||||
this.maxY = maxY;
|
||||
this.air = air;
|
||||
}
|
||||
|
||||
public static long chunkKey(int chunkX, int chunkZ) {
|
||||
return ((long) chunkX << 32) ^ (chunkZ & 0xFFFFFFFFL);
|
||||
}
|
||||
|
||||
public BlockData getBlockData(int x, int y, int z) {
|
||||
if (y < minY || y >= maxY) return air;
|
||||
ChunkSnapshot cs = chunks.get(chunkKey(x >> 4, z >> 4));
|
||||
if (cs == null) return air;
|
||||
return cs.getBlockData(x & 15, y, z & 15);
|
||||
}
|
||||
|
||||
public Biome getBiome(int x, int y, int z) {
|
||||
if (y < minY || y >= maxY) return null;
|
||||
ChunkSnapshot cs = chunks.get(chunkKey(x >> 4, z >> 4));
|
||||
if (cs == null) return null;
|
||||
return cs.getBiome(x & 15, y, z & 15);
|
||||
}
|
||||
|
||||
public int minY() { return minY; }
|
||||
public int maxY() { return maxY; }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.tint;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Hardcoded vanilla temperature/downfall and water color per biome. Used to drive the colormap
|
||||
* lookup, since Paper does not expose the client-side per-block climate reliably. Unknown biomes
|
||||
* fall back to a plains-like default.
|
||||
*/
|
||||
public final class BiomeClimate {
|
||||
|
||||
public record Climate(double temperature, double downfall, int water) {}
|
||||
|
||||
public static final int DEFAULT_WATER = 0x3F76E4;
|
||||
public static final Climate DEFAULT = new Climate(0.8, 0.4, DEFAULT_WATER);
|
||||
|
||||
private static final Map<String, Climate> TABLE = new HashMap<>();
|
||||
|
||||
private static void put(String key, double t, double d) { TABLE.put(key, new Climate(t, d, DEFAULT_WATER)); }
|
||||
private static void put(String key, double t, double d, int water) { TABLE.put(key, new Climate(t, d, water)); }
|
||||
|
||||
static {
|
||||
put("plains", 0.8, 0.4);
|
||||
put("sunflower_plains", 0.8, 0.4);
|
||||
put("snowy_plains", 0.0, 0.5);
|
||||
put("ice_spikes", 0.0, 0.5);
|
||||
put("desert", 2.0, 0.0);
|
||||
put("swamp", 0.8, 0.9, 0x617B64);
|
||||
put("mangrove_swamp", 0.8, 0.9, 0x3A7A6A);
|
||||
put("forest", 0.7, 0.8);
|
||||
put("flower_forest", 0.7, 0.8);
|
||||
put("birch_forest", 0.6, 0.6);
|
||||
put("old_growth_birch_forest", 0.6, 0.6);
|
||||
put("dark_forest", 0.7, 0.8);
|
||||
put("old_growth_pine_taiga", 0.3, 0.8);
|
||||
put("old_growth_spruce_taiga", 0.25, 0.8);
|
||||
put("taiga", 0.25, 0.8);
|
||||
put("snowy_taiga", -0.5, 0.4);
|
||||
put("savanna", 2.0, 0.0);
|
||||
put("savanna_plateau", 2.0, 0.0);
|
||||
put("windswept_hills", 0.2, 0.3);
|
||||
put("windswept_gravelly_hills", 0.2, 0.3);
|
||||
put("windswept_forest", 0.2, 0.3);
|
||||
put("windswept_savanna", 2.0, 0.0);
|
||||
put("jungle", 0.95, 0.9);
|
||||
put("sparse_jungle", 0.95, 0.8);
|
||||
put("bamboo_jungle", 0.95, 0.9);
|
||||
put("badlands", 2.0, 0.0);
|
||||
put("eroded_badlands", 2.0, 0.0);
|
||||
put("wooded_badlands", 2.0, 0.0);
|
||||
put("meadow", 0.5, 0.8);
|
||||
put("cherry_grove", 0.5, 0.8, 0x5DB7DD);
|
||||
put("grove", -0.2, 0.8);
|
||||
put("snowy_slopes", -0.3, 0.9);
|
||||
put("frozen_peaks", -0.7, 0.9);
|
||||
put("jagged_peaks", -0.7, 0.9);
|
||||
put("stony_peaks", 1.0, 0.3);
|
||||
put("river", 0.5, 0.5);
|
||||
put("frozen_river", 0.0, 0.5, 0x3938C9);
|
||||
put("beach", 0.8, 0.4);
|
||||
put("snowy_beach", 0.05, 0.3, 0x3D57D6);
|
||||
put("stony_shore", 0.2, 0.3);
|
||||
put("warm_ocean", 0.5, 0.5, 0x43D5EE);
|
||||
put("lukewarm_ocean", 0.5, 0.5, 0x45ADF2);
|
||||
put("deep_lukewarm_ocean", 0.5, 0.5, 0x45ADF2);
|
||||
put("ocean", 0.5, 0.5);
|
||||
put("deep_ocean", 0.5, 0.5);
|
||||
put("cold_ocean", 0.5, 0.5, 0x3D57D6);
|
||||
put("deep_cold_ocean", 0.5, 0.5, 0x3D57D6);
|
||||
put("frozen_ocean", 0.0, 0.5, 0x3938C9);
|
||||
put("deep_frozen_ocean", 0.5, 0.5, 0x3938C9);
|
||||
put("mushroom_fields", 0.9, 1.0);
|
||||
put("dripstone_caves", 0.8, 0.4);
|
||||
put("lush_caves", 0.5, 0.5);
|
||||
put("deep_dark", 0.8, 0.4);
|
||||
put("nether_wastes", 2.0, 0.0, 0x905957);
|
||||
put("soul_sand_valley", 2.0, 0.0, 0x905957);
|
||||
put("crimson_forest", 2.0, 0.0, 0x905957);
|
||||
put("warped_forest", 2.0, 0.0, 0x905957);
|
||||
put("basalt_deltas", 2.0, 0.0, 0x3F76E4);
|
||||
put("the_end", 0.5, 0.5, 0x62529E);
|
||||
put("end_highlands", 0.5, 0.5, 0x62529E);
|
||||
put("end_midlands", 0.5, 0.5, 0x62529E);
|
||||
put("small_end_islands", 0.5, 0.5, 0x62529E);
|
||||
put("end_barrens", 0.5, 0.5, 0x62529E);
|
||||
put("the_void", 0.5, 0.5);
|
||||
}
|
||||
|
||||
private BiomeClimate() {}
|
||||
|
||||
public static Climate forKey(String biomePath) {
|
||||
return TABLE.getOrDefault(biomePath, DEFAULT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.tint;
|
||||
|
||||
/**
|
||||
* The biome-dependent tint colors (RGB) for the colormap-driven channels.
|
||||
*/
|
||||
public record BiomeTint(int grass, int foliage, int dryFoliage, int water) {
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.tint;
|
||||
|
||||
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
|
||||
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
|
||||
import org.bukkit.block.Biome;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Computes per-biome grass/foliage tint colors by sampling the resource pack's colormaps using the
|
||||
* vanilla temperature/downfall formula, plus a per-biome water color. Results are cached per biome.
|
||||
*/
|
||||
public final class BiomeTintProvider {
|
||||
|
||||
private final int[][] grassMap;
|
||||
private final int[][] foliageMap;
|
||||
private final int[][] dryFoliageMap;
|
||||
private final Map<String, BiomeTint> cache = new ConcurrentHashMap<>();
|
||||
|
||||
public BiomeTintProvider(TextureCache textures) {
|
||||
this.grassMap = textures.get(ResourceLocation.parse("colormap/grass")).orElse(null);
|
||||
this.foliageMap = textures.get(ResourceLocation.parse("colormap/foliage")).orElse(null);
|
||||
this.dryFoliageMap = textures.get(ResourceLocation.parse("colormap/dry_foliage")).orElse(null);
|
||||
}
|
||||
|
||||
public BiomeTint forBiome(Biome biome) {
|
||||
return cache.computeIfAbsent(keyOf(biome), this::compute);
|
||||
}
|
||||
|
||||
private String keyOf(Biome biome) {
|
||||
try {
|
||||
return biome.getKey().getKey();
|
||||
} catch (Throwable t) {
|
||||
return "plains";
|
||||
}
|
||||
}
|
||||
|
||||
private BiomeTint compute(String key) {
|
||||
BiomeClimate.Climate climate = BiomeClimate.forKey(key);
|
||||
int grass = sample(grassMap, climate.temperature(), climate.downfall(), 0xFF91BD59);
|
||||
int foliage = sample(foliageMap, climate.temperature(), climate.downfall(), 0xFF77AB2F);
|
||||
int dry = sample(dryFoliageMap, climate.temperature(), climate.downfall(), 0xFFA9A05B);
|
||||
|
||||
// Vanilla per-biome grass/foliage color overrides and modifiers that the colormap alone misses.
|
||||
switch (key) {
|
||||
case "swamp", "mangrove_swamp" -> {
|
||||
grass = 0xFF6A7039;
|
||||
foliage = 0xFF6A7039;
|
||||
}
|
||||
case "badlands", "eroded_badlands", "wooded_badlands" -> {
|
||||
grass = 0xFF90814D;
|
||||
foliage = 0xFF9E814D;
|
||||
}
|
||||
case "dark_forest" -> {
|
||||
// DARK_FOREST modifier: ((color & 0xFEFEFE) + 0x28340A) >> 1
|
||||
grass = 0xFF000000 | (((grass & 0xFEFEFE) + 0x28340A) >> 1);
|
||||
foliage = 0xFF000000 | (((foliage & 0xFEFEFE) + 0x28340A) >> 1);
|
||||
}
|
||||
default -> { }
|
||||
}
|
||||
return new BiomeTint(grass, foliage, dry, 0xFF000000 | climate.water());
|
||||
}
|
||||
|
||||
/** Vanilla colormap lookup: x = (1-temp)*255, y = (1-downfall*temp)*255. */
|
||||
private int sample(int[][] colormap, double temperature, double downfall, int fallback) {
|
||||
if (colormap == null || colormap.length == 0) return fallback;
|
||||
double temp = clamp01(temperature);
|
||||
double down = clamp01(downfall) * temp;
|
||||
int x = (int) ((1.0 - temp) * 255.0);
|
||||
int y = (int) ((1.0 - down) * 255.0);
|
||||
int h = colormap.length;
|
||||
int w = colormap[0].length;
|
||||
x = Math.max(0, Math.min(w - 1, x));
|
||||
y = Math.max(0, Math.min(h - 1, y));
|
||||
return 0xFF000000 | (colormap[y][x] & 0xFFFFFF);
|
||||
}
|
||||
|
||||
private double clamp01(double v) {
|
||||
return v < 0 ? 0 : Math.min(v, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.tint;
|
||||
|
||||
import org.bukkit.block.data.BlockData;
|
||||
|
||||
/**
|
||||
* Maps a tinted face (material + tintindex) to the concrete tint color, choosing between the
|
||||
* biome-driven channels and a handful of vanilla constants.
|
||||
*/
|
||||
public final class TintResolver {
|
||||
|
||||
private static final int BIRCH = 0xFF80A755;
|
||||
private static final int SPRUCE = 0xFF619961;
|
||||
private static final int LILY_PAD = 0xFF208030;
|
||||
private static final int STEM = 0xFF60A017;
|
||||
|
||||
private TintResolver() {}
|
||||
|
||||
/** Returns the ARGB tint to multiply with, or {@code -1} when the face should not be tinted. */
|
||||
public static int resolve(BlockData data, int tintIndex, BiomeTint biomeTint) {
|
||||
if (tintIndex < 0) return -1;
|
||||
String name = data.getMaterial().name().toLowerCase();
|
||||
|
||||
if (name.equals("birch_leaves")) return BIRCH;
|
||||
if (name.equals("spruce_leaves")) return SPRUCE;
|
||||
if (name.endsWith("leaves") || name.equals("vine")) return biomeTint.foliage();
|
||||
if (name.equals("lily_pad")) return LILY_PAD;
|
||||
|
||||
if (name.equals("water") || name.equals("water_cauldron") || name.equals("bubble_column")) {
|
||||
return biomeTint.water();
|
||||
}
|
||||
if (name.equals("redstone_wire")) return redstone(data);
|
||||
if (name.endsWith("stem")) return STEM;
|
||||
|
||||
// grass_block (top/overlay), short_grass, tall_grass, fern, large_fern, sugar_cane, ...
|
||||
if (name.contains("grass") || name.equals("fern") || name.equals("large_fern")
|
||||
|| name.equals("sugar_cane") || name.equals("potted_fern")) {
|
||||
return biomeTint.grass();
|
||||
}
|
||||
|
||||
// Default for unknown tinted faces: grass channel (the most common tintindex 0 use).
|
||||
return biomeTint.grass();
|
||||
}
|
||||
|
||||
private static int redstone(BlockData data) {
|
||||
int power = 0;
|
||||
String s = data.getAsString(false);
|
||||
int idx = s.indexOf("power=");
|
||||
if (idx >= 0) {
|
||||
int end = idx + 6;
|
||||
int e = end;
|
||||
while (e < s.length() && Character.isDigit(s.charAt(e))) e++;
|
||||
try {
|
||||
power = Integer.parseInt(s.substring(end, e));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
int r = Math.min(255, 75 + power * 12);
|
||||
return 0xFF000000 | (r << 16);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.util;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.util.BlockIterator;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class BlockRaytracer extends BlockIterator {
|
||||
|
||||
private final Vector position;
|
||||
private final Vector direction;
|
||||
|
||||
private Block lastBlock;
|
||||
private BlockFace currentFace;
|
||||
|
||||
public BlockRaytracer(Location loc) {
|
||||
super(loc);
|
||||
|
||||
this.position = loc.toVector();
|
||||
this.direction = loc.getDirection();
|
||||
}
|
||||
|
||||
public BlockFace getIntersectionFace() {
|
||||
if (currentFace == null) {
|
||||
throw new IllegalStateException("Called before next()");
|
||||
}
|
||||
|
||||
return currentFace;
|
||||
}
|
||||
|
||||
public Vector getIntersectionPoint() {
|
||||
BlockFace lastFace = getIntersectionFace();
|
||||
Vector planeNormal = new Vector(lastFace.getModX(), lastFace.getModY(), lastFace.getModZ());
|
||||
Vector planePoint = lastBlock.getLocation()
|
||||
.add(0.5, 0.5, 0.5)
|
||||
.toVector()
|
||||
.add(planeNormal.clone().multiply(0.5));
|
||||
|
||||
return MathUtil.getLinePlaneIntersection(position, direction, planePoint, planeNormal, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Block next() {
|
||||
Block currentBlock = super.next();
|
||||
currentFace = lastBlock == null ? BlockFace.SELF : currentBlock.getFace(lastBlock);
|
||||
|
||||
return (lastBlock = currentBlock);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.util;
|
||||
|
||||
/**
|
||||
* Helpers for packed ARGB integer colors.
|
||||
*/
|
||||
public final class ColorUtil {
|
||||
|
||||
private ColorUtil() {}
|
||||
|
||||
public static int alpha(int argb) { return (argb >> 24) & 0xFF; }
|
||||
public static int red(int argb) { return (argb >> 16) & 0xFF; }
|
||||
public static int green(int argb) { return (argb >> 8) & 0xFF; }
|
||||
public static int blue(int argb) { return argb & 0xFF; }
|
||||
|
||||
public static int argb(int a, int r, int g, int b) {
|
||||
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
/** Multiplies the RGB channels of {@code base} by {@code tint} (per-channel, 0..255), keeping base alpha. */
|
||||
public static int multiply(int base, int tint) {
|
||||
int a = alpha(base);
|
||||
int r = red(base) * red(tint) / 255;
|
||||
int g = green(base) * green(tint) / 255;
|
||||
int b = blue(base) * blue(tint) / 255;
|
||||
return argb(a, r, g, b);
|
||||
}
|
||||
|
||||
/** Scales the RGB channels by {@code factor} (0..1), keeping alpha. Used for directional face shading. */
|
||||
public static int shade(int argb, double factor) {
|
||||
int a = alpha(argb);
|
||||
int r = clamp((int) (red(argb) * factor));
|
||||
int g = clamp((int) (green(argb) * factor));
|
||||
int b = clamp((int) (blue(argb) * factor));
|
||||
return argb(a, r, g, b);
|
||||
}
|
||||
|
||||
private static int clamp(int v) {
|
||||
return v < 0 ? 0 : Math.min(v, 255);
|
||||
}
|
||||
|
||||
// --- Gamma-correct (linear-light) averaging ---
|
||||
|
||||
private static final float[] SRGB_TO_LINEAR = new float[256];
|
||||
static {
|
||||
for (int i = 0; i < 256; i++) {
|
||||
double c = i / 255.0;
|
||||
SRGB_TO_LINEAR[i] = (float) (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
||||
}
|
||||
}
|
||||
|
||||
public static float toLinear(int channel) {
|
||||
return SRGB_TO_LINEAR[channel & 0xFF];
|
||||
}
|
||||
|
||||
public static int toSrgb(double linear) {
|
||||
double c = linear <= 0.0031308 ? linear * 12.92 : 1.055 * Math.pow(linear, 1 / 2.4) - 0.055;
|
||||
return clamp((int) Math.round(c * 255.0));
|
||||
}
|
||||
|
||||
/** Averages a set of RGB colors in linear light and returns the sRGB result (opaque). */
|
||||
public static int averageLinear(int[] colors, int from, int count) {
|
||||
double r = 0, g = 0, b = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
int c = colors[from + i];
|
||||
r += toLinear((c >> 16) & 0xFF);
|
||||
g += toLinear((c >> 8) & 0xFF);
|
||||
b += toLinear(c & 0xFF);
|
||||
}
|
||||
return argb(0xFF, toSrgb(r / count), toSrgb(g / count), toSrgb(b / count));
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package eu.mhsl.minecraft.pixelpics.render.util;
|
||||
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
public final class Intersection {
|
||||
|
||||
private final Vector normal;
|
||||
private final Vector point;
|
||||
private final Vector direction;
|
||||
private final int color;
|
||||
|
||||
private Intersection(Vector normal, Vector point, Vector direction, int color) {
|
||||
this.normal = normal;
|
||||
this.point = point;
|
||||
this.direction = direction;
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
public Vector getNormal() {
|
||||
return normal;
|
||||
}
|
||||
|
||||
public Vector getPoint() {
|
||||
return point;
|
||||
}
|
||||
|
||||
public Vector getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
public int getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
public static Intersection of(Vector normal, Vector point, Vector direction) {
|
||||
return of(normal, point, direction, 0);
|
||||
}
|
||||
|
||||
public static Intersection of(Vector normal, Vector point, Vector direction, int color) {
|
||||
return new Intersection(normal, point, direction, color);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,187 @@
|
||||
import eu.mhsl.minecraft.pixelpics.assets.*;
|
||||
import eu.mhsl.minecraft.pixelpics.render.entity.*;
|
||||
import eu.mhsl.minecraft.pixelpics.render.render.*;
|
||||
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
|
||||
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
|
||||
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
|
||||
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/** Standalone (no server) renderer: every entity twice (yaw 45° / 225°) against empty sky -> contact sheets. */
|
||||
public class EntityTestRender {
|
||||
|
||||
static final String ROOT = "/home/elias/Dokumente/mcTestServer/plugins/PixelPics";
|
||||
static final double H_FOV_HALF = Math.toRadians(35);
|
||||
static final Vector BASE = new Vector(1, 0, 0);
|
||||
static final int SSAA = 3;
|
||||
static final int TW = 200, TH = 230; // per-view tile size
|
||||
|
||||
// Entity type keys to render (current vanilla + bundled bedrock specials). variant=null (base look).
|
||||
static final String[] ENTITIES = {
|
||||
"allay","armadillo","armor_stand","axolotl","bat","bee","blaze","bogged","breeze","camel",
|
||||
"cat","cave_spider","chicken","cod","copper_golem","cow","creaking","creeper","dolphin","donkey",
|
||||
"drowned","elder_guardian","enderman","endermite","evoker","fox","frog","ghast","giant","glow_squid",
|
||||
"goat","guardian","happy_ghast","hoglin","horse","husk","illusioner","iron_golem","llama","magma_cube",
|
||||
"mooshroom","mule","ocelot","panda","parrot","phantom","pig","piglin","piglin_brute","pillager",
|
||||
"polar_bear","pufferfish","rabbit","ravager","salmon","sheep","shulker","silverfish","skeleton","skeleton_horse",
|
||||
"slime","sniffer","snow_golem","spider","squid","stray","strider","tadpole","trader_llama","tropical_fish",
|
||||
"turtle","vex","villager","vindicator","wandering_trader","warden","witch","wither","wither_skeleton","wolf",
|
||||
"zoglin","zombie","zombie_horse","zombie_villager","zombified_piglin","ender_dragon","player","mannequin",
|
||||
"nautilus","zombie_nautilus_coral","parched","camel_husk","oak_boat"
|
||||
};
|
||||
|
||||
// Representative variants (the game always supplies these; null base textures wouldn't exist otherwise).
|
||||
static final Map<String, String> VAR = Map.ofEntries(
|
||||
Map.entry("cat", "tabby"), Map.entry("wolf", "pale"), Map.entry("axolotl", "lucy"),
|
||||
Map.entry("parrot", "red"), Map.entry("rabbit", "brown"), Map.entry("horse", "white"),
|
||||
Map.entry("llama", "creamy"), Map.entry("trader_llama", "creamy"), Map.entry("fox", "red"),
|
||||
Map.entry("mooshroom", "red"), Map.entry("frog", "temperate"), Map.entry("panda", "normal"),
|
||||
Map.entry("cow", "temperate"), Map.entry("pig", "temperate"), Map.entry("chicken", "temperate")
|
||||
);
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Logger log = Logger.getLogger("test");
|
||||
ResourcePack pack = ResourcePackLoader.load(new File(ROOT, "resourcepack"), log).orElseThrow();
|
||||
AssetReader reader = new AssetReader(pack);
|
||||
TextureCache textures = new TextureCache(pack);
|
||||
BlockModelRegistry registry = new BlockModelRegistry(reader, textures);
|
||||
BiomeTintProvider tint = new BiomeTintProvider(textures);
|
||||
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader geo = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader();
|
||||
int n = geo.load(new java.io.FileInputStream("/tmp/cem_models.json"), log);
|
||||
log.info("Loaded " + n + " geometries");
|
||||
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker baker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(geo, textures, new SkinCache());
|
||||
DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, log);
|
||||
|
||||
BlockData air = (BlockData) Proxy.newProxyInstance(EntityTestRender.class.getClassLoader(),
|
||||
new Class[]{BlockData.class}, (p, m, a) -> {
|
||||
switch (m.getName()) {
|
||||
case "getMaterial": return Material.AIR;
|
||||
case "equals": return p == a[0];
|
||||
case "hashCode": return System.identityHashCode(p);
|
||||
case "toString": return "air";
|
||||
}
|
||||
Class<?> rt = m.getReturnType();
|
||||
if (rt == boolean.class) return false;
|
||||
if (rt.isPrimitive()) return 0;
|
||||
return null;
|
||||
});
|
||||
WorldSnapshot empty = new WorldSnapshot(Map.of(), 0, 1, air);
|
||||
SkyContext sky = new SkyContext(6000, 0, 6000);
|
||||
|
||||
String[] list = args.length > 0 ? args : ENTITIES;
|
||||
List<BufferedImage> cells = new ArrayList<>();
|
||||
for (String key : list) {
|
||||
BufferedImage v1 = renderEntity(renderer, baker, empty, sky, key, 45);
|
||||
BufferedImage v2 = renderEntity(renderer, baker, empty, sky, key, 225);
|
||||
cells.add(labelCell(key, v1, v2));
|
||||
log.info("rendered " + key);
|
||||
}
|
||||
|
||||
File outDir = new File("/home/elias/Dokumente/PixelPics-Entity-Renders");
|
||||
outDir.mkdirs();
|
||||
// One image per entity (both 45°/225° views side by side), named by entity key.
|
||||
File single = new File(outDir, "einzeln");
|
||||
single.mkdirs();
|
||||
for (int i = 0; i < list.length; i++) {
|
||||
ImageIO.write(cells.get(i), "png", new File(single, list[i] + ".png"));
|
||||
}
|
||||
|
||||
// Compose contact-sheet pages: 2 columns.
|
||||
int cols = 2, cellW = cells.get(0).getWidth(), cellH = cells.get(0).getHeight();
|
||||
int perPage = cols * 5;
|
||||
for (int page = 0, idx = 0; idx < cells.size(); page++) {
|
||||
int count = Math.min(perPage, cells.size() - idx);
|
||||
int rows = (count + cols - 1) / cols;
|
||||
BufferedImage sheet = new BufferedImage(cols * cellW, rows * cellH, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = sheet.createGraphics();
|
||||
g.setColor(new Color(40, 40, 48));
|
||||
g.fillRect(0, 0, sheet.getWidth(), sheet.getHeight());
|
||||
for (int i = 0; i < count; i++) {
|
||||
int r = i / cols, c = i % cols;
|
||||
g.drawImage(cells.get(idx + i), c * cellW, r * cellH, null);
|
||||
}
|
||||
g.dispose();
|
||||
File f = new File(outDir, String.format("page_%d.png", page));
|
||||
ImageIO.write(sheet, "png", f);
|
||||
System.out.println("WROTE " + f);
|
||||
idx += count;
|
||||
}
|
||||
}
|
||||
|
||||
static BufferedImage renderEntity(DefaultScreenRenderer renderer, eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker baker, WorldSnapshot world,
|
||||
SkyContext sky, String key, float yaw) {
|
||||
boolean isPlayer = key.equals("player");
|
||||
EntityState s = new EntityState(key, 0, 0, 0, yaw, yaw, 0, 0, 0, 0, false, 0.8, 1.0,
|
||||
isPlayer, null, false, VAR.get(key), 0, 1.0);
|
||||
RenderedEntity re = baker.bake(s);
|
||||
double cx = (re.aabbMin[0] + re.aabbMax[0]) / 2;
|
||||
double cy = (re.aabbMin[1] + re.aabbMax[1]) / 2;
|
||||
double cz = (re.aabbMin[2] + re.aabbMax[2]) / 2;
|
||||
double ext = 0;
|
||||
for (int a = 0; a < 3; a++) ext = Math.max(ext, re.aabbMax[a] - re.aabbMin[a]);
|
||||
if (ext < 0.5) ext = 0.5;
|
||||
double dist = ext / (2 * Math.tan(H_FOV_HALF)) * 1.25 + 0.4;
|
||||
|
||||
Vector center = new Vector(cx, cy, cz);
|
||||
Vector cam = new Vector(cx - dist, cy + dist * 0.42, cz - dist * 0.15);
|
||||
Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ());
|
||||
loc.setDirection(center.clone().subtract(cam));
|
||||
|
||||
List<Vector> rayMap = buildRayMap(loc, TW * SSAA, TH * SSAA);
|
||||
RenderJob job = new RenderJob(world, rayMap, cam, TW, TH, sky, List.of(s));
|
||||
return renderer.execute(job);
|
||||
}
|
||||
|
||||
static BufferedImage labelCell(String key, BufferedImage v1, BufferedImage v2) {
|
||||
int w = v1.getWidth() + v2.getWidth();
|
||||
int labelH = 20;
|
||||
BufferedImage cell = new BufferedImage(w, v1.getHeight() + labelH, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = cell.createGraphics();
|
||||
g.setColor(new Color(25, 25, 30));
|
||||
g.fillRect(0, 0, cell.getWidth(), cell.getHeight());
|
||||
g.drawImage(v1, 0, labelH, null);
|
||||
g.drawImage(v2, v1.getWidth(), labelH, null);
|
||||
g.setColor(Color.WHITE);
|
||||
g.setFont(new Font("SansSerif", Font.BOLD, 13));
|
||||
g.drawString(key, 4, 15);
|
||||
g.dispose();
|
||||
return cell;
|
||||
}
|
||||
|
||||
// Replicated from DefaultScreenRenderer.buildRayMap.
|
||||
static List<Vector> buildRayMap(Location eye, int width, int height) {
|
||||
Vector dir = eye.getDirection();
|
||||
double angleYaw = Math.atan2(dir.getZ(), dir.getX());
|
||||
double anglePitch = Math.atan2(dir.getY(), Math.sqrt(dir.getX() * dir.getX() + dir.getZ() * dir.getZ()));
|
||||
double yawHalf = H_FOV_HALF;
|
||||
double pitchHalf = Math.atan(Math.tan(yawHalf) * ((double) height / width));
|
||||
Vector ll = MathUtil.doubleYawPitchRotation(BASE, -yawHalf, -pitchHalf, angleYaw, anglePitch);
|
||||
Vector ul = MathUtil.doubleYawPitchRotation(BASE, -yawHalf, pitchHalf, angleYaw, anglePitch);
|
||||
Vector lr = MathUtil.doubleYawPitchRotation(BASE, yawHalf, -pitchHalf, angleYaw, anglePitch);
|
||||
Vector ur = MathUtil.doubleYawPitchRotation(BASE, yawHalf, pitchHalf, angleYaw, anglePitch);
|
||||
List<Vector> rayMap = new ArrayList<>(width * height);
|
||||
Vector leftFrac = ul.clone().subtract(ll).multiply(1.0 / (height - 1));
|
||||
Vector rightFrac = ur.clone().subtract(lr).multiply(1.0 / (height - 1));
|
||||
for (int pitch = 0; pitch < height; pitch++) {
|
||||
Vector leftPitch = ul.clone().subtract(leftFrac.clone().multiply(pitch));
|
||||
Vector rightPitch = ur.clone().subtract(rightFrac.clone().multiply(pitch));
|
||||
Vector yawFrac = rightPitch.clone().subtract(leftPitch).multiply(1.0 / (width - 1));
|
||||
for (int yaw = 0; yaw < width; yaw++) {
|
||||
rayMap.add(leftPitch.clone().add(yawFrac.clone().multiply(yaw)).normalize());
|
||||
}
|
||||
}
|
||||
return rayMap;
|
||||
}
|
||||
}
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Updates the bundled CEM entity-model set (the only asset PixelPics vendors itself).
|
||||
#
|
||||
# Source: the CEM Template Loader data (Ewan Howell / wynem) — vanilla Java entity models as JSON,
|
||||
# already correctly posed. These ultimately mirror Mojang's hardcoded Java EntityModel classes.
|
||||
#
|
||||
# Most future-proof alternative for a NEW Minecraft version (sourced straight from the game):
|
||||
# 1. Install the JsonEM mod (Fabric), set dump_models=true in .minecraft/config/jsonem.properties
|
||||
# 2. Launch the game once -> models dumped to .minecraft/jsonem_dump
|
||||
# (a small format-adapter would be needed; the CEM source below covers vanilla until then.)
|
||||
#
|
||||
# Usage: tools/update-cem-models.sh # update if newer
|
||||
# tools/update-cem-models.sh --force # always overwrite
|
||||
set -euo pipefail
|
||||
|
||||
URL="https://wynem.com/assets/json/cem_template_models.json"
|
||||
DEST="$(cd "$(dirname "$0")/.." && pwd)/src/main/resources/cem/cem_template_models.json"
|
||||
TMP="$(mktemp)"
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
|
||||
echo "Fetching $URL ..."
|
||||
curl -fsSL "$URL" -o "$TMP"
|
||||
|
||||
# Validate + read versions/counts.
|
||||
read -r NEW_VER NEW_COUNT < <(python3 -c "
|
||||
import json,sys
|
||||
d=json.load(open('$TMP'))
|
||||
assert 'models' in d and len(d['models'])>50, 'unexpected structure'
|
||||
print(d.get('version','?'), len(d['models']))
|
||||
")
|
||||
|
||||
CUR_VER="(none)"
|
||||
if [ -f "$DEST" ]; then
|
||||
CUR_VER="$(python3 -c "import json;print(json.load(open('$DEST')).get('version','?'))" 2>/dev/null || echo '?')"
|
||||
fi
|
||||
|
||||
echo "Current: $CUR_VER Remote: $NEW_VER ($NEW_COUNT models)"
|
||||
|
||||
if [ "${1:-}" != "--force" ] && [ "$CUR_VER" = "$NEW_VER" ]; then
|
||||
echo "Already up to date. (use --force to overwrite)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
cp "$TMP" "$DEST"
|
||||
echo "Updated -> $DEST (version $NEW_VER, $NEW_COUNT models)"
|
||||
echo "Rebuild/redeploy the plugin to apply."
|
||||
Reference in New Issue
Block a user