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; + }; + } +}