From 547c52540a764ffc5151e7ecc970d23f18799fe5 Mon Sep 17 00:00:00 2001 From: piexel Date: Sun, 25 Oct 2026 20:06:30 +0100 Subject: [PATCH] Crossfade between palettes instead of flashing empty Switching source kept nothing on screen until the new one drew. The old frame now stays until the first new frame lands, then fades out over it in 150 ms. Model used: Claude Opus 5.5 --- .../iris/studio/map/FrameCrossfade.java | 100 ++++++++++++++++++ .../iris/studio/map/NoiseExplorerGUI.java | 21 ++++ .../iris/studio/map/FrameCrossfadeTest.java | 62 +++++++++++ 3 files changed, 183 insertions(+) create mode 100644 core/src/main/java/art/arcane/iris/studio/map/FrameCrossfade.java create mode 100644 core/src/test/java/art/arcane/iris/studio/map/FrameCrossfadeTest.java diff --git a/core/src/main/java/art/arcane/iris/studio/map/FrameCrossfade.java b/core/src/main/java/art/arcane/iris/studio/map/FrameCrossfade.java new file mode 100644 index 000000000..8b608ec90 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/map/FrameCrossfade.java @@ -0,0 +1,100 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package 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(150L); + 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 -> 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) { + phase = Phase.HOLDING; + 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 + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java b/core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java index bebaf20a1..bb052c15b 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java +++ b/core/src/main/java/art/arcane/iris/studio/map/NoiseExplorerGUI.java @@ -61,6 +61,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; @@ -143,6 +145,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; @@ -227,6 +230,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); @@ -826,6 +830,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(); @@ -918,6 +923,7 @@ public final class NoiseExplorerGUI extends JPanel { viewRevision++; renderCoordinator.cancel(viewRevision); sampler = null; + crossfade.hold(renderResult); renderResult = null; sourceLoading = true; rendering = false; @@ -963,6 +969,7 @@ public final class NoiseExplorerGUI extends JPanel { return; } clearSampler(); + crossfade.clear(); sourceLoading = false; rendering = false; errorMessage = readableError(error); @@ -1292,6 +1299,7 @@ public final class NoiseExplorerGUI extends JPanel { } refinementTimer.stop(); resizeTimer.stop(); + crossfade.clear(); MapHost.unregisterHotloadHook(hotloadHook); sourceLoadRevision.incrementAndGet(); catalogLoadRevision.incrementAndGet(); @@ -1316,6 +1324,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 { @@ -1337,6 +1346,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(); @@ -1689,6 +1709,7 @@ public final class NoiseExplorerGUI extends JPanel { errorMessage = null; renderResult = result; lastTimedFrame = result; + crossfade.reveal(); repaint(); refreshStatus(); if (finishInteractiveFrame(result.request())) { diff --git a/core/src/test/java/art/arcane/iris/studio/map/FrameCrossfadeTest.java b/core/src/test/java/art/arcane/iris/studio/map/FrameCrossfadeTest.java new file mode 100644 index 000000000..ab122bd5e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/map/FrameCrossfadeTest.java @@ -0,0 +1,62 @@ +package art.arcane.iris.studio.map; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +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(() -> { + }); + + @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 InterruptedException { + crossfade.hold(frame()); + crossfade.reveal(); + Thread.sleep(20L); + + assertTrue(crossfade.outgoingOpacity() < 1F); + } + + @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); + } +}