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/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..be8bb13 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) -> { @@ -357,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 da574ad..fabfd75 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 = ''; @@ -33,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); @@ -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); } }); @@ -235,17 +317,19 @@ } // 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]], - batchDelay: 300, + batchDelay: 150, // Fast batching at zoom >= 0 + batchDelayNegative: 400, // Slower batching at negative zoom (composite tiles) maxBatchSize: 2000, batchEndpoint: '/api/tiles/batch' });