Weltkarte: tile-basiertes Rendering, Hires-Chunks, 5 Zoom-Stufen

- Durchschnittliche Kachel-Farben (terrainColorMap, 2048px) als Basis für Low-Res-Ansicht
- Bilineares Splatmap-Blending (blendTileAvgColors) statt Nearest-Neighbor
- Chunk-basierte Hires-Karte (8192px, 256 Chunks à 512px) auf Disk gecacht
- Zoom-Stufen 0–2: Overview-Bild; Stufen 3–4: Hires-Chunks per LRU-Cache
- 5 geometrisch verteilte Zoom-Stufen (Fit-All bis 0,25m/Pixel)
- Klippen-Erkennung per Steigungswinkel (1,5m Sampling-Distanz, harter 30°-Schwellwert)
- Leichter Box-Blur (Radius 2) über Terrain-Layer
- Wasserfarbe in Hires-Chunks vereinheitlicht (WATER_COLOR)
- Kein Hillshading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 10:39:23 +02:00
parent 43829a42f3
commit abf56f81b6
5 changed files with 477 additions and 413 deletions

View File

@@ -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<Integer, WritableImage> chunkCache =
new java.util.LinkedHashMap<>(CHUNK_CACHE_MAX * 2, 0.75f, true) {
@Override protected boolean removeEldestEntry(java.util.Map.Entry<Integer, WritableImage> e) {
return size() > CHUNK_CACHE_MAX;
}
};
private final java.util.Set<Integer> 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<FD&&nz>=0&&nz<FD) fd[nz*FD+nx]+=1f;
}
}
}
tileOverlayForestD = fd0;
tileOverlayCachedScale = Double.NaN;
}
return fd;
}
// ── Pixel-Buffer nur neu berechnen wenn Pan/Zoom oder Canvas-Größe geändert ──
boolean needsRedraw = tileOverlayPixels == null || tileOverlayPixels.length != cW * cH
|| tileOverlayW != cW || tileOverlayH != cH
|| tileOverlayCachedScale != scale
|| tileOverlayCachedPanX != panX
|| tileOverlayCachedPanY != panY;
if (!needsRedraw) {
gc.drawImage(tileOverlayImg, 0, 0);
return;
}
if (tileOverlayPixels == null || tileOverlayPixels.length != cW * cH) {
tileOverlayPixels = new int[cW * cH];
}
if (forestOverlayPixels == null || forestOverlayPixels.length != cW * cH) {
forestOverlayPixels = new int[cW * cH];
}
if (tileOverlayImg == null || tileOverlayW != cW || tileOverlayH != cH) {
tileOverlayImg = new WritableImage(cW, cH);
tileOverlayW = cW;
tileOverlayH = cH;
}
if (forestOverlayImg == null || forestOverlayW != cW || forestOverlayH != cH) {
forestOverlayImg = new WritableImage(cW, cH);
forestOverlayW = cW;
forestOverlayH = cH;
}
tileOverlayCachedScale = scale;
tileOverlayCachedPanX = panX;
tileOverlayCachedPanY = panY;
final int[] slotTiles = tileOverlaySlots;
final float[] forestDensity = tileOverlayForestD;
final int FD = 512;
final byte[] spG = md.splatG, spB = md.splatB, spA = md.splatA;
final byte[] upR = md.upperSplatR, upG = md.upperSplatG, upB = md.upperSplatB, upA = md.upperSplatA;
final byte[] t3R = md.thirdSplatR, t3G = md.thirdSplatG, t3B = md.thirdSplatB, t3A = md.thirdSplatA;
final int SS = de.blight.common.MapData.SPLAT_SIZE;
int[] tileArgbs = new int[8];
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) {
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<float[][]> 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<PlacedModel> treeList = models.stream()
.filter(WorldMapRenderer::isTree).collect(Collectors.toList());
List<List<PlacedModel>> 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();