mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-26 14:00:26 +00:00
Draw small decorations from the front in palette previews
- Sea pickles, candles, lanterns, turtle eggs and potted plants show their shape instead of their whole unfolded texture - Flat decorations like pressure plates, rails and lily pads keep their top view Model used: Claude Opus 5.5
This commit is contained in:
1 parent
48bfa7d15b
commit
5204d94ebe
3 files changed
+345
-1
No files matched your search
@@ -51,6 +51,7 @@ final class BlockTextures {
|
||||
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 Map<String, Optional<BlockTexture>> icons = new ConcurrentHashMap<>();
|
||||
private final BlockModelBuilder modelBuilder = new BlockModelBuilder(this);
|
||||
|
||||
private BlockTextures(ZipFile clientJar, MinecraftBiomeColors biomeColors) {
|
||||
@@ -89,10 +90,40 @@ final class BlockTextures {
|
||||
|
||||
/**
|
||||
* Like {@link #topTexture}, but plants keep their sprite, so single decorations on the ground stay recognisable.
|
||||
* Small shapes standing on their spot, like sea pickles and candles, are drawn from the front for the same reason.
|
||||
* Two block tall plants give their bottom half, the part standing on the ground.
|
||||
*/
|
||||
Optional<BlockTexture> iconTexture(NativeBlockState block) {
|
||||
return resolve(withHalf(block, BlockHalf.LOWER).orElse(block)).map(ResolvedTexture::texture);
|
||||
NativeBlockState standing = withHalf(block, BlockHalf.LOWER).orElse(block);
|
||||
return icons.computeIfAbsent(standing.key(),
|
||||
state -> frontViewIcon(state).or(() -> resolve(standing).map(ResolvedTexture::texture)));
|
||||
}
|
||||
|
||||
// A small shape keeps all its parts side by side on one texture, so its top face's whole texture shows them all.
|
||||
private Optional<BlockTexture> frontViewIcon(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.elements() == null || model.sideFace("up") == null || ModelFrontView.fillsMostOfItsSpotFromAbove(model.elements())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
ModelFrontView front = ModelFrontView.draw(model.elements(), model.textures(),
|
||||
texture -> readImage(assetPath(texture, "textures", ".png")));
|
||||
if (front == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(BlockTexture.fromImage(
|
||||
front.image(),
|
||||
MinecraftBlockTints.fixedTint(state.name(), state.properties(), front.tintIndex()),
|
||||
MinecraftBlockTints.biomeTint(state.name(), front.tintIndex()).orElse(null)
|
||||
));
|
||||
} catch (IOException | RuntimeException error) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* 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 art.arcane.iris.studio.map;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A block model drawn flat as seen from the south, each box's south face with its own part of the texture. Small
|
||||
* shapes like sea pickles and candles keep all their parts on one texture, which only makes sense mapped onto the
|
||||
* boxes. Tilted boxes are drawn untilted, which is close for the thin crossed planes such shapes use.
|
||||
*/
|
||||
final class ModelFrontView {
|
||||
private static final double MODEL_UNITS = 16D;
|
||||
private static final String FRONT = "south";
|
||||
private static final String TOP = "up";
|
||||
private static final double MOSTLY_COVERED_SHARE = 0.5D;
|
||||
private static final int X = 0;
|
||||
private static final int Y = 1;
|
||||
private static final int Z = 2;
|
||||
|
||||
/**
|
||||
* Reads a texture's image by its sprite name.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
interface TextureImages {
|
||||
BufferedImage read(String texture) throws IOException;
|
||||
}
|
||||
|
||||
private final BufferedImage image;
|
||||
private final int tintIndex;
|
||||
|
||||
private ModelFrontView(BufferedImage image, int tintIndex) {
|
||||
this.image = image;
|
||||
this.tintIndex = tintIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the front view, or null when the model has no front faces with a texture
|
||||
*/
|
||||
static ModelFrontView draw(JsonArray elements, Map<String, String> textures, TextureImages images) throws IOException {
|
||||
List<FrontFace> faces = frontFacesNearestLast(elements, textures);
|
||||
if (faces.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Map<String, BufferedImage> loaded = new HashMap<>();
|
||||
for (FrontFace face : faces) {
|
||||
BufferedImage texture = images.read(face.texture());
|
||||
if (texture != null) {
|
||||
loaded.put(face.texture(), texture);
|
||||
}
|
||||
}
|
||||
if (loaded.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int size = loaded.values().iterator().next().getWidth();
|
||||
BufferedImage front = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
||||
int tintIndex = BlockTextures.NO_TINT_INDEX;
|
||||
for (FrontFace face : faces) {
|
||||
BufferedImage texture = loaded.get(face.texture());
|
||||
if (texture != null) {
|
||||
paint(face, texture, front);
|
||||
tintIndex = tintIndex == BlockTextures.NO_TINT_INDEX ? face.tintIndex() : tintIndex;
|
||||
}
|
||||
}
|
||||
return hasVisiblePixel(front) ? new ModelFrontView(front, tintIndex) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the model's top faces cover most of its spot, like a slab, a carpet or a pressure plate does, rather than
|
||||
* being a small thing standing on it.
|
||||
*/
|
||||
static boolean fillsMostOfItsSpotFromAbove(JsonArray elements) {
|
||||
int size = (int) MODEL_UNITS;
|
||||
return topCoveredPixels(elements, size) >= size * size * MOSTLY_COVERED_SHARE;
|
||||
}
|
||||
|
||||
private static int topCoveredPixels(JsonArray elements, int size) {
|
||||
boolean[] covered = new boolean[size * size];
|
||||
for (JsonElement element : elements) {
|
||||
JsonObject box = element.getAsJsonObject();
|
||||
JsonObject faces = box.getAsJsonObject("faces");
|
||||
if (faces == null || !faces.has(TOP)) {
|
||||
continue;
|
||||
}
|
||||
JsonArray from = box.getAsJsonArray("from");
|
||||
JsonArray to = box.getAsJsonArray("to");
|
||||
fill(covered, size, pixel(from.get(X), size), pixel(to.get(X), size), pixel(from.get(Z), size), pixel(to.get(Z), size));
|
||||
}
|
||||
int count = 0;
|
||||
for (boolean pixelCovered : covered) {
|
||||
count += pixelCovered ? 1 : 0;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
BufferedImage image() {
|
||||
return image;
|
||||
}
|
||||
|
||||
int tintIndex() {
|
||||
return tintIndex;
|
||||
}
|
||||
|
||||
// Faces further south are nearer the viewer, so they are painted over the ones behind them.
|
||||
private static List<FrontFace> frontFacesNearestLast(JsonArray elements, Map<String, String> textures) {
|
||||
List<FrontFace> faces = new ArrayList<>();
|
||||
for (JsonElement element : elements) {
|
||||
JsonObject box = element.getAsJsonObject();
|
||||
JsonObject boxFaces = box.getAsJsonObject("faces");
|
||||
if (boxFaces == null || !boxFaces.has(FRONT)) {
|
||||
continue;
|
||||
}
|
||||
JsonObject face = boxFaces.getAsJsonObject(FRONT);
|
||||
String texture = BlockTextures.dereference(face.get("texture").getAsString(), textures);
|
||||
if (texture != null) {
|
||||
faces.add(FrontFace.of(box, face, texture));
|
||||
}
|
||||
}
|
||||
faces.sort(Comparator.comparingDouble(FrontFace::depth));
|
||||
return faces;
|
||||
}
|
||||
|
||||
// Each pixel samples the texture at its centre, along the face's UVs, which run backwards on mirrored faces.
|
||||
private static void paint(FrontFace face, BufferedImage texture, BufferedImage front) {
|
||||
int size = front.getWidth();
|
||||
double scale = size / MODEL_UNITS;
|
||||
double left = face.left() * scale;
|
||||
double right = face.right() * scale;
|
||||
double top = face.top() * scale;
|
||||
double bottom = face.bottom() * scale;
|
||||
int textureSize = texture.getWidth();
|
||||
for (int y = (int) Math.round(top); y < Math.round(bottom); y++) {
|
||||
double alongV = (y + 0.5D - top) / (bottom - top);
|
||||
int textureY = texel(face.v1() + alongV * (face.v2() - face.v1()), textureSize);
|
||||
for (int x = (int) Math.round(left); x < Math.round(right); x++) {
|
||||
double alongU = (x + 0.5D - left) / (right - left);
|
||||
int textureX = texel(face.u1() + alongU * (face.u2() - face.u1()), textureSize);
|
||||
int argb = texture.getRGB(textureX, textureY);
|
||||
if (argb >>> 24 != 0 && x >= 0 && y >= 0 && x < size && y < size) {
|
||||
front.setRGB(x, y, argb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animated textures stack their frames, so the first frame is as tall as the texture is wide.
|
||||
private static int texel(double modelUnits, int textureSize) {
|
||||
return Math.clamp((int) Math.floor(modelUnits / MODEL_UNITS * textureSize), 0, textureSize - 1);
|
||||
}
|
||||
|
||||
private static boolean hasVisiblePixel(BufferedImage front) {
|
||||
for (int y = 0; y < front.getHeight(); y++) {
|
||||
for (int x = 0; x < front.getWidth(); x++) {
|
||||
if (front.getRGB(x, y) >>> 24 != 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int pixel(JsonElement modelUnits, int size) {
|
||||
return (int) Math.round(modelUnits.getAsDouble() / MODEL_UNITS * size);
|
||||
}
|
||||
|
||||
private static void fill(boolean[] covered, int size, int fromX, int toX, int fromZ, int toZ) {
|
||||
for (int z = Math.max(0, fromZ); z < Math.min(size, toZ); z++) {
|
||||
for (int x = Math.max(0, fromX); x < Math.min(size, toX); x++) {
|
||||
covered[z * size + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A south face in model units, with the image's y running down from the model's top.
|
||||
*/
|
||||
private record FrontFace(String texture, int tintIndex, double left, double right, double top, double bottom, double depth,
|
||||
double u1, double v1, double u2, double v2) {
|
||||
static FrontFace of(JsonObject box, JsonObject face, String texture) {
|
||||
JsonArray from = box.getAsJsonArray("from");
|
||||
JsonArray to = box.getAsJsonArray("to");
|
||||
double left = from.get(X).getAsDouble();
|
||||
double right = to.get(X).getAsDouble();
|
||||
double top = MODEL_UNITS - to.get(Y).getAsDouble();
|
||||
double bottom = MODEL_UNITS - from.get(Y).getAsDouble();
|
||||
double[] uv = face.has("uv") ? uvOf(face.getAsJsonArray("uv")) : new double[]{left, top, right, bottom};
|
||||
return new FrontFace(texture, BlockTextures.tintIndexOf(face), left, right, top, bottom, to.get(Z).getAsDouble(),
|
||||
uv[0], uv[1], uv[2], uv[3]);
|
||||
}
|
||||
|
||||
private static double[] uvOf(JsonArray uv) {
|
||||
return new double[]{uv.get(0).getAsDouble(), uv.get(1).getAsDouble(), uv.get(2).getAsDouble(), uv.get(3).getAsDouble()};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package art.arcane.iris.studio.map;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class ModelFrontViewTest {
|
||||
private static final String SEA_PICKLE = """
|
||||
[
|
||||
{ "from": [6, 0, 6], "to": [10, 6, 10], "faces": {
|
||||
"up": { "uv": [4, 1, 8, 5], "texture": "#all" },
|
||||
"south": { "uv": [0, 5, 4, 11], "texture": "#all" } } },
|
||||
{ "from": [6, 5.95, 6], "to": [10, 5.95, 10], "faces": {
|
||||
"up": { "uv": [8, 1, 12, 5], "texture": "#all" } } },
|
||||
{ "from": [7.5, 5.2, 8], "to": [8.5, 8.7, 8],
|
||||
"rotation": { "origin": [8, 8, 8], "axis": "y", "angle": 45, "rescale": true },
|
||||
"faces": { "south": { "uv": [3, 0, 1, 5], "texture": "#all" } } }
|
||||
]
|
||||
""";
|
||||
private static final String PRESSURE_PLATE = """
|
||||
[
|
||||
{ "from": [1, 0, 1], "to": [15, 1, 15], "faces": {
|
||||
"up": { "uv": [1, 1, 15, 15], "texture": "#all" },
|
||||
"south": { "uv": [1, 15, 15, 16], "texture": "#all" } } }
|
||||
]
|
||||
""";
|
||||
private static final Map<String, String> TEXTURES = Map.of("all", "block/test");
|
||||
private static final int SIZE = 16;
|
||||
|
||||
private final BufferedImage texture = everyTexelItsOwnColour();
|
||||
|
||||
@Test
|
||||
public void bodyShowsItsOwnPartOfTheTexture() throws Exception {
|
||||
ModelFrontView front = draw(SEA_PICKLE);
|
||||
|
||||
assertEquals(texture.getRGB(0, 5), front.image().getRGB(6, 10));
|
||||
assertEquals(texture.getRGB(3, 10), front.image().getRGB(9, 15));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void crossedPlaneIsMirroredWhereItsUvRunsBackwards() throws Exception {
|
||||
ModelFrontView front = draw(SEA_PICKLE);
|
||||
|
||||
assertEquals(texture.getRGB(1, 3), front.image().getRGB(8, 9));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void spotsNoBoxCoversStayClear() throws Exception {
|
||||
ModelFrontView front = draw(SEA_PICKLE);
|
||||
|
||||
assertEquals(0, front.image().getRGB(2, 12) >>> 24);
|
||||
assertEquals(0, front.image().getRGB(7, 2) >>> 24);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void smallShapeLeavesMostOfItsSpotOpen() {
|
||||
assertFalse(ModelFrontView.fillsMostOfItsSpotFromAbove(elements(SEA_PICKLE)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flatShapeFillsMostOfItsSpot() {
|
||||
assertTrue(ModelFrontView.fillsMostOfItsSpotFromAbove(elements(PRESSURE_PLATE)));
|
||||
}
|
||||
|
||||
private ModelFrontView draw(String elements) throws Exception {
|
||||
ModelFrontView front = ModelFrontView.draw(elements(elements), TEXTURES, name -> texture);
|
||||
assertNotNull(front);
|
||||
return front;
|
||||
}
|
||||
|
||||
private static JsonArray elements(String json) {
|
||||
return JsonParser.parseString(json).getAsJsonArray();
|
||||
}
|
||||
|
||||
private static BufferedImage everyTexelItsOwnColour() {
|
||||
BufferedImage image = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB);
|
||||
for (int y = 0; y < SIZE; y++) {
|
||||
for (int x = 0; x < SIZE; x++) {
|
||||
image.setRGB(x, y, 0xFF000000 | x << 16 | y << 8 | 0x40);
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user