From 0ed58f71d3f834c783e98f4847e90e324d5e15fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 12:52:01 +0000 Subject: [PATCH 1/3] Fix slow tile loading at negative zoom levels (zoom out) The main issue was that getBaseTileWithPixels() didn't check the disk cache before regenerating tiles. This meant that even when base tiles were cached on disk, composite tile generation would regenerate all 256 base tiles. Changes: - Add disk cache check to getBaseTileWithPixels() with PNG-to-pixel decoding - Create PngDecoder class to decode PNG bytes back to pixel arrays - Increase MAX_PIXEL_CACHE from 512 to 2048 (prevents cache thrashing) - Increase MAX_CONCURRENT_GENERATIONS from 4 to 16 (faster parallel generation) This significantly improves performance when zooming out, as cached tiles can now be reused for composite tile generation instead of regenerating. --- .../java/com/easywebmap/map/PngDecoder.java | 54 +++++++++++++++++++ .../java/com/easywebmap/map/TileManager.java | 30 +++++++++-- 2 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/easywebmap/map/PngDecoder.java diff --git a/src/main/java/com/easywebmap/map/PngDecoder.java b/src/main/java/com/easywebmap/map/PngDecoder.java new file mode 100644 index 0000000..bac7694 --- /dev/null +++ b/src/main/java/com/easywebmap/map/PngDecoder.java @@ -0,0 +1,54 @@ +package com.easywebmap.map; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import javax.imageio.ImageIO; + +/** + * Decodes PNG bytes back to raw pixel arrays for compositing. + * This is faster than regenerating tiles from WorldMapManager when + * tiles are already cached on disk. + */ +public class PngDecoder { + + /** + * Decode PNG bytes to RGB pixel array. + * + * @param pngBytes The PNG image data + * @param expectedSize The expected width/height of the image + * @return RGB pixel array, or null if decoding fails + */ + public static int[] decode(byte[] pngBytes, int expectedSize) { + if (pngBytes == null || pngBytes.length == 0) { + return null; + } + + try (ByteArrayInputStream bais = new ByteArrayInputStream(pngBytes)) { + BufferedImage image = ImageIO.read(bais); + if (image == null) { + return null; + } + + int width = image.getWidth(); + int height = image.getHeight(); + + // Ensure expected dimensions + if (width != expectedSize || height != expectedSize) { + // Scale if needed (shouldn't normally happen) + BufferedImage scaled = new BufferedImage(expectedSize, expectedSize, BufferedImage.TYPE_INT_RGB); + scaled.getGraphics().drawImage(image, 0, 0, expectedSize, expectedSize, null); + image = scaled; + width = expectedSize; + height = expectedSize; + } + + // Extract pixels + int[] pixels = new int[width * height]; + image.getRGB(0, 0, width, height, pixels, 0, width); + + return pixels; + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/easywebmap/map/TileManager.java b/src/main/java/com/easywebmap/map/TileManager.java index b3eb484..6603cc0 100644 --- a/src/main/java/com/easywebmap/map/TileManager.java +++ b/src/main/java/com/easywebmap/map/TileManager.java @@ -24,9 +24,12 @@ public class TileManager { private final ConcurrentHashMap chunkIndexCache; private final ConcurrentHashMap pixelCache; private CompositeTileGenerator compositeTileGenerator; - private static final int MAX_PIXEL_CACHE = 512; + // Increased from 512 to 2048 to prevent cache thrashing at negative zoom levels + // (A single zoom -4 tile requires 256 base tile pixels) + private static final int MAX_PIXEL_CACHE = 2048; // Limit concurrent tile generations to prevent CPU spikes - private static final int MAX_CONCURRENT_GENERATIONS = 4; + // Increased from 4 to 16 for faster composite tile generation at negative zoom levels + private static final int MAX_CONCURRENT_GENERATIONS = 16; private final Semaphore generationSemaphore = new Semaphore(MAX_CONCURRENT_GENERATIONS); // Empty tiles are ~270 bytes, real tiles are 10KB+ private static final int EMPTY_TILE_THRESHOLD = 500; @@ -178,6 +181,7 @@ public CompletableFuture getBaseTile(String worldName, int tileX, int ti */ public CompletableFuture getBaseTileWithPixels(String worldName, int tileX, int tileZ) { String cacheKey = TileCache.createKey(worldName, 0, tileX, tileZ); + int tileSize = this.plugin.getConfig().getTileSize(); // 1. Check pixel cache PngEncoder.TileData cached = this.pixelCache.get(cacheKey); @@ -191,7 +195,27 @@ public CompletableFuture getBaseTileWithPixels(String world return pending; } - // 3. Generate with pixels + // 3. Check disk cache and decode PNG to pixels (faster than regenerating) + if (this.plugin.getConfig().isUseDiskCache()) { + byte[] diskCached = this.diskCache.get(worldName, 0, tileX, tileZ); + if (diskCached != null && diskCached.length > EMPTY_TILE_THRESHOLD) { + try { + int[] pixels = PngDecoder.decode(diskCached, tileSize); + if (pixels != null) { + PngEncoder.TileData tileData = new PngEncoder.TileData(diskCached, pixels, tileSize); + // Cache the pixels for future compositing + if (this.pixelCache.size() < MAX_PIXEL_CACHE) { + this.pixelCache.put(cacheKey, tileData); + } + return CompletableFuture.completedFuture(tileData); + } + } catch (Exception e) { + // Failed to decode, will regenerate + } + } + } + + // 4. Generate with pixels CompletableFuture future = this.generateTileWithPixels(worldName, tileX, tileZ); this.pendingPixelRequests.put(cacheKey, future); future.whenComplete((data, ex) -> { From 68bd11bb0900a609f72a36f2c33f02a1ab4167d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 13:19:34 +0000 Subject: [PATCH 2/3] Improve tile batching during zoom operations - Add zoom event handlers to pause batching while zooming - Cancel pending/in-flight requests when zoom starts (AbortController) - Use adaptive batch delay: 150ms at zoom >= 0, 400ms at negative zoom - Don't schedule new batches during active zoom - Small delay (100ms) after zoom ends before resuming batch collection This prevents the cascade of batch requests when rapidly zooming, especially at negative zoom levels where composite tiles are expensive. --- src/main/resources/web/js/map.js | 103 ++++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 10 deletions(-) diff --git a/src/main/resources/web/js/map.js b/src/main/resources/web/js/map.js index da574ad..db3802d 100644 --- a/src/main/resources/web/js/map.js +++ b/src/main/resources/web/js/map.js @@ -7,7 +7,8 @@ // ============================================ L.TileLayer.Batch = L.TileLayer.extend({ options: { - batchDelay: 300, + batchDelay: 150, // Base delay for zoom >= 0 + batchDelayNegative: 400, // Longer delay for negative zoom (composite tiles) maxBatchSize: 2000, batchEndpoint: '/api/tiles/batch' }, @@ -20,12 +21,76 @@ this._worldName = 'world'; this._isSending = false; this._queuedWhileSending = new Map(); + this._currentZoom = 0; + this._abortController = null; + this._isZooming = false; + this._zoomEndTimer = null; }, setWorld: function(worldName) { this._worldName = worldName; }, + // Called when layer is added to map + onAdd: function(map) { + L.TileLayer.prototype.onAdd.call(this, map); + + // Listen for zoom events to cancel obsolete requests + map.on('zoomstart', this._onZoomStart, this); + map.on('zoomend', this._onZoomEnd, this); + }, + + onRemove: function(map) { + map.off('zoomstart', this._onZoomStart, this); + map.off('zoomend', this._onZoomEnd, this); + this._cancelAllRequests(); + L.TileLayer.prototype.onRemove.call(this, map); + }, + + _onZoomStart: function() { + this._isZooming = true; + // Cancel pending batches during zoom - they'll be obsolete + if (this._batchTimer) { + clearTimeout(this._batchTimer); + this._batchTimer = null; + } + }, + + _onZoomEnd: function() { + // Small delay after zoom ends to let tiles settle + if (this._zoomEndTimer) clearTimeout(this._zoomEndTimer); + this._zoomEndTimer = setTimeout(() => { + this._isZooming = false; + // Now start collecting and sending tiles + if (this._pendingTiles.size > 0) { + const delay = this._getBatchDelay(); + this._batchTimer = setTimeout(() => this._sendBatch(), delay); + } + }, 100); + }, + + _cancelAllRequests: function() { + // Abort in-flight requests + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + } + // Clear pending tiles + this._pendingTiles.clear(); + this._queuedWhileSending.clear(); + if (this._batchTimer) { + clearTimeout(this._batchTimer); + this._batchTimer = null; + } + this._isSending = false; + }, + + _getBatchDelay: function() { + // Use longer delay for negative zoom levels (composite tiles are expensive) + const zoom = this._map ? Math.floor(this._map.getZoom()) : 0; + return zoom < 0 ? this.options.batchDelayNegative : this.options.batchDelay; + }, + createTile: function(coords, done) { const tile = document.createElement('img'); tile.alt = ''; @@ -50,6 +115,9 @@ coords: coords }); + // Don't schedule batches while zooming + if (this._isZooming) return; + if (this._batchTimer) { clearTimeout(this._batchTimer); } @@ -58,18 +126,25 @@ if (!this._isSending && this._pendingTiles.size >= this.options.maxBatchSize) { this._sendBatch(); } else if (!this._isSending) { - this._batchTimer = setTimeout(() => this._sendBatch(), this.options.batchDelay); + const delay = this._getBatchDelay(); + this._batchTimer = setTimeout(() => this._sendBatch(), delay); } }, _sendBatch: function() { if (this._pendingTiles.size === 0) return; + // Don't send while zooming + if (this._isZooming) return; this._isSending = true; const allTiles = new Map(this._pendingTiles); this._pendingTiles.clear(); this._batchTimer = null; + // Create AbortController for this batch + this._abortController = new AbortController(); + const signal = this._abortController.signal; + // Split into chunks of 200 tiles max const CHUNK_SIZE = 200; const chunks = []; @@ -89,23 +164,25 @@ console.log(`Sending ${allTiles.size} tiles in ${chunks.length} batch(es)`); // Send all chunks in parallel - const chunkPromises = chunks.map(chunk => this._sendChunk(chunk)); + const chunkPromises = chunks.map(chunk => this._sendChunk(chunk, signal)); Promise.all(chunkPromises).finally(() => { + this._abortController = null; this._isSending = false; // Process any tiles that were queued while we were sending - if (this._queuedWhileSending.size > 0) { + if (this._queuedWhileSending.size > 0 && !this._isZooming) { for (const [key, value] of this._queuedWhileSending) { this._pendingTiles.set(key, value); } this._queuedWhileSending.clear(); - // Schedule next batch - this._batchTimer = setTimeout(() => this._sendBatch(), this.options.batchDelay); + // Schedule next batch with appropriate delay + const delay = this._getBatchDelay(); + this._batchTimer = setTimeout(() => this._sendBatch(), delay); } }); }, - _sendChunk: function(batch) { + _sendChunk: function(batch, signal) { const tiles = []; for (const [key, request] of batch) { const [z, x, y] = key.split('/').map(Number); @@ -120,7 +197,8 @@ return fetch(this.options.batchEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) + body: JSON.stringify(requestBody), + signal: signal }) .then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -155,8 +233,12 @@ } }) .catch(error => { - console.error('Batch chunk failed:', error); + // Don't log abort errors - they're intentional + if (error.name !== 'AbortError') { + console.error('Batch chunk failed:', error); + } for (const [key, request] of batch) { + // For aborted requests, just mark as failed silently request.done(error, request.tile); } }); @@ -245,7 +327,8 @@ maxZoom: 4, noWrap: true, bounds: [[-100000, -100000], [100000, 100000]], - batchDelay: 300, + batchDelay: 150, // Fast batching at zoom >= 0 + batchDelayNegative: 400, // Slower batching at negative zoom (composite tiles) maxBatchSize: 2000, batchEndpoint: '/api/tiles/batch' }); From 8c77bbbf3071e64fffeb7a914fdab9c53517ca74 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 13:33:42 +0000 Subject: [PATCH 3/3] Further optimize composite tile generation performance Frontend: - Reduce minNativeZoom from -4 to -3 (64 base tiles instead of 256 = 4x faster) - Users can still zoom out to -4, tiles are scaled from -3 Server: - Increase DiskIO thread pool from 2 to 6 for faster parallel reads - Add early bail-out for unexplored areas in composite tile generation - New hasAnyExploredChunks() method samples corners/center first for speed - Skip fetching 64 base tiles if area is completely unexplored --- .../map/CompositeTileGenerator.java | 6 +++ .../com/easywebmap/map/DiskTileCache.java | 3 +- .../java/com/easywebmap/map/TileManager.java | 52 +++++++++++++++++++ src/main/resources/web/js/map.js | 11 ++-- 4 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/easywebmap/map/CompositeTileGenerator.java b/src/main/java/com/easywebmap/map/CompositeTileGenerator.java index 131dab6..b213059 100644 --- a/src/main/java/com/easywebmap/map/CompositeTileGenerator.java +++ b/src/main/java/com/easywebmap/map/CompositeTileGenerator.java @@ -52,6 +52,12 @@ public CompletableFuture generateCompositeTile(String worldName, int zoo int baseChunkX = tileX * chunksPerAxis; int baseChunkZ = tileZ * chunksPerAxis; + // Early bail-out: if no chunks in this area are explored, return empty tile immediately + // This saves fetching 64+ base tiles for unexplored areas + if (!this.tileManager.hasAnyExploredChunks(worldName, baseChunkX, baseChunkZ, chunksPerAxis)) { + return CompletableFuture.completedFuture(PngEncoder.encodeEmpty(tileSize)); + } + // Fetch all base tiles with pixels in parallel List> futures = new ArrayList<>(); for (int dz = 0; dz < chunksPerAxis; dz++) { diff --git a/src/main/java/com/easywebmap/map/DiskTileCache.java b/src/main/java/com/easywebmap/map/DiskTileCache.java index ed10bfa..0e80b95 100644 --- a/src/main/java/com/easywebmap/map/DiskTileCache.java +++ b/src/main/java/com/easywebmap/map/DiskTileCache.java @@ -20,7 +20,8 @@ public class DiskTileCache { public DiskTileCache(Path dataDirectory) { this.cacheDirectory = dataDirectory.resolve("tilecache"); this.tileTimestamps = new ConcurrentHashMap<>(); - this.diskExecutor = Executors.newFixedThreadPool(2, r -> { + // Increased from 2 to 6 threads for faster parallel disk reads during composite tile generation + this.diskExecutor = Executors.newFixedThreadPool(6, r -> { Thread t = new Thread(r, "EasyWebMap-DiskIO"); t.setDaemon(true); return t; diff --git a/src/main/java/com/easywebmap/map/TileManager.java b/src/main/java/com/easywebmap/map/TileManager.java index 6603cc0..be8bb13 100644 --- a/src/main/java/com/easywebmap/map/TileManager.java +++ b/src/main/java/com/easywebmap/map/TileManager.java @@ -381,6 +381,58 @@ private boolean isChunkExplored(World world, int chunkX, int chunkZ) { } } + /** + * Check if any chunks in the given area are explored. + * Used for early bail-out on composite tiles over unexplored areas. + */ + public boolean hasAnyExploredChunks(String worldName, int baseChunkX, int baseChunkZ, int chunksPerAxis) { + if (!this.plugin.getConfig().isRenderExploredChunksOnly()) { + return true; // If we're not filtering, assume there's content + } + + World world = Universe.get().getWorld(worldName); + if (world == null) { + return false; + } + + try { + LongSet indexes = this.getCachedChunkIndexes(world); + if (indexes == null) { + return true; // Fail open + } + + // Quick sampling - check corners and center first + int[][] samplePoints = { + {0, 0}, // top-left + {chunksPerAxis - 1, 0}, // top-right + {0, chunksPerAxis - 1}, // bottom-left + {chunksPerAxis - 1, chunksPerAxis - 1}, // bottom-right + {chunksPerAxis / 2, chunksPerAxis / 2} // center + }; + + for (int[] point : samplePoints) { + long idx = ChunkUtil.indexChunk(baseChunkX + point[0], baseChunkZ + point[1]); + if (indexes.contains(idx)) { + return true; + } + } + + // If samples show nothing, do a full scan (but this is rare) + for (int dz = 0; dz < chunksPerAxis; dz++) { + for (int dx = 0; dx < chunksPerAxis; dx++) { + long idx = ChunkUtil.indexChunk(baseChunkX + dx, baseChunkZ + dz); + if (indexes.contains(idx)) { + return true; + } + } + } + + return false; + } catch (Exception e) { + return true; // Fail open + } + } + private LongSet getCachedChunkIndexes(World world) { String worldName = world.getName(); CachedChunkIndexes cached = this.chunkIndexCache.get(worldName); diff --git a/src/main/resources/web/js/map.js b/src/main/resources/web/js/map.js index db3802d..fabfd75 100644 --- a/src/main/resources/web/js/map.js +++ b/src/main/resources/web/js/map.js @@ -98,7 +98,7 @@ // Use actual zoom level for tile pyramid support // At zoom < 0, server provides composite tiles - const zoom = Math.min(coords.z, 0); // Clamp to 0 max (server handles -4 to 0) + const zoom = Math.min(coords.z, 0); // Clamp to 0 max (server handles -3 to 0) const key = `${zoom}/${coords.x}/${coords.y}`; this._queueTileRequest(key, coords, tile, done); @@ -317,13 +317,14 @@ } // Batch tile layer - reduces HTTP requests by batching multiple tiles per request - // minNativeZoom: -4 means server provides composite tiles at negative zoom levels - // This dramatically reduces DOM elements at zoomed-out views + // minNativeZoom: -3 means server provides composite tiles at negative zoom levels + // Using -3 instead of -4 reduces base tiles per composite from 256 to 64 (4x faster) + // Users can still zoom out to -4, tiles will be scaled from -3 tileLayer = L.tileLayer.batch('/api/tiles/' + currentWorld + '/{z}/{x}/{y}.png', { tileSize: TILE_SIZE, - minNativeZoom: -4, // Server provides tiles from -4 to 0 + minNativeZoom: -3, // Server provides tiles from -3 to 0 (64 base tiles max) maxNativeZoom: 0, // Max native zoom is 0 (single chunk per tile) - minZoom: -4, + minZoom: -4, // User can still zoom out to -4 (scaled from -3) maxZoom: 4, noWrap: true, bounds: [[-100000, -100000], [100000, 100000]],