Show object loading only when it takes a while

Old picture stays; slow loads play a block animation.
This commit is contained in:
piexel committed 2026-10-21 14:10:06 +02:00
1 parent 93f5615f00
commit 9eb56543a3
4 files changed
+293 -19

No files matched your search

@@ -0,0 +1,145 @@
/*
* 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 java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Path2D;
import java.awt.image.BufferedImage;
/**
* A little cube of blocks building itself while something loads: the blocks drop in one by one, the cube holds a
* moment, fades, and starts over. Loads shorter than {@link #SHOW_AFTER_MILLIS} never show it, so they don't flash.
*/
final class LoadingAnimation {
static final int SHOW_AFTER_MILLIS = 300;
static final int FRAME_MILLIS = 33;
// Back to front, so each block lands on or beside ones already there.
private static final int[][] BUILD_ORDER = {
{0, 0, 0}, {1, 0, 0}, {0, 0, 1}, {1, 0, 1},
{0, 1, 0}, {1, 1, 0}, {0, 1, 1}, {1, 1, 1}};
private static final int STEP_MILLIS = 140;
private static final int DROP_MILLIS = 260;
private static final int HOLD_MILLIS = 450;
private static final int FADE_MILLIS = 320;
private static final int CYCLE_MILLIS = STEP_MILLIS * (BUILD_ORDER.length - 1) + DROP_MILLIS + HOLD_MILLIS + FADE_MILLIS;
private static final double DROP_HEIGHT_BLOCKS = 1.6D;
private static final double ISOMETRIC_WIDTH = Math.cos(Math.PI / 6D);
private static final double ISOMETRIC_DEPTH = 0.5D;
private static final float SIDE_SHADE = 0.78F;
private static final float FAR_SIDE_SHADE = 0.6F;
private LoadingAnimation() {
}
/**
* Whether a load that began at {@code loadingSinceMillis} has taken long enough to show the animation.
*/
static boolean isDue(long loadingSinceMillis) {
return System.currentTimeMillis() - loadingSinceMillis >= SHOW_AFTER_MILLIS;
}
/**
* Draws the cube centred on a point, {@code blockSize} pixels to a block edge, as it looks
* {@code elapsedMillis} after it first showed.
*/
static void paint(Graphics2D graphics, double centerX, double centerY, double blockSize, long elapsedMillis) {
long time = Math.floorMod(elapsedMillis, CYCLE_MILLIS);
long fadeStart = CYCLE_MILLIS - FADE_MILLIS;
if (time < fadeStart) {
drawCube(graphics, centerX, centerY, blockSize, time);
return;
}
fadeCube(graphics, centerX, centerY, blockSize, time, 1F - (time - fadeStart) / (float) FADE_MILLIS);
}
// Faded block by block, the blocks behind would show through; the whole cube is drawn first and faded as one.
private static void fadeCube(Graphics2D graphics, double centerX, double centerY, double blockSize, long time, float opacity) {
int extent = (int) Math.ceil(blockSize * (2D + DROP_HEIGHT_BLOCKS) * 2D);
BufferedImage cube = new BufferedImage(extent, extent, BufferedImage.TYPE_INT_ARGB);
Graphics2D cubeCanvas = cube.createGraphics();
drawCube(cubeCanvas, extent / 2D, extent / 2D, blockSize, time);
cubeCanvas.dispose();
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, Math.max(0F, opacity)));
canvas.drawImage(cube, (int) Math.round(centerX - extent / 2D), (int) Math.round(centerY - extent / 2D), null);
} finally {
canvas.dispose();
}
}
private static void drawCube(Graphics2D graphics, double centerX, double centerY, double blockSize, long time) {
Graphics2D canvas = (Graphics2D) graphics.create();
try {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
for (int index = 0; index < BUILD_ORDER.length; index++) {
long sinceDropStart = time - (long) index * STEP_MILLIS;
if (sinceDropStart < 0) {
break;
}
double fallen = easeOut(Math.min(1D, sinceDropStart / (double) DROP_MILLIS));
int[] block = BUILD_ORDER[index];
drawBlock(canvas, centerX, centerY, blockSize, block[0], block[1] + DROP_HEIGHT_BLOCKS * (1D - fallen), block[2]);
}
} finally {
canvas.dispose();
}
}
private static double easeOut(double progress) {
double remaining = 1D - progress;
return 1D - remaining * remaining * remaining;
}
// The cube's middle, (1, 1, 1), lands on the centre point; the top and the two sides facing the viewer show.
private static void drawBlock(Graphics2D canvas, double centerX, double centerY, double size, double x, double y, double z) {
Color top = StudioTheme.ACCENT;
canvas.setColor(top);
canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{0, 1, 0}, {1, 1, 0}, {1, 1, 1}, {0, 1, 1}}));
canvas.setColor(shaded(top, SIDE_SHADE));
canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{1, 0, 0}, {1, 1, 0}, {1, 1, 1}, {1, 0, 1}}));
canvas.setColor(shaded(top, FAR_SIDE_SHADE));
canvas.fill(face(centerX, centerY, size, x, y, z, new double[][]{{0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1}}));
}
private static Path2D face(double centerX, double centerY, double size, double x, double y, double z, double[][] corners) {
Path2D path = new Path2D.Double();
for (int corner = 0; corner < corners.length; corner++) {
double cornerX = x + corners[corner][0] - 1D;
double cornerY = y + corners[corner][1] - 1D;
double cornerZ = z + corners[corner][2] - 1D;
double screenX = centerX + (cornerX - cornerZ) * ISOMETRIC_WIDTH * size;
double screenY = centerY + (cornerX + cornerZ) * ISOMETRIC_DEPTH * size - cornerY * size;
if (corner == 0) {
path.moveTo(screenX, screenY);
} else {
path.lineTo(screenX, screenY);
}
}
path.closePath();
return path;
}
private static Color shaded(Color color, float shade) {
return new Color(Math.round(color.getRed() * shade), Math.round(color.getGreen() * shade), Math.round(color.getBlue() * shade));
}
}
@@ -155,7 +155,7 @@ final class ObjectExplorer {
// Textures load first, since the first object would otherwise be drawn in flat colours.
void open() {
statusBar.showActivity("Loading Minecraft textures...");
viewport.showLoading("Loading Minecraft textures");
loader.execute(() -> {
loadTextures();
List<String> keys = objectKeys();
@@ -217,7 +217,7 @@ final class ObjectExplorer {
variantPicker.forgetTriedSeeds();
showVariantList(List.of(), OptionalLong.empty(), OptionalLong.empty(), 0);
}
long revision = startLoading(key);
long revision = startBaking(key);
loader.execute(() -> {
if (revision != objectRevision.get()) {
return;
@@ -235,7 +235,7 @@ final class ObjectExplorer {
int variantIndex = shownVariantIndex;
shownSeed = OptionalLong.of(seed);
variantPicker.showSeed(shownSeed, pack);
long revision = startLoading(key + " with seed " + seed);
long revision = startBaking(key + " with seed " + seed);
loader.execute(() -> {
if (revision == objectRevision.get()) {
loadVariants(revision, key, pack, OptionalLong.of(seed), variantIndex);
@@ -277,7 +277,7 @@ final class ObjectExplorer {
return;
}
if (variants.isEmpty()) {
gallery.showMessage("Loading " + shownKey + "...");
gallery.showLoading();
} else if (variants.size() == 1) {
toggleComparing();
} else {
@@ -380,12 +380,19 @@ final class ObjectExplorer {
return new ObjectMesh.Bounds(0, 0, 0, sizeX, sizeY, sizeZ);
}
// A new list of variants is on its way, so every thumbnail is outdated, unlike when stepping to another variant.
private long startBaking(String key) {
if (comparingVariants) {
gallery.showLoading();
}
return startLoading(key);
}
private long startLoading(String key) {
shown = null;
wholeMesh = null;
meshRevision.incrementAndGet();
viewport.showMessage("Loading " + key + "...");
statusBar.showActivity("Loading " + key + "...");
viewport.showLoading("Loading " + key);
return objectRevision.incrementAndGet();
}
@@ -25,6 +25,8 @@ import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.KeyStroke;
import javax.swing.Scrollable;
import javax.swing.Timer;
import java.awt.AlphaComposite;
import java.awt.BorderLayout;
import java.awt.Cursor;
import java.awt.Dimension;
@@ -44,7 +46,8 @@ import java.util.function.IntConsumer;
/**
* Every variant of a procedural object side by side, drawn at one scale so their sizes compare. Clicking one opens it
* in the 3D view.
* in the 3D view. While new thumbnails are drawn, each card keeps its old one faded until its own arrives; cards
* still waiting after a moment play the loading animation, one after another like a wave.
*/
final class ObjectVariantGallery extends JPanel {
private static final long serialVersionUID = 6153024487310558262L;
@@ -54,14 +57,19 @@ final class ObjectVariantGallery extends JPanel {
private static final int EDGE_MARGIN = 16;
// The variant bar floats over the bottom edge, so the last row scrolls clear of it.
private static final int VARIANT_BAR_CLEARANCE = 64;
private static final float OUTDATED_THUMBNAIL_OPACITY = 0.35F;
private static final int THUMBNAIL_SIZE_PER_LOADING_BLOCK = 14;
private static final int LOADING_WAVE_STEP_MILLIS = 90;
private final JLabel title = new JLabel(" ");
private final CardGrid grid = new CardGrid();
private final IntConsumer onVariantOpened;
private final Runnable onThemeChanged;
private final List<VariantCard> cards = new ArrayList<>();
private final Timer loadingTicker = new Timer(LoadingAnimation.FRAME_MILLIS, event -> repaintWaitingCards());
private int coveredLeftWidth;
private int selectedIndex;
private long waitingSinceMillis;
/**
* @param onVariantOpened told the index of a variant the user clicks
@@ -113,10 +121,33 @@ final class ObjectVariantGallery extends JPanel {
}
/**
* Empty cards for {@code count} variants, waiting for their thumbnails.
* Cards for {@code count} variants, waiting for their thumbnails. The cards already shown stay, with their old
* thumbnails faded, so a reroll never blanks the view.
*/
void showVariants(String heading, int count, int selected) {
title.setText(heading);
if (count != cards.size()) {
replaceCards(count);
}
startWaiting();
select(selected);
}
/** Fades every thumbnail while something new loads. */
void showLoading() {
startWaiting();
}
void showThumbnail(int index, BufferedImage thumbnail) {
if (index < cards.size()) {
cards.get(index).showThumbnail(thumbnail);
}
if (cards.stream().noneMatch(VariantCard::isWaiting)) {
loadingTicker.stop();
}
}
private void replaceCards(int count) {
cards.clear();
grid.removeAll();
for (int index = 0; index < count; index++) {
@@ -124,14 +155,21 @@ final class ObjectVariantGallery extends JPanel {
cards.add(card);
grid.add(card);
}
select(selected);
grid.revalidate();
grid.repaint();
}
void showThumbnail(int index, BufferedImage thumbnail) {
if (index < cards.size()) {
cards.get(index).showThumbnail(thumbnail);
private void startWaiting() {
if (!loadingTicker.isRunning()) {
waitingSinceMillis = System.currentTimeMillis();
loadingTicker.start();
}
cards.forEach(VariantCard::markOutdated);
}
private void repaintWaitingCards() {
if (LoadingAnimation.isDue(waitingSinceMillis)) {
cards.stream().filter(VariantCard::isWaiting).forEach(VariantCard::repaint);
}
}
@@ -146,6 +184,7 @@ final class ObjectVariantGallery extends JPanel {
void showMessage(String text) {
title.setText(text);
loadingTicker.stop();
cards.clear();
grid.removeAll();
grid.revalidate();
@@ -230,6 +269,7 @@ final class ObjectVariantGallery extends JPanel {
private final int index;
private BufferedImage thumbnail;
private boolean thumbnailOutdated;
private boolean hovered;
VariantCard(int index) {
@@ -256,9 +296,19 @@ final class ObjectVariantGallery extends JPanel {
void showThumbnail(BufferedImage image) {
thumbnail = image;
thumbnailOutdated = false;
repaint();
}
void markOutdated() {
thumbnailOutdated = true;
repaint();
}
boolean isWaiting() {
return thumbnail == null || thumbnailOutdated;
}
private void setHovered(boolean nowHovered) {
hovered = nowHovered;
repaint();
@@ -277,12 +327,7 @@ final class ObjectVariantGallery extends JPanel {
int size = thumbnailSize();
canvas.setFont(StudioTheme.smallFont());
FontMetrics metrics = canvas.getFontMetrics();
if (thumbnail != null) {
canvas.drawImage(thumbnail, padding, padding, size, size, null);
} else {
canvas.setColor(StudioTheme.MUTED_TEXT);
drawCentered(canvas, "Loading...", padding + size / 2);
}
drawThumbnail(canvas, padding, size);
canvas.setColor(StudioTheme.TEXT);
drawCentered(canvas, "Variant " + (index + 1), padding * 2 + size + metrics.getAscent());
} finally {
@@ -290,6 +335,22 @@ final class ObjectVariantGallery extends JPanel {
}
}
private void drawThumbnail(Graphics2D canvas, int padding, int size) {
if (thumbnail != null) {
Graphics2D faded = (Graphics2D) canvas.create();
if (thumbnailOutdated) {
faded.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, OUTDATED_THUMBNAIL_OPACITY));
}
faded.drawImage(thumbnail, padding, padding, size, size, null);
faded.dispose();
}
if (isWaiting() && LoadingAnimation.isDue(waitingSinceMillis)) {
long shownMillis = System.currentTimeMillis() - waitingSinceMillis - LoadingAnimation.SHOW_AFTER_MILLIS;
LoadingAnimation.paint(canvas, padding + size / 2D, padding + size / 2D, size / (double) THUMBNAIL_SIZE_PER_LOADING_BLOCK,
shownMillis - (long) index * LOADING_WAVE_STEP_MILLIS);
}
}
private void drawCentered(Graphics2D canvas, String text, int baseline) {
canvas.drawString(text, (getWidth() - canvas.getFontMetrics().stringWidth(text)) / 2, baseline);
}
@@ -22,6 +22,8 @@ import javax.swing.AbstractAction;
import javax.swing.JComponent;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import java.awt.AlphaComposite;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.Graphics2D;
@@ -42,12 +44,16 @@ import java.util.function.UnaryOperator;
/**
* The 3D view of one object. Drag to orbit, shift-drag or middle-drag to pan, scroll to zoom; arrow keys orbit and
* plus and minus zoom too. Frames are drawn on a background thread, one at a time, always for the latest camera.
* While the next object loads, the last picture stays up; only a slow load dims it under the loading animation.
*/
final class ObjectViewport extends JComponent {
private static final long serialVersionUID = -3175226517409913524L;
private static final double WHEEL_ZOOM_EXPONENT = 0.12D;
private static final double KEY_ZOOM_FACTOR = 1.1D;
private static final int KEY_ORBIT_PIXELS = 12;
private static final float LOADING_DIM_OPACITY = 0.88F;
private static final double LOADING_BLOCK_SIZE = 16D;
private static final int LOADING_TEXT_GAP = 46;
private final ExecutorService renderThread = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Iris Object Viewer");
@@ -62,6 +68,9 @@ final class ObjectViewport extends JComponent {
private boolean renderAgain;
private Point dragPoint;
private int coveredLeftWidth;
private final Timer loadingTicker = new Timer(LoadingAnimation.FRAME_MILLIS, event -> repaint());
private String loadingText;
private long loadingSinceMillis;
ObjectViewport() {
setOpaque(true);
@@ -84,15 +93,37 @@ final class ObjectViewport extends JComponent {
requestFrame();
}
// The last picture stays up until the first one of this mesh is drawn.
void show(ObjectMesh nextMesh) {
mesh = nextMesh;
camera = ObjectCamera.START;
message = nextMesh.isEmpty() ? "No visible blocks" : null;
frame = null;
if (nextMesh.isEmpty()) {
frame = null;
finishLoading();
}
requestFrame();
repaint();
}
/**
* Marks the view as loading something new. A load already under way keeps its start, so stepping quickly through
* objects still shows the animation once the wait adds up.
*/
void showLoading(String text) {
if (loadingText == null) {
loadingSinceMillis = System.currentTimeMillis();
}
loadingText = text;
message = null;
loadingTicker.start();
}
private void finishLoading() {
loadingText = null;
loadingTicker.stop();
}
/**
* Swaps in another mesh of the same object, like one with its top layers cut away, keeping the camera.
*/
@@ -117,6 +148,7 @@ final class ObjectViewport extends JComponent {
mesh = null;
message = text;
frame = null;
finishLoading();
repaint();
}
@@ -136,6 +168,7 @@ final class ObjectViewport extends JComponent {
}
void close() {
loadingTicker.stop();
renderThread.shutdownNow();
}
@@ -168,6 +201,7 @@ final class ObjectViewport extends JComponent {
rendering = false;
if (frameMesh == mesh) {
frame = image;
finishLoading();
repaint();
}
if (renderAgain) {
@@ -183,11 +217,38 @@ final class ObjectViewport extends JComponent {
if (frame != null) {
graphics.drawImage(frame, 0, 0, null);
}
if (loadingText != null && LoadingAnimation.isDue(loadingSinceMillis)) {
drawLoading((Graphics2D) graphics);
}
if (message != null) {
drawMessage((Graphics2D) graphics);
}
}
private void drawLoading(Graphics2D graphics) {
Graphics2D canvas = (Graphics2D) graphics.create();
try {
if (frame != null) {
canvas.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, LOADING_DIM_OPACITY));
canvas.setColor(getBackground());
canvas.fillRect(0, 0, getWidth(), getHeight());
canvas.setComposite(AlphaComposite.SrcOver);
}
int centerX = coveredLeftWidth + (getWidth() - coveredLeftWidth) / 2;
int centerY = getHeight() / 2;
long shownMillis = System.currentTimeMillis() - loadingSinceMillis - LoadingAnimation.SHOW_AFTER_MILLIS;
LoadingAnimation.paint(canvas, centerX, centerY, StudioTheme.scale(LOADING_BLOCK_SIZE), shownMillis);
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setFont(StudioTheme.font());
canvas.setColor(StudioTheme.MUTED_TEXT);
String text = loadingText + "...";
int textWidth = canvas.getFontMetrics().stringWidth(text);
canvas.drawString(text, centerX - textWidth / 2, centerY + StudioTheme.scale(LOADING_TEXT_GAP));
} finally {
canvas.dispose();
}
}
private void drawMessage(Graphics2D canvas) {
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setFont(StudioTheme.font());