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 a8ec90c..6589d00 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 @@ -1080,24 +1080,15 @@ public final class WorldMapRenderer { float[] voxelSurface, List waters, List waterfalls) { final int SIZE = COLOR_MAP_SIZE; - final int COARSE = 512; final int SS = MapData.SPLAT_SIZE; float[] heights = buildTerrainSamples(m, SIZE, voxelSurface); float[] waterH = buildWaterHeightMap(waters, SIZE); - float cellWC = WORLD_SIZE / (COARSE - 1); - // Coarse-Typ-Map bei 512×512 (4m/px) – filtert dünne Texturen heraus - byte[] coarseTypes = new byte[COARSE * COARSE]; - for (int mz = 0; mz < COARSE; mz++) { - float wz = -WORLD_HALF + mz * cellWC; - for (int mx = 0; mx < COARSE; mx++) { - float wx = -WORLD_HALF + mx * cellWC; - coarseTypes[mz * COARSE + mx] = (byte) getTerrainTile(m, SS, wx, wz, slotColors); - } - } + // Coarse-Typ-Map bei 256×256 (8m/px) – filtert Texturen <8m heraus + int[] coarseColors = buildCoarseColorMap(m, SS, slotColors, 256); - // Hochskalieren (NN) auf SIZE×SIZE und Flachfarbe zuweisen + // Bilinear-Interpolation der Flachfarben auf SIZE×SIZE → keine Block-Artefakte int[] result = new int[SIZE * SIZE]; for (int py = 0; py < SIZE; py++) { for (int px = 0; px < SIZE; px++) { @@ -1107,15 +1098,44 @@ public final class WorldMapRenderer { int[] wc = TILE_FALLBACK_RGB[TILE_WATER]; result[idx] = (wc[0] << 16) | (wc[1] << 8) | wc[2]; } else { - int cx = Math.min(COARSE - 1, (int)((float) px / (SIZE - 1) * (COARSE - 1))); - int cz = Math.min(COARSE - 1, (int)((float) py / (SIZE - 1) * (COARSE - 1))); - result[idx] = schematicColor(coarseTypes[cz * COARSE + cx] & 0xFF); + result[idx] = sampleCoarseBilinear(coarseColors, 256, px, py, SIZE); } } } return result; } + /** Baut eine Coarse-Farbkarte bei gegebener Auflösung: 1 Pixel = WORLD_SIZE/coarseSize m. */ + private static int[] buildCoarseColorMap(MapData m, int SS, int[] slotColors, int coarseSize) { + float cellW = WORLD_SIZE / (coarseSize - 1); + int[] colors = new int[coarseSize * coarseSize]; + for (int mz = 0; mz < coarseSize; mz++) { + float wz = -WORLD_HALF + mz * cellW; + for (int mx = 0; mx < coarseSize; mx++) { + float wx = -WORLD_HALF + mx * cellW; + colors[mz * coarseSize + mx] = schematicColor(getTerrainTile(m, SS, wx, wz, slotColors)); + } + } + return colors; + } + + /** Bilinear-Sample einer Coarse-Farbkarte (packed RGB) auf einen Ziel-Pixel. */ + private static int sampleCoarseBilinear(int[] coarseColors, int coarseSize, int px, int py, int targetSize) { + float fx = (float) px / (targetSize - 1) * (coarseSize - 1); + float fz = (float) py / (targetSize - 1) * (coarseSize - 1); + int cx0 = (int) fx, cx1 = Math.min(cx0 + 1, coarseSize - 1); + int cz0 = (int) fz, cz1 = Math.min(cz0 + 1, coarseSize - 1); + float tx = fx - cx0, tz = fz - cz0; + int c00 = coarseColors[cz0 * coarseSize + cx0]; + int c10 = coarseColors[cz0 * coarseSize + cx1]; + int c01 = coarseColors[cz1 * coarseSize + cx0]; + int c11 = coarseColors[cz1 * coarseSize + cx1]; + int r = 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)); + int g = 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)); + int b = clamp((int)((c00&0xFF)*(1-tx)*(1-tz)+(c10&0xFF)*tx*(1-tz)+(c01&0xFF)*(1-tx)*tz+(c11&0xFF)*tx*tz)); + return (r << 16) | (g << 8) | b; + } + /** * Berechnet das schematische Cliff-Overlay (ARGB, COLOR_MAP_SIZE×COLOR_MAP_SIZE): * graue Füllung wo Steigung > 30°, dunklere Linie an der oberen Kante (Kamm), @@ -1127,9 +1147,7 @@ public final class WorldMapRenderer { float cellW = WORLD_SIZE / (SIZE - 1); int dPx = Math.max(1, (int)(1.5f / cellW)); - boolean[] cliffMask = new boolean[SIZE * SIZE]; - float[] gradX = new float[SIZE * SIZE]; - float[] gradZ = new float[SIZE * SIZE]; + float[] floatMask = new float[SIZE * SIZE]; for (int py = 0; py < SIZE; py++) { for (int px = 0; px < SIZE; px++) { int idx = py * SIZE + px; @@ -1140,49 +1158,19 @@ public final class WorldMapRenderer { 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)))); - cliffMask[idx] = slope > 30.0; - gradX[idx] = (hR - hL) / (2 * dist); - gradZ[idx] = (hD - hU) / (2 * dist); + floatMask[idx] = slope > 30.0 ? 1f : 0f; } } - float[] floatMask = new float[SIZE * SIZE]; - for (int i = 0; i < SIZE * SIZE; i++) { floatMask[i] = cliffMask[i] ? 1f : 0f; } float[] softMask = boxBlurFloat(floatMask, SIZE, 3); int[] result = new int[SIZE * SIZE]; - // Füllung: weiches Grau, max 33% alpha for (int i = 0; i < SIZE * SIZE; i++) { if (softMask[i] > 0.05f) { int alpha = clamp((int)(softMask[i] * 85)); result[i] = (alpha << 24) | 0x999090; } } - // Kantenerkennung: obere Kante (Kamm) dunkler, untere Kante (Fuß) heller - for (int py = 1; py < SIZE - 1; py++) { - for (int px = 1; px < SIZE - 1; px++) { - int idx = py * SIZE + px; - if (!cliffMask[idx]) { continue; } - float gx = gradX[idx], gz = gradZ[idx]; - float glen = (float) Math.sqrt(gx * gx + gz * gz); - if (glen < 1e-6f) { continue; } - float upX = -gx / glen, upZ = -gz / glen; // bergauf - float dnX = gx / glen, dnZ = gz / glen; // bergab - // Obere Kante: bergauf-Nachbar ist kein Cliff - int uhx = px + Math.round(upX * 2), uhz = py + Math.round(upZ * 2); - if (uhx >= 0 && uhx < SIZE && uhz >= 0 && uhz < SIZE - && !cliffMask[uhz * SIZE + uhx]) { - result[idx] = (0xCC << 24) | 0x504848; - continue; - } - // Untere Kante: bergab-Nachbar ist kein Cliff - int dhx = px + Math.round(dnX), dhz = py + Math.round(dnZ); - if (dhx >= 0 && dhx < SIZE && dhz >= 0 && dhz < SIZE - && !cliffMask[dhz * SIZE + dhx]) { - result[idx] = (0x88 << 24) | 0x686060; - } - } - } return result; } @@ -1219,17 +1207,8 @@ public final class WorldMapRenderer { float[] waterH = buildWaterHeightMap(waters, SIZE); int written = 0; - // Coarse-Typ-Map (4m/px) – identische Filterung wie buildTerrainColorMap - final int COARSE = 512; - float cellWC = WORLD_SIZE / (COARSE - 1); - byte[] coarseTypes = new byte[COARSE * COARSE]; - for (int mz = 0; mz < COARSE; mz++) { - float wz = -WORLD_HALF + mz * cellWC; - for (int mx = 0; mx < COARSE; mx++) { - float wx = -WORLD_HALF + mx * cellWC; - coarseTypes[mz * COARSE + mx] = (byte) getTerrainTile(m, SS, wx, wz, slotColors); - } - } + // Coarse-Farbkarte bei 256×256 (8m/px) – identische Filterung wie buildTerrainColorMap + int[] coarseColors = buildCoarseColorMap(m, SS, slotColors, 256); for (int cy = 0; cy < COLS; cy++) { for (int cx = 0; cx < COLS; cx++) { @@ -1249,9 +1228,7 @@ public final class WorldMapRenderer { tg = (WATER_COLOR >> 8) & 0xFF; tb = WATER_COLOR & 0xFF; } else { - int ctX = Math.min(COARSE - 1, (int)((float) gx / (SIZE - 1) * (COARSE - 1))); - int ctZ = Math.min(COARSE - 1, (int)((float) gy / (SIZE - 1) * (COARSE - 1))); - int col = schematicColor(coarseTypes[ctZ * COARSE + ctX] & 0xFF); + int col = sampleCoarseBilinear(coarseColors, 256, gx, gy, SIZE); tr = (col >> 16) & 0xFF; tg = (col >> 8) & 0xFF; tb = col & 0xFF; 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 0f05662..2cff726 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 @@ -96,7 +96,8 @@ public class WorldMapView extends VBox { private WritableImage mapFxImage; private BufferedImage mapBuffered; - private WritableImage bakedCliffOverlay = null; + private WritableImage bakedCliffOverlay = null; + private List cliffPaths = new ArrayList<>(); 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 @@ -118,7 +119,8 @@ public class WorldMapView extends VBox { java.util.Collections.synchronizedSet(new java.util.HashSet<>()); private volatile boolean chunkRedrawPending = false; - private WritableImage bakedForestOverlay = null; + private WritableImage bakedForestOverlay = null; + private List forestPaths = new ArrayList<>(); private List cachedAreas = new ArrayList<>(); private List cachedLocs = new ArrayList<>(); @@ -355,9 +357,11 @@ public class WorldMapView extends VBox { log.debug("[WorldMap] Hintergrund-PNG gerendert ({}×{} px, {} ms)", RENDER_SIZE, RENDER_SIZE, System.currentTimeMillis() - t1); - float[] fDensity = computeForestDensity(model); - int[] bakedFstPx = computeForestPixels(model, fDensity, 2.0f); - int[] cliffPx = WorldMapRenderer.buildCliffOverlay(model.mapData(), model.voxelSurface()); + float[] fDensity = computeForestDensity(model); + int[] bakedFstPx = computeForestPixels(model, fDensity, 2.0f); + int[] cliffPx = WorldMapRenderer.buildCliffOverlay(model.mapData(), model.voxelSurface()); + List newCliffPaths = buildCliffPaths(model.mapData(), model.voxelSurface()); + List newForestPaths = buildForestOutlinePaths(fDensity, 512, 2.0f); // Hires-Chunks im Hintergrund generieren (nach dem UI-Update, nicht-blockierend) final WorldMapRenderModel chunkModel = model; @@ -376,6 +380,8 @@ public class WorldMapView extends VBox { mapFxImage = SwingFXUtils.toFXImage(bi, null); bakedForestOverlay = toWritableImage(bakedFstPx, RENDER_SIZE); bakedCliffOverlay = toWritableImage(cliffPx, WorldMapRenderer.COLOR_MAP_SIZE); + cliffPaths = newCliffPaths; + forestPaths = newForestPaths; fitToView(); redraw(); progress.setVisible(false); @@ -450,12 +456,12 @@ public class WorldMapView extends VBox { } else { gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale); } - // Layer 2: Küstenlinie (Vektor) - if (cbWater.isSelected()) drawWaterOverlay(gc); - // Layer 3: Wald (Füllung + Rand) - if (cbForest.isSelected()) drawForestOverlay(gc); - // Layer 4: Klippen (Grau-Füllung + Kantenlinien) + // Layer 2: Klippen (Grau-Füllung + Vektor-Kontur) if (cbCliffs.isSelected()) drawCliffOverlay(gc); + // Layer 3: Küstenlinie (Vektor) + if (cbWater.isSelected()) drawWaterOverlay(gc); + // Layer 4: Wald (Füllung + Vektor-Kontur) + if (cbForest.isSelected()) drawForestOverlay(gc); // Layer 5: Areas (Vektoren) if (cbAreas.isSelected()) drawAreasOverlay(gc); if (!isFullscreen) drawCameraIndicator(gc); @@ -552,15 +558,37 @@ public class WorldMapView extends VBox { } 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); + double cW = canvas.getWidth(), cH = canvas.getHeight(); + if (bakedForestOverlay != null) { + gc.drawImage(bakedForestOverlay, -panX / scale, -panY / scale, cW / scale, cH / scale, 0, 0, cW, cH); + } + if (!forestPaths.isEmpty()) { + gc.save(); + gc.setStroke(Color.rgb(45, 99, 22, 0.85)); + gc.setLineWidth(Math.max(1.0, 1.5 / scale)); + gc.setLineDashes(); + for (float[][] path : forestPaths) { + drawCoastPath(gc, path, cW, cH, 8); + } + gc.restore(); + } } private void drawCliffOverlay(GraphicsContext gc) { - if (bakedCliffOverlay == null) return; - int cW = (int) canvas.getWidth(), cH = (int) canvas.getHeight(); - gc.drawImage(bakedCliffOverlay, -panX / scale, -panY / scale, cW / scale, cH / scale, 0, 0, cW, cH); + double cW = canvas.getWidth(), cH = canvas.getHeight(); + if (bakedCliffOverlay != null) { + gc.drawImage(bakedCliffOverlay, -panX / scale, -panY / scale, cW / scale, cH / scale, 0, 0, cW, cH); + } + if (!cliffPaths.isEmpty()) { + gc.save(); + gc.setStroke(Color.rgb(80, 72, 72, 0.80)); + gc.setLineWidth(Math.max(1.0, 1.5 / scale)); + gc.setLineDashes(); + for (float[][] path : cliffPaths) { + drawCoastPath(gc, path, cW, cH, 8); + } + gc.restore(); + } } // ── Wald-Baking (läuft im Background-Thread) ───────────────────────────── @@ -590,37 +618,20 @@ public class WorldMapView extends VBox { 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 forestFill = 0x80_4a9a28; // 50% alpha grün - final int forestOutline = 0xCC_2d6316; // 80% alpha dunkelgrün + final int forestFill = 0x80_4a9a28; // 50% alpha grün final int[] px = new int[SIZE * SIZE]; - boolean[] isFill = new boolean[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=(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; - if (fd > threshold) { - px[py * SIZE + bx] = forestFill; - isFill[py * SIZE + bx] = true; - } - } - } - // Randlinie: Füll-Pixel mit mindestens einem Nicht-Füll-Nachbarn - for (int py = 1; py < SIZE - 1; py++) { - for (int bx = 1; bx < SIZE - 1; bx++) { - int idx = py * SIZE + bx; - if (!isFill[idx]) { continue; } - boolean edge = !isFill[idx - 1] || !isFill[idx + 1] - || !isFill[idx - SIZE] || !isFill[idx + SIZE] - || !isFill[idx - SIZE - 1] || !isFill[idx - SIZE + 1] - || !isFill[idx + SIZE - 1] || !isFill[idx + SIZE + 1]; - if (edge) { px[idx] = forestOutline; } + 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 = (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; + if (fd > threshold) { px[py * SIZE + bx] = forestFill; } } } return px; @@ -994,6 +1005,54 @@ public class WorldMapView extends VBox { return result; } + /** Extrahiert Klippen-Konturen aus Terrain-Höhen als geglättete Weltkoordinaten-Pfade. */ + private static List buildCliffPaths(MapData mapData, float[] voxelSurface) { + final int SIZE = SEA_MASK_SIZE; + float[] heights = WorldMapRenderer.buildTerrainSamples(mapData, SIZE, voxelSurface); + float cellW = WorldMapRenderer.WORLD_SIZE / (SIZE - 1); + int dPx = Math.max(1, (int)(1.5f / cellW)); + + float[] mask = new float[SIZE * SIZE]; + for (int py = 0; py < SIZE; py++) { + for (int px = 0; px < SIZE; px++) { + int idx = py * SIZE + px; + 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)))); + // Kliff-Bereich = "inside" (< threshold 0.5): -1; Nicht-Kliff = 1 + mask[idx] = slope > 30.0 ? -1f : 1f; + } + } + + List segs = new ArrayList<>(); + marchingSquares(mask, SIZE, 0.5f, null, null, segs); + return buildSeaCoastPaths(segs.toArray(new float[0][])); + } + + /** Extrahiert Wald-Konturen aus der Tree-Density-Map als geglättete Weltkoordinaten-Pfade. */ + private static List buildForestOutlinePaths(float[] density, int fdSize, float threshold) { + final int SIZE = SEA_MASK_SIZE; + float[] mask = new float[SIZE * SIZE]; + for (int py = 0; py < SIZE; py++) { + for (int px = 0; px < SIZE; px++) { + float fx = (float) px / (SIZE - 1) * (fdSize - 1); + float fz = (float) py / (SIZE - 1) * (fdSize - 1); + int sx = Math.min(fdSize - 1, (int) fx), sz = Math.min(fdSize - 1, (int) fz); + float d = density[sz * fdSize + sx]; + // Wald-Bereich = "inside" (< threshold 0.5): -1; Nicht-Wald = 1 + mask[py * SIZE + px] = d >= threshold ? -1f : 1f; + } + } + + List segs = new ArrayList<>(); + marchingSquares(mask, SIZE, 0.5f, null, null, segs); + return buildSeaCoastPaths(segs.toArray(new float[0][])); + } + // Entfernt kollineare Zwischenpunkte (lange H/V-Läufe → ein Segment). private static float[][] simplifyCoastPath(float[][] path) { int n = path.length;