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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -26,5 +26,6 @@ public record WorldMapRenderModel(
float[] terrainSamples, // Höhenwerte bei SEA_MASK_SIZE (für Wellen-Prüfung) float[] terrainSamples, // Höhenwerte bei SEA_MASK_SIZE (für Wellen-Prüfung)
List<float[][]> coastPaths, // geglättete Marching-Squares Küstenpfade (Weltkoord.) List<float[][]> coastPaths, // geglättete Marching-Squares Küstenpfade (Weltkoord.)
List<List<PlacedModel>> treeClusters, List<List<PlacedModel>> 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
) {} ) {}

View File

@@ -29,6 +29,18 @@ public final class WorldMapRenderer {
public static final float WORLD_SIZE = 2048f; public static final float WORLD_SIZE = 2048f;
/** Auflösung des voxelSurface-Bake-Arrays (1 Zelle pro Welteinheit). */ /** Auflösung des voxelSurface-Bake-Arrays (1 Zelle pro Welteinheit). */
public static final int VOXEL_SURFACE_RES = 2048; 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( public record RenderInput(
MapData mapData, MapData mapData,
@@ -146,49 +158,37 @@ public final class WorldMapRenderer {
} }
} }
// 3. Tile-Rendering: tileIdx + Rohfarbe pro Pixel speichern // 3. Terrain-Farben aus vorberechneter Farbkarte (oder intern berechnen als Fallback)
float cellW = WORLD_SIZE / (targetSize - 1); float cellW = WORLD_SIZE / (targetSize - 1);
int SS = MapData.SPLAT_SIZE; int[] slotCols = model.slotColorsRGB();
int[] slotCols = model.slotColorsRGB(); int[] colorMap = model.terrainColorMap() != null
int[][] tiles = model.mapTilePixels(); ? model.terrainColorMap()
int[] rawRGB = new int[targetSize * targetSize]; : buildTerrainColorMap(m, slotCols, model.mapTilePixels(), model.voxelSurface(), model.waters());
int[] rawRGB = new int[targetSize * targetSize];
int waterR = (WATER_COLOR >> 16) & 0xFF; int waterR = (WATER_COLOR >> 16) & 0xFF;
int waterG = (WATER_COLOR >> 8) & 0xFF; int waterG = (WATER_COLOR >> 8) & 0xFF;
int waterB = WATER_COLOR & 0xFF; int waterB = WATER_COLOR & 0xFF;
for (int py = 0; py < targetSize; py++) { for (int py = 0; py < targetSize; py++) {
float wz = -WORLD_HALF + py * cellW;
for (int px = 0; px < targetSize; px++) { for (int px = 0; px < targetSize; px++) {
float wx = -WORLD_HALF + px * cellW;
int idx = py * targetSize + px; int idx = py * targetSize + px;
int tileIdx;
int tr, tg, tb;
if (isWater[idx]) { if (isWater[idx]) {
tileIdx = TILE_WATER; rawRGB[idx] = (waterR << 16) | (waterG << 8) | waterB;
tr = waterR; tg = waterG; tb = waterB;
} else { } else {
int dPx = Math.max(1, (int)(0.25f / cellW)); // Bilinear sample aus der Farbkarte (COLOR_MAP_SIZE → targetSize)
float hR = heights[py * targetSize + Math.min(targetSize-1, px+dPx)]; float fx = (float) px / (targetSize - 1) * (COLOR_MAP_SIZE - 1);
float hL = heights[py * targetSize + Math.max(0, px-dPx)]; float fz = (float) py / (targetSize - 1) * (COLOR_MAP_SIZE - 1);
float hD = heights[Math.min(targetSize-1, py+dPx) * targetSize + px]; int cx0 = (int) fx, cx1 = Math.min(cx0 + 1, COLOR_MAP_SIZE - 1);
float hU = heights[Math.max(0, py-dPx) * targetSize + px]; int cz0 = (int) fz, cz1 = Math.min(cz0 + 1, COLOR_MAP_SIZE - 1);
float dist = dPx * cellW; float tx = fx - cx0, tz = fz - cz0;
double slope = Math.toDegrees(Math.atan(Math.sqrt( int c00 = colorMap[cz0 * COLOR_MAP_SIZE + cx0];
Math.pow((hR-hL) / (2*dist), 2) + Math.pow((hD-hU) / (2*dist), 2)))); int c10 = colorMap[cz0 * COLOR_MAP_SIZE + cx1];
tileIdx = (slope > 30.0) ? TILE_CLIFF : getTerrainTile(m, SS, wx, wz, slotCols); int c01 = colorMap[cz1 * COLOR_MAP_SIZE + cx0];
if (tiles != null && tileIdx < tiles.length && tiles[tileIdx] != null) { int c11 = colorMap[cz1 * COLOR_MAP_SIZE + cx1];
int tu = Math.floorMod((int)Math.floor((wx + WORLD_HALF) / TILE_WU), 16); 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)
int tv = Math.floorMod((int)Math.floor((wz + WORLD_HALF) / TILE_WU), 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)
int argb = tiles[tileIdx][tv * 16 + tu]; | clamp((int)(( c00 &0xFF)*(1-tx)*(1-tz)+( c10 &0xFF)*tx*(1-tz)+( c01 &0xFF)*(1-tx)*tz+( c11 &0xFF)*tx*tz));
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];
}
} }
rawRGB[idx] = (tr << 16) | (tg << 8) | tb;
} }
} }
@@ -223,31 +223,10 @@ public final class WorldMapRenderer {
} }
} }
float wFrac = wCount * subInv; float wFrac = wCount * subInv;
// Land-Farbe: fuer Wasser-Pixel on-the-fly berechnen, sonst aus rawRGB // Land-Farbe aus Farbkarte (Wasser-Pixel bekommen ihren Nachbar-Landwert)
int landRGB; int mapX = Math.min((int)((float)px / (targetSize-1) * (COLOR_MAP_SIZE-1)), COLOR_MAP_SIZE-1);
if (isS) { int mapZ = Math.min((int)((float)py / (targetSize-1) * (COLOR_MAP_SIZE-1)), COLOR_MAP_SIZE-1);
int dPx = Math.max(1, (int)(0.25f / cellW)); int landRGB = colorMap[mapZ * COLOR_MAP_SIZE + mapX];
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];
}
int lr=(landRGB>>16)&0xFF, lg=(landRGB>>8)&0xFF, lb=landRGB&0xFF; int lr=(landRGB>>16)&0xFF, lg=(landRGB>>8)&0xFF, lb=landRGB&0xFF;
rawRGB[idx] = (clamp((int)(waterR*wFrac + lr*(1-wFrac))) << 16) rawRGB[idx] = (clamp((int)(waterR*wFrac + lr*(1-wFrac))) << 16)
| (clamp((int)(waterG*wFrac + lg*(1-wFrac))) << 8) | (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 x1 = Math.min(targetSize-1, bb2.x + bb2.width + 1);
int y1 = Math.min(targetSize-1, bb2.y + bb2.height + 1); int y1 = Math.min(targetSize-1, bb2.y + bb2.height + 1);
for (int py = y0; py <= y1; py++) { for (int py = y0; py <= y1; py++) {
float wz = -WORLD_HALF + py * cellW;
for (int px = x0; px <= x1; px++) { for (int px = x0; px <= x1; px++) {
float wx = -WORLD_HALF + px * cellW;
int idx = py * targetSize + px; int idx = py * targetSize + px;
boolean inPoly = wp.contains(px, py) && heights[idx] < wh2; boolean inPoly = wp.contains(px, py) && heights[idx] < wh2;
boolean boundary2 = false; boolean boundary2 = false;
@@ -294,31 +271,9 @@ public final class WorldMapRenderer {
} }
} }
float wFrac2 = wCount2 * subInv; float wFrac2 = wCount2 * subInv;
int landRGB2; int mapX2 = Math.min((int)((float)px / (targetSize-1) * (COLOR_MAP_SIZE-1)), COLOR_MAP_SIZE-1);
if (inPoly) { int mapZ2 = Math.min((int)((float)py / (targetSize-1) * (COLOR_MAP_SIZE-1)), COLOR_MAP_SIZE-1);
// Wasser-Pixel: Land-Farbe on-the-fly int landRGB2 = colorMap[mapZ2 * COLOR_MAP_SIZE + mapX2];
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 lr2=(landRGB2>>16)&0xFF, lg2=(landRGB2>>8)&0xFF, lb2=landRGB2&0xFF; int lr2=(landRGB2>>16)&0xFF, lg2=(landRGB2>>8)&0xFF, lb2=landRGB2&0xFF;
rawRGB[idx] = (clamp((int)(waterR*wFrac2 + lr2*(1-wFrac2))) << 16) rawRGB[idx] = (clamp((int)(waterR*wFrac2 + lr2*(1-wFrac2))) << 16)
| (clamp((int)(waterG*wFrac2 + lg2*(1-wFrac2))) << 8) | (clamp((int)(waterG*wFrac2 + lg2*(1-wFrac2))) << 8)
@@ -531,7 +486,7 @@ public final class WorldMapRenderer {
return render(new WorldMapRenderModel( return render(new WorldMapRenderModel(
input.mapData(), input.areas(), input.zones(), input.locations(), input.mapData(), input.areas(), input.zones(), input.locations(),
input.waters(), input.models(), null, input.waters(), input.models(), null,
input.slotColorsRGB(), null, null, null, null, null, null input.slotColorsRGB(), null, null, null, null, null, null, null
), targetSize, opts); ), targetSize, opts);
} }
@@ -1243,6 +1198,256 @@ public final class WorldMapRenderer {
return m.modelPath().replace('\\', '/').toLowerCase().contains("/trees/"); 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<PlacedWater> 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<PlacedWater> 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 * Kuwahara-Filter: Für jeden Pixel das Quadranten-Fenster mit der kleinsten
* Varianz wählen und dessen Mittelwert setzen → Ölgemälde-/gemalt-Effekt. * Varianz wählen und dessen Mittelwert setzen → Ölgemälde-/gemalt-Effekt.

View File

@@ -108,27 +108,28 @@ public class WorldMapView extends VBox {
private byte[] categoryMap = null; private byte[] categoryMap = null;
private float[] heightMap = null; // terrain+voxel, CAT_SIZE×CAT_SIZE (für Klippen) 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 float[] heightMapTerrain = null; // terrain-only, CAT_SIZE×CAT_SIZE (für Wasser-Füllung)
private WritableImage tileOverlayImg = null; private static final int ZOOM_LEVELS = 5;
private int[] tileOverlayPixels = null; private static final double ZOOM_MAX_SCALE = 4.0; // 1 Canvas-Pixel = 0,25 WU = 0,25m
private int tileOverlayW = 0; private static final int ZOOM_HIRES_CUTOFF = 3; // ab diesem Index Chunk-Rendering
private int tileOverlayH = 0; private static final int CHUNK_CACHE_MAX = 24; // max. Chunks gleichzeitig im RAM
private WritableImage forestOverlayImg = null; private static final java.nio.file.Path CHUNK_CACHE_DIR =
private int[] forestOverlayPixels = null; java.nio.file.Paths.get(System.getProperty("user.home"), ".blight", "map_chunks");
private int forestOverlayW = 0;
private int forestOverlayH = 0; private double[] zoomLevels = null;
// Gecachte Vorab-Berechnungen — nur bei Modell-Wechsel neu berechnen private int zoomLevelIdx = 0;
private WorldMapRenderModel tileOverlayModel = null;
private float[] tileOverlayForestD = null; /** LRU-Cache: chunk-Key (cy*COLS+cx) → WritableImage. */
private int[] tileOverlaySlots = null; private final java.util.LinkedHashMap<Integer, WritableImage> chunkCache =
// Dirty-Flag: Overlay-Pixel neu rendern wenn Pan/Zoom oder Modell geändert new java.util.LinkedHashMap<>(CHUNK_CACHE_MAX * 2, 0.75f, true) {
private double tileOverlayCachedScale = Double.NaN; @Override protected boolean removeEldestEntry(java.util.Map.Entry<Integer, WritableImage> e) {
private double tileOverlayCachedPanX = Double.NaN; return size() > CHUNK_CACHE_MAX;
private double tileOverlayCachedPanY = Double.NaN; }
// Wasser-Fill-Overlay (Layer über Terrain-Textur, unter Küstenlinie) };
private WritableImage waterFillImg = null; private final java.util.Set<Integer> chunksLoading =
private int[] waterFillPixels = null; java.util.Collections.synchronizedSet(new java.util.HashSet<>());
private int waterFillW = 0;
private int waterFillH = 0; 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 // PlacedWater-Wasserhoehe pro Zelle (0 = kein PlacedWater); fuer glatte Uebergaenge
private float[] waterHeightMap = null; private float[] waterHeightMap = null;
@@ -282,10 +283,13 @@ public class WorldMapView extends VBox {
}); });
canvas.setOnScroll(e -> { canvas.setOnScroll(e -> {
if (e.getDeltaY() == 0) return; if (zoomLevels == null || e.getDeltaY() == 0) return;
double factor = e.getDeltaY() > 0 ? 1.15 : 1.0 / 1.15; 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; 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; panX = e.getX() - (e.getX() - panX) * scale / oldScale;
panY = e.getY() - (e.getY() - panY) * scale / oldScale; panY = e.getY() - (e.getY() - panY) * scale / oldScale;
redraw(); redraw();
@@ -365,6 +369,17 @@ public class WorldMapView extends VBox {
log.debug("[WorldMap] Hintergrund-PNG + Kategorie-Karte gerendert ({}×{} px, {} ms)", log.debug("[WorldMap] Hintergrund-PNG + Kategorie-Karte gerendert ({}×{} px, {} ms)",
RENDER_SIZE, RENDER_SIZE, System.currentTimeMillis() - t1); 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(() -> { Platform.runLater(() -> {
currentModel = model; currentModel = model;
cachedAreas = new ArrayList<>(model.areas()); cachedAreas = new ArrayList<>(model.areas());
@@ -375,6 +390,7 @@ public class WorldMapView extends VBox {
heightMapTerrain = hMapT; heightMapTerrain = hMapT;
categoryMap = catMap; categoryMap = catMap;
waterHeightMap = whMap; waterHeightMap = whMap;
bakedForestOverlay = toWritableImage(bakedFstPx, RENDER_SIZE);
fitToView(); fitToView();
redraw(); redraw();
progress.setVisible(false); progress.setVisible(false);
@@ -409,22 +425,13 @@ public class WorldMapView extends VBox {
Thread t = new Thread(() -> { Thread t = new Thread(() -> {
log.debug("[WorldMap] PNG-Rerender aus gecachtem Modell gestartet ({}×{})", RENDER_SIZE, RENDER_SIZE); log.debug("[WorldMap] PNG-Rerender aus gecachtem Modell gestartet ({}×{})", RENDER_SIZE, RENDER_SIZE);
long t0 = System.currentTimeMillis(); 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()); 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); log.debug("[WorldMap] PNG-Rerender fertig ({} ms)", System.currentTimeMillis() - t0);
Platform.runLater(() -> { Platform.runLater(() -> {
mapBuffered = bi; mapBuffered = bi;
mapFxImage = SwingFXUtils.toFXImage(bi, null); mapFxImage = SwingFXUtils.toFXImage(bi, null);
heightMap = hMap;
heightMapTerrain = hMapT;
categoryMap = catMap;
waterHeightMap = whMap;
redraw(); redraw();
progress.setVisible(false); progress.setVisible(false);
statusLbl.setText("Bereit"); statusLbl.setText("Bereit");
@@ -454,20 +461,71 @@ public class WorldMapView extends VBox {
return; return;
} }
gc.setImageSmoothing(false);
gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale);
gc.setImageSmoothing(true); gc.setImageSmoothing(true);
// Layer 1: Terrain-Textur (überall, opak) if (zoomLevelIdx >= ZOOM_HIRES_CUTOFF) {
drawTileOverlay(gc); drawHiresChunks(gc);
// Layer 2: Wasser darüber (glatte Küste via seaCoastPaths) } 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); 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); drawForestOverlay(gc);
if (cbAreas.isSelected()) drawAreasOverlay(gc); if (cbAreas.isSelected()) drawAreasOverlay(gc);
if (cbLocations.isSelected()) drawLocationsOverlay(gc); if (cbLocations.isSelected()) drawLocationsOverlay(gc);
if (!isFullscreen) drawCameraIndicator(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) ──────────────────────── // ── Canvas Tile-Overlay (zoom-unabhängige Kacheln) ────────────────────────
private static int tileSample(int[][] tilePx, int cat, 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; return (v00*(1-tx)+v10*tx)*(1-tz)+(v01*(1-tx)+v11*tx)*tz;
} }
private void drawTileOverlay(GraphicsContext gc) { private void drawForestOverlay(GraphicsContext gc) {
if (heightMap == null || currentModel == null) return; if (bakedForestOverlay == null) return;
de.blight.common.MapData md = currentModel.mapData(); int cW = (int) canvas.getWidth(), cH = (int) canvas.getHeight();
if (md == null) return; gc.drawImage(bakedForestOverlay, -panX / scale, -panY / scale, cW / scale, cH / scale, 0, 0, cW, cH);
int[][] tilePx = currentModel.mapTilePixels(); }
if (tilePx == null) return;
int cW = (int) canvas.getWidth(); // ── Wald-Baking (läuft im Background-Thread) ─────────────────────────────
int cH = (int) canvas.getHeight();
if (cW <= 0 || cH <= 0) return;
final float WH = WorldMapRenderer.WORLD_HALF; private static float[] computeForestDensity(WorldMapRenderModel model) {
final float WS = WorldMapRenderer.WORLD_SIZE; final int FD = 512;
final float TW = WorldMapRenderer.TILE_WU; final float WH = WorldMapRenderer.WORLD_HALF, WS = WorldMapRenderer.WORLD_SIZE;
final float scaleF = (float) scale; final float radius = 14f;
final float panXf = (float) panX; final int rc = Math.max(1, (int)(radius / (WS / FD)));
final float panYf = (float) panY; final float[] fd = new float[FD * FD];
final float imgW = (float)(RENDER_SIZE - 1); for (de.blight.common.PlacedModel pm : model.models()) {
final int CS = CAT_SIZE; if (!WorldMapRenderer.isTree(pm)) continue;
final float[] hm = heightMap; int fx = Math.max(0, Math.min(FD-1, (int)((pm.x()+WH)/WS*FD)));
final float CS1 = CS - 1f; int fz = Math.max(0, Math.min(FD-1, (int)((WH-pm.z())/WS*FD)));
final float CLIFF_LO = 0.4663f, CLIFF_RNG = 0.8391f - 0.4663f; for (int dz=-rc; dz<=rc; dz++) {
for (int dx=-rc; dx<=rc; dx++) {
// ── Modell-abhängige Vorab-Berechnungen cachen ──────────────────── if (dx*dx+dz*dz <= rc*rc) {
if (tileOverlayModel != currentModel) { int nx=fx+dx, nz=fz+dz;
tileOverlayModel = currentModel; if (nx>=0&&nx<FD&&nz>=0&&nz<FD) fd[nz*FD+nx]+=1f;
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;
}
} }
} }
} }
tileOverlayForestD = fd0;
tileOverlayCachedScale = Double.NaN;
} }
return fd;
}
// ── Pixel-Buffer nur neu berechnen wenn Pan/Zoom oder Canvas-Größe geändert ── private static int[] computeForestPixels(WorldMapRenderModel model, float[] density, float threshold) {
boolean needsRedraw = tileOverlayPixels == null || tileOverlayPixels.length != cW * cH final int SIZE = RENDER_SIZE, FD = 512;
|| tileOverlayW != cW || tileOverlayH != cH final float WH = WorldMapRenderer.WORLD_HALF, WS = WorldMapRenderer.WORLD_SIZE;
|| tileOverlayCachedScale != scale final int[][] tilePx = model.mapTilePixels();
|| tileOverlayCachedPanX != panX final int forestArgb = (tilePx != null && WorldMapRenderer.TILE_FOREST < tilePx.length
|| tileOverlayCachedPanY != panY; && tilePx[WorldMapRenderer.TILE_FOREST] != null)
? tileSample(tilePx, WorldMapRenderer.TILE_FOREST, 0, 0, 0, 0, 1f, 0f, 0f, 0f)
if (!needsRedraw) { : 0x3D7A28;
gc.drawImage(tileOverlayImg, 0, 0); final int forestColor = 0x80000000 | (forestArgb & 0x00FFFFFF);
return; final int[] px = new int[SIZE * SIZE];
} for (int py = 0; py < SIZE; py++) {
float wz = (float) py / (SIZE - 1) * WS - WH;
if (tileOverlayPixels == null || tileOverlayPixels.length != cW * cH) { for (int bx = 0; bx < SIZE; bx++) {
tileOverlayPixels = new int[cW * cH]; float wx = (float) bx / (SIZE - 1) * WS - WH;
} float fsfx=Math.max(0f,Math.min(FD-1.001f,(wx+WH)/WS*FD));
if (forestOverlayPixels == null || forestOverlayPixels.length != cW * cH) { float fsfz=Math.max(0f,Math.min(FD-1.001f,(WH-wz)/WS*FD));
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));
int fsx0=(int)fsfx, fsx1=Math.min(fsx0+1,FD-1); int fsx0=(int)fsfx, fsx1=Math.min(fsx0+1,FD-1);
int fsz0=(int)fsfz, fsz1=Math.min(fsz0+1,FD-1); int fsz0=(int)fsfz, fsz1=Math.min(fsz0+1,FD-1);
float fstx=fsfx-fsx0, fstz=fsfz-fsz0; float fstx=fsfx-fsx0, fstz=fsfz-fsz0;
float fd = (forestDensity[fsz0*FD+fsx0]*(1-fstx)+forestDensity[fsz0*FD+fsx1]*fstx)*(1-fstz) float fd=(density[fsz0*FD+fsx0]*(1-fstx)+density[fsz0*FD+fsx1]*fstx)*(1-fstz)
+ (forestDensity[fsz1*FD+fsx0]*(1-fstx)+forestDensity[fsz1*FD+fsx1]*fstx)*fstz; +(density[fsz1*FD+fsx0]*(1-fstx)+density[fsz1*FD+fsx1]*fstx)*fstz;
// mind. 3 Bäume im Überlappungsbereich (fd > 2.0 bedeutet >2 Bäume) px[py*SIZE+bx] = (fd > threshold) ? forestColor : 0x00000000;
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);
} }
} }
return px;
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);
} }
private void drawForestOverlay(GraphicsContext gc) { private static WritableImage toWritableImage(int[] pixels, int size) {
if (forestOverlayImg == null) return; WritableImage img = new WritableImage(size, size);
gc.drawImage(forestOverlayImg, 0, 0); img.getPixelWriter().setPixels(0, 0, size, size, PixelFormat.getIntArgbInstance(), pixels, 0, size);
return img;
} }
// ── Canvas-Vektor-Overlays ──────────────────────────────────────────────── // ── Canvas-Vektor-Overlays ────────────────────────────────────────────────
@@ -768,82 +687,11 @@ public class WorldMapView extends VBox {
} }
private void drawWaterOverlay(GraphicsContext gc) { 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(); // ── Küstenlinie ───────────────────────────────────────────────────────────
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 ─────────────
List<float[][]> seaCoastPaths = currentModel.coastPaths(); List<float[][]> seaCoastPaths = currentModel.coastPaths();
if (cbCoastline.isSelected() && seaCoastPaths != null && !seaCoastPaths.isEmpty()) { if (cbCoastline.isSelected() && seaCoastPaths != null && !seaCoastPaths.isEmpty()) {
double canvasW = canvas.getWidth(), canvasH = canvas.getHeight(); double canvasW = canvas.getWidth(), canvasH = canvas.getHeight();
@@ -1068,9 +916,10 @@ public class WorldMapView extends VBox {
List<PlacedModel> treeList = models.stream() List<PlacedModel> treeList = models.stream()
.filter(WorldMapRenderer::isTree).collect(Collectors.toList()); .filter(WorldMapRenderer::isTree).collect(Collectors.toList());
List<List<PlacedModel>> tClusters = clusterTreeModels(treeList, 40f); List<List<PlacedModel>> tClusters = clusterTreeModels(treeList, 40f);
int[] colorMap = WorldMapRenderer.buildTerrainColorMap(mapData, slotColors, mapTiles, voxelSurface, waters);
return new WorldMapRenderModel( return new WorldMapRenderModel(
mapData, areas, zones, locs, waters, models, waterfalls, slotColors, 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. // Erzeugt interpolierte Marching-Squares-Segmente für Meer + Wasserflächen.
@@ -1274,9 +1123,21 @@ public class WorldMapView extends VBox {
private void fitToView() { private void fitToView() {
if (canvas.getWidth() <= 0 || mapFxImage == null) return; if (canvas.getWidth() <= 0 || mapFxImage == null) return;
scale = Math.min(canvas.getWidth() / mapFxImage.getWidth(), canvas.getHeight() / mapFxImage.getHeight()); double fitScale = Math.min(canvas.getWidth() / RENDER_SIZE, canvas.getHeight() / RENDER_SIZE);
panX = (canvas.getWidth() - mapFxImage.getWidth() * scale) / 2; computeZoomLevels(fitScale);
panY = (canvas.getHeight() - mapFxImage.getHeight() * scale) / 2; 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) { 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 cw = canvasPane.getWidth();
double ch = canvasPane.getHeight(); double ch = canvasPane.getHeight();
if (cw <= 0 || ch <= 0 || mapFxImage == null) return; 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; panX = (cw - RENDER_SIZE * scale) / 2.0;
panY = (ch - RENDER_SIZE * scale) / 2.0; panY = (ch - RENDER_SIZE * scale) / 2.0;
redraw(); redraw();

View File

@@ -40,7 +40,9 @@ import de.blight.common.PlacedLocationZone;
import de.blight.common.PlacedModel; import de.blight.common.PlacedModel;
import de.blight.common.PlacedModelIO; import de.blight.common.PlacedModelIO;
import de.blight.common.PlacedWater; import de.blight.common.PlacedWater;
import de.blight.common.PlacedWaterfall;
import de.blight.common.WaterBodyIO; import de.blight.common.WaterBodyIO;
import de.blight.common.WaterfallIO;
import de.blight.common.map.WorldMapRenderer; import de.blight.common.map.WorldMapRenderer;
import de.blight.common.map.WorldMapRenderer.RenderInput; import de.blight.common.map.WorldMapRenderer.RenderInput;
import de.blight.common.map.WorldMapRenderer.RenderOptions; import de.blight.common.map.WorldMapRenderer.RenderOptions;
@@ -546,30 +548,22 @@ public class MinimapState extends BaseAppState {
List<Location> locs = LocationIO.load(); List<Location> locs = LocationIO.load();
List<PlacedWater> waters = WaterBodyIO.load(); List<PlacedWater> waters = WaterBodyIO.load();
List<PlacedModel> models = PlacedModelIO.load(); List<PlacedModel> models = PlacedModelIO.load();
List<PlacedWaterfall> waterfalls = WaterfallIO.load();
int[] slotColors = computeSlotColors(mapData, root); int[] slotColors = computeSlotColors(mapData, root);
int[][] tilePixels = WorldMapRenderer.loadMapTiles(root);
renderInput = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors); 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)", log.debug("[Minimap] Render-Modell bereit – {} Areas, {} Orte, {} Wasser ({} ms)",
areas.size(), locs.size(), waters.size(), System.currentTimeMillis() - t0); areas.size(), locs.size(), waters.size(), System.currentTimeMillis() - t0);
boolean needsRender = !Files.exists(png); log.info("[Minimap] Rendere Weltkarte {}×{}…", TEXTURE_SIZE, TEXTURE_SIZE);
if (!needsRender) { long t1 = System.currentTimeMillis();
// Cache ungültig wenn Karte neuer als das PNG BufferedImage bi = WorldMapRenderer.render(renderModel, TEXTURE_SIZE, RenderOptions.all());
try { ImageIO.write(bi, "PNG", png.toFile());
long pngMod = Files.getLastModifiedTime(png).toMillis(); log.debug("[Minimap] Weltkarte gerendert und gespeichert ({} ms): {}",
long mapMod = Files.getLastModifiedTime(MapIO.getMapPath()).toMillis(); System.currentTimeMillis() - t1, png);
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);
}
app.enqueue(() -> { onTextureReady(); return null; }); app.enqueue(() -> { onTextureReady(); return null; });
} catch (Exception e) { } catch (Exception e) {