Weltkarte: schematische 5-Layer-Optik

Layer 1: Flachfarben aus Splatmap (Coarse 512px filtert dünne Texturen <4m heraus, kein Hillshading)
Layer 2: Wasser-Füllung (PNG) + Küstenlinie (Canvas-Vektor), eine Checkbox
Layer 3: Wald – 50% alpha grün + dunkelgrüne Randlinie (vorgebacken)
Layer 4: Klippen – graue Füllung + Kammlinie + Fußlinie (vorgebacken, buildCliffOverlay)
Layer 5: Areas – gestrichelte Vektorpolygone (Canvas)

Entfernt: Wellenmarken, Wasserfall-Symbole, Zonen, Modell-Punkte, Orte-Labels, Baum-Symbole

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 16:32:18 +02:00
parent e8896c7a4d
commit ddec931ee8
2 changed files with 198 additions and 455 deletions

View File

@@ -289,195 +289,6 @@ public final class WorldMapRenderer {
img.setRGB(i % targetSize, i / targetSize, rawRGB[i]);
}
// ── 3. Vektor-Overlays ────────────────────────────────────────────────
Graphics2D gfx = img.createGraphics();
gfx.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
float lineW = Math.max(1.5f, targetSize / 400f);
// Wasser
if (opts.showWater()) {
// ── Wellenmarken ──────────────────────────────────────────────────────
if (opts.showWaterWaves()) {
float wmW = Math.max(20f, targetSize / 70f);
float wmA = wmW * 0.2f, spX = wmW * 1.8f, spY = wmW * 1.05f;
gfx.setColor(Color.BLACK);
gfx.setStroke(new BasicStroke(Math.max(0.7f, lineW * 0.4f),
BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
// Meer: versetzt + gejittert
int seaRow = 0;
for (float wy = spY * 0.5f; wy < targetSize; wy += spY, seaRow++) {
float rowOff = (seaRow & 1) == 1 ? spX * 0.5f : 0f;
int seaCol = 0;
for (float wx = rowOff; wx + wmW <= targetSize; wx += spX, seaCol++) {
float jx = (float)(Math.abs(Math.sin(seaRow * 73.1 + seaCol * 157.3)) * spX * 0.25);
float jy = (float)(Math.sin(seaRow * 211.7 + seaCol * 89.5) * spY * 0.2);
float ox = wx + jx, oy = wy + jy;
int cx = (int)(ox + wmW * 0.5f), cy = (int)oy;
if (cx >= 0 && cx < targetSize && cy >= 0 && cy < targetSize
&& ox >= 0 && ox + wmW < targetSize
&& seaPixels[cy * targetSize + cx]) {
Path2D mark = new Path2D.Float();
mark.moveTo(ox, oy);
mark.curveTo(ox + wmW*0.3f, oy - wmA, ox + wmW*0.7f, oy + wmA, ox + wmW, oy);
gfx.draw(mark);
}
}
}
// Wasserflächen: nur wenn groß genug
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);
Rectangle bb = poly.getBounds();
if (bb.width <= wmW * 1.5f || bb.height <= spY) continue;
gfx.setClip(poly);
int wRow = 0;
for (float wy = bb.y + spY * 0.5f; wy < bb.y + bb.height; wy += spY, wRow++) {
float rowOff = (wRow & 1) == 1 ? spX * 0.5f : 0f;
int wCol = 0;
for (float wx = bb.x + rowOff; wx + wmW <= bb.x + bb.width; wx += spX, wCol++) {
float jx = (float)(Math.abs(Math.sin(wRow * 73.1 + wCol * 157.3)) * spX * 0.25);
float jy = (float)(Math.sin(wRow * 211.7 + wCol * 89.5) * spY * 0.2);
float ox = wx + jx, oy = wy + jy;
if (ox < bb.x || ox + wmW > bb.x + bb.width) continue;
Path2D mark = new Path2D.Float();
mark.moveTo(ox, oy);
mark.curveTo(ox + wmW*0.3f, oy - wmA, ox + wmW*0.7f, oy + wmA, ox + wmW, oy);
gfx.draw(mark);
}
}
gfx.setClip(null);
}
}
}
// Wasserfälle – klassisches Kartensymbol: Kammlinie + Kaskaden-Linien
if (opts.showWaterfalls() && model.waterfalls() != null && !model.waterfalls().isEmpty()) {
float wfLineW = Math.max(1.5f, targetSize / 800f);
for (PlacedWaterfall wf : model.waterfalls()) {
// Kamm A→B und Basis D→C in Pixel-Koordinaten
int pax = worldToPixel(wf.ax(), targetSize), paz = worldToPixel(wf.az(), targetSize);
int pbx = worldToPixel(wf.bx(), targetSize), pbz = worldToPixel(wf.bz(), targetSize);
int pdx = worldToPixel(wf.dx(), targetSize), pdz = worldToPixel(wf.dz(), targetSize);
int pcx = worldToPixel(wf.cx(), targetSize), pcz = worldToPixel(wf.cz(), targetSize);
// Kammrichtung in Pixel-Space
float cdx = pbx - pax, cdz = pbz - paz;
float clen = (float) Math.sqrt(cdx * cdx + cdz * cdz);
if (clen < 1f) { continue; }
float ncx = cdx / clen, ncz = cdz / clen;
// Fließrichtung: Basismitten → Kammmitte im Pixel-Space
float pmcx = (pax + pbx) * 0.5f, pmcz = (paz + pbz) * 0.5f;
float pmbx = (pdx + pcx) * 0.5f, pmbz = (pdz + pcz) * 0.5f;
float fvx = pmbx - pmcx, fvz = pmbz - pmcz;
float flen = (float) Math.sqrt(fvx * fvx + fvz * fvz);
float perpX, perpZ;
if (flen > 2f) {
// echte XZ-Fallrichtung verfügbar
perpX = fvx / flen;
perpZ = fvz / flen;
} else {
// Wasserfall senkrecht → Rechtsnormale der Kammlinie als Fallback
perpX = ncz;
perpZ = -ncx;
}
// Pixelabstand pro Kaskaden-Stufe (mindestens 3 px, skaliert mit Bild)
float step = Math.max(3f, targetSize / 512f) * 1.8f;
// ① Dunkler Halo unter der Kammlinie
gfx.setStroke(new BasicStroke(wfLineW * 2.5f + 1f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
gfx.setColor(new Color(10, 50, 120, 160));
gfx.drawLine(pax, paz, pbx, pbz);
// ② Helle Kammlinie (Kante, über die das Wasser fällt)
gfx.setStroke(new BasicStroke(wfLineW * 2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
gfx.setColor(new Color(220, 240, 255, 255));
gfx.drawLine(pax, paz, pbx, pbz);
// ③ Kaskaden-Linien: 2 parallele Linien in Fallrichtung, jede schwächer
int[] alpha = {190, 100};
float[] wf2 = {wfLineW * 1.3f, wfLineW * 0.8f};
for (int ci = 0; ci < 2; ci++) {
float off = step * (ci + 1);
int ox1 = Math.round(pax + perpX * off), oz1 = Math.round(paz + perpZ * off);
int ox2 = Math.round(pbx + perpX * off), oz2 = Math.round(pbz + perpZ * off);
gfx.setStroke(new BasicStroke(wf2[ci], BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
gfx.setColor(new Color(160, 210, 255, alpha[ci]));
gfx.drawLine(ox1, oz1, ox2, oz2);
}
}
}
// Area-Polygone
if (opts.showAreas()) {
float dash = lineW * 7f, gap = lineW * 4f;
BasicStroke dashed = new BasicStroke(lineW * 1.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND,
10f, new float[]{dash, gap}, 0f);
int aFontSize = Math.max(8, targetSize / 160);
gfx.setFont(new Font("SansSerif", Font.BOLD, aFontSize));
for (PlacedArea a : model.areas()) {
int[] xs = worldToPixels(a.pointsX(), targetSize);
int[] ys = worldToPixels(a.pointsZ(), targetSize);
gfx.setStroke(dashed);
gfx.setColor(Color.BLACK);
gfx.draw(toPath(xs, ys));
String lbl = friendlyAreaName(a.areaId());
if (lbl != null) {
float[] cen = centroid(a.pointsX(), a.pointsZ());
float lx = Float.isNaN(a.labelX()) ? cen[0] : a.labelX();
float lz = Float.isNaN(a.labelZ()) ? cen[1] : a.labelZ();
int plx = worldToPixel(lx, targetSize);
int plz = worldToPixel(lz, targetSize);
drawLabel(gfx, lbl, plx, plz);
}
}
}
// Location-Zonen
if (opts.showZones()) {
gfx.setStroke(new BasicStroke(lineW));
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));
gfx.fillPolygon(xs, ys, xs.length);
gfx.setColor(new Color(200, 150, 20));
gfx.drawPolygon(xs, ys, xs.length);
}
}
if (opts.showModels()) {
int dotR = Math.max(1, targetSize / 600);
gfx.setColor(new Color(160, 80, 20, 200));
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);
}
}
// Locations (nur Label)
if (opts.showLocations()) {
int fontSize = Math.max(8, targetSize / 140);
gfx.setFont(new Font("SansSerif", Font.BOLD, fontSize));
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();
float wz = Float.isNaN(loc.getLabelZ()) ? loc.getCenterZ() : loc.getLabelZ();
int lx = worldToPixel(wx, targetSize);
int lz = worldToPixel(wz, targetSize);
drawLabel(gfx, friendlyLocationName(loc.getId()), lx, lz);
}
}
gfx.dispose();
return img;
}
@@ -1248,62 +1059,131 @@ public final class WorldMapRenderer {
return (r<<16)|(g<<8)|b;
}
/** Schematische Flachfarbe pro Tile-Typ (kein Hillshading, keine Klippen-Blend). */
private static int schematicColor(int tileType) {
return switch (tileType) {
case TILE_GRASS -> (125 << 16) | (180 << 8) | 72;
case TILE_DIRT -> (182 << 16) | (138 << 8) | 80;
case TILE_SAND -> (212 << 16) | (198 << 8) | 128;
case TILE_ROCK -> (162 << 16) | (155 << 8) | 148;
case TILE_PAVEMENT -> (155 << 16) | (145 << 8) | 128;
default -> (125 << 16) | (180 << 8) | 72;
};
}
/**
* 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.
* Baut die schematische Terrain-Farbkarte: Tile-Typ bei 512×512 (4m/px) bestimmen,
* dann per Nearest-Neighbor auf COLOR_MAP_SIZE hochskalieren und Flachfarbe zuweisen.
* Dünne Texturen (<4m) werden durch das Coarse-Sampling automatisch gefiltert.
*/
public static int[] buildTerrainColorMap(MapData m, int[] slotColors, int[][] tilePixels,
float[] voxelSurface, List<PlacedWater> waters,
List<PlacedWaterfall> waterfalls) {
final int SIZE = COLOR_MAP_SIZE;
final int SS = MapData.SPLAT_SIZE;
float[] heights = buildTerrainSamples(m, SIZE, voxelSurface);
float[] waterH = buildWaterHeightMap(waters, SIZE);
boolean[] wfMask = buildWaterfallMask(waterfalls, SIZE);
int[] tileAvg = computeTileAvgColors(tilePixels);
int[] slotTypes = computeSlotTileTypes(m, slotColors);
float cellW = WORLD_SIZE / (SIZE - 1);
int[] terrainRGB = new int[SIZE * SIZE];
float[] cliffMask = new float[SIZE * SIZE];
final int SIZE = COLOR_MAP_SIZE;
final int COARSE = 512;
final int SS = MapData.SPLAT_SIZE;
float[] heights = buildTerrainSamples(m, SIZE, voxelSurface);
float[] waterH = buildWaterHeightMap(waters, SIZE);
float cellWC = WORLD_SIZE / (COARSE - 1);
// Coarse-Typ-Map bei 512×512 (4m/px) – filtert dünne Texturen heraus
byte[] coarseTypes = new byte[COARSE * COARSE];
for (int mz = 0; mz < COARSE; mz++) {
float wz = -WORLD_HALF + mz * cellWC;
for (int mx = 0; mx < COARSE; mx++) {
float wx = -WORLD_HALF + mx * cellWC;
coarseTypes[mz * COARSE + mx] = (byte) getTerrainTile(m, SS, wx, wz, slotColors);
}
}
// Hochskalieren (NN) auf SIZE×SIZE und Flachfarbe zuweisen
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];
if (h < 0f || (waterH[idx] > 0f && waterH[idx] > h) || wfMask[idx]) {
int idx = py * SIZE + px;
float h = heights[idx];
if (h < 0f || (waterH[idx] > 0f && waterH[idx] > h)) {
int[] wc = TILE_FALLBACK_RGB[TILE_WATER];
terrainRGB[idx] = (wc[0]<<16)|(wc[1]<<8)|wc[2];
result[idx] = (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))));
cliffMask[idx] = (slope > 30.0) ? 1f : 0f;
terrainRGB[idx] = blendTileAvgColors(m, SS, wx, wz, tileAvg, slotTypes);
int cx = Math.min(COARSE - 1, (int)((float) px / (SIZE - 1) * (COARSE - 1)));
int cz = Math.min(COARSE - 1, (int)((float) py / (SIZE - 1) * (COARSE - 1)));
result[idx] = schematicColor(coarseTypes[cz * COARSE + cx] & 0xFF);
}
}
}
// Cliff-Maske weichzeichnen → weiche Klippen-Kanten ohne Änderung der Schwellwert-Logik
float[] softMask = boxBlurFloat(cliffMask, SIZE, 3);
int cliffRgb = tileAvg[Math.min(TILE_CLIFF, tileAvg.length-1)];
int cr = (cliffRgb>>16)&0xFF, cg = (cliffRgb>>8)&0xFF, cb = cliffRgb&0xFF;
int[] result = new int[SIZE * SIZE];
for (int i = 0; i < SIZE * SIZE; i++) {
float t = softMask[i];
int tc = terrainRGB[i];
int r = (tc>>16)&0xFF, g = (tc>>8)&0xFF, b = tc&0xFF;
result[i] = (clamp((int)(r + (cr-r)*t)) << 16)
| (clamp((int)(g + (cg-g)*t)) << 8)
| clamp((int)(b + (cb-b)*t));
return result;
}
/**
* Berechnet das schematische Cliff-Overlay (ARGB, COLOR_MAP_SIZE×COLOR_MAP_SIZE):
* graue Füllung wo Steigung > 30°, dunklere Linie an der oberen Kante (Kamm),
* mittlere Linie an der unteren Kante (Fuß).
*/
public static int[] buildCliffOverlay(MapData m, float[] voxelSurface) {
final int SIZE = COLOR_MAP_SIZE;
float[] heights = buildTerrainSamples(m, SIZE, voxelSurface);
float cellW = WORLD_SIZE / (SIZE - 1);
int dPx = Math.max(1, (int)(1.5f / cellW));
boolean[] cliffMask = new boolean[SIZE * SIZE];
float[] gradX = new float[SIZE * SIZE];
float[] gradZ = new float[SIZE * SIZE];
for (int py = 0; py < SIZE; py++) {
for (int px = 0; px < SIZE; px++) {
int idx = py * SIZE + px;
float hR = heights[py * SIZE + Math.min(SIZE - 1, px + dPx)];
float hL = heights[py * SIZE + Math.max(0, px - dPx)];
float hD = heights[Math.min(SIZE - 1, py + dPx) * SIZE + px];
float hU = heights[Math.max(0, py - dPx) * SIZE + px];
float dist = dPx * cellW;
double slope = Math.toDegrees(Math.atan(Math.sqrt(
Math.pow((hR - hL) / (2 * dist), 2) + Math.pow((hD - hU) / (2 * dist), 2))));
cliffMask[idx] = slope > 30.0;
gradX[idx] = (hR - hL) / (2 * dist);
gradZ[idx] = (hD - hU) / (2 * dist);
}
}
return boxBlur(result, SIZE, 2);
float[] floatMask = new float[SIZE * SIZE];
for (int i = 0; i < SIZE * SIZE; i++) { floatMask[i] = cliffMask[i] ? 1f : 0f; }
float[] softMask = boxBlurFloat(floatMask, SIZE, 3);
int[] result = new int[SIZE * SIZE];
// Füllung: weiches Grau, max 33% alpha
for (int i = 0; i < SIZE * SIZE; i++) {
if (softMask[i] > 0.05f) {
int alpha = clamp((int)(softMask[i] * 85));
result[i] = (alpha << 24) | 0x999090;
}
}
// Kantenerkennung: obere Kante (Kamm) dunkler, untere Kante (Fuß) heller
for (int py = 1; py < SIZE - 1; py++) {
for (int px = 1; px < SIZE - 1; px++) {
int idx = py * SIZE + px;
if (!cliffMask[idx]) { continue; }
float gx = gradX[idx], gz = gradZ[idx];
float glen = (float) Math.sqrt(gx * gx + gz * gz);
if (glen < 1e-6f) { continue; }
float upX = -gx / glen, upZ = -gz / glen; // bergauf
float dnX = gx / glen, dnZ = gz / glen; // bergab
// Obere Kante: bergauf-Nachbar ist kein Cliff
int uhx = px + Math.round(upX * 2), uhz = py + Math.round(upZ * 2);
if (uhx >= 0 && uhx < SIZE && uhz >= 0 && uhz < SIZE
&& !cliffMask[uhz * SIZE + uhx]) {
result[idx] = (0xCC << 24) | 0x504848;
continue;
}
// Untere Kante: bergab-Nachbar ist kein Cliff
int dhx = px + Math.round(dnX), dhz = py + Math.round(dnZ);
if (dhx >= 0 && dhx < SIZE && dhz >= 0 && dhz < SIZE
&& !cliffMask[dhz * SIZE + dhx]) {
result[idx] = (0x88 << 24) | 0x686060;
}
}
}
return result;
}
/**
@@ -1335,75 +1215,46 @@ public final class WorldMapRenderer {
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);
boolean[] wfMask = buildWaterfallMask(waterfalls, SIZE);
int[] tileAvg = computeTileAvgColors(tilePixels);
int[] slotTypes = computeSlotTileTypes(m, slotColors);
float cellW = WORLD_SIZE / (SIZE - 1);
float[] heights = buildTerrainSamples(m, SIZE, voxelSurface);
float[] waterH = buildWaterHeightMap(waters, SIZE);
int written = 0;
final int CLIFF_BLUR_R = 4; // Blur-Radius für weiche Klippen-Kanten
final int EXT = CLIFF_BLUR_R;
final int EXTW = CS + 2 * EXT; // erweitertes Fenster inkl. Rand für nahtlosen Blur
int dPxHires = Math.max(1, (int)(1.5f / cellW));
// Coarse-Typ-Map (4m/px) – identische Filterung wie buildTerrainColorMap
final int COARSE = 512;
float cellWC = WORLD_SIZE / (COARSE - 1);
byte[] coarseTypes = new byte[COARSE * COARSE];
for (int mz = 0; mz < COARSE; mz++) {
float wz = -WORLD_HALF + mz * cellWC;
for (int mx = 0; mx < COARSE; mx++) {
float wx = -WORLD_HALF + mx * cellWC;
coarseTypes[mz * COARSE + mx] = (byte) getTerrainTile(m, SS, wx, wz, slotColors);
}
}
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;
// Erweiterte Cliff-Maske + Terrain-Farben (mit Rand über Chunk-Grenze)
float[] cliffMaskExt = new float[EXTW * EXTW];
boolean[] isWaterExt = new boolean[EXTW * EXTW];
int[] terrainColExt = new int[EXTW * EXTW];
for (int ely = 0; ely < EXTW; ely++) {
int gy = cy * CS + ely - EXT;
int gyC = Math.max(0, Math.min(SIZE-1, gy));
float wz = -WORLD_HALF + gyC * cellW;
for (int elx = 0; elx < EXTW; elx++) {
int gx = cx * CS + elx - EXT;
int gxC = Math.max(0, Math.min(SIZE-1, gx));
float wx = -WORLD_HALF + gxC * cellW;
int eidx = ely * EXTW + elx;
int hidx = gyC * SIZE + gxC;
float h = heights[hidx];
if (h < 0f || (waterH[hidx] > 0f && waterH[hidx] > h) || wfMask[hidx]) {
isWaterExt[eidx] = true;
} else {
float hR = heights[gyC * SIZE + Math.min(SIZE-1, gxC+dPxHires)];
float hL = heights[gyC * SIZE + Math.max(0, gxC-dPxHires)];
float hD = heights[Math.min(SIZE-1, gyC+dPxHires) * SIZE + gxC];
float hU = heights[Math.max(0, gyC-dPxHires) * SIZE + gxC];
float dist = dPxHires * cellW;
double slope = Math.toDegrees(Math.atan(Math.sqrt(
Math.pow((hR-hL)/(2*dist),2) + Math.pow((hD-hU)/(2*dist),2))));
cliffMaskExt[eidx] = (slope > 30.0) ? 1f : 0f;
terrainColExt[eidx] = blendTileAvgColors(m, SS, wx, wz, tileAvg, slotTypes);
}
}
}
float[] softMaskExt = boxBlurFloat(cliffMaskExt, EXTW, CLIFF_BLUR_R);
int cliffRgb = tileAvg[Math.min(TILE_CLIFF, tileAvg.length-1)];
int cr = (cliffRgb>>16)&0xFF, cg = (cliffRgb>>8)&0xFF, cb = cliffRgb&0xFF;
// Chunk-Pixel aus dem inneren Bereich der erweiterten Maske zusammensetzen
int[] chunk = new int[CS * CS];
for (int ly = 0; ly < CS; ly++) {
int gy = cy * CS + ly;
for (int lx = 0; lx < CS; lx++) {
int extIdx = (ly + EXT) * EXTW + (lx + EXT);
int gx = cx * CS + lx;
int hidx = gy * SIZE + gx;
int tr, tg, tb;
if (isWaterExt[extIdx]) {
float h = heights[hidx];
if (h < 0f || (waterH[hidx] > 0f && waterH[hidx] > h)) {
tr = (WATER_COLOR >> 16) & 0xFF;
tg = (WATER_COLOR >> 8) & 0xFF;
tb = WATER_COLOR & 0xFF;
} else {
float t = softMaskExt[extIdx];
int tc = terrainColExt[extIdx];
int r2 = (tc>>16)&0xFF, g2 = (tc>>8)&0xFF, b2 = tc&0xFF;
tr = clamp((int)(r2 + (cr-r2)*t));
tg = clamp((int)(g2 + (cg-g2)*t));
tb = clamp((int)(b2 + (cb-b2)*t));
int ctX = Math.min(COARSE - 1, (int)((float) gx / (SIZE - 1) * (COARSE - 1)));
int ctZ = Math.min(COARSE - 1, (int)((float) gy / (SIZE - 1) * (COARSE - 1)));
int col = schematicColor(coarseTypes[ctZ * COARSE + ctX] & 0xFF);
tr = (col >> 16) & 0xFF;
tg = (col >> 8) & 0xFF;
tb = col & 0xFF;
}
chunk[ly * CS + lx] = (0xFF << 24) | (tr << 16) | (tg << 8) | tb;
}