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

@@ -1080,24 +1080,15 @@ public final class WorldMapRenderer {
float[] voxelSurface, List<PlacedWater> waters,
List<PlacedWaterfall> 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;

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;