diff --git a/blight-assets/src/main/resources/Textures/hud/minimap_world.png b/blight-assets/src/main/resources/Textures/hud/minimap_world.png index 0ba1932..1ce75ad 100644 Binary files a/blight-assets/src/main/resources/Textures/hud/minimap_world.png and b/blight-assets/src/main/resources/Textures/hud/minimap_world.png differ diff --git a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderModel.java b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderModel.java index 2e86df4..48aa29b 100644 --- a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderModel.java +++ b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderModel.java @@ -26,5 +26,6 @@ public record WorldMapRenderModel( float[] terrainSamples, // Höhenwerte bei SEA_MASK_SIZE (für Wellen-Prüfung) List coastPaths, // geglättete Marching-Squares Küstenpfade (Weltkoord.) List> treeClusters, - int[][] mapTilePixels // 6 Kacheln (TILE_WATER..TILE_ROCK), je 16×16 ARGB; null = Fallback + int[][] mapTilePixels, // 6 Kacheln (TILE_WATER..TILE_ROCK), je 16×16 ARGB; null = Fallback + int[] terrainColorMap // COLOR_MAP_SIZE×COLOR_MAP_SIZE packed RGB; null → render() berechnet intern ) {} diff --git a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java index ac54bf1..7b28cc2 100644 --- a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java +++ b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java @@ -29,6 +29,18 @@ public final class WorldMapRenderer { public static final float WORLD_SIZE = 2048f; /** Auflösung des voxelSurface-Bake-Arrays (1 Zelle pro Welteinheit). */ public static final int VOXEL_SURFACE_RES = 2048; + /** + * Auflösung der vorberechneten Übersichts-Farbkarte (terrainColorMap, für Zoom 0-2). + * 2048 → 1 Farbwert pro Meter. + */ + public static final int COLOR_MAP_SIZE = 2048; + /** + * Auflösung der hochauflösenden Chunk-Karte (für Zoom 3-4). + * 8192 → 1 Farbwert pro 0,25m. Auf 4096 für 0,5m/px ändern. + */ + public static final int HIRES_MAP_SIZE = 8192; + /** Pixelgröße eines Chunks (quadratisch). HIRES_MAP_SIZE muss ganzzahlig teilbar sein. */ + public static final int CHUNK_SIZE = 512; public record RenderInput( MapData mapData, @@ -146,49 +158,37 @@ public final class WorldMapRenderer { } } - // 3. Tile-Rendering: tileIdx + Rohfarbe pro Pixel speichern - float cellW = WORLD_SIZE / (targetSize - 1); - int SS = MapData.SPLAT_SIZE; - int[] slotCols = model.slotColorsRGB(); - int[][] tiles = model.mapTilePixels(); - int[] rawRGB = new int[targetSize * targetSize]; + // 3. Terrain-Farben aus vorberechneter Farbkarte (oder intern berechnen als Fallback) + float cellW = WORLD_SIZE / (targetSize - 1); + int[] slotCols = model.slotColorsRGB(); + int[] colorMap = model.terrainColorMap() != null + ? model.terrainColorMap() + : buildTerrainColorMap(m, slotCols, model.mapTilePixels(), model.voxelSurface(), model.waters()); + int[] rawRGB = new int[targetSize * targetSize]; int waterR = (WATER_COLOR >> 16) & 0xFF; int waterG = (WATER_COLOR >> 8) & 0xFF; int waterB = WATER_COLOR & 0xFF; for (int py = 0; py < targetSize; py++) { - float wz = -WORLD_HALF + py * cellW; for (int px = 0; px < targetSize; px++) { - float wx = -WORLD_HALF + px * cellW; int idx = py * targetSize + px; - - int tileIdx; - int tr, tg, tb; if (isWater[idx]) { - tileIdx = TILE_WATER; - tr = waterR; tg = waterG; tb = waterB; + rawRGB[idx] = (waterR << 16) | (waterG << 8) | waterB; } else { - int dPx = Math.max(1, (int)(0.25f / cellW)); - float hR = heights[py * targetSize + Math.min(targetSize-1, px+dPx)]; - float hL = heights[py * targetSize + Math.max(0, px-dPx)]; - float hD = heights[Math.min(targetSize-1, py+dPx) * targetSize + px]; - float hU = heights[Math.max(0, py-dPx) * targetSize + px]; - float dist = dPx * cellW; - double slope = Math.toDegrees(Math.atan(Math.sqrt( - Math.pow((hR-hL) / (2*dist), 2) + Math.pow((hD-hU) / (2*dist), 2)))); - tileIdx = (slope > 30.0) ? TILE_CLIFF : getTerrainTile(m, SS, wx, wz, slotCols); - if (tiles != null && tileIdx < tiles.length && tiles[tileIdx] != null) { - int tu = Math.floorMod((int)Math.floor((wx + WORLD_HALF) / TILE_WU), 16); - int tv = Math.floorMod((int)Math.floor((wz + WORLD_HALF) / TILE_WU), 16); - int argb = tiles[tileIdx][tv * 16 + tu]; - tr = (argb >> 16) & 0xFF; tg = (argb >> 8) & 0xFF; tb = argb & 0xFF; - } else { - tr = TILE_FALLBACK_RGB[tileIdx][0]; - tg = TILE_FALLBACK_RGB[tileIdx][1]; - tb = TILE_FALLBACK_RGB[tileIdx][2]; - } + // Bilinear sample aus der Farbkarte (COLOR_MAP_SIZE → targetSize) + float fx = (float) px / (targetSize - 1) * (COLOR_MAP_SIZE - 1); + float fz = (float) py / (targetSize - 1) * (COLOR_MAP_SIZE - 1); + int cx0 = (int) fx, cx1 = Math.min(cx0 + 1, COLOR_MAP_SIZE - 1); + int cz0 = (int) fz, cz1 = Math.min(cz0 + 1, COLOR_MAP_SIZE - 1); + float tx = fx - cx0, tz = fz - cz0; + int c00 = colorMap[cz0 * COLOR_MAP_SIZE + cx0]; + int c10 = colorMap[cz0 * COLOR_MAP_SIZE + cx1]; + int c01 = colorMap[cz1 * COLOR_MAP_SIZE + cx0]; + int c11 = colorMap[cz1 * COLOR_MAP_SIZE + cx1]; + rawRGB[idx] = (clamp((int)(((c00>>16)&0xFF)*(1-tx)*(1-tz)+((c10>>16)&0xFF)*tx*(1-tz)+((c01>>16)&0xFF)*(1-tx)*tz+((c11>>16)&0xFF)*tx*tz)) << 16) + | (clamp((int)(((c00>>8) &0xFF)*(1-tx)*(1-tz)+((c10>>8) &0xFF)*tx*(1-tz)+((c01>>8) &0xFF)*(1-tx)*tz+((c11>>8) &0xFF)*tx*tz)) << 8) + | clamp((int)(( c00 &0xFF)*(1-tx)*(1-tz)+( c10 &0xFF)*tx*(1-tz)+( c01 &0xFF)*(1-tx)*tz+( c11 &0xFF)*tx*tz)); } - rawRGB[idx] = (tr << 16) | (tg << 8) | tb; } } @@ -223,31 +223,10 @@ public final class WorldMapRenderer { } } float wFrac = wCount * subInv; - // Land-Farbe: fuer Wasser-Pixel on-the-fly berechnen, sonst aus rawRGB - int landRGB; - if (isS) { - int dPx = Math.max(1, (int)(0.25f / cellW)); - float hR2 = heights[py * targetSize + Math.min(targetSize-1, px+dPx)]; - float hL2 = heights[py * targetSize + Math.max(0, px-dPx)]; - float hD2 = heights[Math.min(targetSize-1, py+dPx) * targetSize + px]; - float hU2 = heights[Math.max(0, py-dPx) * targetSize + px]; - float d2 = dPx * cellW; - double sl2 = Math.toDegrees(Math.atan(Math.sqrt( - Math.pow((hR2-hL2)/(2*d2), 2) + Math.pow((hD2-hU2)/(2*d2), 2)))); - int ti2 = (sl2 > 30.0) ? TILE_CLIFF : getTerrainTile(m, SS, wx, wz, slotCols); - int tr2, tg2, tb2; - if (tiles != null && ti2 < tiles.length && tiles[ti2] != null) { - int tu=Math.floorMod((int)Math.floor((wx+WORLD_HALF)/TILE_WU),16); - int tv=Math.floorMod((int)Math.floor((wz+WORLD_HALF)/TILE_WU),16); - int argb = tiles[ti2][tv * 16 + tu]; - tr2=(argb>>16)&0xFF; tg2=(argb>>8)&0xFF; tb2=argb&0xFF; - } else { - tr2=TILE_FALLBACK_RGB[ti2][0]; tg2=TILE_FALLBACK_RGB[ti2][1]; tb2=TILE_FALLBACK_RGB[ti2][2]; - } - landRGB = (tr2<<16)|(tg2<<8)|tb2; - } else { - landRGB = rawRGB[idx]; - } + // Land-Farbe aus Farbkarte (Wasser-Pixel bekommen ihren Nachbar-Landwert) + int mapX = Math.min((int)((float)px / (targetSize-1) * (COLOR_MAP_SIZE-1)), COLOR_MAP_SIZE-1); + int mapZ = Math.min((int)((float)py / (targetSize-1) * (COLOR_MAP_SIZE-1)), COLOR_MAP_SIZE-1); + int landRGB = colorMap[mapZ * COLOR_MAP_SIZE + mapX]; int lr=(landRGB>>16)&0xFF, lg=(landRGB>>8)&0xFF, lb=landRGB&0xFF; rawRGB[idx] = (clamp((int)(waterR*wFrac + lr*(1-wFrac))) << 16) | (clamp((int)(waterG*wFrac + lg*(1-wFrac))) << 8) @@ -267,9 +246,7 @@ public final class WorldMapRenderer { int x1 = Math.min(targetSize-1, bb2.x + bb2.width + 1); int y1 = Math.min(targetSize-1, bb2.y + bb2.height + 1); for (int py = y0; py <= y1; py++) { - float wz = -WORLD_HALF + py * cellW; for (int px = x0; px <= x1; px++) { - float wx = -WORLD_HALF + px * cellW; int idx = py * targetSize + px; boolean inPoly = wp.contains(px, py) && heights[idx] < wh2; boolean boundary2 = false; @@ -294,31 +271,9 @@ public final class WorldMapRenderer { } } float wFrac2 = wCount2 * subInv; - int landRGB2; - if (inPoly) { - // Wasser-Pixel: Land-Farbe on-the-fly - int dPx = Math.max(1, (int)(0.25f / cellW)); - float hR2 = heights[py * targetSize + Math.min(targetSize-1, px+dPx)]; - float hL2 = heights[py * targetSize + Math.max(0, px-dPx)]; - float hD2 = heights[Math.min(targetSize-1, py+dPx) * targetSize + px]; - float hU2 = heights[Math.max(0, py-dPx) * targetSize + px]; - float d2 = dPx * cellW; - double sl2 = Math.toDegrees(Math.atan(Math.sqrt( - Math.pow((hR2-hL2)/(2*d2),2) + Math.pow((hD2-hU2)/(2*d2),2)))); - int ti2 = (sl2 > 30.0) ? TILE_CLIFF : getTerrainTile(m, SS, wx, wz, slotCols); - int tr2, tg2, tb2; - if (tiles != null && ti2 < tiles.length && tiles[ti2] != null) { - int tu2=Math.floorMod((int)Math.floor((wx+WORLD_HALF)/TILE_WU),16); - int tv2=Math.floorMod((int)Math.floor((wz+WORLD_HALF)/TILE_WU),16); - int argb = tiles[ti2][tv2 * 16 + tu2]; - tr2=(argb>>16)&0xFF; tg2=(argb>>8)&0xFF; tb2=argb&0xFF; - } else { - tr2=TILE_FALLBACK_RGB[ti2][0]; tg2=TILE_FALLBACK_RGB[ti2][1]; tb2=TILE_FALLBACK_RGB[ti2][2]; - } - landRGB2 = (tr2<<16)|(tg2<<8)|tb2; - } else { - landRGB2 = rawRGB[idx]; - } + int mapX2 = Math.min((int)((float)px / (targetSize-1) * (COLOR_MAP_SIZE-1)), COLOR_MAP_SIZE-1); + int mapZ2 = Math.min((int)((float)py / (targetSize-1) * (COLOR_MAP_SIZE-1)), COLOR_MAP_SIZE-1); + int landRGB2 = colorMap[mapZ2 * COLOR_MAP_SIZE + mapX2]; int lr2=(landRGB2>>16)&0xFF, lg2=(landRGB2>>8)&0xFF, lb2=landRGB2&0xFF; rawRGB[idx] = (clamp((int)(waterR*wFrac2 + lr2*(1-wFrac2))) << 16) | (clamp((int)(waterG*wFrac2 + lg2*(1-wFrac2))) << 8) @@ -531,7 +486,7 @@ public final class WorldMapRenderer { return render(new WorldMapRenderModel( input.mapData(), input.areas(), input.zones(), input.locations(), input.waters(), input.models(), null, - input.slotColorsRGB(), null, null, null, null, null, null + input.slotColorsRGB(), null, null, null, null, null, null, null ), targetSize, opts); } @@ -1243,6 +1198,256 @@ public final class WorldMapRenderer { return m.modelPath().replace('\\', '/').toLowerCase().contains("/trees/"); } + /** Bilineare Tile-Textur-Abfrage mit UV-Gewichten (w00/w10/w01/w11 = bilinear weights). */ + private static int tileSampleBil(int[][] tiles, int tileIdx, + int u0, int u1, int v0, int v1, float w00, float w10, float w01, float w11) { + if (tiles == null || tileIdx < 0 || tileIdx >= tiles.length || tiles[tileIdx] == null) { + int[] fb = TILE_FALLBACK_RGB[Math.max(0, Math.min(TILE_FALLBACK_RGB.length-1, tileIdx))]; + return (fb[0]<<16)|(fb[1]<<8)|fb[2]; + } + int[] t = tiles[tileIdx]; + int c00=t[v0*16+u0], c10=t[v0*16+u1], c01=t[v1*16+u0], c11=t[v1*16+u1]; + int r=clamp((int)(((c00>>16)&0xFF)*w00+((c10>>16)&0xFF)*w10+((c01>>16)&0xFF)*w01+((c11>>16)&0xFF)*w11)); + int g=clamp((int)(((c00>>8)&0xFF)*w00+((c10>>8)&0xFF)*w10+((c01>>8)&0xFF)*w01+((c11>>8)&0xFF)*w11)); + int b=clamp((int)((c00&0xFF)*w00+(c10&0xFF)*w10+(c01&0xFF)*w01+(c11&0xFF)*w11)); + return (r<<16)|(g<<8)|b; + } + + /** + * Baut die Terrain-Farbkarte vor: für jeden Pixel den dominanten Splatmap-Slot + * ermitteln, Durchschnittsfarbe der Kachel lesen, Hillshading anwenden. + * Das Ergebnis wird im WorldMapRenderModel gespeichert und von render() direkt + * verwendet — kein Splatmap-Blend zur Renderzeit mehr. + */ + public static int[] buildTerrainColorMap(MapData m, int[] slotColors, int[][] tilePixels, + float[] voxelSurface, List waters) { + final int SIZE = COLOR_MAP_SIZE; + final int SS = MapData.SPLAT_SIZE; + float[] heights = buildTerrainSamples(m, SIZE, voxelSurface); + float[] waterH = buildWaterHeightMap(waters, SIZE); + int[] tileAvg = computeTileAvgColors(tilePixels); + int[] slotTypes = computeSlotTileTypes(m, slotColors); + float cellW = WORLD_SIZE / (SIZE - 1); + int[] result = new int[SIZE * SIZE]; + for (int py = 0; py < SIZE; py++) { + float wz = -WORLD_HALF + py * cellW; + for (int px = 0; px < SIZE; px++) { + float wx = -WORLD_HALF + px * cellW; + int idx = py * SIZE + px; + float h = heights[idx]; + int rgb; + if (h < 0f || (waterH[idx] > 0f && waterH[idx] > h)) { + int[] wc = TILE_FALLBACK_RGB[TILE_WATER]; + rgb = (wc[0]<<16)|(wc[1]<<8)|wc[2]; + } else { + int dPx = Math.max(1, (int)(1.5f / cellW)); + float hR = heights[py * SIZE + Math.min(SIZE-1, px+dPx)]; + float hL = heights[py * SIZE + Math.max(0, px-dPx)]; + float hD = heights[Math.min(SIZE-1, py+dPx) * SIZE + px]; + float hU = heights[Math.max(0, py-dPx) * SIZE + px]; + float dist = dPx * cellW; + double slope = Math.toDegrees(Math.atan(Math.sqrt( + Math.pow((hR-hL)/(2*dist),2) + Math.pow((hD-hU)/(2*dist),2)))); + if (slope > 30.0) { + rgb = tileAvg[Math.min(TILE_CLIFF, tileAvg.length-1)]; + } else { + rgb = blendTileAvgColors(m, SS, wx, wz, tileAvg, slotTypes); + } + } + result[idx] = rgb; + } + } + return boxBlur(result, SIZE, 2); + } + + /** + * Generiert die hochauflösende Terrain-Farbkarte (HIRES_MAP_SIZE×HIRES_MAP_SIZE) in + * CHUNK_SIZE×CHUNK_SIZE-Kacheln und speichert sie als PNG unter {@code cacheDir}. + * Bereits vorhandene Chunks werden übersprungen, wenn {@code forceRegen} false ist. + * + * @return Anzahl neu geschriebener Chunks (0 = alle aus Cache geladen) + */ + public static int buildAndSaveHiresChunks(MapData m, int[] slotColors, int[][] tilePixels, + float[] voxelSurface, List waters, + java.nio.file.Path cacheDir, boolean forceRegen) { + final int SIZE = HIRES_MAP_SIZE; + final int CS = CHUNK_SIZE; + final int COLS = SIZE / CS; // Chunks pro Zeile/Spalte + final int SS = MapData.SPLAT_SIZE; + + try { Files.createDirectories(cacheDir); } catch (Exception e) { log.error("Chunk-Dir nicht erstellbar", e); return 0; } + + // Prüfen ob alle Chunks vorhanden sind (dann Früh-Exit) + if (!forceRegen) { + boolean allExist = true; + for (int cy = 0; cy < COLS && allExist; cy++) { + for (int cx = 0; cx < COLS && allExist; cx++) { + if (!Files.exists(chunkPath(cacheDir, cx, cy))) allExist = false; + } + } + if (allExist) { log.debug("[HiresChunks] Alle {} Chunks im Cache – übersprungen.", COLS*COLS); return 0; } + } + + float[] heights = buildTerrainSamples(m, SIZE, voxelSurface); + float[] waterH = buildWaterHeightMap(waters, SIZE); + int[] tileAvg = computeTileAvgColors(tilePixels); + int[] slotTypes = computeSlotTileTypes(m, slotColors); + float cellW = WORLD_SIZE / (SIZE - 1); + int written = 0; + + for (int cy = 0; cy < COLS; cy++) { + for (int cx = 0; cx < COLS; cx++) { + java.nio.file.Path p = chunkPath(cacheDir, cx, cy); + if (!forceRegen && Files.exists(p)) continue; + // Chunk-Pixel berechnen + int[] chunk = new int[CS * CS]; + for (int ly = 0; ly < CS; ly++) { + int gy = cy * CS + ly; // globaler Y-Index in der HIRES-Karte + float wz = -WORLD_HALF + gy * cellW; + for (int lx = 0; lx < CS; lx++) { + int gx = cx * CS + lx; + float wx = -WORLD_HALF + gx * cellW; + int idx = gy * SIZE + gx; + float h = heights[idx]; + int tr, tg, tb; + if (h < 0f || (waterH[idx] > 0f && waterH[idx] > h)) { + tr = (WATER_COLOR >> 16) & 0xFF; + tg = (WATER_COLOR >> 8) & 0xFF; + tb = WATER_COLOR & 0xFF; + } else { + int dPx = Math.max(1, (int)(1.5f / cellW)); + float hR = heights[gy * SIZE + Math.min(SIZE-1, gx+dPx)]; + float hL = heights[gy * SIZE + Math.max(0, gx-dPx)]; + float hD = heights[Math.min(SIZE-1, gy+dPx) * SIZE + gx]; + float hU = heights[Math.max(0, gy-dPx) * SIZE + gx]; + float dist = dPx * cellW; + double slope = Math.toDegrees(Math.atan(Math.sqrt( + Math.pow((hR-hL)/(2*dist),2) + Math.pow((hD-hU)/(2*dist),2)))); + int rgb = (slope > 30.0) + ? tileAvg[Math.min(TILE_CLIFF, tileAvg.length-1)] + : blendTileAvgColors(m, SS, wx, wz, tileAvg, slotTypes); + tr = (rgb>>16)&0xFF; tg = (rgb>>8)&0xFF; tb = rgb&0xFF; + } + chunk[ly * CS + lx] = (0xFF << 24) | (tr << 16) | (tg << 8) | tb; + } + } + // Als PNG speichern + BufferedImage img = new BufferedImage(CS, CS, BufferedImage.TYPE_INT_ARGB); + img.setRGB(0, 0, CS, CS, chunk, 0, CS); + try { javax.imageio.ImageIO.write(img, "PNG", p.toFile()); written++; } + catch (Exception e) { log.error("Chunk {}/{} konnte nicht gespeichert werden", cx, cy, e); } + } + } + log.debug("[HiresChunks] {} neue Chunks geschrieben nach {}", written, cacheDir); + return written; + } + + /** Lädt einen Chunk aus dem Cache. Gibt null zurück wenn nicht vorhanden. */ + public static int[] loadChunk(int chunkX, int chunkY, java.nio.file.Path cacheDir) { + java.nio.file.Path p = chunkPath(cacheDir, chunkX, chunkY); + if (!Files.exists(p)) return null; + try { + BufferedImage img = javax.imageio.ImageIO.read(p.toFile()); + if (img == null) return null; + return img.getRGB(0, 0, CHUNK_SIZE, CHUNK_SIZE, null, 0, CHUNK_SIZE); + } catch (Exception e) { + log.error("Chunk {}/{} konnte nicht geladen werden", chunkX, chunkY, e); + return null; + } + } + + private static java.nio.file.Path chunkPath(java.nio.file.Path dir, int cx, int cy) { + return dir.resolve(String.format("chunk_%02x_%02x.png", cx, cy)); + } + + /** Separabler Box-Blur (horizontal + vertikal), Radius in Pixeln. */ + private static int[] boxBlur(int[] src, int size, int radius) { + int[] tmp = new int[size * size]; + int[] dst = new int[size * size]; + int diam = radius * 2 + 1; + // Horizontaler Pass + for (int py = 0; py < size; py++) { + for (int px = 0; px < size; px++) { + long r = 0, g = 0, b = 0; + for (int dx = -radius; dx <= radius; dx++) { + int c = src[py * size + Math.max(0, Math.min(size - 1, px + dx))]; + r += (c >> 16) & 0xFF; g += (c >> 8) & 0xFF; b += c & 0xFF; + } + tmp[py * size + px] = ((int)(r/diam) << 16) | ((int)(g/diam) << 8) | (int)(b/diam); + } + } + // Vertikaler Pass + for (int py = 0; py < size; py++) { + for (int px = 0; px < size; px++) { + long r = 0, g = 0, b = 0; + for (int dz = -radius; dz <= radius; dz++) { + int c = tmp[Math.max(0, Math.min(size - 1, py + dz)) * size + px]; + r += (c >> 16) & 0xFF; g += (c >> 8) & 0xFF; b += c & 0xFF; + } + dst[py * size + px] = ((int)(r/diam) << 16) | ((int)(g/diam) << 8) | (int)(b/diam); + } + } + return dst; + } + + /** + * Bilineare Splatmap-Blendung der Tile-Durchschnittsfarben an Weltposition (wx, wz). + * Identische Logik wie der alte progressiver Splatmap-Blend in render(), aber mit + * vorberechneten Durchschnittsfarben statt Tile-UV-Sampling. + */ + private static int blendTileAvgColors(MapData m, int SS, float wx, float wz, + int[] tileAvg, int[] slotTypes) { + float sfx = Math.max(0f, Math.min(SS-1, (wx+WORLD_HALF)/WORLD_SIZE*(SS-1))); + float sfz = Math.max(0f, Math.min(SS-1, (WORLD_HALF-wz)/WORLD_SIZE*(SS-1))); + int sx0=(int)sfx, sx1=Math.min(sx0+1,SS-1); + int sz0=(int)sfz, sz1=Math.min(sz0+1,SS-1); + float stx=sfx-sx0, stz=sfz-sz0; + int bi00=sz0*SS+sx0, bi10=sz0*SS+sx1, bi01=sz1*SS+sx0, bi11=sz1*SS+sx1; + float wg = bilerp(m.splatG, bi00,bi10,bi01,bi11,stx,stz); + float wb = bilerp(m.splatB, bi00,bi10,bi01,bi11,stx,stz); + float wa = bilerp(m.splatA, bi00,bi10,bi01,bi11,stx,stz); + float up1 = bilerp(m.upperSplatR, bi00,bi10,bi01,bi11,stx,stz); + float up2 = bilerp(m.upperSplatG, bi00,bi10,bi01,bi11,stx,stz); + float up3 = bilerp(m.upperSplatB, bi00,bi10,bi01,bi11,stx,stz); + float up4 = bilerp(m.upperSplatA, bi00,bi10,bi01,bi11,stx,stz); + float t31 = bilerp(m.thirdSplatR, bi00,bi10,bi01,bi11,stx,stz); + float t32 = bilerp(m.thirdSplatG, bi00,bi10,bi01,bi11,stx,stz); + float t33 = bilerp(m.thirdSplatB, bi00,bi10,bi01,bi11,stx,stz); + float t34 = bilerp(m.thirdSplatA, bi00,bi10,bi01,bi11,stx,stz); + int c0 = tileAvg[slotTypes[0]]; + float fr=(c0>>16)&0xFF, fg=(c0>>8)&0xFF, fb=c0&0xFF; + int col; + col=tileAvg[slotTypes[1]]; fr=fr*(1-wg) +((col>>16)&0xFF)*wg; fg=fg*(1-wg) +((col>>8)&0xFF)*wg; fb=fb*(1-wg) +(col&0xFF)*wg; + col=tileAvg[slotTypes[2]]; fr=fr*(1-wb) +((col>>16)&0xFF)*wb; fg=fg*(1-wb) +((col>>8)&0xFF)*wb; fb=fb*(1-wb) +(col&0xFF)*wb; + col=tileAvg[slotTypes[3]]; fr=fr*(1-wa) +((col>>16)&0xFF)*wa; fg=fg*(1-wa) +((col>>8)&0xFF)*wa; fb=fb*(1-wa) +(col&0xFF)*wa; + col=tileAvg[slotTypes[4]]; fr=fr*(1-up1)+((col>>16)&0xFF)*up1; fg=fg*(1-up1)+((col>>8)&0xFF)*up1; fb=fb*(1-up1)+(col&0xFF)*up1; + col=tileAvg[slotTypes[5]]; fr=fr*(1-up2)+((col>>16)&0xFF)*up2; fg=fg*(1-up2)+((col>>8)&0xFF)*up2; fb=fb*(1-up2)+(col&0xFF)*up2; + col=tileAvg[slotTypes[6]]; fr=fr*(1-up3)+((col>>16)&0xFF)*up3; fg=fg*(1-up3)+((col>>8)&0xFF)*up3; fb=fb*(1-up3)+(col&0xFF)*up3; + col=tileAvg[slotTypes[7]]; fr=fr*(1-up4)+((col>>16)&0xFF)*up4; fg=fg*(1-up4)+((col>>8)&0xFF)*up4; fb=fb*(1-up4)+(col&0xFF)*up4; + col=tileAvg[slotTypes[8]]; fr=fr*(1-t31)+((col>>16)&0xFF)*t31; fg=fg*(1-t31)+((col>>8)&0xFF)*t31; fb=fb*(1-t31)+(col&0xFF)*t31; + col=tileAvg[slotTypes[9]]; fr=fr*(1-t32)+((col>>16)&0xFF)*t32; fg=fg*(1-t32)+((col>>8)&0xFF)*t32; fb=fb*(1-t32)+(col&0xFF)*t32; + col=tileAvg[slotTypes[10]]; fr=fr*(1-t33)+((col>>16)&0xFF)*t33; fg=fg*(1-t33)+((col>>8)&0xFF)*t33; fb=fb*(1-t33)+(col&0xFF)*t33; + col=tileAvg[slotTypes[11]]; fr=fr*(1-t34)+((col>>16)&0xFF)*t34; fg=fg*(1-t34)+((col>>8)&0xFF)*t34; fb=fb*(1-t34)+(col&0xFF)*t34; + return (clamp((int)fr)<<16)|(clamp((int)fg)<<8)|clamp((int)fb); + } + + private static int[] computeTileAvgColors(int[][] tiles) { + int n = TILE_FALLBACK_RGB.length; + int[] avg = new int[n]; + for (int i = 0; i < n; i++) { + if (tiles != null && i < tiles.length && tiles[i] != null) { + long r = 0, g = 0, b = 0; + for (int argb : tiles[i]) { r += (argb>>16)&0xFF; g += (argb>>8)&0xFF; b += argb&0xFF; } + int len = tiles[i].length; + avg[i] = ((int)(r/len) << 16) | ((int)(g/len) << 8) | (int)(b/len); + } else { + int[] fb = TILE_FALLBACK_RGB[i]; + avg[i] = (fb[0] << 16) | (fb[1] << 8) | fb[2]; + } + } + return avg; + } + /** * Kuwahara-Filter: Für jeden Pixel das Quadranten-Fenster mit der kleinsten * Varianz wählen und dessen Mittelwert setzen → Ölgemälde-/gemalt-Effekt. diff --git a/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java b/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java index bf6bc1e..0348496 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java @@ -108,27 +108,28 @@ public class WorldMapView extends VBox { private byte[] categoryMap = null; private float[] heightMap = null; // terrain+voxel, CAT_SIZE×CAT_SIZE (für Klippen) private float[] heightMapTerrain = null; // terrain-only, CAT_SIZE×CAT_SIZE (für Wasser-Füllung) - private WritableImage tileOverlayImg = null; - private int[] tileOverlayPixels = null; - private int tileOverlayW = 0; - private int tileOverlayH = 0; - private WritableImage forestOverlayImg = null; - private int[] forestOverlayPixels = null; - private int forestOverlayW = 0; - private int forestOverlayH = 0; - // Gecachte Vorab-Berechnungen — nur bei Modell-Wechsel neu berechnen - private WorldMapRenderModel tileOverlayModel = null; - private float[] tileOverlayForestD = null; - private int[] tileOverlaySlots = null; - // Dirty-Flag: Overlay-Pixel neu rendern wenn Pan/Zoom oder Modell geändert - private double tileOverlayCachedScale = Double.NaN; - private double tileOverlayCachedPanX = Double.NaN; - private double tileOverlayCachedPanY = Double.NaN; - // Wasser-Fill-Overlay (Layer über Terrain-Textur, unter Küstenlinie) - private WritableImage waterFillImg = null; - private int[] waterFillPixels = null; - private int waterFillW = 0; - private int waterFillH = 0; + private static final int ZOOM_LEVELS = 5; + private static final double ZOOM_MAX_SCALE = 4.0; // 1 Canvas-Pixel = 0,25 WU = 0,25m + private static final int ZOOM_HIRES_CUTOFF = 3; // ab diesem Index Chunk-Rendering + private static final int CHUNK_CACHE_MAX = 24; // max. Chunks gleichzeitig im RAM + private static final java.nio.file.Path CHUNK_CACHE_DIR = + java.nio.file.Paths.get(System.getProperty("user.home"), ".blight", "map_chunks"); + + private double[] zoomLevels = null; + private int zoomLevelIdx = 0; + + /** LRU-Cache: chunk-Key (cy*COLS+cx) → WritableImage. */ + private final java.util.LinkedHashMap chunkCache = + new java.util.LinkedHashMap<>(CHUNK_CACHE_MAX * 2, 0.75f, true) { + @Override protected boolean removeEldestEntry(java.util.Map.Entry e) { + return size() > CHUNK_CACHE_MAX; + } + }; + private final java.util.Set chunksLoading = + java.util.Collections.synchronizedSet(new java.util.HashSet<>()); + + private WritableImage bakedForestOverlay = null; + // Wasser-Fill-Overlay entfernt: ocean + PlacedWater sind bereits in mapFxImage korrekt gerendert // PlacedWater-Wasserhoehe pro Zelle (0 = kein PlacedWater); fuer glatte Uebergaenge private float[] waterHeightMap = null; @@ -282,10 +283,13 @@ public class WorldMapView extends VBox { }); canvas.setOnScroll(e -> { - if (e.getDeltaY() == 0) return; - double factor = e.getDeltaY() > 0 ? 1.15 : 1.0 / 1.15; + if (zoomLevels == null || e.getDeltaY() == 0) return; + int newIdx = zoomLevelIdx + (e.getDeltaY() > 0 ? 1 : -1); + newIdx = Math.max(0, Math.min(ZOOM_LEVELS - 1, newIdx)); + if (newIdx == zoomLevelIdx) return; double oldScale = scale; - scale = Math.max(0.1, Math.min(30.0, scale * factor)); + zoomLevelIdx = newIdx; + scale = zoomLevels[zoomLevelIdx]; panX = e.getX() - (e.getX() - panX) * scale / oldScale; panY = e.getY() - (e.getY() - panY) * scale / oldScale; redraw(); @@ -365,6 +369,17 @@ public class WorldMapView extends VBox { log.debug("[WorldMap] Hintergrund-PNG + Kategorie-Karte gerendert ({}×{} px, {} ms)", RENDER_SIZE, RENDER_SIZE, System.currentTimeMillis() - t1); + float[] fDensity = computeForestDensity(model); + int[] bakedFstPx = computeForestPixels(model, fDensity, 2.0f); + + // Hires-Chunks im Hintergrund generieren (nach dem UI-Update, nicht-blockierend) + final WorldMapRenderModel chunkModel = model; + Platform.runLater(() -> statusLbl.setText("Generiere Hires-Chunks…")); + WorldMapRenderer.buildAndSaveHiresChunks( + chunkModel.mapData(), chunkModel.slotColorsRGB(), chunkModel.mapTilePixels(), + chunkModel.voxelSurface(), chunkModel.waters(), CHUNK_CACHE_DIR, true); + chunkCache.clear(); + Platform.runLater(() -> { currentModel = model; cachedAreas = new ArrayList<>(model.areas()); @@ -375,6 +390,7 @@ public class WorldMapView extends VBox { heightMapTerrain = hMapT; categoryMap = catMap; waterHeightMap = whMap; + bakedForestOverlay = toWritableImage(bakedFstPx, RENDER_SIZE); fitToView(); redraw(); progress.setVisible(false); @@ -409,22 +425,13 @@ public class WorldMapView extends VBox { Thread t = new Thread(() -> { log.debug("[WorldMap] PNG-Rerender aus gecachtem Modell gestartet ({}×{})", RENDER_SIZE, RENDER_SIZE); long t0 = System.currentTimeMillis(); + // Nur das PNG neu rendern — heightMap/catMap/waterHeightMap kommen aus Stage 1 + // und sind bereits in den Instanz-Feldern vorhanden BufferedImage bi = WorldMapRenderer.render(model, RENDER_SIZE, buildBackgroundOptions()); - float[] hMap = WorldMapRenderer.buildTerrainSamples( - model.mapData(), CAT_SIZE, model.voxelSurface()); - float[] hMapT = WorldMapRenderer.buildTerrainSamples( - model.mapData(), CAT_SIZE, null); - byte[] catMap = WorldMapRenderer.buildCategoryMap( - model.mapData(), CAT_SIZE, model.voxelSurface(), model.waters(), model.slotColorsRGB()); - float[] whMap = WorldMapRenderer.buildWaterHeightMap(model.waters(), CAT_SIZE); log.debug("[WorldMap] PNG-Rerender fertig ({} ms)", System.currentTimeMillis() - t0); Platform.runLater(() -> { mapBuffered = bi; mapFxImage = SwingFXUtils.toFXImage(bi, null); - heightMap = hMap; - heightMapTerrain = hMapT; - categoryMap = catMap; - waterHeightMap = whMap; redraw(); progress.setVisible(false); statusLbl.setText("Bereit"); @@ -454,20 +461,71 @@ public class WorldMapView extends VBox { return; } - gc.setImageSmoothing(false); - gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale); gc.setImageSmoothing(true); - // Layer 1: Terrain-Textur (überall, opak) - drawTileOverlay(gc); - // Layer 2: Wasser darüber (glatte Küste via seaCoastPaths) + if (zoomLevelIdx >= ZOOM_HIRES_CUTOFF) { + drawHiresChunks(gc); + } else { + gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale); + } + // Layer 1: Wasser (glatte Küste via seaCoastPaths) if (cbWater.isSelected()) drawWaterOverlay(gc); - // Layer 3: Wald-Overlay (nach Wasser, damit Bäume nicht im Wasser erscheinen) + // Layer 2: Wald-Overlay (nach Wasser, damit Bäume nicht im Wasser erscheinen) drawForestOverlay(gc); if (cbAreas.isSelected()) drawAreasOverlay(gc); if (cbLocations.isSelected()) drawLocationsOverlay(gc); if (!isFullscreen) drawCameraIndicator(gc); } + private void drawHiresChunks(GraphicsContext gc) { + final int CS = WorldMapRenderer.CHUNK_SIZE; + final int COLS = WorldMapRenderer.HIRES_MAP_SIZE / CS; + // Pixelgröße eines Hires-Chunks auf dem Canvas + final double chunkCanvasW = (double) RENDER_SIZE * scale / COLS; + final double chunkCanvasH = chunkCanvasW; + int cW = (int) canvas.getWidth(), cH = (int) canvas.getHeight(); + + for (int cy = 0; cy < COLS; cy++) { + double canvasTop = panY + cy * chunkCanvasH; + if (canvasTop > cH) break; + if (canvasTop + chunkCanvasH < 0) continue; + for (int cx = 0; cx < COLS; cx++) { + double canvasLeft = panX + cx * chunkCanvasW; + if (canvasLeft > cW) break; + if (canvasLeft + chunkCanvasW < 0) continue; + + int key = cy * COLS + cx; + WritableImage img = chunkCache.get(key); + if (img != null) { + gc.drawImage(img, canvasLeft, canvasTop, chunkCanvasW, chunkCanvasH); + } else { + // Platzhalter (dunkelgrau) + asynchrones Laden + gc.setFill(javafx.scene.paint.Color.rgb(40, 40, 40)); + gc.fillRect(canvasLeft, canvasTop, chunkCanvasW, chunkCanvasH); + if (chunksLoading.add(key)) { + final int fcx = cx, fcy = cy, fkey = key; + Thread lt = new Thread(() -> { + int[] pixels = WorldMapRenderer.loadChunk(fcx, fcy, CHUNK_CACHE_DIR); + if (pixels != null) { + WritableImage wi = new WritableImage(CS, CS); + wi.getPixelWriter().setPixels(0, 0, CS, CS, + javafx.scene.image.PixelFormat.getIntArgbInstance(), pixels, 0, CS); + Platform.runLater(() -> { + chunkCache.put(fkey, wi); + chunksLoading.remove(fkey); + redraw(); + }); + } else { + chunksLoading.remove(fkey); + } + }, "ChunkLoader-" + fcx + "-" + fcy); + lt.setDaemon(true); + lt.start(); + } + } + } + } + } + // ── Canvas Tile-Overlay (zoom-unabhängige Kacheln) ──────────────────────── private static int tileSample(int[][] tilePx, int cat, @@ -492,206 +550,67 @@ public class WorldMapView extends VBox { return (v00*(1-tx)+v10*tx)*(1-tz)+(v01*(1-tx)+v11*tx)*tz; } - private void drawTileOverlay(GraphicsContext gc) { - if (heightMap == null || currentModel == null) return; - de.blight.common.MapData md = currentModel.mapData(); - if (md == null) return; - int[][] tilePx = currentModel.mapTilePixels(); - if (tilePx == null) return; + private void drawForestOverlay(GraphicsContext gc) { + if (bakedForestOverlay == null) return; + int cW = (int) canvas.getWidth(), cH = (int) canvas.getHeight(); + gc.drawImage(bakedForestOverlay, -panX / scale, -panY / scale, cW / scale, cH / scale, 0, 0, cW, cH); + } - int cW = (int) canvas.getWidth(); - int cH = (int) canvas.getHeight(); - if (cW <= 0 || cH <= 0) return; + // ── Wald-Baking (läuft im Background-Thread) ───────────────────────────── - final float WH = WorldMapRenderer.WORLD_HALF; - final float WS = WorldMapRenderer.WORLD_SIZE; - final float TW = WorldMapRenderer.TILE_WU; - final float scaleF = (float) scale; - final float panXf = (float) panX; - final float panYf = (float) panY; - final float imgW = (float)(RENDER_SIZE - 1); - final int CS = CAT_SIZE; - final float[] hm = heightMap; - final float CS1 = CS - 1f; - final float CLIFF_LO = 0.4663f, CLIFF_RNG = 0.8391f - 0.4663f; - - // ── Modell-abhängige Vorab-Berechnungen cachen ──────────────────── - if (tileOverlayModel != currentModel) { - tileOverlayModel = currentModel; - tileOverlaySlots = WorldMapRenderer.computeSlotTileTypes(md, currentModel.slotColorsRGB()); - final int FD0 = 512; - final float forestRadius0 = 14f; - final int forestRCells0 = Math.max(1, (int)(forestRadius0 / (WS / FD0))); - final float[] fd0 = new float[FD0 * FD0]; - for (de.blight.common.PlacedModel pm : currentModel.models()) { - if (!WorldMapRenderer.isTree(pm)) continue; - int fx = Math.max(0, Math.min(FD0-1, (int)((pm.x() + WH) / WS * FD0))); - int fz = Math.max(0, Math.min(FD0-1, (int)((WH - pm.z()) / WS * FD0))); - for (int dz = -forestRCells0; dz <= forestRCells0; dz++) { - for (int dx = -forestRCells0; dx <= forestRCells0; dx++) { - if (dx*dx + dz*dz <= forestRCells0*forestRCells0) { - int nx = fx+dx, nz = fz+dz; - if (nx >= 0 && nx < FD0 && nz >= 0 && nz < FD0) fd0[nz*FD0+nx] += 1f; - } + private static float[] computeForestDensity(WorldMapRenderModel model) { + final int FD = 512; + final float WH = WorldMapRenderer.WORLD_HALF, WS = WorldMapRenderer.WORLD_SIZE; + final float radius = 14f; + final int rc = Math.max(1, (int)(radius / (WS / FD))); + final float[] fd = new float[FD * FD]; + for (de.blight.common.PlacedModel pm : model.models()) { + if (!WorldMapRenderer.isTree(pm)) continue; + int fx = Math.max(0, Math.min(FD-1, (int)((pm.x()+WH)/WS*FD))); + int fz = Math.max(0, Math.min(FD-1, (int)((WH-pm.z())/WS*FD))); + for (int dz=-rc; dz<=rc; dz++) { + for (int dx=-rc; dx<=rc; dx++) { + if (dx*dx+dz*dz <= rc*rc) { + int nx=fx+dx, nz=fz+dz; + if (nx>=0&&nx=0&&nz= WH || wz < -WH || wz >= WH) { - tileOverlayPixels[cy * cW + cx] = 0x00000000; - continue; - } - - // ── Steigung aus heightMap → Cliff-Blend - float fmx = (wx + WH) / WS * CS1; - float fmz = (wz + WH) / WS * CS1; - int cmx = Math.max(0, Math.min(CS-1, (int)fmx)); - int cmz = Math.max(0, Math.min(CS-1, (int)fmz)); - int cmxR = Math.min(CS-1, cmx+2), cmxL = Math.max(0, cmx-2); - int cmzD = Math.min(CS-1, cmz+2), cmzU = Math.max(0, cmz-2); - float hC = hm[cmz*CS+cmx]; - float hR = hm[cmz*CS+cmxR]; if (hR < 0f) hR = hC; - float hL = hm[cmz*CS+cmxL]; if (hL < 0f) hL = hC; - float hD = hm[cmzD*CS+cmx]; if (hD < 0f) hD = hC; - float hU = hm[cmzU*CS+cmx]; if (hU < 0f) hU = hC; - float dx = (hR - hL) / (cmxR - cmxL), dz = (hD - hU) / (cmzD - cmzU); - float gradMag = (float)Math.sqrt((double)(dx*dx + dz*dz)); - float t = Math.max(0f, Math.min(1f, (gradMag - CLIFF_LO) / CLIFF_RNG)); - t = t * t * (3f - 2f * t); - - // ── Tile-UV bilinear - float rawU = (wx + WH) / TW, rawV = (wz + WH) / TW; - float uf16 = (rawU - (float)Math.floor(rawU)) * 16f; - float vf16 = (rawV - (float)Math.floor(rawV)) * 16f; - int u0 = Math.floorMod((int)uf16,16), u1 = Math.floorMod(u0+1,16); - int v0 = Math.floorMod((int)vf16,16), v1 = Math.floorMod(v0+1,16); - float ux = uf16-(float)Math.floor(uf16), vxf = vf16-(float)Math.floor(vf16); - float w00=(1-ux)*(1-vxf), w10=ux*(1-vxf), w01=(1-ux)*vxf, w11=ux*vxf; - - // ── Splatmap bilinear (Y-flip) - float sfx = Math.max(0f, Math.min(SS-1, (wx+WH)/WS*(SS-1))); - float sfz = Math.max(0f, Math.min(SS-1, (WH-wz)/WS*(SS-1))); - int sx0=(int)sfx, sx1=Math.min(sx0+1,SS-1); - int sz0=(int)sfz, sz1=Math.min(sz0+1,SS-1); - float stx=sfx-sx0, stz=sfz-sz0; - int bi00=sz0*SS+sx0, bi10=sz0*SS+sx1, bi01=sz1*SS+sx0, bi11=sz1*SS+sx1; - float wg = spBilerp(spG,bi00,bi10,bi01,bi11,stx,stz); - float wb = spBilerp(spB,bi00,bi10,bi01,bi11,stx,stz); - float wa = spBilerp(spA,bi00,bi10,bi01,bi11,stx,stz); - float up1 = spBilerp(upR,bi00,bi10,bi01,bi11,stx,stz); - float up2 = spBilerp(upG,bi00,bi10,bi01,bi11,stx,stz); - float up3 = spBilerp(upB,bi00,bi10,bi01,bi11,stx,stz); - float up4 = spBilerp(upA,bi00,bi10,bi01,bi11,stx,stz); - float t31 = spBilerp(t3R,bi00,bi10,bi01,bi11,stx,stz); - float t32 = spBilerp(t3G,bi00,bi10,bi01,bi11,stx,stz); - float t33 = spBilerp(t3B,bi00,bi10,bi01,bi11,stx,stz); - float t34 = spBilerp(t3A,bi00,bi10,bi01,bi11,stx,stz); - - // ── Alle Tile-Farben sampeln - for (int ti = 0; ti < 8; ti++) tileArgbs[ti] = tileSample(tilePx,ti,u0,u1,v0,v1,w00,w10,w01,w11); - - // ── Splatmap-Blend - int col = tileArgbs[slotTiles[0]]; - int cr=(col>>16)&0xFF, cgv=(col>>8)&0xFF, cbv=col&0xFF; - col=tileArgbs[slotTiles[1]]; cr=(int)(cr*(1-wg) +((col>>16)&0xFF)*wg ); cgv=(int)(cgv*(1-wg) +((col>>8)&0xFF)*wg ); cbv=(int)(cbv*(1-wg) +(col&0xFF)*wg ); - col=tileArgbs[slotTiles[2]]; cr=(int)(cr*(1-wb) +((col>>16)&0xFF)*wb ); cgv=(int)(cgv*(1-wb) +((col>>8)&0xFF)*wb ); cbv=(int)(cbv*(1-wb) +(col&0xFF)*wb ); - col=tileArgbs[slotTiles[3]]; cr=(int)(cr*(1-wa) +((col>>16)&0xFF)*wa ); cgv=(int)(cgv*(1-wa) +((col>>8)&0xFF)*wa ); cbv=(int)(cbv*(1-wa) +(col&0xFF)*wa ); - col=tileArgbs[slotTiles[4]]; cr=(int)(cr*(1-up1)+((col>>16)&0xFF)*up1); cgv=(int)(cgv*(1-up1)+((col>>8)&0xFF)*up1); cbv=(int)(cbv*(1-up1)+(col&0xFF)*up1); - col=tileArgbs[slotTiles[5]]; cr=(int)(cr*(1-up2)+((col>>16)&0xFF)*up2); cgv=(int)(cgv*(1-up2)+((col>>8)&0xFF)*up2); cbv=(int)(cbv*(1-up2)+(col&0xFF)*up2); - col=tileArgbs[slotTiles[6]]; cr=(int)(cr*(1-up3)+((col>>16)&0xFF)*up3); cgv=(int)(cgv*(1-up3)+((col>>8)&0xFF)*up3); cbv=(int)(cbv*(1-up3)+(col&0xFF)*up3); - col=tileArgbs[slotTiles[7]]; cr=(int)(cr*(1-up4)+((col>>16)&0xFF)*up4); cgv=(int)(cgv*(1-up4)+((col>>8)&0xFF)*up4); cbv=(int)(cbv*(1-up4)+(col&0xFF)*up4); - col=tileArgbs[slotTiles[8]]; cr=(int)(cr*(1-t31)+((col>>16)&0xFF)*t31); cgv=(int)(cgv*(1-t31)+((col>>8)&0xFF)*t31); cbv=(int)(cbv*(1-t31)+(col&0xFF)*t31); - col=tileArgbs[slotTiles[9]]; cr=(int)(cr*(1-t32)+((col>>16)&0xFF)*t32); cgv=(int)(cgv*(1-t32)+((col>>8)&0xFF)*t32); cbv=(int)(cbv*(1-t32)+(col&0xFF)*t32); - col=tileArgbs[slotTiles[10]];cr=(int)(cr*(1-t33)+((col>>16)&0xFF)*t33); cgv=(int)(cgv*(1-t33)+((col>>8)&0xFF)*t33); cbv=(int)(cbv*(1-t33)+(col&0xFF)*t33); - col=tileArgbs[slotTiles[11]];cr=(int)(cr*(1-t34)+((col>>16)&0xFF)*t34); cgv=(int)(cgv*(1-t34)+((col>>8)&0xFF)*t34); cbv=(int)(cbv*(1-t34)+(col&0xFF)*t34); - - // ── Wald-Dichte (bilinear) → separater Buffer, nach Wasser gezeichnet - float fsfx = Math.max(0f, Math.min(FD-1.001f, (wx + WH) / WS * FD)); - float fsfz = Math.max(0f, Math.min(FD-1.001f, (WH - wz) / WS * FD)); + private static int[] computeForestPixels(WorldMapRenderModel model, float[] density, float threshold) { + final int SIZE = RENDER_SIZE, FD = 512; + final float WH = WorldMapRenderer.WORLD_HALF, WS = WorldMapRenderer.WORLD_SIZE; + final int[][] tilePx = model.mapTilePixels(); + final int forestArgb = (tilePx != null && WorldMapRenderer.TILE_FOREST < tilePx.length + && tilePx[WorldMapRenderer.TILE_FOREST] != null) + ? tileSample(tilePx, WorldMapRenderer.TILE_FOREST, 0, 0, 0, 0, 1f, 0f, 0f, 0f) + : 0x3D7A28; + final int forestColor = 0x80000000 | (forestArgb & 0x00FFFFFF); + final int[] px = new int[SIZE * SIZE]; + for (int py = 0; py < SIZE; py++) { + float wz = (float) py / (SIZE - 1) * WS - WH; + for (int bx = 0; bx < SIZE; bx++) { + float wx = (float) bx / (SIZE - 1) * WS - WH; + float fsfx=Math.max(0f,Math.min(FD-1.001f,(wx+WH)/WS*FD)); + float fsfz=Math.max(0f,Math.min(FD-1.001f,(WH-wz)/WS*FD)); int fsx0=(int)fsfx, fsx1=Math.min(fsx0+1,FD-1); int fsz0=(int)fsfz, fsz1=Math.min(fsz0+1,FD-1); float fstx=fsfx-fsx0, fstz=fsfz-fsz0; - float fd = (forestDensity[fsz0*FD+fsx0]*(1-fstx)+forestDensity[fsz0*FD+fsx1]*fstx)*(1-fstz) - + (forestDensity[fsz1*FD+fsx0]*(1-fstx)+forestDensity[fsz1*FD+fsx1]*fstx)*fstz; - // mind. 3 Bäume im Überlappungsbereich (fd > 2.0 bedeutet >2 Bäume) - forestOverlayPixels[cy * cW + cx] = (fd > 2.0f) - ? (0x80000000 | (tileArgbs[WorldMapRenderer.TILE_FOREST] & 0x00FFFFFF)) - : 0x00000000; - - int argb = (cr<<16)|(cgv<<8)|cbv; - - // ── Cliff-Overlay - if (t > 0f) { - int cArgb = tileArgbs[WorldMapRenderer.TILE_CLIFF]; - int ccr=(cArgb>>16)&0xFF, ccg=(cArgb>>8)&0xFF, ccb=cArgb&0xFF; - argb = ((int)(cr+(ccr-cr)*t)<<16)|((int)(cgv+(ccg-cgv)*t)<<8)|(int)(cbv+(ccb-cbv)*t); - } - - tileOverlayPixels[cy * cW + cx] = 0xFF000000 | (argb & 0x00FFFFFF); + float fd=(density[fsz0*FD+fsx0]*(1-fstx)+density[fsz0*FD+fsx1]*fstx)*(1-fstz) + +(density[fsz1*FD+fsx0]*(1-fstx)+density[fsz1*FD+fsx1]*fstx)*fstz; + px[py*SIZE+bx] = (fd > threshold) ? forestColor : 0x00000000; } } - - tileOverlayImg.getPixelWriter().setPixels( - 0, 0, cW, cH, PixelFormat.getIntArgbInstance(), tileOverlayPixels, 0, cW); - forestOverlayImg.getPixelWriter().setPixels( - 0, 0, cW, cH, PixelFormat.getIntArgbInstance(), forestOverlayPixels, 0, cW); - gc.drawImage(tileOverlayImg, 0, 0); + return px; } - private void drawForestOverlay(GraphicsContext gc) { - if (forestOverlayImg == null) return; - gc.drawImage(forestOverlayImg, 0, 0); + private static WritableImage toWritableImage(int[] pixels, int size) { + WritableImage img = new WritableImage(size, size); + img.getPixelWriter().setPixels(0, 0, size, size, PixelFormat.getIntArgbInstance(), pixels, 0, size); + return img; } // ── Canvas-Vektor-Overlays ──────────────────────────────────────────────── @@ -768,82 +687,11 @@ public class WorldMapView extends VBox { } private void drawWaterOverlay(GraphicsContext gc) { - if (currentModel == null || heightMap == null || waterHeightMap == null) return; + if (currentModel == null) return; + // Wasser-Füllung kommt aus mapFxImage (bereits korrekt gerendert). + // Hier nur noch Küstenlinie und Wellen-Vektoren. - int cW = (int) canvas.getWidth(), cH = (int) canvas.getHeight(); - if (cW <= 0 || cH <= 0) return; - - // ── Wasser-Füllung: blauer Pixel-Layer über dem Terrain-Overlay ────────── - if (waterFillPixels == null || waterFillPixels.length != cW * cH) { - waterFillPixels = new int[cW * cH]; - } - if (waterFillImg == null || waterFillW != cW || waterFillH != cH) { - waterFillImg = new WritableImage(cW, cH); - waterFillW = cW; - waterFillH = cH; - } - - final float WH = WorldMapRenderer.WORLD_HALF; - final float WS = WorldMapRenderer.WORLD_SIZE; - final float scaleF = (float) scale; - final float panXf = (float) panX; - final float panYf = (float) panY; - final float imgW = (float)(RENDER_SIZE - 1); - final int CS = CAT_SIZE; - final float[] hm = heightMap; // terrain+voxel - final float[] whm = waterHeightMap; // PlacedWater-Höhe pro Zelle (0 = kein PlacedWater) - final float CS1 = CS - 1f; - final int WR = 0xAD, WG = 0xD8, WB = 0xE6; // WATER_COLOR = #ADD8E6 - - for (int cy = 0; cy < cH; cy++) { - float wz = (cy - panYf) / scaleF / imgW * WS - WH; - for (int cx = 0; cx < cW; cx++) { - float wx = (cx - panXf) / scaleF / imgW * WS - WH; - - if (wx < -WH || wx >= WH || wz < -WH || wz >= WH) { - waterFillPixels[cy * cW + cx] = 0; - continue; - } - - float fmx = (wx + WH) / WS * CS1; - float fmz = (wz + WH) / WS * CS1; - int cmx = Math.max(0, Math.min(CS-1, (int) fmx)); - int cmz = Math.max(0, Math.min(CS-1, (int) fmz)); - int cmx1 = Math.min(CS-1, cmx+1), cmz1 = Math.min(CS-1, cmz+1); - float tx = fmx - cmx, tz = fmz - cmz; - - // Bilinear heightMap (terrain+voxel) - float hPx = hm[cmz *CS+cmx ] * (1-tx)*(1-tz) - + hm[cmz *CS+cmx1] * tx *(1-tz) - + hm[cmz1*CS+cmx ] * (1-tx)*tz - + hm[cmz1*CS+cmx1] * tx *tz; - - // Bilinear waterHeightMap → PlacedWater-Schwelle (0 = nur Ozean) - float wH = whm[cmz *CS+cmx ] * (1-tx)*(1-tz) - + whm[cmz *CS+cmx1] * tx *(1-tz) - + whm[cmz1*CS+cmx ] * (1-tx)*tz - + whm[cmz1*CS+cmx1] * tx *tz; - - // delta < 0 → unter Wasseroberfläche; Übergangsband ±0.5 WU - float delta = hPx - wH; - int alpha; - if (delta <= -0.5f) { - alpha = 255; - } else if (delta < 0.5f) { - alpha = Math.max(0, Math.min(255, (int)((0.5f - delta) * 255f))); - } else { - alpha = 0; - } - - waterFillPixels[cy * cW + cx] = (alpha << 24) | (WR << 16) | (WG << 8) | WB; - } - } - - waterFillImg.getPixelWriter().setPixels( - 0, 0, cW, cH, PixelFormat.getIntArgbInstance(), waterFillPixels, 0, cW); - gc.drawImage(waterFillImg, 0, 0); - - // ── Küstenlinie – geglättete Pfade über der Wasser-Füllung ───────────── + // ── Küstenlinie ─────────────────────────────────────────────────────────── List seaCoastPaths = currentModel.coastPaths(); if (cbCoastline.isSelected() && seaCoastPaths != null && !seaCoastPaths.isEmpty()) { double canvasW = canvas.getWidth(), canvasH = canvas.getHeight(); @@ -1068,9 +916,10 @@ public class WorldMapView extends VBox { List treeList = models.stream() .filter(WorldMapRenderer::isTree).collect(Collectors.toList()); List> tClusters = clusterTreeModels(treeList, 40f); + int[] colorMap = WorldMapRenderer.buildTerrainColorMap(mapData, slotColors, mapTiles, voxelSurface, waters); return new WorldMapRenderModel( mapData, areas, zones, locs, waters, models, waterfalls, slotColors, - mask, voxelSurface, tSamp, paths, tClusters, mapTiles); + mask, voxelSurface, tSamp, paths, tClusters, mapTiles, colorMap); } // Erzeugt interpolierte Marching-Squares-Segmente für Meer + Wasserflächen. @@ -1274,9 +1123,21 @@ public class WorldMapView extends VBox { private void fitToView() { if (canvas.getWidth() <= 0 || mapFxImage == null) return; - scale = Math.min(canvas.getWidth() / mapFxImage.getWidth(), canvas.getHeight() / mapFxImage.getHeight()); - panX = (canvas.getWidth() - mapFxImage.getWidth() * scale) / 2; - panY = (canvas.getHeight() - mapFxImage.getHeight() * scale) / 2; + double fitScale = Math.min(canvas.getWidth() / RENDER_SIZE, canvas.getHeight() / RENDER_SIZE); + computeZoomLevels(fitScale); + zoomLevelIdx = 0; + scale = zoomLevels[0]; + panX = (canvas.getWidth() - RENDER_SIZE * scale) / 2; + panY = (canvas.getHeight() - RENDER_SIZE * scale) / 2; + } + + private void computeZoomLevels(double fitScale) { + zoomLevels = new double[ZOOM_LEVELS]; + zoomLevels[0] = fitScale; + double ratio = Math.pow(ZOOM_MAX_SCALE / fitScale, 1.0 / (ZOOM_LEVELS - 1)); + for (int i = 1; i < ZOOM_LEVELS; i++) { + zoomLevels[i] = zoomLevels[i-1] * ratio; + } } private static boolean polyContains(float[] xs, float[] zs, float px, float pz) { @@ -1400,7 +1261,10 @@ public class WorldMapView extends VBox { double cw = canvasPane.getWidth(); double ch = canvasPane.getHeight(); if (cw <= 0 || ch <= 0 || mapFxImage == null) return; - scale = Math.min(cw / RENDER_SIZE, ch / RENDER_SIZE); + double fitScale = Math.min(cw / RENDER_SIZE, ch / RENDER_SIZE); + computeZoomLevels(fitScale); + zoomLevelIdx = 0; + scale = zoomLevels[0]; panX = (cw - RENDER_SIZE * scale) / 2.0; panY = (ch - RENDER_SIZE * scale) / 2.0; redraw(); diff --git a/blight-game/src/main/java/de/blight/game/state/MinimapState.java b/blight-game/src/main/java/de/blight/game/state/MinimapState.java index 866605b..46a0b57 100644 --- a/blight-game/src/main/java/de/blight/game/state/MinimapState.java +++ b/blight-game/src/main/java/de/blight/game/state/MinimapState.java @@ -40,7 +40,9 @@ import de.blight.common.PlacedLocationZone; import de.blight.common.PlacedModel; import de.blight.common.PlacedModelIO; import de.blight.common.PlacedWater; +import de.blight.common.PlacedWaterfall; import de.blight.common.WaterBodyIO; +import de.blight.common.WaterfallIO; import de.blight.common.map.WorldMapRenderer; import de.blight.common.map.WorldMapRenderer.RenderInput; import de.blight.common.map.WorldMapRenderer.RenderOptions; @@ -546,30 +548,22 @@ public class MinimapState extends BaseAppState { List locs = LocationIO.load(); List waters = WaterBodyIO.load(); List models = PlacedModelIO.load(); + List waterfalls = WaterfallIO.load(); int[] slotColors = computeSlotColors(mapData, root); + int[][] tilePixels = WorldMapRenderer.loadMapTiles(root); renderInput = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors); + de.blight.common.map.WorldMapRenderModel renderModel = new de.blight.common.map.WorldMapRenderModel( + mapData, areas, zones, locs, waters, models, waterfalls, + slotColors, null, null, null, null, null, tilePixels, null); log.debug("[Minimap] Render-Modell bereit – {} Areas, {} Orte, {} Wasser ({} ms)", areas.size(), locs.size(), waters.size(), System.currentTimeMillis() - t0); - boolean needsRender = !Files.exists(png); - if (!needsRender) { - // Cache ungültig wenn Karte neuer als das PNG - try { - long pngMod = Files.getLastModifiedTime(png).toMillis(); - long mapMod = Files.getLastModifiedTime(MapIO.getMapPath()).toMillis(); - if (mapMod > pngMod) { needsRender = true; } - } catch (Exception ignored) { needsRender = true; } - } - if (needsRender) { - log.info("[Minimap] Rendere Weltkarte {}×{}…", TEXTURE_SIZE, TEXTURE_SIZE); - long t1 = System.currentTimeMillis(); - BufferedImage bi = WorldMapRenderer.render(renderInput, TEXTURE_SIZE, RenderOptions.all()); - ImageIO.write(bi, "PNG", png.toFile()); - log.debug("[Minimap] Weltkarte gerendert und gespeichert ({} ms): {}", - System.currentTimeMillis() - t1, png); - } else { - log.info("[Minimap] Gecachte Weltkarte: {}", png); - } + log.info("[Minimap] Rendere Weltkarte {}×{}…", TEXTURE_SIZE, TEXTURE_SIZE); + long t1 = System.currentTimeMillis(); + BufferedImage bi = WorldMapRenderer.render(renderModel, TEXTURE_SIZE, RenderOptions.all()); + ImageIO.write(bi, "PNG", png.toFile()); + log.debug("[Minimap] Weltkarte gerendert und gespeichert ({} ms): {}", + System.currentTimeMillis() - t1, png); app.enqueue(() -> { onTextureReady(); return null; }); } catch (Exception e) {