diff --git a/core/src/main/java/com/volmit/iris/core/gui/BlockModel.java b/core/src/main/java/com/volmit/iris/core/gui/BlockModel.java
index a6dcc8e83..5a68f8bee 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/BlockModel.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/BlockModel.java
@@ -16,112 +16,198 @@
* along with this program. If not, see .
*/
+
package com.volmit.iris.core.gui;
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
-import java.util.EnumMap;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.IdentityHashMap;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.function.UnaryOperator;
/**
- * A block as the object viewer draws it: a box with a texture on each side, or two crossed sprites for plants.
- * Models made of several boxes, like stairs, are drawn as a full block.
+ * A block as the object viewer draws it: the textured faces of its model, in block units, already turned the way the
+ * blockstate faces.
*
- * @param box the box in block units, from 0 to 1 on every axis for a full block
- * @param faces the texture on each side of the box; a side the model leaves open is missing
- * @param crossSprite the sprite drawn on two crossed planes, or null for a box
+ * @param faces every face of every box in the model
+ * @param coveredSides the block's sides that a solid face fills completely, which hide whatever touches them
+ * @param hidesSameBlockSides whether the model fills the whole block, so like glass it hides the sides between two of
+ * the same blocks
*/
-record BlockModel(Box box, Map faces, BlockTexture crossSprite) {
- static BlockModel box(Box box, Map faces) {
- return new BlockModel(box, Map.copyOf(faces), null);
- }
+record BlockModel(List faces, Set coveredSides, boolean hidesSameBlockSides) {
+ static final BlockModel EMPTY = new BlockModel(List.of(), Set.of(), false);
+ private static final int SIDE_CELLS = 16;
+ private static final double ON_SIDE_TOLERANCE = 1e-4D;
- static BlockModel cross(BlockTexture sprite) {
- return new BlockModel(Box.FULL, Map.of(), sprite);
+ static BlockModel of(List faces) {
+ Set coveredSides = EnumSet.noneOf(CubeFace.class);
+ boolean fillsEverySide = true;
+ for (CubeFace side : CubeFace.values()) {
+ if (sideIsFilled(faces, side, true)) {
+ coveredSides.add(side);
+ }
+ fillsEverySide &= sideIsFilled(faces, side, false);
+ }
+ return new BlockModel(List.copyOf(faces), Set.copyOf(coveredSides), fillsEverySide);
}
static BlockModel solidColor(int color) {
- BlockTexture texture = new BlockTexture(1, new int[]{color}, new boolean[]{true}, color, null);
- Map faces = new EnumMap<>(CubeFace.class);
- for (CubeFace face : CubeFace.values()) {
- faces.put(face, texture);
+ BlockTexture texture = BlockTexture.solid(color);
+ List faces = new ArrayList<>();
+ for (CubeFace side : CubeFace.values()) {
+ faces.add(Face.boxSide(Vec3.ZERO, new Vec3(1D, 1D, 1D), side, texture, side));
}
- return box(Box.FULL, faces);
+ return of(faces);
}
- boolean isCross() {
- return crossSprite != null;
+ boolean isEmpty() {
+ return faces.isEmpty();
}
- /**
- * Whether a neighbour's side touching this block can never be seen.
- */
- boolean hidesTouchingSides() {
- if (isCross() || !box.isFull() || faces.size() < CubeFace.values().length) {
- return false;
+ BlockModel withTextures(UnaryOperator change) {
+ Map changed = new IdentityHashMap<>();
+ List changedFaces = new ArrayList<>(faces.size());
+ for (Face face : faces) {
+ changedFaces.add(face.withTexture(changed.computeIfAbsent(face.texture(), change)));
}
- for (BlockTexture texture : faces.values()) {
- for (boolean opaque : texture.opaque()) {
- if (!opaque) {
- return false;
- }
+ return new BlockModel(List.copyOf(changedFaces), coveredSides, hidesSameBlockSides);
+ }
+
+ // The side is split into a grid, and every cell has to lie under a face pressed flat against it.
+ private static boolean sideIsFilled(List faces, CubeFace side, boolean onlySolidFaces) {
+ boolean[] filled = new boolean[SIDE_CELLS * SIDE_CELLS];
+ for (Face face : faces) {
+ if (face.liesOn(side) && (!onlySolidFaces || face.texture().isFullyOpaque())) {
+ face.fillCells(side, filled, SIDE_CELLS);
+ }
+ }
+ for (boolean cell : filled) {
+ if (!cell) {
+ return false;
}
}
return true;
}
- record Box(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) {
- static final Box FULL = new Box(0D, 0D, 0D, 1D, 1D, 1D);
- private static final double BLOCK_CENTER = 0.5D;
+ /**
+ * A parallelogram from {@code origin} along {@code edgeU} and {@code edgeV}. The texture point at the origin and
+ * how it moves along each edge are given as fractions of the texture.
+ *
+ * @param normal the way the face looks out; it is hidden when seen from behind
+ * @param cullFace the block side whose neighbour hides this face when it covers that side, or null
+ */
+ record Face(Vec3 origin, Vec3 edgeU, Vec3 edgeV, Vec3 normal, BlockTexture texture, TextureMapping mapping,
+ double shade, CubeFace cullFace) {
- static Box ofModelElement(double[] from, double[] to) {
- double pixels = 16D;
- return new Box(from[0] / pixels, from[1] / pixels, from[2] / pixels, to[0] / pixels, to[1] / pixels, to[2] / pixels);
+ // Laid out as seen from outside: u to the right and v downwards, matching Minecraft's UVs for a whole texture.
+ static Face boxSide(Vec3 from, Vec3 to, CubeFace side, BlockTexture texture, CubeFace cullFace) {
+ Vec3[] corners = BoxCorners.of(from, to, side);
+ return new Face(corners[0], corners[3].minus(corners[0]), corners[1].minus(corners[0]), side.direction(),
+ texture, TextureMapping.WHOLE, side.shade(), cullFace);
}
- boolean isFull() {
- return equals(FULL);
+ Face withTexture(BlockTexture changedTexture) {
+ return new Face(origin, edgeU, edgeV, normal, changedTexture, mapping, shade, cullFace);
}
- double min(int axis) {
- return switch (axis) {
- case 0 -> minX;
- case 1 -> minY;
- default -> minZ;
- };
+ Face transformed(Transform transform) {
+ Vec3 turnedNormal = transform.direction(normal);
+ double length = Math.sqrt(turnedNormal.x() * turnedNormal.x() + turnedNormal.y() * turnedNormal.y() + turnedNormal.z() * turnedNormal.z());
+ return new Face(transform.point(origin), transform.direction(edgeU), transform.direction(edgeV),
+ length == 0D ? turnedNormal : turnedNormal.times(1D / length), texture, mapping, shade,
+ cullFace == null ? null : CubeFace.nearest(transform.direction(cullFace.direction())));
}
- double max(int axis) {
- return switch (axis) {
- case 0 -> maxX;
- case 1 -> maxY;
- default -> maxZ;
- };
+ Face withCullFace(CubeFace side) {
+ return new Face(origin, edgeU, edgeV, normal, texture, mapping, shade, side);
}
- // Blockstates turn a model around the block's centre, the x axis first.
- Box rotated(int rotationX, int rotationY) {
- if (rotationX % 360 == 0 && rotationY % 360 == 0) {
- return this;
+ Face withShade(double changedShade) {
+ return new Face(origin, edgeU, edgeV, normal, texture, mapping, changedShade, cullFace);
+ }
+
+ private boolean liesOn(CubeFace side) {
+ int axis = side.axis();
+ double plane = side.pointsPositive() ? 1D : 0D;
+ return CubeFace.nearest(normal) == side
+ && Math.abs(normal.component(axis)) > 1D - ON_SIDE_TOLERANCE
+ && Math.abs(origin.component(axis) - plane) < ON_SIDE_TOLERANCE
+ && Math.abs(edgeU.component(axis)) < ON_SIDE_TOLERANCE
+ && Math.abs(edgeV.component(axis)) < ON_SIDE_TOLERANCE;
+ }
+
+ private void fillCells(CubeFace side, boolean[] filled, int cells) {
+ int firstAxis = side.axis() == 0 ? 1 : 0;
+ int secondAxis = side.axis() == 2 ? 1 : 2;
+ double[] firstRange = range(firstAxis);
+ double[] secondRange = range(secondAxis);
+ for (int first = 0; first < cells; first++) {
+ double firstCenter = (first + 0.5D) / cells;
+ if (firstCenter < firstRange[0] || firstCenter > firstRange[1]) {
+ continue;
+ }
+ for (int second = 0; second < cells; second++) {
+ double secondCenter = (second + 0.5D) / cells;
+ if (secondCenter >= secondRange[0] && secondCenter <= secondRange[1]) {
+ filled[first * cells + second] = true;
+ }
+ }
}
- double[] first = turned(minX, minY, minZ, rotationX, rotationY);
- double[] second = turned(maxX, maxY, maxZ, rotationX, rotationY);
- return new Box(Math.min(first[0], second[0]), Math.min(first[1], second[1]), Math.min(first[2], second[2]),
- Math.max(first[0], second[0]), Math.max(first[1], second[1]), Math.max(first[2], second[2]));
}
- private static double[] turned(double x, double y, double z, int rotationX, int rotationY) {
- int[] axisX = CubeFace.turnAroundY(CubeFace.turnAroundX(new int[]{1, 0, 0}, rotationX), rotationY);
- int[] axisY = CubeFace.turnAroundY(CubeFace.turnAroundX(new int[]{0, 1, 0}, rotationX), rotationY);
- int[] axisZ = CubeFace.turnAroundY(CubeFace.turnAroundX(new int[]{0, 0, 1}, rotationX), rotationY);
- double offsetX = x - BLOCK_CENTER;
- double offsetY = y - BLOCK_CENTER;
- double offsetZ = z - BLOCK_CENTER;
- return new double[]{
- BLOCK_CENTER + axisX[0] * offsetX + axisY[0] * offsetY + axisZ[0] * offsetZ,
- BLOCK_CENTER + axisX[1] * offsetX + axisY[1] * offsetY + axisZ[1] * offsetZ,
- BLOCK_CENTER + axisX[2] * offsetX + axisY[2] * offsetY + axisZ[2] * offsetZ
+ private double[] range(int axis) {
+ double start = origin.component(axis);
+ double alongU = edgeU.component(axis);
+ double alongV = edgeV.component(axis);
+ double low = start + Math.min(0D, alongU) + Math.min(0D, alongV);
+ double high = start + Math.max(0D, alongU) + Math.max(0D, alongV);
+ return new double[]{low, high};
+ }
+ }
+
+ /**
+ * Where a face's corner sits in its texture and how the texture point moves along the face's two edges, all as
+ * fractions of the texture's width and height.
+ */
+ record TextureMapping(double u, double v, double uAlongU, double vAlongU, double uAlongV, double vAlongV) {
+ static final TextureMapping WHOLE = new TextureMapping(0D, 0D, 1D, 0D, 0D, 1D);
+ }
+
+ /**
+ * A box side's corners in Minecraft's order: top left, bottom left, bottom right and top right, as seen from
+ * outside the box.
+ */
+ static final class BoxCorners {
+ private BoxCorners() {
+ }
+
+ static Vec3[] of(Vec3 from, Vec3 to, CubeFace side) {
+ double minX = from.x();
+ double minY = from.y();
+ double minZ = from.z();
+ double maxX = to.x();
+ double maxY = to.y();
+ double maxZ = to.z();
+ return switch (side) {
+ case DOWN -> corners(minX, minY, maxZ, minX, minY, minZ, maxX, minY, minZ, maxX, minY, maxZ);
+ case UP -> corners(minX, maxY, minZ, minX, maxY, maxZ, maxX, maxY, maxZ, maxX, maxY, minZ);
+ case NORTH -> corners(maxX, maxY, minZ, maxX, minY, minZ, minX, minY, minZ, minX, maxY, minZ);
+ case SOUTH -> corners(minX, maxY, maxZ, minX, minY, maxZ, maxX, minY, maxZ, maxX, maxY, maxZ);
+ case WEST -> corners(minX, maxY, minZ, minX, minY, minZ, minX, minY, maxZ, minX, maxY, maxZ);
+ case EAST -> corners(maxX, maxY, maxZ, maxX, minY, maxZ, maxX, minY, minZ, maxX, maxY, minZ);
};
}
+
+ private static Vec3[] corners(double... coordinates) {
+ Vec3[] corners = new Vec3[4];
+ for (int corner = 0; corner < 4; corner++) {
+ corners[corner] = new Vec3(coordinates[corner * 3], coordinates[corner * 3 + 1], coordinates[corner * 3 + 2]);
+ }
+ return corners;
+ }
}
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/BlockModelBuilder.java b/core/src/main/java/com/volmit/iris/core/gui/BlockModelBuilder.java
new file mode 100644
index 000000000..42e118020
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/BlockModelBuilder.java
@@ -0,0 +1,351 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+
+package com.volmit.iris.core.gui;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.volmit.iris.core.gui.BlockModel.BoxCorners;
+import com.volmit.iris.core.gui.BlockModel.Face;
+import com.volmit.iris.core.gui.BlockModel.TextureMapping;
+import com.volmit.iris.core.gui.BlockTextures.BlockStateId;
+import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
+import com.volmit.iris.core.gui.BlockTextures.ModelDefinition;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Reads a block's look the way Minecraft does: every model part the blockstate picks, every box with its own texture
+ * and UVs, tilted boxes, the blockstate's turn with or without locked textures, and which faces a neighbour hides.
+ */
+final class BlockModelBuilder {
+ private static final double MODEL_UNITS = 16D;
+ private static final double BLOCK_CENTER = 0.5D;
+ private static final int QUARTER_TURN = 90;
+
+ private final BlockTextures textures;
+ private final EntityBlockShapes entityShapes;
+
+ BlockModelBuilder(BlockTextures textures) {
+ this.textures = textures;
+ this.entityShapes = new EntityBlockShapes(textures);
+ }
+
+ Optional build(String blockState) {
+ try {
+ BlockStateId state = BlockStateId.parse(blockState);
+ List placements = placementsOf(state);
+ if (placements == null) {
+ return Optional.empty();
+ }
+ List faces = new ArrayList<>();
+ for (JsonObject placement : placements) {
+ faces.addAll(placedFaces(state, placement));
+ }
+ faces.addAll(entityShapes.facesAddedTo(state));
+ return Optional.of(BlockModel.of(faces));
+ } catch (IOException | RuntimeException error) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * A box with Minecraft's default UVs on each side, for shapes the game draws in code.
+ */
+ static List box(Vec3 fromPixels, Vec3 toPixels, BlockTexture texture, boolean culledBySides) {
+ List faces = new ArrayList<>();
+ for (CubeFace side : CubeFace.values()) {
+ FaceUv uv = FaceUv.byDefault(side, fromPixels, toPixels);
+ Face face = boxFace(fromPixels, toPixels, side, texture, uv).withShade(side.shade());
+ faces.add(culledBySides ? face.withCullFace(side) : face);
+ }
+ return faces;
+ }
+
+ // A variant names one model; a multipart blockstate, like a fence's, adds a part for every condition that holds.
+ private List placementsOf(BlockStateId state) throws IOException {
+ JsonObject definition = textures.readJson(BlockTextures.assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
+ if (definition == null) {
+ return null;
+ }
+ List placements = new ArrayList<>();
+ if (definition.has("variants")) {
+ for (Map.Entry variant : definition.getAsJsonObject("variants").entrySet()) {
+ if (BlockTextures.variantMatches(variant.getKey(), state.properties())) {
+ addPlacement(placements, variant.getValue());
+ break;
+ }
+ }
+ }
+ if (definition.has("multipart")) {
+ for (JsonElement part : definition.getAsJsonArray("multipart")) {
+ JsonObject rule = part.getAsJsonObject();
+ if (!rule.has("when") || BlockTextures.conditionMatches(rule.getAsJsonObject("when"), state.properties())) {
+ addPlacement(placements, rule.get("apply"));
+ }
+ }
+ }
+ return placements;
+ }
+
+ private static void addPlacement(List placements, JsonElement models) {
+ JsonObject first = BlockTextures.firstModel(models);
+ if (first != null && first.has("model")) {
+ placements.add(first);
+ }
+ }
+
+ private List placedFaces(BlockStateId state, JsonObject placement) throws IOException {
+ ModelDefinition model = textures.readModel(placement.get("model").getAsString());
+ if (model.elements() == null) {
+ return entityShapes.facesOf(state, model);
+ }
+ BlockRotation rotation = BlockRotation.of(placement);
+ List faces = new ArrayList<>();
+ for (JsonElement element : model.elements()) {
+ addElementFaces(faces, state, model, element.getAsJsonObject(), rotation);
+ }
+ return faces;
+ }
+
+ private void addElementFaces(List faces, BlockStateId state, ModelDefinition model, JsonObject element, BlockRotation rotation) {
+ JsonObject elementFaces = element.getAsJsonObject("faces");
+ if (elementFaces == null) {
+ return;
+ }
+ Vec3 from = vector(element.getAsJsonArray("from"));
+ Vec3 to = vector(element.getAsJsonArray("to"));
+ Transform tilt = tiltOf(element);
+ boolean shaded = !element.has("shade") || element.get("shade").getAsBoolean();
+ CubeFace shadeSide = element.has("shade_direction_override")
+ ? CubeFace.named(element.get("shade_direction_override").getAsString()) : null;
+ for (Map.Entry entry : elementFaces.entrySet()) {
+ CubeFace side = CubeFace.named(entry.getKey());
+ JsonObject faceJson = entry.getValue().getAsJsonObject();
+ if (side == null || !faceJson.has("texture")) {
+ continue;
+ }
+ String textureName = BlockTextures.dereference(faceJson.get("texture").getAsString(), model.textures());
+ if (textureName == null) {
+ continue;
+ }
+ Optional texture = textures.faceTexture(state, textureName, BlockTextures.tintIndexOf(faceJson));
+ if (texture.isEmpty()) {
+ continue;
+ }
+ FaceUv uv = FaceUv.of(faceJson, side, from, to);
+ if (rotation.locksTextures()) {
+ uv = uv.lockedTo(side, rotation);
+ }
+ Face face = boxFace(from, to, side, texture.get(), uv).transformed(tilt);
+ if (faceJson.has("cullface")) {
+ face = face.withCullFace(CubeFace.named(faceJson.get("cullface").getAsString()));
+ }
+ face = face.transformed(rotation.transform());
+ faces.add(face.withShade(shadeOf(face, shaded, shadeSide, rotation)));
+ }
+ }
+
+ private static Face boxFace(Vec3 fromPixels, Vec3 toPixels, CubeFace side, BlockTexture texture, FaceUv uv) {
+ Vec3[] corners = BoxCorners.of(fromPixels.times(1D / MODEL_UNITS), toPixels.times(1D / MODEL_UNITS), side);
+ return new Face(corners[0], corners[3].minus(corners[0]), corners[1].minus(corners[0]), side.direction(),
+ texture, uv.mapping(), side.shade(), null);
+ }
+
+ private static double shadeOf(Face face, boolean shaded, CubeFace shadeSide, BlockRotation rotation) {
+ if (!shaded) {
+ return CubeFace.UP.shade();
+ }
+ if (shadeSide != null) {
+ return CubeFace.nearest(rotation.transform().direction(shadeSide.direction())).shade();
+ }
+ return CubeFace.nearest(face.normal()).shade();
+ }
+
+ // A box can be tilted around a point, on one axis or, since 1.21.?, on all three; rescaling stretches a tilted
+ // plant back to the full block width.
+ private static Transform tiltOf(JsonObject element) {
+ if (!element.has("rotation")) {
+ return Transform.IDENTITY;
+ }
+ JsonObject rotation = element.getAsJsonObject("rotation");
+ Vec3 origin = rotation.has("origin") ? vector(rotation.getAsJsonArray("origin")).times(1D / MODEL_UNITS) : new Vec3(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER);
+ Transform around = Transform.IDENTITY.translate(origin.x(), origin.y(), origin.z());
+ Transform back = Transform.IDENTITY.translate(-origin.x(), -origin.y(), -origin.z());
+ if (!rotation.has("axis")) {
+ return around
+ .rotateX(numberOr(rotation, "x", 0D))
+ .rotateY(numberOr(rotation, "y", 0D))
+ .rotateZ(numberOr(rotation, "z", 0D))
+ .then(back);
+ }
+ String axis = rotation.get("axis").getAsString();
+ double angle = numberOr(rotation, "angle", 0D);
+ boolean rescale = rotation.has("rescale") && rotation.get("rescale").getAsBoolean();
+ double stretch = rescale ? 1D / Math.cos(Math.toRadians(angle)) : 1D;
+ Transform stretched = around.scale(axis.equals("x") ? 1D : stretch, axis.equals("y") ? 1D : stretch, axis.equals("z") ? 1D : stretch);
+ Transform tilted = switch (axis) {
+ case "x" -> stretched.rotateX(angle);
+ case "y" -> stretched.rotateY(angle);
+ default -> stretched.rotateZ(angle);
+ };
+ return tilted.then(back);
+ }
+
+ private static double numberOr(JsonObject object, String key, double fallback) {
+ return object.has(key) ? object.get(key).getAsDouble() : fallback;
+ }
+
+ private static Vec3 vector(JsonArray values) {
+ return new Vec3(values.get(0).getAsDouble(), values.get(1).getAsDouble(), values.get(2).getAsDouble());
+ }
+
+ /**
+ * How a blockstate turns its model: around the x axis first, then the y axis, both in quarter turns against the
+ * right-hand rule. With uvlock the textures stay put while the shape turns.
+ */
+ private record BlockRotation(int x, int y, boolean uvlock) {
+ static BlockRotation of(JsonObject placement) {
+ int x = placement.has("x") ? Math.floorMod(placement.get("x").getAsInt(), 360) : 0;
+ int y = placement.has("y") ? Math.floorMod(placement.get("y").getAsInt(), 360) : 0;
+ return new BlockRotation(x, y, placement.has("uvlock") && placement.get("uvlock").getAsBoolean());
+ }
+
+ boolean locksTextures() {
+ return uvlock && (x != 0 || y != 0);
+ }
+
+ Transform transform() {
+ return Transform.IDENTITY
+ .translate(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER)
+ .then(turn())
+ .translate(-BLOCK_CENTER, -BLOCK_CENTER, -BLOCK_CENTER);
+ }
+
+ Transform turn() {
+ return Transform.IDENTITY.rotateY(-y).rotateX(-x);
+ }
+
+ Transform inverseTurn() {
+ return Transform.IDENTITY.rotateX(x).rotateY(y);
+ }
+ }
+
+ /**
+ * A face's corner UVs in model pixels and the quarter turn of the texture on it.
+ */
+ private record FaceUv(double u1, double v1, double u2, double v2, int rotation) {
+ static FaceUv of(JsonObject face, CubeFace side, Vec3 from, Vec3 to) {
+ int rotation = face.has("rotation") ? Math.floorMod(face.get("rotation").getAsInt(), 360) : 0;
+ if (!face.has("uv")) {
+ return byDefault(side, from, to).withRotation(rotation);
+ }
+ JsonArray uv = face.getAsJsonArray("uv");
+ return new FaceUv(uv.get(0).getAsDouble(), uv.get(1).getAsDouble(), uv.get(2).getAsDouble(), uv.get(3).getAsDouble(), rotation);
+ }
+
+ // The part of the texture the face would cover if the texture were laid over the whole block side.
+ static FaceUv byDefault(CubeFace side, Vec3 from, Vec3 to) {
+ double full = MODEL_UNITS;
+ return switch (side) {
+ case DOWN -> new FaceUv(from.x(), full - to.z(), to.x(), full - from.z(), 0);
+ case UP -> new FaceUv(from.x(), from.z(), to.x(), to.z(), 0);
+ case NORTH -> new FaceUv(full - to.x(), full - to.y(), full - from.x(), full - from.y(), 0);
+ case SOUTH -> new FaceUv(from.x(), full - to.y(), to.x(), full - from.y(), 0);
+ case WEST -> new FaceUv(from.z(), full - to.y(), to.z(), full - from.y(), 0);
+ case EAST -> new FaceUv(full - to.z(), full - to.y(), full - from.z(), full - from.y(), 0);
+ };
+ }
+
+ FaceUv withRotation(int changedRotation) {
+ return new FaceUv(u1, v1, u2, v2, changedRotation);
+ }
+
+ // Each corner takes the UV a quarter turn further along for every quarter turn of the texture.
+ TextureMapping mapping() {
+ double[] origin = cornerUv(0);
+ double[] alongU = cornerUv(3);
+ double[] alongV = cornerUv(1);
+ return new TextureMapping(origin[0] / MODEL_UNITS, origin[1] / MODEL_UNITS,
+ (alongU[0] - origin[0]) / MODEL_UNITS, (alongU[1] - origin[1]) / MODEL_UNITS,
+ (alongV[0] - origin[0]) / MODEL_UNITS, (alongV[1] - origin[1]) / MODEL_UNITS);
+ }
+
+ private double[] cornerUv(int corner) {
+ return switch ((corner + rotation / QUARTER_TURN) % 4) {
+ case 0 -> new double[]{u1, v1};
+ case 1 -> new double[]{u1, v2};
+ case 2 -> new double[]{u2, v2};
+ default -> new double[]{u2, v1};
+ };
+ }
+
+ /**
+ * Minecraft's uvlock: the UVs are carried through the reverse of the blockstate's turn, so the texture keeps
+ * its place in the world while the face moves to another side.
+ */
+ FaceUv lockedTo(CubeFace side, BlockRotation blockRotation) {
+ CubeFace turnedSide = CubeFace.nearest(blockRotation.turn().direction(side.direction()));
+ Transform uvTurn = Transform.IDENTITY
+ .translate(BLOCK_CENTER, BLOCK_CENTER, BLOCK_CENTER)
+ .then(faceFromWorld(side))
+ .then(blockRotation.inverseTurn())
+ .then(worldFromFace(turnedSide))
+ .translate(-BLOCK_CENTER, -BLOCK_CENTER, -BLOCK_CENTER);
+ Vec3 first = uvTurn.point(new Vec3(u1 / MODEL_UNITS, v1 / MODEL_UNITS, 0D)).times(MODEL_UNITS);
+ Vec3 second = uvTurn.point(new Vec3(u2 / MODEL_UNITS, v2 / MODEL_UNITS, 0D)).times(MODEL_UNITS);
+ boolean keepsUOrder = Math.signum(u2 - u1) == Math.signum(second.x() - first.x());
+ boolean keepsVOrder = Math.signum(v2 - v1) == Math.signum(second.y() - first.y());
+ Vec3 textureRight = uvTurn.direction(new Vec3(Math.cos(Math.toRadians(rotation)), Math.sin(Math.toRadians(rotation)), 0D));
+ long quarterTurns = Math.round(Math.toDegrees(Math.atan2(textureRight.y(), textureRight.x())) / QUARTER_TURN);
+ return new FaceUv(
+ keepsUOrder ? first.x() : second.x(), keepsVOrder ? first.y() : second.y(),
+ keepsUOrder ? second.x() : first.x(), keepsVOrder ? second.y() : first.y(),
+ (int) Math.floorMod(-quarterTurns * QUARTER_TURN, 360L));
+ }
+
+ // Minecraft lays every side's texture out as if it were the south side, turned into place.
+ private static Transform worldFromFace(CubeFace side) {
+ return switch (side) {
+ case SOUTH -> Transform.IDENTITY;
+ case EAST -> Transform.IDENTITY.rotateY(90D);
+ case WEST -> Transform.IDENTITY.rotateY(-90D);
+ case NORTH -> Transform.IDENTITY.rotateY(180D);
+ case UP -> Transform.IDENTITY.rotateX(-90D);
+ case DOWN -> Transform.IDENTITY.rotateX(90D);
+ };
+ }
+
+ private static Transform faceFromWorld(CubeFace side) {
+ return switch (side) {
+ case SOUTH -> Transform.IDENTITY;
+ case EAST -> Transform.IDENTITY.rotateY(-90D);
+ case WEST -> Transform.IDENTITY.rotateY(90D);
+ case NORTH -> Transform.IDENTITY.rotateY(180D);
+ case UP -> Transform.IDENTITY.rotateX(90D);
+ case DOWN -> Transform.IDENTITY.rotateX(-90D);
+ };
+ }
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java b/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java
index b83f6830c..8c287deeb 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/BlockTextures.java
@@ -28,7 +28,6 @@ import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Arrays;
-import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -43,7 +42,7 @@ final class BlockTextures {
private static final String DEFAULT_NAMESPACE = "minecraft";
private static final int MAX_MODEL_PARENTS = 16;
private static final int MAX_TEXTURE_REFERENCES = 16;
- private static final int NO_TINT_INDEX = -1;
+ static final int NO_TINT_INDEX = -1;
private static final Object SHARED_LOCK = new Object();
private static CompletableFuture shared;
@@ -51,6 +50,8 @@ final class BlockTextures {
private final MinecraftBiomeColors biomeColors;
private final Map> resolvedTextures = new ConcurrentHashMap<>();
private final Map> resolvedModels = new ConcurrentHashMap<>();
+ private final Map> faceTextures = new ConcurrentHashMap<>();
+ private final BlockModelBuilder modelBuilder = new BlockModelBuilder(this);
private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) {
this.clientJar = clientJar;
@@ -109,7 +110,7 @@ final class BlockTextures {
* What the block looks like from every side, for drawing it in 3D.
*/
Optional model(BlockData block) {
- return resolvedModels.computeIfAbsent(block.getAsString(false), this::resolveModel);
+ return resolvedModels.computeIfAbsent(block.getAsString(false), modelBuilder::build);
}
private static BlockData withHalf(BlockData block, Bisected.Half half) {
@@ -140,32 +141,6 @@ final class BlockTextures {
}
}
- private Optional resolveModel(String blockState) {
- try {
- BlockStateId state = BlockStateId.parse(blockState);
- JsonObject variant = variantOf(state);
- if (variant == null || !variant.has("model")) {
- return Optional.empty();
- }
- ModelDefinition model = readModel(variant.get("model").getAsString());
- if (model.isSprite()) {
- return readTexture(state, model.spriteFace()).map(BlockModel::cross);
- }
- int rotationX = rotationOf(variant, "x");
- int rotationY = rotationOf(variant, "y");
- Map faces = new EnumMap<>(CubeFace.class);
- for (CubeFace face : CubeFace.values()) {
- ModelFace modelFace = model.sideFace(face.beforeRotation(rotationX, rotationY).modelName());
- if (modelFace != null) {
- readTexture(state, modelFace).ifPresent(texture -> faces.put(face, texture));
- }
- }
- return faces.isEmpty() ? Optional.empty() : Optional.of(BlockModel.box(model.box().rotated(rotationX, rotationY), faces));
- } catch (IOException | RuntimeException error) {
- return Optional.empty();
- }
- }
-
private JsonObject variantOf(BlockStateId state) throws IOException {
JsonObject definition = readJson(assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
return definition == null ? null : pickVariant(definition, state.properties());
@@ -175,6 +150,35 @@ final class BlockTextures {
return variant.has(axis) ? Math.floorMod(variant.get(axis).getAsInt(), 360) : 0;
}
+ /**
+ * One model face's texture with the block's tint, shared by every block state that uses it.
+ */
+ Optional faceTexture(BlockStateId state, String texture, int tintIndex) {
+ int fixedTint = MinecraftBlockTints.fixedTint(state.name(), state.properties(), tintIndex);
+ BiomeTint biomeTint = MinecraftBlockTints.biomeTint(state.name(), tintIndex).orElse(null);
+ return faceTextures.computeIfAbsent(texture + "|" + fixedTint + "|" + biomeTint, key -> {
+ try {
+ return readTexture(state, new ModelFace(List.of(texture), tintIndex, false));
+ } catch (IOException | RuntimeException error) {
+ return Optional.empty();
+ }
+ });
+ }
+
+ /**
+ * A texture drawn on a block entity, like a chest, whole and tinted with a fixed colour.
+ */
+ Optional entityTexture(String texture, int tint) {
+ return faceTextures.computeIfAbsent(texture + "|entity|" + tint, key -> {
+ try {
+ BufferedImage image = readImage(assetPath(texture, "textures", ".png"));
+ return image == null ? Optional.empty() : Optional.of(BlockTexture.fromWholeImage(image, tint));
+ } catch (IOException | RuntimeException error) {
+ return Optional.empty();
+ }
+ });
+ }
+
private Optional readTexture(BlockStateId state, ModelFace face) throws IOException {
BufferedImage image = readLayeredImage(face.textures());
if (image == null) {
@@ -191,16 +195,18 @@ final class BlockTextures {
}
}
- // A block rotated on its x axis, like a sideways log, shows a side face on top.
+ // A block rotated on its x axis, like a sideways log, shows a side face on top. Blockstates turn the model
+ // against the right-hand rule, so 90 degrees brings the south side up.
private static String faceSeenFromAbove(JsonObject variant) {
return switch (rotationOf(variant, "x")) {
- case 90, 270 -> "north";
+ case 90 -> "south";
+ case 270 -> "north";
case 180 -> "down";
default -> "up";
};
}
- private ModelDefinition readModel(String modelReference) throws IOException {
+ ModelDefinition readModel(String modelReference) throws IOException {
Map textures = new HashMap<>();
JsonArray elements = null;
String current = modelReference;
@@ -211,7 +217,7 @@ final class BlockTextures {
}
if (model.has("textures")) {
for (Map.Entry texture : model.getAsJsonObject("textures").entrySet()) {
- textures.putIfAbsent(texture.getKey(), texture.getValue().getAsString());
+ textures.putIfAbsent(texture.getKey(), spriteName(texture.getValue()));
}
}
if (elements == null && model.has("elements")) {
@@ -222,6 +228,11 @@ final class BlockTextures {
return new ModelDefinition(textures, elements);
}
+ // Since 1.21.? a texture can also be an object naming its sprite, like glass's {"sprite": ..., "force_translucent": true}.
+ private static String spriteName(JsonElement texture) {
+ return texture.isJsonObject() ? texture.getAsJsonObject().get("sprite").getAsString() : texture.getAsString();
+ }
+
// Some plants draw a second sprite over the first, like the glowing fireflies on a firefly bush.
private static List spriteLayers(JsonArray elements, Map textures) {
Set layers = new LinkedHashSet<>();
@@ -256,7 +267,7 @@ final class BlockTextures {
return NO_TINT_INDEX;
}
- private static int tintIndexOf(JsonObject face) {
+ static int tintIndexOf(JsonObject face) {
return face.has("tintindex") ? face.get("tintindex").getAsInt() : NO_TINT_INDEX;
}
@@ -283,7 +294,7 @@ final class BlockTextures {
return best;
}
- private static String dereference(String reference, Map textures) {
+ static String dereference(String reference, Map textures) {
String resolved = reference;
for (int hop = 0; resolved != null && resolved.startsWith("#") && hop < MAX_TEXTURE_REFERENCES; hop++) {
resolved = textures.get(resolved.substring(1));
@@ -311,7 +322,7 @@ final class BlockTextures {
return null;
}
- private static boolean variantMatches(String variantKey, Map properties) {
+ static boolean variantMatches(String variantKey, Map properties) {
if (variantKey.isEmpty()) {
return true;
}
@@ -324,7 +335,7 @@ final class BlockTextures {
return true;
}
- private static boolean conditionMatches(JsonObject condition, Map properties) {
+ static boolean conditionMatches(JsonObject condition, Map properties) {
if (condition.has("OR")) {
for (JsonElement option : condition.getAsJsonArray("OR")) {
if (conditionMatches(option.getAsJsonObject(), properties)) {
@@ -354,7 +365,7 @@ final class BlockTextures {
return true;
}
- private static JsonObject firstModel(JsonElement models) {
+ static JsonObject firstModel(JsonElement models) {
if (models.isJsonArray()) {
JsonArray choices = models.getAsJsonArray();
return choices.isEmpty() ? null : choices.get(0).getAsJsonObject();
@@ -362,14 +373,14 @@ final class BlockTextures {
return models.getAsJsonObject();
}
- private static String assetPath(String resource, String folder, String extension) {
+ static String assetPath(String resource, String folder, String extension) {
int separator = resource.indexOf(':');
String namespace = separator < 0 ? DEFAULT_NAMESPACE : resource.substring(0, separator);
String path = separator < 0 ? resource : resource.substring(separator + 1);
return "assets/" + namespace + "/" + folder + "/" + path + extension;
}
- private JsonObject readJson(String path) throws IOException {
+ JsonObject readJson(String path) throws IOException {
return ClientJarFiles.readJson(clientJar, path);
}
@@ -407,7 +418,7 @@ final class BlockTextures {
/**
* A block model with its parents' textures merged in. Models drawn in code, like chests, have no elements.
*/
- private record ModelDefinition(Map textures, JsonArray elements) {
+ record ModelDefinition(Map textures, JsonArray elements) {
ModelFace visibleFace(String faceName) {
ModelFace face = sideFace(faceName);
if (face != null) {
@@ -430,14 +441,6 @@ final class BlockTextures {
return texture == null ? null : new ModelFace(List.of(texture), tintIndexOf(face), false);
}
- // Plants have only upright planes, so nothing of them faces up or down.
- boolean isSprite() {
- return elements != null
- && topmostFace(elements, CubeFace.UP.modelName()) == null
- && topmostFace(elements, CubeFace.DOWN.modelName()) == null
- && !spriteLayers(elements, textures).isEmpty();
- }
-
ModelFace spriteFace() {
return new ModelFace(spriteLayers(elements, textures), firstTintIndex(elements), true);
}
@@ -447,25 +450,13 @@ final class BlockTextures {
return particle == null ? null : new ModelFace(List.of(particle), NO_TINT_INDEX, false);
}
- // One box, like a slab or a carpet, keeps its shape; several, like stairs, become a full block.
- BlockModel.Box box() {
- if (elements == null || elements.size() != 1) {
- return BlockModel.Box.FULL;
- }
- JsonObject element = elements.get(0).getAsJsonObject();
- return BlockModel.Box.ofModelElement(coordinates(element.getAsJsonArray("from")), coordinates(element.getAsJsonArray("to")));
- }
-
- private static double[] coordinates(JsonArray values) {
- return new double[]{values.get(0).getAsDouble(), values.get(1).getAsDouble(), values.get(2).getAsDouble()};
- }
}
private record ResolvedTexture(BlockTexture texture, boolean isSideOnSprite) {
}
- private record BlockStateId(String namespace, String name, Map properties) {
- private static BlockStateId parse(String blockState) {
+ record BlockStateId(String namespace, String name, Map properties) {
+ static BlockStateId parse(String blockState) {
int propertiesStart = blockState.indexOf('[');
String id = propertiesStart < 0 ? blockState : blockState.substring(0, propertiesStart);
int separator = id.indexOf(':');
@@ -487,26 +478,45 @@ final class BlockTextures {
// Biome tinted textures keep their grey pixels, because the biome colour changes from column to column.
// See-through pixels get a darker shade of the average colour, and the opaque mask remembers which they were.
- record BlockTexture(int size, int[] pixels, boolean[] opaque, int averageColor, BiomeTint biomeTint) {
- private static final int OPAQUE_ALPHA = 128;
+ // Textures with partly see-through pixels, like stained glass, ice and water, keep each pixel's alpha too.
+ record BlockTexture(int width, int height, int[] pixels, boolean[] opaque, int[] alphas, int averageColor,
+ BiomeTint biomeTint) {
private static final double GAP_SHADE = 0.55D;
+ private static final int FULL_ALPHA = 255;
// Animated textures stack their frames vertically, so only the first square frame is used.
static BlockTexture fromImage(BufferedImage image, int fixedTint, BiomeTint biomeTint) {
- int size = image.getWidth();
- int[] pixels = new int[size * size];
+ return fromPixels(image, image.getWidth(), Math.min(image.getWidth(), image.getHeight()), fixedTint, biomeTint);
+ }
+
+ // Entity textures, like a chest's, are laid out for a model and can be wider than they are tall.
+ static BlockTexture fromWholeImage(BufferedImage image, int fixedTint) {
+ return fromPixels(image, image.getWidth(), image.getHeight(), fixedTint, null);
+ }
+
+ static BlockTexture solid(int color) {
+ return new BlockTexture(1, 1, new int[]{color}, new boolean[]{true}, null, color, null);
+ }
+
+ private static BlockTexture fromPixels(BufferedImage image, int width, int height, int fixedTint, BiomeTint biomeTint) {
+ int[] pixels = new int[width * height];
boolean[] opaque = new boolean[pixels.length];
+ int[] alphas = new int[pixels.length];
+ boolean translucent = false;
long red = 0;
long green = 0;
long blue = 0;
int opaqueCount = 0;
- for (int z = 0; z < size; z++) {
- for (int x = 0; x < size; x++) {
- int argb = image.getRGB(x, Math.min(z, image.getHeight() - 1));
- int index = z * size + x;
- if ((argb >>> 24) < OPAQUE_ALPHA) {
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ int argb = image.getRGB(x, y);
+ int index = y * width + x;
+ int alpha = argb >>> 24;
+ alphas[index] = alpha;
+ if (alpha == 0) {
continue;
}
+ translucent |= alpha < FULL_ALPHA;
int color = multiply(argb & 0xFFFFFF, fixedTint);
pixels[index] = color;
opaque[index] = true;
@@ -526,7 +536,23 @@ final class BlockTextures {
pixels[index] = gapColor;
}
}
- return new BlockTexture(size, pixels, opaque, average, biomeTint);
+ return new BlockTexture(width, height, pixels, opaque, translucent ? alphas : null, average, biomeTint);
+ }
+
+ boolean isTranslucent() {
+ return alphas != null;
+ }
+
+ boolean isFullyOpaque() {
+ if (isTranslucent()) {
+ return false;
+ }
+ for (boolean pixelOpaque : opaque) {
+ if (!pixelOpaque) {
+ return false;
+ }
+ }
+ return true;
}
BlockTexture flattened() {
@@ -534,7 +560,7 @@ final class BlockTextures {
Arrays.fill(flat, averageColor);
boolean[] allOpaque = new boolean[pixels.length];
Arrays.fill(allOpaque, true);
- return new BlockTexture(size, flat, allOpaque, averageColor, biomeTint);
+ return new BlockTexture(width, height, flat, allOpaque, null, averageColor, biomeTint);
}
BlockTexture tinted(int tint) {
@@ -542,7 +568,7 @@ final class BlockTextures {
for (int index = 0; index < pixels.length; index++) {
tintedPixels[index] = multiply(pixels[index], tint);
}
- return new BlockTexture(size, tintedPixels, opaque, multiply(averageColor, tint), null);
+ return new BlockTexture(width, height, tintedPixels, opaque, alphas, multiply(averageColor, tint), null);
}
int texelAt(double worldX, double worldZ) {
@@ -554,14 +580,14 @@ final class BlockTextures {
}
private int texelIndex(double worldX, double worldZ) {
- int x = Math.min(size - 1, (int) ((worldX - Math.floor(worldX)) * size));
- int z = Math.min(size - 1, (int) ((worldZ - Math.floor(worldZ)) * size));
- return z * size + x;
+ int x = Math.min(width - 1, (int) ((worldX - Math.floor(worldX)) * width));
+ int z = Math.min(height - 1, (int) ((worldZ - Math.floor(worldZ)) * height));
+ return z * width + x;
}
BufferedImage toImage() {
- BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
- image.setRGB(0, 0, size, size, pixels, 0, size);
+ BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ image.setRGB(0, 0, width, height, pixels, 0, width);
return image;
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ClientJarFiles.java b/core/src/main/java/com/volmit/iris/core/gui/ClientJarFiles.java
index 1e5b79e3f..3bc31994a 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/ClientJarFiles.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/ClientJarFiles.java
@@ -23,7 +23,9 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import javax.imageio.ImageIO;
+import java.awt.color.ColorSpace;
import java.awt.image.BufferedImage;
+import java.awt.image.Raster;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
@@ -51,7 +53,30 @@ final class ClientJarFiles {
return null;
}
try (InputStream input = clientJar.getInputStream(entry)) {
- return ImageIO.read(input);
+ BufferedImage image = ImageIO.read(input);
+ return image != null && isGrey(image) ? withGreyKept(image) : image;
}
}
+
+ private static boolean isGrey(BufferedImage image) {
+ return image.getColorModel().getColorSpace().getType() == ColorSpace.TYPE_GRAY;
+ }
+
+ // Java treats grey PNGs, like stone's, as linear light and brightens them when converting to RGB; Minecraft reads
+ // the stored values as they are.
+ private static BufferedImage withGreyKept(BufferedImage image) {
+ Raster raster = image.getRaster();
+ boolean hasAlpha = raster.getNumBands() > 1;
+ int greyMax = (1 << raster.getSampleModel().getSampleSize(0)) - 1;
+ int alphaMax = hasAlpha ? (1 << raster.getSampleModel().getSampleSize(1)) - 1 : 1;
+ BufferedImage converted = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB);
+ for (int y = 0; y < image.getHeight(); y++) {
+ for (int x = 0; x < image.getWidth(); x++) {
+ int grey = raster.getSample(x, y, 0) * 255 / greyMax;
+ int alpha = hasAlpha ? raster.getSample(x, y, 1) * 255 / alphaMax : 255;
+ converted.setRGB(x, y, alpha << 24 | grey << 16 | grey << 8 | grey);
+ }
+ }
+ return converted;
+ }
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/CubeFace.java b/core/src/main/java/com/volmit/iris/core/gui/CubeFace.java
index d319a3bf3..e5853bfe0 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/CubeFace.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/CubeFace.java
@@ -16,18 +16,19 @@
* along with this program. If not, see .
*/
+
package com.volmit.iris.core.gui;
/**
- * The six sides of a block, named as in Minecraft's model files, with the fixed light each side gets in the object viewer.
+ * The six sides of a block, named as in Minecraft's model files, with the light Minecraft gives each side.
*/
enum CubeFace {
- UP("up", 0, 1, 0, 1.00D),
- DOWN("down", 0, -1, 0, 0.48D),
- NORTH("north", 0, 0, -1, 0.62D),
- SOUTH("south", 0, 0, 1, 0.88D),
- EAST("east", 1, 0, 0, 0.80D),
- WEST("west", -1, 0, 0, 0.69D);
+ UP("up", 0, 1, 0, 1.0D),
+ DOWN("down", 0, -1, 0, 0.5D),
+ NORTH("north", 0, 0, -1, 0.8D),
+ SOUTH("south", 0, 0, 1, 0.8D),
+ EAST("east", 1, 0, 0, 0.6D),
+ WEST("west", -1, 0, 0, 0.6D);
private final String modelName;
private final int stepX;
@@ -43,6 +44,31 @@ enum CubeFace {
this.shade = shade;
}
+ static CubeFace named(String modelName) {
+ for (CubeFace face : values()) {
+ if (face.modelName.equals(modelName)) {
+ return face;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The side a direction points at most, like Minecraft picks for a tilted face.
+ */
+ static CubeFace nearest(Vec3 direction) {
+ CubeFace nearest = UP;
+ double best = Double.NEGATIVE_INFINITY;
+ for (CubeFace face : values()) {
+ double alignment = face.stepX * direction.x() + face.stepY * direction.y() + face.stepZ * direction.z();
+ if (alignment > best) {
+ best = alignment;
+ nearest = face;
+ }
+ }
+ return nearest;
+ }
+
String modelName() {
return modelName;
}
@@ -63,51 +89,26 @@ enum CubeFace {
return shade;
}
- /**
- * The side of the model that ends up facing this way once a blockstate turns the model, which Minecraft does around
- * the x axis first and the y axis second, each in steps of 90 degrees.
- */
- CubeFace beforeRotation(int rotationX, int rotationY) {
- int[] direction = {stepX, stepY, stepZ};
- direction = turnAroundY(direction, -rotationY);
- direction = turnAroundX(direction, -rotationX);
- return pointing(direction[0], direction[1], direction[2]);
+ Vec3 direction() {
+ return new Vec3(stepX, stepY, stepZ);
}
- static int[] turnAroundX(int[] vector, int degrees) {
- int cos = cosine(degrees);
- int sin = sine(degrees);
- return new int[]{vector[0], vector[1] * cos - vector[2] * sin, vector[1] * sin + vector[2] * cos};
- }
-
- static int[] turnAroundY(int[] vector, int degrees) {
- int cos = cosine(degrees);
- int sin = sine(degrees);
- return new int[]{vector[0] * cos - vector[2] * sin, vector[1], vector[0] * sin + vector[2] * cos};
- }
-
- private static int cosine(int degrees) {
- return switch (Math.floorMod(degrees, 360)) {
- case 90, 270 -> 0;
- case 180 -> -1;
- default -> 1;
+ CubeFace opposite() {
+ return switch (this) {
+ case UP -> DOWN;
+ case DOWN -> UP;
+ case NORTH -> SOUTH;
+ case SOUTH -> NORTH;
+ case EAST -> WEST;
+ case WEST -> EAST;
};
}
- private static int sine(int degrees) {
- return switch (Math.floorMod(degrees, 360)) {
- case 90 -> 1;
- case 270 -> -1;
- default -> 0;
- };
+ int axis() {
+ return stepX != 0 ? 0 : stepY != 0 ? 1 : 2;
}
- private static CubeFace pointing(int x, int y, int z) {
- for (CubeFace face : values()) {
- if (face.stepX == x && face.stepY == y && face.stepZ == z) {
- return face;
- }
- }
- throw new IllegalArgumentException("Not a block side: " + x + ", " + y + ", " + z);
+ boolean pointsPositive() {
+ return stepX + stepY + stepZ > 0;
}
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/EntityBlockShapes.java b/core/src/main/java/com/volmit/iris/core/gui/EntityBlockShapes.java
new file mode 100644
index 000000000..20d55f884
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/EntityBlockShapes.java
@@ -0,0 +1,298 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+
+package com.volmit.iris.core.gui;
+
+import com.volmit.iris.core.gui.BlockModel.Face;
+import com.volmit.iris.core.gui.BlockModel.TextureMapping;
+import com.volmit.iris.core.gui.BlockTextures.BlockStateId;
+import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
+import com.volmit.iris.core.gui.BlockTextures.ModelDefinition;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Shapes for the blocks Minecraft draws in code instead of from a model file, like chests, heads, banners and
+ * fluids, which fill their block. Chests, heads, banners and the bell use their entity textures laid out the way Minecraft's model parts are;
+ * rarer ones are a simple box in their particle texture.
+ */
+final class EntityBlockShapes {
+ private static final Set INVISIBLE = Set.of("air", "cave_air", "void_air", "barrier", "light",
+ "structure_void", "moving_piston", "pitcher_crop");
+ private static final int END_PORTAL_COLOR = 0x0E1219;
+ private static final double SKULL_WALL_OFFSET = 0.25D;
+ private static final double ROTATION_SEGMENT_DEGREES = 22.5D;
+ private static final double BANNER_SCALE = 2D / 3D;
+ private static final double MODEL_UNITS = 16D;
+ private static final Map DYE_COLORS = Map.ofEntries(
+ Map.entry("white", 0xF9FFFE), Map.entry("orange", 0xF9801D), Map.entry("magenta", 0xC74EBD),
+ Map.entry("light_blue", 0x3AB3DA), Map.entry("yellow", 0xFED83D), Map.entry("lime", 0x80C71F),
+ Map.entry("pink", 0xF38BAA), Map.entry("gray", 0x474F52), Map.entry("light_gray", 0x9D9D97),
+ Map.entry("cyan", 0x169C9C), Map.entry("purple", 0x8932B8), Map.entry("blue", 0x3C44AA),
+ Map.entry("brown", 0x835432), Map.entry("green", 0x5E7C16), Map.entry("red", 0xB02E26),
+ Map.entry("black", 0x1D1D21));
+
+ private final BlockTextures textures;
+
+ EntityBlockShapes(BlockTextures textures) {
+ this.textures = textures;
+ }
+
+ /**
+ * The faces of a block whose model has no boxes of its own.
+ */
+ List facesOf(BlockStateId state, ModelDefinition model) {
+ String name = state.name();
+ if (INVISIBLE.contains(name)) {
+ return List.of();
+ }
+ if (name.endsWith("chest")) {
+ return chest(state);
+ }
+ if (name.endsWith("_skull") || name.endsWith("_head")) {
+ return head(state, model);
+ }
+ if (name.endsWith("banner")) {
+ return banner(state);
+ }
+ return switch (name) {
+ case "conduit" -> particleBox(state, model, new Vec3(5, 5, 5), new Vec3(11, 11, 11), false);
+ case "decorated_pot" -> decoratedPot(state, model);
+ case "end_portal" -> BlockModelBuilder.box(new Vec3(0, 0, 0), new Vec3(16, 12, 16), BlockTexture.solid(END_PORTAL_COLOR), true);
+ case "end_gateway" -> BlockModelBuilder.box(new Vec3(0, 0, 0), new Vec3(16, 16, 16), BlockTexture.solid(END_PORTAL_COLOR), true);
+ default -> name.endsWith("copper_golem_statue")
+ ? particleBox(state, model, new Vec3(4, 0, 4), new Vec3(12, 14, 12), false)
+ : particleBox(state, model, new Vec3(0, 0, 0), new Vec3(16, 16, 16), true);
+ };
+ }
+
+ /**
+ * Parts a block draws in code on top of its model, like the bell hanging in its frame.
+ */
+ List facesAddedTo(BlockStateId state) {
+ if (!state.name().equals("bell")) {
+ return List.of();
+ }
+ Optional texture = textures.entityTexture("entity/bell/bell_body", MinecraftBlockTints.NO_TINT);
+ if (texture.isEmpty()) {
+ return List.of();
+ }
+ List faces = new ArrayList<>();
+ faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(5, 6, 5), new Vec3(6, 7, 6), Transform.IDENTITY));
+ faces.addAll(modelPartBox(texture.get(), 0, 13, new Vec3(4, 4, 4), new Vec3(8, 2, 8), Transform.IDENTITY));
+ return faces;
+ }
+
+ private List particleBox(BlockStateId state, ModelDefinition model, Vec3 from, Vec3 to, boolean culledBySides) {
+ String particle = BlockTextures.dereference("#particle", model.textures());
+ if (particle == null) {
+ return List.of();
+ }
+ return textures.faceTexture(state, particle, BlockTextures.NO_TINT_INDEX)
+ .map(texture -> BlockModelBuilder.box(from, to, texture, culledBySides))
+ .orElse(List.of());
+ }
+
+ private List decoratedPot(BlockStateId state, ModelDefinition model) {
+ List faces = new ArrayList<>(particleBox(state, model, new Vec3(1, 0, 1), new Vec3(15, 16, 15), false));
+ faces.addAll(particleBox(state, model, new Vec3(4, 16, 4), new Vec3(12, 20, 12), false));
+ return faces;
+ }
+
+ // Single chests are 14 pixels wide; each half of a double chest is 15, reaching over to its partner.
+ private List chest(BlockStateId state) {
+ String type = state.properties().getOrDefault("type", "single");
+ String suffix = type.equals("single") ? "" : "_" + type;
+ Optional texture = textures.entityTexture("entity/chest/" + chestTextureName(state.name()) + suffix, MinecraftBlockTints.NO_TINT);
+ if (texture.isEmpty()) {
+ return List.of();
+ }
+ Transform place = Transform.IDENTITY
+ .translate(0.5D, 0.5D, 0.5D)
+ .rotateY(-yRotationOf(state.properties().getOrDefault("facing", "north")))
+ .translate(-0.5D, -0.5D, -0.5D);
+ double left = type.equals("left") ? 0D : 1D;
+ double width = type.equals("single") ? 14D : 15D;
+ double lockX = switch (type) {
+ case "left" -> 0D;
+ case "right" -> 15D;
+ default -> 7D;
+ };
+ double lockWidth = type.equals("single") ? 2D : 1D;
+ List faces = new ArrayList<>();
+ faces.addAll(modelPartBox(texture.get(), 0, 19, new Vec3(left, 0, 1), new Vec3(width, 10, 14), place));
+ faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(left, 9, 1), new Vec3(width, 5, 14), place));
+ faces.addAll(modelPartBox(texture.get(), 0, 0, new Vec3(lockX, 7, 15), new Vec3(lockWidth, 4, 1), place));
+ return faces;
+ }
+
+ private static String chestTextureName(String blockName) {
+ String name = blockName.startsWith("waxed_") ? blockName.substring("waxed_".length()) : blockName;
+ return switch (name) {
+ case "trapped_chest" -> "trapped";
+ case "ender_chest" -> "ender";
+ case "copper_chest" -> "copper";
+ case "exposed_copper_chest" -> "copper_exposed";
+ case "weathered_copper_chest" -> "copper_weathered";
+ case "oxidized_copper_chest" -> "copper_oxidized";
+ default -> "normal";
+ };
+ }
+
+ // Heads are drawn upside down and mirrored in their model, and turn in sixteenths like signs.
+ private List head(BlockStateId state, ModelDefinition model) {
+ boolean onWall = state.name().contains("_wall_");
+ String kind = state.name().replace("_wall_", "_");
+ Optional look = HeadLook.of(kind);
+ Optional texture = look.flatMap(found -> textures.entityTexture(found.texture(), MinecraftBlockTints.NO_TINT));
+ if (texture.isEmpty()) {
+ return onWall
+ ? particleBox(state, model, new Vec3(4, 4, 8), new Vec3(12, 12, 16), false)
+ : particleBox(state, model, new Vec3(4, 0, 4), new Vec3(12, 8, 12), false);
+ }
+ Transform place;
+ if (onWall) {
+ CubeFace facing = CubeFace.named(state.properties().getOrDefault("facing", "north"));
+ place = Transform.IDENTITY
+ .translate(0.5D - facing.stepX() * SKULL_WALL_OFFSET, SKULL_WALL_OFFSET, 0.5D - facing.stepZ() * SKULL_WALL_OFFSET)
+ .scale(-1D, -1D, 1D)
+ .rotateY(yRotationOf(facing.opposite().modelName()));
+ } else {
+ int rotation = Integer.parseInt(state.properties().getOrDefault("rotation", "0"));
+ place = Transform.IDENTITY
+ .translate(0.5D, 0D, 0.5D)
+ .scale(-1D, -1D, 1D)
+ .rotateY(rotation * ROTATION_SEGMENT_DEGREES);
+ }
+ HeadLook found = look.get();
+ return modelPartBox(texture.get(), 0, 0, new Vec3(-found.width() / 2D, -8, -4), new Vec3(found.width(), 8, 8), place);
+ }
+
+ private List banner(BlockStateId state) {
+ boolean onWall = state.name().endsWith("_wall_banner");
+ String color = state.name().substring(0, state.name().length() - (onWall ? "_wall_banner" : "_banner").length());
+ Optional frame = textures.entityTexture("entity/banner/banner_base", MinecraftBlockTints.NO_TINT);
+ Optional cloth = textures.entityTexture("entity/banner/base", DYE_COLORS.getOrDefault(color, MinecraftBlockTints.NO_TINT));
+ if (frame.isEmpty() || cloth.isEmpty()) {
+ return List.of();
+ }
+ Transform place;
+ if (onWall) {
+ CubeFace facing = CubeFace.named(state.properties().getOrDefault("facing", "north"));
+ place = Transform.IDENTITY
+ .translate(0.5D, -1D / 6D, 0.5D)
+ .rotateY(-yRotationOf(facing.modelName()))
+ .translate(0D, -0.3125D, -0.4375D)
+ .scale(BANNER_SCALE, -BANNER_SCALE, -BANNER_SCALE);
+ } else {
+ int rotation = Integer.parseInt(state.properties().getOrDefault("rotation", "0"));
+ place = Transform.IDENTITY
+ .translate(0.5D, 0.5D, 0.5D)
+ .rotateY(-rotation * ROTATION_SEGMENT_DEGREES)
+ .scale(BANNER_SCALE, -BANNER_SCALE, -BANNER_SCALE);
+ }
+ List faces = new ArrayList<>();
+ if (!onWall) {
+ faces.addAll(modelPartBox(frame.get(), 44, 0, new Vec3(-1, -30, -1), new Vec3(2, 42, 2), place));
+ }
+ faces.addAll(modelPartBox(frame.get(), 0, 42, new Vec3(-10, -32, -1), new Vec3(20, 2, 2), place));
+ faces.addAll(modelPartBox(cloth.get(), 0, 0, new Vec3(-10, -32, -2), new Vec3(20, 40, 1), place));
+ return faces;
+ }
+
+ // Minecraft's facing angle: south is 0 and each quarter turn clockwise seen from above adds 90 degrees.
+ private static double yRotationOf(String facing) {
+ return switch (facing) {
+ case "west" -> 90D;
+ case "north" -> 180D;
+ case "east" -> 270D;
+ default -> 0D;
+ };
+ }
+
+ /**
+ * A box of a block entity's model, in model pixels, with its sides laid out on the texture the way Minecraft's
+ * model parts are: the top and bottom in a row above the four sides.
+ */
+ static List modelPartBox(BlockTexture texture, int textureU, int textureV, Vec3 from, Vec3 size, Transform place) {
+ double minX = from.x() / MODEL_UNITS;
+ double minY = from.y() / MODEL_UNITS;
+ double minZ = from.z() / MODEL_UNITS;
+ double maxX = (from.x() + size.x()) / MODEL_UNITS;
+ double maxY = (from.y() + size.y()) / MODEL_UNITS;
+ double maxZ = (from.z() + size.z()) / MODEL_UNITS;
+ Vec3 v0 = new Vec3(minX, minY, minZ);
+ Vec3 v1 = new Vec3(maxX, minY, minZ);
+ Vec3 v2 = new Vec3(maxX, maxY, minZ);
+ Vec3 v3 = new Vec3(minX, maxY, minZ);
+ Vec3 v4 = new Vec3(minX, minY, maxZ);
+ Vec3 v5 = new Vec3(maxX, minY, maxZ);
+ Vec3 v6 = new Vec3(maxX, maxY, maxZ);
+ Vec3 v7 = new Vec3(minX, maxY, maxZ);
+ double u = textureU;
+ double v = textureV;
+ double width = size.x();
+ double height = size.y();
+ double depth = size.z();
+ List faces = new ArrayList<>(6);
+ faces.add(polygon(texture, place, CubeFace.DOWN, v5, v4, v0, u + depth, v, u + depth + width, v + depth));
+ faces.add(polygon(texture, place, CubeFace.UP, v2, v3, v7, u + depth + width, v + depth, u + depth + width + width, v));
+ faces.add(polygon(texture, place, CubeFace.WEST, v0, v4, v7, u, v + depth, u + depth, v + depth + height));
+ faces.add(polygon(texture, place, CubeFace.NORTH, v1, v0, v3, u + depth, v + depth, u + depth + width, v + depth + height));
+ faces.add(polygon(texture, place, CubeFace.EAST, v5, v1, v2, u + depth + width, v + depth, u + depth + width + depth, v + depth + height));
+ faces.add(polygon(texture, place, CubeFace.SOUTH, v4, v5, v6, u + depth + width + depth, v + depth, u + depth + width + depth + width, v + depth + height));
+ return faces;
+ }
+
+ // A model part's polygon gives its first corner (u2, v1), its second (u1, v1) and its third (u1, v2).
+ private static Face polygon(BlockTexture texture, Transform place, CubeFace side, Vec3 first, Vec3 second, Vec3 third,
+ double u1, double v1, double u2, double v2) {
+ double textureWidth = texture.width();
+ double textureHeight = texture.height();
+ TextureMapping mapping = new TextureMapping(u1 / textureWidth, v1 / textureHeight,
+ (u2 - u1) / textureWidth, 0D, 0D, (v2 - v1) / textureHeight);
+ Face face = new Face(second, first.minus(second), third.minus(second), side.direction(), texture, mapping, side.shade(), null)
+ .transformed(place);
+ return face.withShade(CubeFace.nearest(face.normal()).shade());
+ }
+
+ /**
+ * The entity texture of a mob head and how wide its head is.
+ */
+ private record HeadLook(String texture, double width) {
+ private static final double HEAD_WIDTH = 8D;
+ private static final double PIGLIN_HEAD_WIDTH = 10D;
+
+ static Optional of(String kind) {
+ return Optional.ofNullable(switch (kind) {
+ case "skeleton_skull" -> new HeadLook("entity/skeleton/skeleton", HEAD_WIDTH);
+ case "wither_skeleton_skull" -> new HeadLook("entity/skeleton/wither_skeleton", HEAD_WIDTH);
+ case "zombie_head" -> new HeadLook("entity/zombie/zombie", HEAD_WIDTH);
+ case "creeper_head" -> new HeadLook("entity/creeper/creeper", HEAD_WIDTH);
+ case "player_head" -> new HeadLook("entity/player/wide/steve", HEAD_WIDTH);
+ case "piglin_head" -> new HeadLook("entity/piglin/piglin", PIGLIN_HEAD_WIDTH);
+ default -> null;
+ });
+ }
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java
index 945d878c0..1644b8d00 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectExplorer.java
@@ -29,7 +29,6 @@ import javax.swing.JComponent;
import java.awt.EventQueue;
import java.awt.image.BufferedImage;
import java.util.Arrays;
-import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
@@ -182,7 +181,7 @@ final class ObjectExplorer {
viewport.show(loaded.mesh());
overview.show(loaded.summary());
statusBar.showSummary(String.format(Locale.ROOT, "%s · %,d blocks · %,d faces drawn",
- loaded.summary().key(), loaded.summary().blockCount(), loaded.mesh().quads().size()));
+ loaded.summary().key(), loaded.summary().blockCount(), loaded.mesh().faceCount()));
}
private void showFailure(long revision, String key, Throwable error) {
@@ -228,12 +227,7 @@ final class ObjectExplorer {
// Objects have no biome of their own, so grass and leaves get plains' colours.
private BlockModel withBiomeTint(BlockModel model) {
- if (model.isCross()) {
- return BlockModel.cross(withBiomeTint(model.crossSprite()));
- }
- Map faces = new EnumMap<>(CubeFace.class);
- model.faces().forEach((face, texture) -> faces.put(face, withBiomeTint(texture)));
- return BlockModel.box(model.box(), faces);
+ return model.withTextures(this::withBiomeTint);
}
private BlockTexture withBiomeTint(BlockTexture texture) {
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectMesh.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectMesh.java
index 287231756..b32037f79 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/ObjectMesh.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectMesh.java
@@ -16,9 +16,10 @@
* along with this program. If not, see .
*/
+
package com.volmit.iris.core.gui;
-import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
+import com.volmit.iris.core.gui.BlockModel.Face;
import org.bukkit.block.data.BlockData;
import org.bukkit.util.BlockVector;
@@ -28,154 +29,72 @@ import java.util.Map;
import java.util.function.Function;
/**
- * The sides of an object's blocks that can be seen from outside, as flat textured quads. Sides pressed against a solid
- * neighbour are left out, which is most of them in a large object.
+ * The faces of an object's blocks that can be seen from outside. A face is left out when the neighbour on its side
+ * covers that side, which is most of them in a large object. See-through faces, like water and stained glass, are
+ * kept apart because they are drawn last, blended over everything behind them.
*/
-record ObjectMesh(List quads, Bounds bounds) {
- private static final double CROSS_SHADE = 0.9D;
-
+record ObjectMesh(List solidQuads, List translucentQuads, Bounds bounds) {
static ObjectMesh build(Map blocks, Function modelOf) {
- List quads = new ArrayList<>();
+ List solid = new ArrayList<>();
+ List translucent = new ArrayList<>();
for (Map.Entry entry : blocks.entrySet()) {
BlockVector position = entry.getKey();
BlockData block = entry.getValue();
BlockModel model = modelOf.apply(block);
- if (model.isCross()) {
- addCross(quads, position, model.crossSprite());
- continue;
- }
- for (Map.Entry face : model.faces().entrySet()) {
- if (!isCoveredByNeighbour(blocks, modelOf, position, block, model, face.getKey())) {
- quads.add(Quad.side(position, model.box(), face.getKey(), face.getValue()));
+ for (Face face : model.faces()) {
+ if (isHiddenByNeighbour(blocks, modelOf, position, block, model, face)) {
+ continue;
}
+ Quad quad = new Quad(face, position.getBlockX(), position.getBlockY(), position.getBlockZ());
+ (face.texture().isTranslucent() ? translucent : solid).add(quad);
}
}
- return new ObjectMesh(List.copyOf(quads), Bounds.of(blocks.keySet()));
+ return new ObjectMesh(List.copyOf(solid), List.copyOf(translucent), Bounds.of(blocks.keySet()));
}
boolean isEmpty() {
- return quads.isEmpty();
+ return solidQuads.isEmpty() && translucentQuads.isEmpty();
}
- // Leaves next to leaves and glass next to glass hide the side between them too, as they do in the game.
- private static boolean isCoveredByNeighbour(Map blocks, Function modelOf,
- BlockVector position, BlockData block, BlockModel model, CubeFace face) {
- if (!touchesBlockEdge(model.box(), face)) {
+ int faceCount() {
+ return solidQuads.size() + translucentQuads.size();
+ }
+
+ // Glass next to glass and water next to water hide the side between them too, as they do in the game.
+ private static boolean isHiddenByNeighbour(Map blocks, Function modelOf,
+ BlockVector position, BlockData block, BlockModel model, Face face) {
+ CubeFace side = face.cullFace();
+ if (side == null) {
return false;
}
BlockData neighbour = blocks.get(new BlockVector(
- position.getBlockX() + face.stepX(),
- position.getBlockY() + face.stepY(),
- position.getBlockZ() + face.stepZ()));
+ position.getBlockX() + side.stepX(),
+ position.getBlockY() + side.stepY(),
+ position.getBlockZ() + side.stepZ()));
if (neighbour == null) {
return false;
}
- if (neighbour.equals(block) && model.box().isFull()) {
+ BlockModel neighbourModel = modelOf.apply(neighbour);
+ if (neighbourModel.coveredSides().contains(side.opposite())) {
return true;
}
- return modelOf.apply(neighbour).hidesTouchingSides();
- }
-
- private static boolean touchesBlockEdge(BlockModel.Box box, CubeFace face) {
- return switch (face) {
- case UP -> box.maxY() >= 1D;
- case DOWN -> box.minY() <= 0D;
- case NORTH -> box.minZ() <= 0D;
- case SOUTH -> box.maxZ() >= 1D;
- case EAST -> box.maxX() >= 1D;
- case WEST -> box.minX() <= 0D;
- };
- }
-
- private static void addCross(List quads, BlockVector position, BlockTexture sprite) {
- double x = position.getBlockX();
- double y = position.getBlockY();
- double z = position.getBlockZ();
- quads.add(new Quad(x, y + 1D, z, 1D, 0D, 1D, 0D, -1D, 0D, sprite, 0D, 0D, 1D, 1D, CROSS_SHADE, 0D, 0D, 0D));
- quads.add(new Quad(x, y + 1D, z + 1D, 1D, 0D, -1D, 0D, -1D, 0D, sprite, 0D, 0D, 1D, 1D, CROSS_SHADE, 0D, 0D, 0D));
+ return neighbour.getMaterial() == block.getMaterial() && model.hidesSameBlockSides() && neighbourModel.hidesSameBlockSides();
}
/**
- * A parallelogram from its corner along two edges. Texture u runs along the first edge and v along the second,
- * starting at {@code textureU}, {@code textureV} and covering {@code textureWidth} by {@code textureHeight} of
- * the texture. A zero normal marks a quad seen from both sides, like a plant's sprite.
+ * A model face placed at a block in the object.
*/
- record Quad(double originX, double originY, double originZ,
- double edgeUX, double edgeUY, double edgeUZ,
- double edgeVX, double edgeVY, double edgeVZ,
- BlockTexture texture, double textureU, double textureV, double textureWidth, double textureHeight,
- double shade, double normalX, double normalY, double normalZ) {
-
- boolean isTwoSided() {
- return normalX == 0D && normalY == 0D && normalZ == 0D;
+ record Quad(Face face, int x, int y, int z) {
+ double originX() {
+ return x + face.origin().x();
}
- // Each side is laid out as seen from outside the block: u to the right, v downwards, matching Minecraft's UVs.
- static Quad side(BlockVector position, BlockModel.Box box, CubeFace face, BlockTexture texture) {
- double[] origin = switch (face) {
- case UP -> new double[]{box.minX(), box.maxY(), box.minZ()};
- case DOWN -> new double[]{box.minX(), box.minY(), box.maxZ()};
- case NORTH -> new double[]{box.maxX(), box.maxY(), box.minZ()};
- case SOUTH -> new double[]{box.minX(), box.maxY(), box.maxZ()};
- case EAST -> new double[]{box.maxX(), box.maxY(), box.maxZ()};
- case WEST -> new double[]{box.minX(), box.maxY(), box.minZ()};
- };
- Edge u = switch (face) {
- case UP, DOWN, SOUTH -> Edge.POSITIVE_X;
- case NORTH -> Edge.NEGATIVE_X;
- case EAST -> Edge.NEGATIVE_Z;
- case WEST -> Edge.POSITIVE_Z;
- };
- Edge v = switch (face) {
- case UP -> Edge.POSITIVE_Z;
- case DOWN -> Edge.NEGATIVE_Z;
- default -> Edge.NEGATIVE_Y;
- };
- double uLength = box.max(u.axis()) - box.min(u.axis());
- double vLength = box.max(v.axis()) - box.min(v.axis());
- return new Quad(
- position.getBlockX() + origin[0], position.getBlockY() + origin[1], position.getBlockZ() + origin[2],
- u.x() * uLength, u.y() * uLength, u.z() * uLength,
- v.x() * vLength, v.y() * vLength, v.z() * vLength,
- texture, u.textureStart(origin), v.textureStart(origin), uLength, vLength,
- face.shade(), face.stepX(), face.stepY(), face.stepZ());
- }
- }
-
- private enum Edge {
- POSITIVE_X(0, 1),
- NEGATIVE_X(0, -1),
- NEGATIVE_Y(1, -1),
- POSITIVE_Z(2, 1),
- NEGATIVE_Z(2, -1);
-
- private final int axis;
- private final int sign;
-
- Edge(int axis, int sign) {
- this.axis = axis;
- this.sign = sign;
+ double originY() {
+ return y + face.origin().y();
}
- int axis() {
- return axis;
- }
-
- double x() {
- return axis == 0 ? sign : 0D;
- }
-
- double y() {
- return axis == 1 ? sign : 0D;
- }
-
- double z() {
- return axis == 2 ? sign : 0D;
- }
-
- // An edge running against its axis starts its texture at the far side, like Minecraft's default UVs.
- double textureStart(double[] origin) {
- return sign > 0 ? origin[axis] : 1D - origin[axis];
+ double originZ() {
+ return z + face.origin().z();
}
}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java b/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java
index 61b118bcb..60c37117d 100644
--- a/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java
+++ b/core/src/main/java/com/volmit/iris/core/gui/ObjectRenderer.java
@@ -18,15 +18,21 @@
package com.volmit.iris.core.gui;
+import com.volmit.iris.core.gui.BlockModel.Face;
+import com.volmit.iris.core.gui.BlockModel.TextureMapping;
import com.volmit.iris.core.gui.BlockTextures.BlockTexture;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
/**
* Draws an object mesh with an orthographic camera into a picture, without a graphics card: each quad is filled pixel
- * by pixel, and a depth buffer keeps the nearest one.
+ * by pixel, and a depth buffer keeps the nearest one. See-through quads come last, farthest first, each blended over
+ * what is already drawn.
*/
final class ObjectRenderer {
private static final int BACKGROUND = StudioTheme.EDITOR_BACKGROUND.getRGB() & 0xFFFFFF;
@@ -37,6 +43,9 @@ final class ObjectRenderer {
// Shared quad edges land exactly on pixel centres now and then; a little overlap keeps them from leaving cracks.
private static final double EDGE_TOLERANCE = 1e-6D;
private static final double EDGE_ON_DETERMINANT = 1e-9D;
+ // A face's own texels end a hair inside its UV rectangle, so rounding never picks the texel next to it.
+ private static final double TEXEL_EDGE_MARGIN = 1e-6D;
+ private static final double FULL_ALPHA = 255D;
private final int width;
private final int height;
@@ -62,12 +71,29 @@ final class ObjectRenderer {
Arrays.fill(renderer.pixels, BACKGROUND);
Arrays.fill(renderer.depths, Double.NEGATIVE_INFINITY);
renderer.drawGrid(mesh.bounds());
- for (ObjectMesh.Quad quad : mesh.quads()) {
- renderer.drawQuad(quad);
+ for (ObjectMesh.Quad quad : mesh.solidQuads()) {
+ renderer.drawQuad(quad, false);
+ }
+ for (ObjectMesh.Quad quad : renderer.farthestFirst(mesh.translucentQuads())) {
+ renderer.drawQuad(quad, true);
}
return image;
}
+ private List farthestFirst(List quads) {
+ List sorted = new ArrayList<>(quads);
+ sorted.sort(Comparator.comparingDouble(this::centerDepth));
+ return sorted;
+ }
+
+ private double centerDepth(ObjectMesh.Quad quad) {
+ Face face = quad.face();
+ return projection.depth(
+ quad.originX() + (face.edgeU().x() + face.edgeV().x()) / 2D,
+ quad.originY() + (face.edgeU().y() + face.edgeV().y()) / 2D,
+ quad.originZ() + (face.edgeU().z() + face.edgeV().z()) / 2D);
+ }
+
private void drawGrid(ObjectMesh.Bounds bounds) {
int reach = (int) Math.ceil(bounds.largestSize() * GRID_REACH);
int step = Math.max(1, (int) Math.ceil(reach / (double) GRID_LINES_PER_SIDE));
@@ -104,19 +130,23 @@ final class ObjectRenderer {
}
}
- private void drawQuad(ObjectMesh.Quad quad) {
- if (!quad.isTwoSided() && !projection.facesCamera(quad.normalX(), quad.normalY(), quad.normalZ())) {
+ // Later faces win ties, so an overlay drawn on the same box, like grass on a grass block's side, shows on top.
+ private void drawQuad(ObjectMesh.Quad quad, boolean blended) {
+ Face face = quad.face();
+ if (!projection.facesCamera(face.normal().x(), face.normal().y(), face.normal().z())) {
return;
}
+ Vec3 u = face.edgeU();
+ Vec3 v = face.edgeV();
double originX = projection.screenX(quad.originX(), quad.originY(), quad.originZ());
double originY = projection.screenY(quad.originX(), quad.originY(), quad.originZ());
double originDepth = projection.depth(quad.originX(), quad.originY(), quad.originZ());
- double edgeUX = projection.screenStepX(quad.edgeUX(), quad.edgeUZ());
- double edgeUY = projection.screenStepY(quad.edgeUX(), quad.edgeUY(), quad.edgeUZ());
- double edgeUDepth = projection.depthStep(quad.edgeUX(), quad.edgeUY(), quad.edgeUZ());
- double edgeVX = projection.screenStepX(quad.edgeVX(), quad.edgeVZ());
- double edgeVY = projection.screenStepY(quad.edgeVX(), quad.edgeVY(), quad.edgeVZ());
- double edgeVDepth = projection.depthStep(quad.edgeVX(), quad.edgeVY(), quad.edgeVZ());
+ double edgeUX = projection.screenStepX(u.x(), u.z());
+ double edgeUY = projection.screenStepY(u.x(), u.y(), u.z());
+ double edgeUDepth = projection.depthStep(u.x(), u.y(), u.z());
+ double edgeVX = projection.screenStepX(v.x(), v.z());
+ double edgeVY = projection.screenStepY(v.x(), v.y(), v.z());
+ double edgeVDepth = projection.depthStep(v.x(), v.y(), v.z());
double determinant = edgeUX * edgeVY - edgeUY * edgeVX;
if (Math.abs(determinant) < EDGE_ON_DETERMINANT) {
return;
@@ -130,10 +160,14 @@ final class ObjectRenderer {
return;
}
- BlockTexture texture = quad.texture();
- int textureSize = texture.size();
+ BlockTexture texture = face.texture();
+ TextureMapping mapping = face.mapping();
+ TexelRange columns = TexelRange.of(mapping.u(), mapping.uAlongU(), mapping.uAlongV(), texture.width());
+ TexelRange rows = TexelRange.of(mapping.v(), mapping.vAlongU(), mapping.vAlongV(), texture.height());
int[] texels = texture.pixels();
boolean[] opaque = texture.opaque();
+ int[] alphas = texture.alphas();
+ double shade = face.shade();
double alongUPerPixelX = edgeVY / determinant;
double alongVPerPixelX = -edgeUY / determinant;
for (int y = top; y <= bottom; y++) {
@@ -148,23 +182,32 @@ final class ObjectRenderer {
}
int index = rowStart + x;
double depth = originDepth + alongU * edgeUDepth + alongV * edgeVDepth;
- if (depth <= depths[index]) {
+ if (depth < depths[index]) {
continue;
}
- int texelX = texelCoordinate(quad.textureU() + alongU * quad.textureWidth(), textureSize);
- int texelY = texelCoordinate(quad.textureV() + alongV * quad.textureHeight(), textureSize);
- int texel = texelY * textureSize + texelX;
+ int texelX = columns.texelAt(mapping.u() + alongU * mapping.uAlongU() + alongV * mapping.uAlongV());
+ int texelY = rows.texelAt(mapping.v() + alongU * mapping.vAlongU() + alongV * mapping.vAlongV());
+ int texel = texelY * texture.width() + texelX;
if (!opaque[texel]) {
continue;
}
+ int color = BlockPreviewColors.shade(texels[texel], shade);
+ if (blended) {
+ pixels[index] = blend(color, pixels[index], alphas == null ? 1D : alphas[texel] / FULL_ALPHA);
+ continue;
+ }
depths[index] = depth;
- pixels[index] = BlockPreviewColors.shade(texels[texel], quad.shade());
+ pixels[index] = color;
}
}
}
- private static int texelCoordinate(double fraction, int textureSize) {
- return Math.max(0, Math.min(textureSize - 1, (int) (fraction * textureSize)));
+ private static int blend(int over, int under, double coverage) {
+ return blendChannel(over, under, coverage, 16) << 16 | blendChannel(over, under, coverage, 8) << 8 | blendChannel(over, under, coverage, 0);
+ }
+
+ private static int blendChannel(int over, int under, double coverage, int shift) {
+ return (int) Math.round(((over >>> shift) & 0xFF) * coverage + ((under >>> shift) & 0xFF) * (1D - coverage));
}
private static double min4(double a, double b, double c, double d) {
@@ -175,6 +218,28 @@ final class ObjectRenderer {
return Math.max(Math.max(a, b), Math.max(c, d));
}
+ /**
+ * The texels a face covers along one texture axis, so a pixel on the face's edge never takes its neighbour's.
+ */
+ private record TexelRange(int first, int last, int size) {
+ static TexelRange of(double start, double alongU, double alongV, int size) {
+ double low = start + Math.min(0D, alongU) + Math.min(0D, alongV);
+ double high = start + Math.max(0D, alongU) + Math.max(0D, alongV);
+ int first = clamp((int) Math.floor(low * size + TEXEL_EDGE_MARGIN), size);
+ int last = Math.max(first, clamp((int) Math.ceil(high * size - TEXEL_EDGE_MARGIN) - 1, size));
+ return new TexelRange(first, last, size);
+ }
+
+ int texelAt(double fraction) {
+ int texel = (int) Math.floor(fraction * size);
+ return texel < first ? first : Math.min(texel, last);
+ }
+
+ private static int clamp(int texel, int size) {
+ return Math.max(0, Math.min(size - 1, texel));
+ }
+ }
+
/**
* Turns block positions into pixels. Larger depth is nearer the camera.
*/
diff --git a/core/src/main/java/com/volmit/iris/core/gui/Transform.java b/core/src/main/java/com/volmit/iris/core/gui/Transform.java
new file mode 100644
index 000000000..2f019b2b3
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/Transform.java
@@ -0,0 +1,107 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+
+package com.volmit.iris.core.gui;
+
+/**
+ * An affine transform built up like Minecraft's pose stack: each step applies to points before the steps added
+ * earlier. Rotations follow the right-hand rule, the way Minecraft turns model parts.
+ */
+final class Transform {
+ static final Transform IDENTITY = new Transform(new double[]{1D, 0D, 0D, 0D, 1D, 0D, 0D, 0D, 1D}, Vec3.ZERO);
+
+ private final double[] matrix;
+ private final Vec3 translation;
+
+ private Transform(double[] matrix, Vec3 translation) {
+ this.matrix = matrix;
+ this.translation = translation;
+ }
+
+ Transform translate(double x, double y, double z) {
+ return then(new Transform(IDENTITY.matrix, new Vec3(x, y, z)));
+ }
+
+ Transform rotateX(double degrees) {
+ double cos = cosine(degrees);
+ double sin = sine(degrees);
+ return then(new Transform(new double[]{1D, 0D, 0D, 0D, cos, -sin, 0D, sin, cos}, Vec3.ZERO));
+ }
+
+ Transform rotateY(double degrees) {
+ double cos = cosine(degrees);
+ double sin = sine(degrees);
+ return then(new Transform(new double[]{cos, 0D, sin, 0D, 1D, 0D, -sin, 0D, cos}, Vec3.ZERO));
+ }
+
+ Transform rotateZ(double degrees) {
+ double cos = cosine(degrees);
+ double sin = sine(degrees);
+ return then(new Transform(new double[]{cos, -sin, 0D, sin, cos, 0D, 0D, 0D, 1D}, Vec3.ZERO));
+ }
+
+ Transform scale(double x, double y, double z) {
+ return then(new Transform(new double[]{x, 0D, 0D, 0D, y, 0D, 0D, 0D, z}, Vec3.ZERO));
+ }
+
+ /**
+ * This transform applied after {@code next}.
+ */
+ Transform then(Transform next) {
+ double[] product = new double[9];
+ for (int row = 0; row < 3; row++) {
+ for (int column = 0; column < 3; column++) {
+ product[row * 3 + column] = matrix[row * 3] * next.matrix[column]
+ + matrix[row * 3 + 1] * next.matrix[3 + column]
+ + matrix[row * 3 + 2] * next.matrix[6 + column];
+ }
+ }
+ return new Transform(product, direction(next.translation).plus(translation));
+ }
+
+ Vec3 point(Vec3 point) {
+ return direction(point).plus(translation);
+ }
+
+ Vec3 direction(Vec3 direction) {
+ return new Vec3(
+ matrix[0] * direction.x() + matrix[1] * direction.y() + matrix[2] * direction.z(),
+ matrix[3] * direction.x() + matrix[4] * direction.y() + matrix[5] * direction.z(),
+ matrix[6] * direction.x() + matrix[7] * direction.y() + matrix[8] * direction.z());
+ }
+
+ // Quarter turns stay exact, so faces turned by a blockstate still line up with the block's sides.
+ private static double cosine(double degrees) {
+ return sine(degrees + 90D);
+ }
+
+ private static double sine(double degrees) {
+ double turned = ((degrees % 360D) + 360D) % 360D;
+ if (turned == 0D || turned == 180D) {
+ return 0D;
+ }
+ if (turned == 90D) {
+ return 1D;
+ }
+ if (turned == 270D) {
+ return -1D;
+ }
+ return Math.sin(Math.toRadians(turned));
+ }
+}
diff --git a/core/src/main/java/com/volmit/iris/core/gui/Vec3.java b/core/src/main/java/com/volmit/iris/core/gui/Vec3.java
new file mode 100644
index 000000000..7229db399
--- /dev/null
+++ b/core/src/main/java/com/volmit/iris/core/gui/Vec3.java
@@ -0,0 +1,47 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+
+package com.volmit.iris.core.gui;
+
+/**
+ * A position or direction in a block, in block units.
+ */
+record Vec3(double x, double y, double z) {
+ static final Vec3 ZERO = new Vec3(0D, 0D, 0D);
+
+ Vec3 plus(Vec3 other) {
+ return new Vec3(x + other.x, y + other.y, z + other.z);
+ }
+
+ Vec3 minus(Vec3 other) {
+ return new Vec3(x - other.x, y - other.y, z - other.z);
+ }
+
+ Vec3 times(double factor) {
+ return new Vec3(x * factor, y * factor, z * factor);
+ }
+
+ double component(int axis) {
+ return switch (axis) {
+ case 0 -> x;
+ case 1 -> y;
+ default -> z;
+ };
+ }
+}