Update Studio previews

- Merge the eight additional commits from PR #1254.
- Preserve closure guards while refreshing object and hidden 3D views.
- Stop hidden crossfade timers and verify frame retention.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-25 17:25:18 -04:00
commit 584e8758a7
8 files changed
+659 -12

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,103 @@
/*
* 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 javax.swing.Timer;
import java.util.concurrent.TimeUnit;
/**
* Keeps the last frame of what the explorer showed before on screen while the next thing loads, then fades it out over
* the first frame of the new one, so switching never shows an empty view in between.
*/
final class FrameCrossfade {
private static final long FADE_NANOS = TimeUnit.MILLISECONDS.toNanos(70L);
private static final int REPAINT_INTERVAL_MILLIS = 16;
private final Timer repaintTimer;
private Phase phase = Phase.NONE;
private NoiseRenderCoordinator.Result outgoing;
private long fadeStartedNanos;
FrameCrossfade(Runnable repaint) {
this.repaintTimer = new Timer(REPAINT_INTERVAL_MILLIS, event -> {
outgoing();
repaint.run();
});
}
/**
* Keeps showing the frame until the next one arrives. Without a frame, as when the switch before hasn't drawn yet,
* the frame already held stays.
*/
void hold(NoiseRenderCoordinator.Result frame) {
if (frame == null && outgoing != null) {
enter(Phase.HOLDING, outgoing);
return;
}
enter(frame == null ? Phase.NONE : Phase.HOLDING, frame);
}
/**
* The new frame is in, so the held one starts fading out over it.
*/
void reveal() {
if (phase != Phase.HOLDING) {
return;
}
fadeStartedNanos = System.nanoTime();
phase = Phase.FADING;
repaintTimer.start();
}
void clear() {
enter(Phase.NONE, null);
}
/**
* @return the frame to draw over the current one, or null when there is none
*/
NoiseRenderCoordinator.Result outgoing() {
if (phase == Phase.FADING && fadedShare() >= 1F) {
clear();
}
return outgoing;
}
float outgoingOpacity() {
return phase == Phase.FADING ? 1F - fadedShare() : 1F;
}
private float fadedShare() {
return Math.min(1F, (System.nanoTime() - fadeStartedNanos) / (float) FADE_NANOS);
}
private void enter(Phase nextPhase, NoiseRenderCoordinator.Result frame) {
phase = nextPhase;
outgoing = frame;
if (nextPhase != Phase.FADING) {
repaintTimer.stop();
}
}
private enum Phase {
NONE,
HOLDING,
FADING
}
}
@@ -37,7 +37,7 @@ enum MapView {
Group.BIOME_STREAMS, TileType.BIOME_LAND, KeyEvent.VK_6),
SEA_BIOMES("Sea biomes", "The sea biome each spot would get, also where the land is",
Group.BIOME_STREAMS, TileType.BIOME_SEA, KeyEvent.VK_7),
CAVE_BIOMES("Cave biomes", "The cave biome under each spot", Group.BIOME_STREAMS, TileType.CAVE_LAND, KeyEvent.VK_8),
CAVE_BIOMES("Cave land","The cave biome under each spot", Group.BIOME_STREAMS, TileType.CAVE_LAND, KeyEvent.VK_8),
PALETTES("Palettes", "The blocks each biome's palette layers place, with their textures",
Group.BLOCKS, null, KeyEvent.VK_P),
OBJECTS("Objects", "Every object in the pack in 3D, with the blocks it is made of",
@@ -0,0 +1,259 @@
/*
* 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 and a little above, each box's south and top faces with their 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. A top face shows half as tall as the box is deep, and boxes further back sit higher, as
* in a three-quarter view, each face lit as Minecraft lights its side. 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 double TOP_FORESHORTENING = 0.5D;
private static final CubeFace FRONT = CubeFace.SOUTH;
private static final CubeFace TOP = CubeFace.UP;
private static final int ALPHA_MASK = 0xFF000000;
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 view, or null when the model has no front or top faces with a texture
*/
static ModelFrontView draw(JsonArray elements, Map<String, String> textures, TextureImages images) throws IOException {
List<ViewedFace> faces = viewedFacesNearestLast(elements, textures);
if (faces.isEmpty()) {
return null;
}
Map<String, BufferedImage> loaded = new HashMap<>();
for (ViewedFace 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 view = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
int tintIndex = BlockTextures.NO_TINT_INDEX;
for (ViewedFace face : faces) {
BufferedImage texture = loaded.get(face.texture());
if (texture != null) {
paint(face, texture, view);
tintIndex = tintIndex == BlockTextures.NO_TINT_INDEX ? face.tintIndex() : tintIndex;
}
}
return hasVisiblePixel(view) ? new ModelFrontView(view, 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.modelName())) {
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;
}
// Boxes further south are nearer the viewer, and of two at the same depth the higher one hides the lower.
private static List<ViewedFace> viewedFacesNearestLast(JsonArray elements, Map<String, String> textures) {
List<ViewedFace> faces = new ArrayList<>();
for (JsonElement element : elements) {
JsonObject box = element.getAsJsonObject();
JsonObject boxFaces = box.getAsJsonObject("faces");
if (boxFaces == null) {
continue;
}
addFace(faces, box, boxFaces, TOP, textures);
addFace(faces, box, boxFaces, FRONT, textures);
}
faces.sort(Comparator.comparingDouble(ViewedFace::depth).thenComparingDouble(ViewedFace::height));
return faces;
}
private static void addFace(List<ViewedFace> faces, JsonObject box, JsonObject boxFaces, CubeFace side, Map<String, String> textures) {
if (!boxFaces.has(side.modelName())) {
return;
}
JsonObject face = boxFaces.getAsJsonObject(side.modelName());
String texture = BlockTextures.dereference(face.get("texture").getAsString(), textures);
if (texture != null) {
faces.add(side == TOP ? ViewedFace.top(box, face, texture) : ViewedFace.front(box, face, texture));
}
}
// Each pixel samples the texture at its centre, along the face's UVs, which run backwards on mirrored faces.
private static void paint(ViewedFace face, BufferedImage texture, BufferedImage view) {
int size = view.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) {
view.setRGB(x, y, argb & ALPHA_MASK | BlockPreviewColors.shade(argb, face.side().shade()));
}
}
}
}
// 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 view) {
for (int y = 0; y < view.getHeight(); y++) {
for (int x = 0; x < view.getWidth(); x++) {
if (view.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 face as it shows in the view, in model units with y running down from the top of the view. The texture's v
* runs from the face's top edge in the view to its bottom edge.
*/
private record ViewedFace(CubeFace side, String texture, int tintIndex, double left, double right, double top, double bottom,
double depth, double height, double u1, double v1, double u2, double v2) {
static ViewedFace front(JsonObject box, JsonObject face, String texture) {
Box bounds = Box.of(box);
double top = bounds.viewY(bounds.toY(), bounds.toZ());
double bottom = bounds.viewY(bounds.fromY(), bounds.toZ());
double[] uv = face.has("uv")
? uvOf(face.getAsJsonArray("uv"))
: new double[]{bounds.fromX(), MODEL_UNITS - bounds.toY(), bounds.toX(), MODEL_UNITS - bounds.fromY()};
return new ViewedFace(FRONT, texture, BlockTextures.tintIndexOf(face), bounds.fromX(), bounds.toX(), top, bottom,
bounds.toZ(), bounds.toY(), uv[0], uv[1], uv[2], uv[3]);
}
// The top's back edge is its north side, which the texture's v starts at.
static ViewedFace top(JsonObject box, JsonObject face, String texture) {
Box bounds = Box.of(box);
double top = bounds.viewY(bounds.toY(), bounds.fromZ());
double bottom = bounds.viewY(bounds.toY(), bounds.toZ());
double[] uv = face.has("uv")
? uvOf(face.getAsJsonArray("uv"))
: new double[]{bounds.fromX(), bounds.fromZ(), bounds.toX(), bounds.toZ()};
return new ViewedFace(TOP, texture, BlockTextures.tintIndexOf(face), bounds.fromX(), bounds.toX(), top, bottom,
bounds.toZ(), bounds.toY(), 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()};
}
}
private record Box(double fromX, double fromY, double fromZ, double toX, double toY, double toZ) {
static Box of(JsonObject box) {
JsonArray from = box.getAsJsonArray("from");
JsonArray to = box.getAsJsonArray("to");
return new Box(from.get(X).getAsDouble(), from.get(Y).getAsDouble(), from.get(Z).getAsDouble(),
to.get(X).getAsDouble(), to.get(Y).getAsDouble(), to.get(Z).getAsDouble());
}
// The front edge of the block stays put; everything further back rises with its distance from it.
double viewY(double modelY, double modelZ) {
return MODEL_UNITS - modelY - (MODEL_UNITS - modelZ) * TOP_FORESHORTENING;
}
}
}
@@ -59,6 +59,8 @@ import java.awt.Component;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.AlphaComposite;
import java.awt.Composite;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.RenderingHints;
@@ -94,6 +96,7 @@ public final class NoiseExplorerGUI extends JPanel {
private static final int REFINEMENT_DELAY_MILLIS = 180;
private static final long DEFAULT_SEED = 12345L;
private static final double TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL = 0.25D;
private static final int FULL_DETAIL_SAMPLE_STEP = 1;
private static final double PALETTE_START_BLOCKS_PER_PIXEL = 1D / 16D;
private static final long COARSE_SAMPLE_BUDGET = 4_096L;
private static final double REFINEMENT_TARGET_MILLIS = 250D;
@@ -137,6 +140,7 @@ public final class NoiseExplorerGUI extends JPanel {
private final NoiseRenderCoordinator renderCoordinator;
private final Timer refinementTimer;
private final Timer resizeTimer;
private final FrameCrossfade crossfade;
private final AtomicLong sourceLoadRevision = new AtomicLong();
private final AtomicLong catalogLoadRevision = new AtomicLong();
private final Runnable hotloadHook = this::handleHotload;
@@ -171,6 +175,7 @@ public final class NoiseExplorerGUI extends JPanel {
private PaletteLayerPreview palettePreview;
private int paletteDepth;
private NoiseRenderCoordinator.Result renderResult;
private NoiseRenderCoordinator.Result lastTimedFrame;
private NoiseRenderCoordinator.Request interactiveFrame;
private boolean interactiveFramePending;
private Point pointer;
@@ -220,6 +225,7 @@ public final class NoiseExplorerGUI extends JPanel {
this.refinementTimer.setRepeats(false);
this.resizeTimer = new Timer(RESIZE_SETTLE_MILLIS, event -> requestPreview());
this.resizeTimer.setRepeats(false);
this.crossfade = new FrameCrossfade(this::repaint);
this.fixedSources = buildFixedSources();
setBackground(StudioTheme.EDITOR_BACKGROUND);
setFocusable(true);
@@ -762,6 +768,7 @@ public final class NoiseExplorerGUI extends JPanel {
selectedSource = null;
clearSampler();
renderResult = null;
crossfade.clear();
sourceLoading = false;
rendering = false;
errorMessage = "Pack source removed during hotload: " + removedSource.text();
@@ -854,6 +861,7 @@ public final class NoiseExplorerGUI extends JPanel {
viewRevision++;
renderCoordinator.cancel(viewRevision);
sampler = null;
crossfade.hold(renderResult);
renderResult = null;
sourceLoading = true;
rendering = false;
@@ -899,6 +907,7 @@ public final class NoiseExplorerGUI extends JPanel {
return;
}
clearSampler();
crossfade.clear();
sourceLoading = false;
rendering = false;
errorMessage = readableError(error);
@@ -929,12 +938,17 @@ public final class NoiseExplorerGUI extends JPanel {
}
private NoiseColorScale activeColorScale() {
return activeColorScale(FULL_DETAIL_SAMPLE_STEP);
}
// A coarse frame's sample stands for a whole square of pixels, so texture pixels would show magnified there.
private NoiseColorScale activeColorScale(int sampleStep) {
PaletteLayerPreview preview = palettePreview;
if (preview == null) {
BlockRange blockRange = samplerBlockRange;
return blockRange == null ? palette : new BlockHeightColorScale(palette, blockRange.lowest(), blockRange.highest());
}
boolean showTexturePixels = viewport.blocksPerPixel() <= TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL;
boolean showTexturePixels = viewport.blocksPerPixel() * sampleStep <= TEXTURE_PIXELS_MAX_BLOCKS_PER_PIXEL;
return new PaletteLayerScale(paletteInspector.selectedView(), preview, palette, showTexturePixels);
}
@@ -1044,9 +1058,10 @@ public final class NoiseExplorerGUI extends JPanel {
return interactiveFrame != null && interactiveFrame.revision() == viewRevision;
}
// The last frame's speed predicts this one, so cheap views draw sharp straight away instead of starting blocky.
// The last frame's speed predicts this one, so cheap views draw sharp straight away instead of starting blocky. It
// outlives a switch to another source, which samples about as fast.
private long interactiveSampleBudget() {
NoiseRenderCoordinator.Result lastResult = renderResult;
NoiseRenderCoordinator.Result lastResult = lastTimedFrame;
if (lastResult == null) {
return COARSE_SAMPLE_BUDGET;
}
@@ -1104,7 +1119,7 @@ public final class NoiseExplorerGUI extends JPanel {
viewRevision,
sampler,
viewport,
activeColorScale(),
activeColorScale(sampleStep),
getWidth(),
renderHeight(),
sampleStep
@@ -1222,6 +1237,7 @@ public final class NoiseExplorerGUI extends JPanel {
}
refinementTimer.stop();
resizeTimer.stop();
crossfade.clear();
MapHost.unregisterHotloadHook(hotloadHook);
sourceLoadRevision.incrementAndGet();
catalogLoadRevision.incrementAndGet();
@@ -1246,6 +1262,7 @@ public final class NoiseExplorerGUI extends JPanel {
if (renderResult != null) {
drawLatestFrame(canvas, renderResult, getWidth(), renderHeight);
}
drawOutgoingFrame(canvas, getWidth(), renderHeight);
drawCrosshair(canvas, getWidth(), renderHeight);
drawLegend(canvas, getWidth());
} finally {
@@ -1267,6 +1284,17 @@ public final class NoiseExplorerGUI extends JPanel {
return true;
}
private void drawOutgoingFrame(Graphics2D canvas, int width, int renderHeight) {
NoiseRenderCoordinator.Result outgoing = crossfade.outgoing();
if (outgoing == null) {
return;
}
Composite opaque = canvas.getComposite();
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, crossfade.outgoingOpacity()));
drawLatestFrame(canvas, outgoing, width, renderHeight);
canvas.setComposite(opaque);
}
// Moves and scales the last finished frame to the current view, so the map follows the mouse before the next frame lands.
private void drawLatestFrame(Graphics2D canvas, NoiseRenderCoordinator.Result frame, int width, int renderHeight) {
NoiseRenderCoordinator.Request request = frame.request();
@@ -1618,6 +1646,8 @@ public final class NoiseExplorerGUI extends JPanel {
rendering = false;
errorMessage = null;
renderResult = result;
lastTimedFrame = result;
crossfade.reveal();
repaint();
refreshStatus();
if (finishInteractiveFrame(result.request())) {
@@ -406,8 +406,9 @@ final class OrbitViewport extends JComponent {
}
/**
* Frames go straight into the window when the scene can be drawn that way, Swing has nothing to paint over the
* whole view, and the screen's pixels are the view's own. The window is shown before the frame is drawn into it.
* Frames go straight into the window when the view is on screen, the scene can be drawn that way, Swing has nothing
* to paint over the whole view, and the screen's pixels are the view's own. The window is shown before the frame is
* drawn into it. Showing it maps it even while its card is hidden, where it would cover the card that is open.
*
* @return the window, or nothing to draw a picture for Swing instead
*/
@@ -422,8 +423,8 @@ final class OrbitViewport extends JComponent {
}
private boolean drawsOnScreen() {
return canvas != null && canvas.window() != 0L && scene != null && !scene.isEmpty() && scene.canRenderOnScreen()
&& !isLoadingShown() && hasUnscaledPixels();
return canvas != null && isShowing() && canvas.window() != 0L && scene != null && !scene.isEmpty()
&& scene.canRenderOnScreen() && !isLoadingShown() && hasUnscaledPixels();
}
private boolean hasUnscaledPixels() {
@@ -434,20 +435,21 @@ final class OrbitViewport extends JComponent {
/**
* Until the card is opened, nothing knows whether frames can go into the window; the first picture opens it. A
* frame that should have gone into the window but couldn't isn't tried again right away. A frame for a scene
* replaced meanwhile is never shown.
* replaced meanwhile is never shown. The frame counts as rendering until it is on its way to the screen, so a
* frame asked for while it is taken, like when it ends a load, can't be drawn over it first.
*/
private synchronized void finishFrame(OrbitScene frameScene, OrbitCamera frameCamera, ScreenTarget screen, OrbitPicture drawn,
boolean missedScreen, long requestedAtNanos) {
if (closed) {
return;
}
rendering = false;
measureFrameTime(System.nanoTime() - requestedAtNanos);
if (frameScene == scene) {
takePicture(drawn, frameCamera);
paintPicture(drawn);
showInWindow(screen, drawn);
}
rendering = false;
boolean canGoOnScreen = !missedScreen && canvas != null && !canvas.isVisible() && drawsOnScreen();
if (renderAgain || drawn.catchingUp() || canGoOnScreen) {
renderAgain = false;
@@ -0,0 +1,119 @@
package art.arcane.iris.studio.map;
import org.junit.After;
import org.junit.Test;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import java.lang.reflect.Field;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public final class FrameCrossfadeTest {
private final FrameCrossfade crossfade = new FrameCrossfade(() -> {
});
@After
public void stopRepaintTimer() throws Exception {
SwingUtilities.invokeAndWait(crossfade::clear);
}
@Test
public void heldFrameStaysFullyVisibleUntilTheNextFrameArrives() {
NoiseRenderCoordinator.Result frame = frame();
crossfade.hold(frame);
assertSame(frame, crossfade.outgoing());
assertEquals(1F, crossfade.outgoingOpacity(), 0F);
}
@Test
public void heldFrameFadesOnceTheNextFrameArrives() throws Exception {
SwingUtilities.invokeAndWait(() -> {
crossfade.hold(frame());
crossfade.reveal();
});
Thread.sleep(20L);
SwingUtilities.invokeAndWait(() -> assertTrue(crossfade.outgoing() == null || crossfade.outgoingOpacity() < 1F));
}
@Test
public void fadingStopsAndReleasesTheFrameWithoutPainting() throws Exception {
Timer timer = (Timer) field("repaintTimer");
SwingUtilities.invokeAndWait(() -> {
crossfade.hold(frame());
crossfade.reveal();
assertTrue(timer.isRunning());
});
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2L);
while (timer.isRunning() && System.nanoTime() < deadline) {
Thread.sleep(10L);
}
SwingUtilities.invokeAndWait(() -> { });
assertFalse("Hidden views must stop scheduling fade repaints", timer.isRunning());
assertNull(field("outgoing"));
}
@Test
public void holdingDuringFadeStopsRepaintsUntilAnotherFrameArrives() throws Exception {
Timer timer = (Timer) field("repaintTimer");
NoiseRenderCoordinator.Result frame = frame();
SwingUtilities.invokeAndWait(() -> {
crossfade.hold(frame);
crossfade.reveal();
crossfade.hold(null);
assertFalse("A held frame must not keep the fade timer running", timer.isRunning());
assertSame(frame, crossfade.outgoing());
assertEquals(1F, crossfade.outgoingOpacity(), 0F);
crossfade.reveal();
assertTrue(timer.isRunning());
});
}
@Test
public void switchingAgainBeforeAnythingDrewKeepsTheFrameHeld() {
NoiseRenderCoordinator.Result frame = frame();
crossfade.hold(frame);
crossfade.hold(null);
assertSame(frame, crossfade.outgoing());
}
@Test
public void frameWithoutAHeldFrameHasNothingToFade() {
crossfade.reveal();
assertNull(crossfade.outgoing());
}
@Test
public void clearDropsTheHeldFrame() {
crossfade.hold(frame());
crossfade.clear();
assertNull(crossfade.outgoing());
}
private static NoiseRenderCoordinator.Result frame() {
return new NoiseRenderCoordinator.Result(null, null, 1D, 1L, 0D, 1D, 0.5D, 0L, 0L, 0L);
}
private Object field(String name) throws Exception {
Field field = FrameCrossfade.class.getDeclaredField(name);
field.setAccessible(true);
return field.get(crossfade);
}
}
@@ -0,0 +1,103 @@
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(litAsFront(0, 5), front.image().getRGB(6, 7));
assertEquals(litAsFront(3, 10), front.image().getRGB(9, 12));
}
@Test
public void topShowsAboveTheFrontOverThePlaneJustUnderIt() throws Exception {
ModelFrontView front = draw(SEA_PICKLE);
assertEquals(texture.getRGB(5, 2), front.image().getRGB(7, 5));
}
@Test
public void crossedPlaneIsMirroredWhereItsUvRunsBackwards() throws Exception {
ModelFrontView front = draw(SEA_PICKLE);
assertEquals(litAsFront(1, 1), front.image().getRGB(8, 4));
}
@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 int litAsFront(int textureX, int textureY) {
return 0xFF000000 | BlockPreviewColors.shade(texture.getRGB(textureX, textureY), CubeFace.SOUTH.shade());
}
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;
}
}