Weltkarte: Vektor-Konturen für Wald/Klippen, 8m Textur-Filter, Render-Reihenfolge

- Cliff- und Wald-Randlinien via Marching Squares + Chaikin als Canvas-Vektoren
  statt Pixel-Kanten im Raster → immer scharf wie Küstenlinie und Areas
- COARSE von 512px (4m/px) auf 256px (8m/px) erhöht + Bilinear-Interpolation
  der Flachfarben → dünne Texturen (<8m) verschwinden, keine Block-Artefakte
  bei Hires-Zoom
- Render-Reihenfolge: Klippen → Wasser → Wald (laut User-Anforderung)
- buildCliffOverlay: nur noch weiche Grau-Füllung, keine pixel-basierten Kanten
- computeForestPixels: nur noch Füllung, Randlinie kommt als Vektor-Overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:18:43 +02:00
parent ddec931ee8
commit 81c9512244
2 changed files with 141 additions and 105 deletions

View File

@@ -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<float[][]> 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<float[][]> forestPaths = new ArrayList<>();
private List<PlacedArea> cachedAreas = new ArrayList<>();
private List<Location> 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<float[][]> newCliffPaths = buildCliffPaths(model.mapData(), model.voxelSurface());
List<float[][]> 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<float[][]> 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<float[]> 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<float[][]> 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<float[]> 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;