Make dragging the explorer map follow the mouse smoothly

While dragging or zooming, the map stood still until the mouse paused,
then restarted from a blocky draft. The last finished frame is now moved
and scaled with the view straight away, and a new frame starts as soon
as the previous one is done, sized from how fast the last frame drew so
cheap views come out sharp immediately.

Budgets are now measured in drawn pixels, so a frame where the layer is
missing everywhere no longer breaks the refinement step.

Model used: Claude Opus 5.5
This commit is contained in:
piexel committed 2026-10-18 21:34:05 +02:00
1 parent 02956fd358
commit 143ba4771b
2 files changed
+110 -33

No files matched your search

@@ -109,6 +109,8 @@ public final class NoiseExplorerGUI extends JPanel {
private static final long MAX_REFINED_SAMPLE_BUDGET = 48_000L;
private static final double REFINEMENT_TARGET_MILLIS = 250D;
private static final double PALETTE_REFINEMENT_TARGET_MILLIS = 2_000D;
private static final double INTERACTIVE_FRAME_TARGET_MILLIS = 33D;
private static final int RESIZE_SETTLE_MILLIS = 45;
private static final double WHEEL_ZOOM_EXPONENT = 0.08D;
private static final NoiseCategory[] CATEGORY_ORDER = {
NoiseCategory.CUSTOM,
@@ -142,7 +144,7 @@ public final class NoiseExplorerGUI extends JPanel {
private final ThreadPoolExecutor catalogExecutor;
private final NoiseRenderCoordinator renderCoordinator;
private final Timer refinementTimer;
private final Timer interactionTimer;
private final Timer resizeTimer;
private final AtomicLong sourceLoadRevision = new AtomicLong();
private final AtomicLong catalogLoadRevision = new AtomicLong();
private final Runnable hotloadHook = this::handleHotload;
@@ -167,7 +169,8 @@ public final class NoiseExplorerGUI extends JPanel {
private PaletteLayerPreview palettePreview;
private int paletteDepth;
private NoiseRenderCoordinator.Result renderResult;
private BufferedImage renderedImage;
private NoiseRenderCoordinator.Request interactiveFrame;
private boolean interactiveFramePending;
private Point pointer;
private Point dragPoint;
private long seed;
@@ -204,8 +207,8 @@ public final class NoiseExplorerGUI extends JPanel {
GuiHost.interruptOnPluginShutdown(catalogExecutor);
this.refinementTimer = new Timer(REFINEMENT_DELAY_MILLIS, event -> requestRefinement());
this.refinementTimer.setRepeats(false);
this.interactionTimer = new Timer(45, event -> requestPreview());
this.interactionTimer.setRepeats(false);
this.resizeTimer = new Timer(RESIZE_SETTLE_MILLIS, event -> requestPreview());
this.resizeTimer.setRepeats(false);
this.fixedSources = buildFixedSources();
setBackground(BACKGROUND);
setFocusable(true);
@@ -715,7 +718,6 @@ public final class NoiseExplorerGUI extends JPanel {
renderCoordinator.cancel(viewRevision);
selectedSource = null;
clearSampler();
renderedImage = null;
renderResult = null;
sourceLoading = false;
rendering = false;
@@ -810,7 +812,6 @@ public final class NoiseExplorerGUI extends JPanel {
viewRevision++;
renderCoordinator.cancel(viewRevision);
sampler = null;
renderedImage = null;
renderResult = null;
sourceLoading = true;
rendering = false;
@@ -841,7 +842,6 @@ public final class NoiseExplorerGUI extends JPanel {
showPaletteInspector();
sourceLoading = false;
errorMessage = null;
renderedImage = null;
renderResult = null;
progressLabel.setForeground(SECONDARY_TEXT);
progressLabel.setText(source.usesSeed() ? "Ready · seed " + seed : "Ready");
@@ -916,9 +916,32 @@ public final class NoiseExplorerGUI extends JPanel {
int sampleStep = NoiseRenderCoordinator.sampleStepForBudget(
getWidth(),
renderHeight(),
COARSE_SAMPLE_BUDGET
interactiveSampleBudget()
);
requestRender(sampleStep);
interactiveFrame = requestRender(sampleStep);
}
// Draws one frame at a time while the view moves, so each frame finishes and the next one starts from where the view is by then.
private void requestInteractiveFrame() {
repaint();
if (isInteractiveFrameInFlight()) {
interactiveFramePending = true;
return;
}
requestPreview();
}
private boolean isInteractiveFrameInFlight() {
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.
private long interactiveSampleBudget() {
NoiseRenderCoordinator.Result lastResult = renderResult;
if (lastResult == null) {
return COARSE_SAMPLE_BUDGET;
}
return timedSampleBudget(lastResult, INTERACTIVE_FRAME_TARGET_MILLIS);
}
private void requestRefinement() {
@@ -944,20 +967,33 @@ public final class NoiseExplorerGUI extends JPanel {
// Smooth noise looks fine upscaled, but blocks and their textures only read correctly at full resolution.
private long refinedSampleBudget(NoiseRenderCoordinator.Result currentResult) {
boolean fullResolution = mode == ExplorerMode.PALETTES;
long maximumBudget = fullResolution
? Math.max(COARSE_SAMPLE_BUDGET, (long) currentResult.request().width() * currentResult.request().height())
: MAX_REFINED_SAMPLE_BUDGET;
double targetMilliseconds = mode == ExplorerMode.PALETTES ? PALETTE_REFINEMENT_TARGET_MILLIS : REFINEMENT_TARGET_MILLIS;
return timedSampleBudget(currentResult, targetMilliseconds);
}
private long timedSampleBudget(NoiseRenderCoordinator.Result lastResult, double targetMilliseconds) {
BufferedImage lastImage = lastResult.image();
long renderedPixels = (long) lastImage.getWidth() * lastImage.getHeight();
if (lastResult.milliseconds() <= 0D) {
return maximumSampleBudget();
}
return NoiseRenderCoordinator.timeBoundSampleBudget(
currentResult.samples(),
currentResult.milliseconds(),
fullResolution ? PALETTE_REFINEMENT_TARGET_MILLIS : REFINEMENT_TARGET_MILLIS,
renderedPixels,
lastResult.milliseconds(),
targetMilliseconds,
COARSE_SAMPLE_BUDGET,
maximumBudget
maximumSampleBudget()
);
}
private void requestRender(int sampleStep) {
private long maximumSampleBudget() {
if (mode == ExplorerMode.PALETTES) {
return Math.max(COARSE_SAMPLE_BUDGET, (long) getWidth() * renderHeight());
}
return MAX_REFINED_SAMPLE_BUDGET;
}
private NoiseRenderCoordinator.Request requestRender(int sampleStep) {
NoiseRenderCoordinator.Request request = new NoiseRenderCoordinator.Request(
viewRevision,
sampler,
@@ -968,6 +1004,7 @@ public final class NoiseExplorerGUI extends JPanel {
sampleStep
);
renderCoordinator.request(request);
return request;
}
private void installInputHandlers() {
@@ -984,8 +1021,7 @@ public final class NoiseExplorerGUI extends JPanel {
boolean wasDragging = dragPoint != null;
dragPoint = null;
if (wasDragging) {
interactionTimer.stop();
requestPreview();
requestInteractiveFrame();
}
}
@@ -998,7 +1034,7 @@ public final class NoiseExplorerGUI extends JPanel {
double deltaZ = nextPoint.getY() - dragPoint.getY();
viewport = viewport.panPixels(deltaX, deltaZ);
dragPoint = nextPoint;
interactionTimer.restart();
requestInteractiveFrame();
}
}
@@ -1025,7 +1061,7 @@ public final class NoiseExplorerGUI extends JPanel {
renderHeight(),
factor
);
interactionTimer.restart();
requestInteractiveFrame();
event.consume();
}
};
@@ -1035,7 +1071,7 @@ public final class NoiseExplorerGUI extends JPanel {
addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent event) {
interactionTimer.restart();
resizeTimer.restart();
}
});
getInputMap(WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke('R'), "reset-view");
@@ -1077,7 +1113,7 @@ public final class NoiseExplorerGUI extends JPanel {
locationTracker.stop();
}
refinementTimer.stop();
interactionTimer.stop();
resizeTimer.stop();
GuiHost.unregisterHotloadHook(hotloadHook);
sourceLoadRevision.incrementAndGet();
catalogLoadRevision.incrementAndGet();
@@ -1094,14 +1130,9 @@ public final class NoiseExplorerGUI extends JPanel {
canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
canvas.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
int renderHeight = renderHeight();
if (renderedImage == null) {
drawEmptyState(canvas, getWidth(), renderHeight);
} else {
Object interpolation = renderedImage.getWidth() == getWidth() || mode == ExplorerMode.PALETTES
? RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR
: RenderingHints.VALUE_INTERPOLATION_BILINEAR;
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, interpolation);
canvas.drawImage(renderedImage, 0, 0, getWidth(), renderHeight, null);
drawEmptyState(canvas, getWidth(), renderHeight);
if (renderResult != null) {
drawLatestFrame(canvas, renderResult, getWidth(), renderHeight);
}
drawCrosshair(canvas, getWidth(), renderHeight);
drawLegend(canvas, getWidth());
@@ -1111,6 +1142,38 @@ public final class NoiseExplorerGUI extends JPanel {
}
}
// Returns true when the view moved while the frame rendered, after starting the frame for where it is now.
private boolean finishInteractiveFrame(NoiseRenderCoordinator.Request finishedRequest) {
if (finishedRequest != interactiveFrame) {
return false;
}
interactiveFrame = null;
if (!interactiveFramePending) {
return false;
}
interactiveFramePending = false;
requestPreview();
return true;
}
// 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();
BufferedImage image = frame.image();
NoiseViewport frameViewport = request.viewport();
double frameScreenWidth = image.getWidth() * (double) request.sampleStep();
double frameScreenHeight = image.getHeight() * (double) request.sampleStep();
int left = (int) Math.round(viewport.screenX(frameViewport.worldX(0D, request.width()), width));
int top = (int) Math.round(viewport.screenZ(frameViewport.worldZ(0D, request.height()), renderHeight));
int right = (int) Math.round(viewport.screenX(frameViewport.worldX(frameScreenWidth, request.width()), width));
int bottom = (int) Math.round(viewport.screenZ(frameViewport.worldZ(frameScreenHeight, request.height()), renderHeight));
Object interpolation = request.sampleStep() == 1 || mode == ExplorerMode.PALETTES
? RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR
: RenderingHints.VALUE_INTERPOLATION_BILINEAR;
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, interpolation);
canvas.drawImage(image, left, top, right - left, bottom - top, null);
}
private static void drawEmptyState(Graphics2D canvas, int width, int height) {
canvas.setColor(BACKGROUND);
canvas.fillRect(0, 0, width, height);
@@ -1395,8 +1458,10 @@ public final class NoiseExplorerGUI extends JPanel {
rendering = false;
errorMessage = null;
renderResult = result;
renderedImage = result.image();
repaint();
if (finishInteractiveFrame(result.request())) {
return;
}
if (result.request().sampleStep() <= 1) {
return;
}
@@ -1420,6 +1485,10 @@ public final class NoiseExplorerGUI extends JPanel {
if (disposed || request.revision() != viewRevision) {
return;
}
if (request == interactiveFrame) {
interactiveFrame = null;
interactiveFramePending = false;
}
rendering = false;
errorMessage = readableError(error);
repaint();
@@ -39,6 +39,14 @@ record NoiseViewport(double centerX, double centerZ, double blocksPerPixel) {
return centerZ + ((screenZ - (height / 2D)) * blocksPerPixel);
}
double screenX(double worldX, int width) {
return ((worldX - centerX) / blocksPerPixel) + (width / 2D);
}
double screenZ(double worldZ, int height) {
return ((worldZ - centerZ) / blocksPerPixel) + (height / 2D);
}
NoiseViewport panPixels(double deltaX, double deltaZ) {
return new NoiseViewport(
centerX - (deltaX * blocksPerPixel),