mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-19 23:14:10 +00:00
Draw every block in the Objects view the way Minecraft models it
The 3D object view guessed block shapes: stairs, gates, lanterns and similar became full cubes, fences and walls showed only their post, glass fell back to grey, and vines and ladders became crossed plants. It now reads the real models: every matching multipart part, each box with its own texture, UVs and face rotation, tilted and rescaled boxes, blockstate turns with uvlock, cullfaces against covering neighbours, and Minecraft's side shading. Chests, heads, banners and the bell use their entity textures; fluids and other code-drawn blocks get simple shapes. See-through textures like water, ice and stained glass are blended back to front. Texture objects with a "sprite" key (glass in 26.x) are read, which also fixes them in the palette map, and grey PNGs such as stone are no longer brightened by Java's colour conversion. Model used: Claude Opus 5.5
This commit is contained in:
@@ -16,112 +16,198 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
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<CubeFace, BlockTexture> faces, BlockTexture crossSprite) {
|
||||
static BlockModel box(Box box, Map<CubeFace, BlockTexture> faces) {
|
||||
return new BlockModel(box, Map.copyOf(faces), null);
|
||||
}
|
||||
record BlockModel(List<Face> faces, Set<CubeFace> 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<Face> faces) {
|
||||
Set<CubeFace> 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<CubeFace, BlockTexture> faces = new EnumMap<>(CubeFace.class);
|
||||
for (CubeFace face : CubeFace.values()) {
|
||||
faces.put(face, texture);
|
||||
BlockTexture texture = BlockTexture.solid(color);
|
||||
List<Face> 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<BlockTexture> change) {
|
||||
Map<BlockTexture, BlockTexture> changed = new IdentityHashMap<>();
|
||||
List<Face> 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<Face> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
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<BlockModel> build(String blockState) {
|
||||
try {
|
||||
BlockStateId state = BlockStateId.parse(blockState);
|
||||
List<JsonObject> placements = placementsOf(state);
|
||||
if (placements == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Face> 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<Face> box(Vec3 fromPixels, Vec3 toPixels, BlockTexture texture, boolean culledBySides) {
|
||||
List<Face> 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<JsonObject> placementsOf(BlockStateId state) throws IOException {
|
||||
JsonObject definition = textures.readJson(BlockTextures.assetPath(state.namespace() + ":" + state.name(), "blockstates", ".json"));
|
||||
if (definition == null) {
|
||||
return null;
|
||||
}
|
||||
List<JsonObject> placements = new ArrayList<>();
|
||||
if (definition.has("variants")) {
|
||||
for (Map.Entry<String, JsonElement> 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<JsonObject> placements, JsonElement models) {
|
||||
JsonObject first = BlockTextures.firstModel(models);
|
||||
if (first != null && first.has("model")) {
|
||||
placements.add(first);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Face> 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<Face> faces = new ArrayList<>();
|
||||
for (JsonElement element : model.elements()) {
|
||||
addElementFaces(faces, state, model, element.getAsJsonObject(), rotation);
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
private void addElementFaces(List<Face> 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<String, JsonElement> 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<BlockTexture> 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<BlockTextures> shared;
|
||||
|
||||
@@ -51,6 +50,8 @@ final class BlockTextures {
|
||||
private final MinecraftBiomeColors biomeColors;
|
||||
private final Map<String, Optional<ResolvedTexture>> resolvedTextures = new ConcurrentHashMap<>();
|
||||
private final Map<String, Optional<BlockModel>> resolvedModels = new ConcurrentHashMap<>();
|
||||
private final Map<String, Optional<BlockTexture>> 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<BlockModel> 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<BlockModel> 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<CubeFace, BlockTexture> 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<BlockTexture> 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<BlockTexture> 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<BlockTexture> 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<String, String> textures = new HashMap<>();
|
||||
JsonArray elements = null;
|
||||
String current = modelReference;
|
||||
@@ -211,7 +217,7 @@ final class BlockTextures {
|
||||
}
|
||||
if (model.has("textures")) {
|
||||
for (Map.Entry<String, JsonElement> 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<String> spriteLayers(JsonArray elements, Map<String, String> textures) {
|
||||
Set<String> 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<String, String> textures) {
|
||||
static String dereference(String reference, Map<String, String> 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<String, String> properties) {
|
||||
static boolean variantMatches(String variantKey, Map<String, String> properties) {
|
||||
if (variantKey.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
@@ -324,7 +335,7 @@ final class BlockTextures {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean conditionMatches(JsonObject condition, Map<String, String> properties) {
|
||||
static boolean conditionMatches(JsonObject condition, Map<String, String> 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<String, String> textures, JsonArray elements) {
|
||||
record ModelDefinition(Map<String, String> 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<String, String> properties) {
|
||||
private static BlockStateId parse(String blockState) {
|
||||
record BlockStateId(String namespace, String name, Map<String, String> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,18 +16,19 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
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<String> 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<String, Integer> 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<Face> 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<Face> facesAddedTo(BlockStateId state) {
|
||||
if (!state.name().equals("bell")) {
|
||||
return List.of();
|
||||
}
|
||||
Optional<BlockTexture> texture = textures.entityTexture("entity/bell/bell_body", MinecraftBlockTints.NO_TINT);
|
||||
if (texture.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Face> 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<Face> 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<Face> decoratedPot(BlockStateId state, ModelDefinition model) {
|
||||
List<Face> 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<Face> chest(BlockStateId state) {
|
||||
String type = state.properties().getOrDefault("type", "single");
|
||||
String suffix = type.equals("single") ? "" : "_" + type;
|
||||
Optional<BlockTexture> 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<Face> 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<Face> head(BlockStateId state, ModelDefinition model) {
|
||||
boolean onWall = state.name().contains("_wall_");
|
||||
String kind = state.name().replace("_wall_", "_");
|
||||
Optional<HeadLook> look = HeadLook.of(kind);
|
||||
Optional<BlockTexture> 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<Face> 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<BlockTexture> frame = textures.entityTexture("entity/banner/banner_base", MinecraftBlockTints.NO_TINT);
|
||||
Optional<BlockTexture> 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<Face> 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<Face> 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<Face> 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<HeadLook> 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CubeFace, BlockTexture> 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) {
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
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<Quad> quads, Bounds bounds) {
|
||||
private static final double CROSS_SHADE = 0.9D;
|
||||
|
||||
record ObjectMesh(List<Quad> solidQuads, List<Quad> translucentQuads, Bounds bounds) {
|
||||
static ObjectMesh build(Map<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> modelOf) {
|
||||
List<Quad> quads = new ArrayList<>();
|
||||
List<Quad> solid = new ArrayList<>();
|
||||
List<Quad> translucent = new ArrayList<>();
|
||||
for (Map.Entry<BlockVector, BlockData> 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<CubeFace, BlockTexture> 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<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> 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<BlockVector, BlockData> blocks, Function<BlockData, BlockModel> 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<Quad> 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ObjectMesh.Quad> farthestFirst(List<ObjectMesh.Quad> quads) {
|
||||
List<ObjectMesh.Quad> 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.
|
||||
*/
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user