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

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