Weltkarte: Render-Modell-Trennung, UI-Umbau und Kamera-Indikator

- WorldMapRenderModel: neues Record als Zwischenschicht zwischen I/O und Rendering
- WorldMapRenderer: render() akzeptiert Modell direkt; rückwärtskompatible Wrapper-Signatur für MinimapState
- WorldMapView: Layer-Auswahl als MenuButton mit Checkboxen; Vollbild-Modus (centerStack-Swap); Kamera-Indikator mit FOV-Kegel (120ms-Timer, unabhängig vom Karten-Rendering); Linksklick teleportiert JME3-Kamera unter Beibehaltung der Terrain-Höhendifferenz
- EditorApp: CameraInfoSupplier, TeleportCallback und FullscreenCallbacks verdrahtet
- MinimapState, Logback: Debug-Logging für Modell-Build und Render-Zyklen
- Map-Daten: Zwischenstand Weltdaten

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 13:24:35 +02:00
parent 68ec75c41a
commit 36c6a9b7d8
23 changed files with 806 additions and 189 deletions

View File

@@ -0,0 +1,27 @@
package de.blight.common.map;
import de.blight.common.*;
import de.blight.common.model.Location;
import java.util.List;
/**
* Fertig vorberechnetes Karten-Modell: enthält alle Rohdaten + die im Editor
* vorcompilierten Overlay-Informationen (SeaMask, Küstenpfade, Baum-Cluster).
* WorldMapRenderer.render() arbeitet nur noch gegen dieses Modell.
*/
public record WorldMapRenderModel(
// Terrain-Daten + Polygon-Objekte (werden vom Renderer für das PNG genutzt)
MapData mapData,
List<PlacedArea> areas,
List<PlacedLocationZone> zones,
List<Location> locations,
List<PlacedWater> waters,
List<PlacedModel> models,
int[] slotColorsRGB,
// Vorberechnete Overlay-Daten (werden vom Canvas genutzt, nicht vom PNG-Renderer)
boolean[] seaMask, // Wasser-Pixel-Maske bei SEA_MASK_SIZE-Auflösung
float[] terrainSamples, // Höhenwerte bei SEA_MASK_SIZE (für Wellen-Prüfung)
List<float[][]> coastPaths, // geglättete Marching-Squares Küstenpfade (Weltkoord.)
List<List<PlacedModel>> treeClusters
) {}

View File

@@ -62,17 +62,25 @@ public final class WorldMapRenderer {
public static boolean[] buildSeaMask(MapData m, int size) {
int TV = MapData.TERRAIN_VERTS;
int UV = MapData.UPPER_VERTS;
boolean[] mask = new boolean[size * size];
for (int py = 0; py < size; py++) {
for (int px = 0; px < size; px++) {
int hx = Math.min((int)((float) px / (size - 1) * (TV - 1)), TV - 1);
int hz = Math.min((int)((float) py / (size - 1) * (TV - 1)), TV - 1);
mask[py * size + px] = m.terrainHeight[hz * TV + hx] < 0f;
int ux = Math.min((int)((float) px / (size - 1) * (UV - 1)), UV - 1);
int uz = Math.min((int)((float) py / (size - 1) * (UV - 1)), UV - 1);
float h = m.terrainHeight[hz * TV + hx];
float upper = m.upperTop[uz * UV + ux];
if (upper > 0f && upper > h) { h = upper; }
mask[py * size + px] = h < 0f;
}
}
return mask;
}
private static final int WATER_COLOR = 0xFF_ADD8E6;
// Default-Slot-Farben für Slots 1-8 (Base-Layer 1-4 + Upper-Layer 5-8)
private static final int[] DEF_SLOT_R = { 71, 115, 140, 204, 90, 130, 110, 180 };
private static final int[] DEF_SLOT_G = { 148, 82, 115, 184, 80, 90, 60, 100 };
@@ -80,15 +88,17 @@ public final class WorldMapRenderer {
private WorldMapRenderer() {}
public static BufferedImage render(RenderInput input, int targetSize, RenderOptions opts) {
MapData m = input.mapData();
/** Hauptmethode: rendert das Hintergrund-PNG aus einem vorberechneten Modell. */
public static BufferedImage render(WorldMapRenderModel model, int targetSize, RenderOptions opts) {
MapData m = model.mapData();
int TV = MapData.TERRAIN_VERTS;
int SS = MapData.SPLAT_SIZE;
int[] slotR = slotChannel(input, 0);
int[] slotG = slotChannel(input, 1);
int[] slotB = slotChannel(input, 2);
int[] slotR = slotChannel(model.slotColorsRGB(), 0);
int[] slotG = slotChannel(model.slotColorsRGB(), 1);
int[] slotB = slotChannel(model.slotColorsRGB(), 2);
// ── 1. Heightmap auf Zielauflösung samplen ────────────────────────────
int UV = MapData.UPPER_VERTS;
float[] heights = new float[targetSize * targetSize];
float minH = Float.MAX_VALUE, maxH = -Float.MAX_VALUE;
@@ -96,7 +106,11 @@ public final class WorldMapRenderer {
for (int px = 0; px < targetSize; px++) {
int hx = Math.min((int)((float) px / (targetSize - 1) * (TV - 1)), TV - 1);
int hz = Math.min((int)((float) py / (targetSize - 1) * (TV - 1)), TV - 1);
int ux = Math.min((int)((float) px / (targetSize - 1) * (UV - 1)), UV - 1);
int uz = Math.min((int)((float) py / (targetSize - 1) * (UV - 1)), UV - 1);
float h = m.terrainHeight[hz * TV + hx];
float upper = m.upperTop[uz * UV + ux];
if (upper > 0f && upper > h) { h = upper; }
heights[py * targetSize + px] = h;
if (h < minH) minH = h;
if (h > maxH) maxH = h;
@@ -161,6 +175,9 @@ public final class WorldMapRenderer {
}
}
// Kuwahara-Filter: lässt das Terrain wie gemalt wirken
if (opts.showSplatColors()) { applyKuwahara(img, 3); }
// ── 3. Vektor-Overlays ────────────────────────────────────────────────
Graphics2D gfx = img.createGraphics();
gfx.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
@@ -171,10 +188,10 @@ public final class WorldMapRenderer {
// ── Weißfüllung ───────────────────────────────────────────────────────
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, 0xFFFFFFFF);
if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, WATER_COLOR);
}
}
for (PlacedWater w : input.waters()) {
for (PlacedWater w : model.waters()) {
int[] xs = worldToPixels(w.pointsX(), targetSize);
int[] ys = worldToPixels(w.pointsZ(), targetSize);
Polygon poly = new Polygon(xs, ys, xs.length);
@@ -186,7 +203,7 @@ public final class WorldMapRenderer {
for (int py = y0; py <= y1; py++) {
for (int px = x0; px <= x1; px++) {
if (poly.contains(px, py) && heights[py * targetSize + px] < wh)
img.setRGB(px, py, 0xFFFFFFFF);
img.setRGB(px, py, WATER_COLOR);
}
}
}
@@ -219,7 +236,7 @@ public final class WorldMapRenderer {
}
}
// Wasserflächen: nur wenn groß genug
for (PlacedWater w : input.waters()) {
for (PlacedWater w : model.waters()) {
int[] xs = worldToPixels(w.pointsX(), targetSize);
int[] ys = worldToPixels(w.pointsZ(), targetSize);
Polygon poly = new Polygon(xs, ys, xs.length);
@@ -250,7 +267,7 @@ public final class WorldMapRenderer {
gfx.setColor(Color.BLACK);
gfx.setStroke(new BasicStroke(3.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
// Wasserflächen: Polygon-Umriss
for (PlacedWater w : input.waters()) {
for (PlacedWater w : model.waters()) {
int[] xs = worldToPixels(w.pointsX(), targetSize);
int[] ys = worldToPixels(w.pointsZ(), targetSize);
gfx.drawPolygon(xs, ys, xs.length);
@@ -293,7 +310,7 @@ public final class WorldMapRenderer {
10f, new float[]{dash, gap}, 0f);
int aFontSize = Math.max(8, targetSize / 160);
gfx.setFont(new Font("SansSerif", Font.BOLD, aFontSize));
for (PlacedArea a : input.areas()) {
for (PlacedArea a : model.areas()) {
int[] xs = worldToPixels(a.pointsX(), targetSize);
int[] ys = worldToPixels(a.pointsZ(), targetSize);
gfx.setStroke(dashed);
@@ -314,7 +331,7 @@ public final class WorldMapRenderer {
// Location-Zonen
if (opts.showZones()) {
gfx.setStroke(new BasicStroke(lineW));
for (PlacedLocationZone z : input.zones()) {
for (PlacedLocationZone z : model.zones()) {
int[] xs = worldToPixels(z.pointsX(), targetSize);
int[] ys = worldToPixels(z.pointsZ(), targetSize);
gfx.setColor(new Color(240, 190, 40, 70));
@@ -324,13 +341,13 @@ public final class WorldMapRenderer {
}
}
// Modell-Punkte
if (opts.showModels()) {
int dotR = Math.max(1, targetSize / 600);
gfx.setColor(new Color(160, 80, 20, 200));
for (PlacedModel model : input.models()) {
int mx = worldToPixel(model.x(), targetSize);
int mz = worldToPixel(model.z(), targetSize);
for (PlacedModel pm : model.models()) {
if (isTree(pm)) { continue; }
int mx = worldToPixel(pm.x(), targetSize);
int mz = worldToPixel(pm.z(), targetSize);
gfx.fillRect(mx - dotR, mz - dotR, dotR * 2 + 1, dotR * 2 + 1);
}
}
@@ -339,7 +356,7 @@ public final class WorldMapRenderer {
if (opts.showLocations()) {
int fontSize = Math.max(8, targetSize / 140);
gfx.setFont(new Font("SansSerif", Font.BOLD, fontSize));
for (Location loc : input.locations()) {
for (Location loc : model.locations()) {
if (!loc.isShowOnMap()) continue;
if (loc.getId() == null || loc.getId().isEmpty()) continue;
float wx = Float.isNaN(loc.getLabelX()) ? loc.getCenterX() : loc.getLabelX();
@@ -354,6 +371,15 @@ public final class WorldMapRenderer {
return img;
}
/** Rückwärts-kompatibel: baut ein minimales Modell (ohne Overlay-Daten) und delegiert. */
public static BufferedImage render(RenderInput input, int targetSize, RenderOptions opts) {
return render(new WorldMapRenderModel(
input.mapData(), input.areas(), input.zones(), input.locations(),
input.waters(), input.models(), input.slotColorsRGB(),
null, null, null, null
), targetSize, opts);
}
/**
* Rendert einen rechteckigen Weltausschnitt als {@link BufferedImage}.
* Koordinatenursprung und Skalierung passen sich dem Ausschnitt an,
@@ -383,16 +409,21 @@ public final class WorldMapRenderer {
float minH = 0f, maxH = 1f;
if (opts.showTerrain() || opts.showWater()) {
int UV2 = MapData.UPPER_VERTS;
heights = new float[targetSize * targetSize];
minH = Float.MAX_VALUE;
maxH = -Float.MAX_VALUE;
for (int py = 0; py < targetSize; py++) {
float wz = wz0 + (float) py / (targetSize - 1) * rSize;
int hz = iclamp((int) ((wz + WORLD_HALF) / WORLD_SIZE * (TV - 1)), 0, TV - 1);
int hz = iclamp((int) ((wz + WORLD_HALF) / WORLD_SIZE * (TV - 1)), 0, TV - 1);
int uz = iclamp((int) ((wz + WORLD_HALF) / WORLD_SIZE * (UV2 - 1)), 0, UV2 - 1);
for (int px = 0; px < targetSize; px++) {
float wx = wx0 + (float) px / (targetSize - 1) * rSize;
int hx = iclamp((int) ((wx + WORLD_HALF) / WORLD_SIZE * (TV - 1)), 0, TV - 1);
int hx = iclamp((int) ((wx + WORLD_HALF) / WORLD_SIZE * (TV - 1)), 0, TV - 1);
int ux = iclamp((int) ((wx + WORLD_HALF) / WORLD_SIZE * (UV2 - 1)), 0, UV2 - 1);
float h = m.terrainHeight[hz * TV + hx];
float upper = m.upperTop[uz * UV2 + ux];
if (upper > 0f && upper > h) { h = upper; }
heights[py * targetSize + px] = h;
if (h < minH) { minH = h; }
if (h > maxH) { maxH = h; }
@@ -403,9 +434,9 @@ public final class WorldMapRenderer {
// ── Terrain (optional) ────────────────────────────────────────────────
if (opts.showTerrain()) {
float heightRange = Math.max(0.01f, maxH - minH);
int[] sR = slotChannel(input, 0);
int[] sG = slotChannel(input, 1);
int[] sB = slotChannel(input, 2);
int[] sR = slotChannel(input.slotColorsRGB(), 0);
int[] sG = slotChannel(input.slotColorsRGB(), 1);
int[] sB = slotChannel(input.slotColorsRGB(), 2);
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
@@ -465,6 +496,8 @@ public final class WorldMapRenderer {
}
}
if (opts.showSplatColors()) { applyKuwahara(img, 3); }
// ── Vektor-Overlays ───────────────────────────────────────────────────
Graphics2D gfx = img.createGraphics();
gfx.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
@@ -474,7 +507,7 @@ public final class WorldMapRenderer {
// ── Weißfüllung ───────────────────────────────────────────────────────
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, 0xFFFFFFFF);
if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, WATER_COLOR);
}
}
for (PlacedWater w : input.waters()) {
@@ -489,7 +522,7 @@ public final class WorldMapRenderer {
for (int py = y0; py <= y1; py++) {
for (int px = x0; px <= x1; px++) {
if (poly.contains(px, py) && heights[py * targetSize + px] < wh)
img.setRGB(px, py, 0xFFFFFFFF);
img.setRGB(px, py, WATER_COLOR);
}
}
}
@@ -629,6 +662,7 @@ public final class WorldMapRenderer {
int dotR = Math.max(1, targetSize / 600);
gfx.setColor(new Color(160, 80, 20, 200));
for (PlacedModel model : input.models()) {
if (isTree(model)) { continue; }
int mx = wrp1(model.x(), wx0, rSize, targetSize);
int mz = wrp1(model.z(), wz0, rSize, targetSize);
gfx.fillRect(mx - dotR, mz - dotR, dotR * 2 + 1, dotR * 2 + 1);
@@ -680,13 +714,12 @@ public final class WorldMapRenderer {
// Gibt den R-, G- oder B-Kanal (channel=0/1/2) aller 8 Splatmap-Slots zurück.
// slotColorsRGB: 24 Werte (8 Slots × 3), 12 Werte (4 Slots, Upper-Layer = Defaults) oder null.
private static int[] slotChannel(RenderInput input, int channel) {
int[] rgb = input.slotColorsRGB();
private static int[] slotChannel(int[] slotColorsRGB, int channel) {
int[] def = channel == 0 ? DEF_SLOT_R : (channel == 1 ? DEF_SLOT_G : DEF_SLOT_B);
if (rgb == null || rgb.length < 12) { return def; }
if (slotColorsRGB == null || slotColorsRGB.length < 12) { return def; }
int[] out = new int[8];
for (int s = 0; s < 8; s++) {
out[s] = (rgb.length >= (s + 1) * 3) ? rgb[s * 3 + channel] : def[s];
out[s] = (slotColorsRGB.length >= (s + 1) * 3) ? slotColorsRGB[s * 3 + channel] : def[s];
}
return out;
}
@@ -802,4 +835,53 @@ public final class WorldMapRenderer {
float v01 = (arr[i01] & 0xFF) / 255f, v11 = (arr[i11] & 0xFF) / 255f;
return (v00*(1-tx) + v10*tx)*(1-tz) + (v01*(1-tx) + v11*tx)*tz;
}
public static boolean isTree(PlacedModel m) {
return m.modelPath().replace('\\', '/').toLowerCase().contains("/trees/");
}
/**
* Kuwahara-Filter: Für jeden Pixel das Quadranten-Fenster mit der kleinsten
* Varianz wählen und dessen Mittelwert setzen → Ölgemälde-/gemalt-Effekt.
* Radius r=3 → Fenster 7×7, 4 Quadranten je 4×4.
*/
private static void applyKuwahara(BufferedImage img, int r) {
int w = img.getWidth(), h = img.getHeight();
int[] src = img.getRGB(0, 0, w, h, null, 0, w);
int[] dst = new int[src.length];
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
float bestVar = Float.MAX_VALUE;
int bestPacked = src[y * w + x];
// 4 Quadranten: [xOff0..xOff1] × [yOff0..yOff1]
int[][] quads = {{-r,-r,0,0},{0,-r,r,0},{-r,0,0,r},{0,0,r,r}};
for (int[] q : quads) {
int x0 = Math.max(0, x+q[0]), y0 = Math.max(0, y+q[1]);
int x1 = Math.min(w-1, x+q[2]), y1 = Math.min(h-1, y+q[3]);
float sumR=0,sumG=0,sumB=0, sum2R=0,sum2G=0,sum2B=0;
int cnt = 0;
for (int qy = y0; qy <= y1; qy++) {
for (int qx = x0; qx <= x1; qx++) {
int p = src[qy * w + qx];
float pr = (p>>16)&0xFF, pg = (p>>8)&0xFF, pb = p&0xFF;
sumR+=pr; sumG+=pg; sumB+=pb;
sum2R+=pr*pr; sum2G+=pg*pg; sum2B+=pb*pb;
cnt++;
}
}
float inv = 1f / cnt;
float mR=sumR*inv, mG=sumG*inv, mB=sumB*inv;
float var = (sum2R*inv - mR*mR) + (sum2G*inv - mG*mG) + (sum2B*inv - mB*mB);
if (var < bestVar) {
bestVar = var;
bestPacked = (clamp((int)mR)<<16) | (clamp((int)mG)<<8) | clamp((int)mB);
}
}
dst[y * w + x] = bestPacked | 0xFF000000;
}
}
img.setRGB(0, 0, w, h, dst, 0, w);
}
}