Einmal den Fortschritt mit dem Wasser sichern

This commit is contained in:
2026-06-06 09:05:25 +02:00
parent d56f2ea41f
commit 7faed35287
21 changed files with 853 additions and 152 deletions

View File

@@ -569,7 +569,11 @@ public class EditorApp extends Application {
Menu viewMenu = new Menu("Ansicht");
MenuItem resetCam = new MenuItem("Kamera zurücksetzen");
resetCam.setOnAction(e -> input.addMouseDelta(0, 0));
viewMenu.getItems().add(resetCam);
MenuItem viewTexture = new MenuItem("Textur");
MenuItem viewWireframe = new MenuItem("Drahtgitter");
viewTexture.setOnAction(e -> input.wireframeRequest = 2);
viewWireframe.setOnAction(e -> input.wireframeRequest = 1);
viewMenu.getItems().addAll(resetCam, new SeparatorMenuItem(), viewTexture, viewWireframe);
menuBar.getMenus().addAll(fileMenu, toolsMenu, viewMenu);
@@ -1781,11 +1785,11 @@ public class EditorApp extends Application {
VBox inner = new VBox(10);
inner.setPadding(new Insets(10));
// Width slider
Label widthTitle = sectionTitle("Flussbreite");
// Width slider — setzt die Breite des NÄCHSTEN platzierten Punktes
Label widthTitle = sectionTitle("Breite (nächster Punkt)");
Label widthVal = new Label(String.format("%.1f m", input.riverNewWidth));
widthVal.setStyle("-fx-font-size: 11;");
Slider widthSlider = new Slider(1.0, 20.0, input.riverNewWidth);
widthVal.setStyle("-fx-font-size: 11; -fx-font-weight: bold;");
Slider widthSlider = new Slider(8.0, 40.0, input.riverNewWidth);
widthSlider.setBlockIncrement(0.5);
widthSlider.setShowTickLabels(true);
widthSlider.setMajorTickUnit(5);
@@ -1822,6 +1826,7 @@ public class EditorApp extends Application {
undoBtn,
new Separator(),
styledHint("L-Klick → Punkt setzen"),
styledHint("Breite ändern → nächsten Punkt setzen"),
styledHint("R-Klick → Fluss abschließen"),
styledHint("Backspace → letzten Punkt löschen"));

View File

@@ -121,6 +121,10 @@ public class SharedInput {
public volatile boolean saveRequested = false;
public volatile String saveStatusMsg = null;
// ── Wireframe-Modus ───────────────────────────────────────────────────────
// 0 = keine Änderung, 1 = Drahtgitter aktivieren, 2 = Textur-Modus aktivieren
public volatile int wireframeRequest = 0;
// ── Vorschau-Viewport (gemeinsam für Baum-Generator & EZ-Tree) ───────────
public volatile float treePreviewRotY = 0f; // Yaw-Winkel in Grad
public volatile float treePreviewRotX = 30f; // Elevation in Grad [5, 80]
@@ -349,7 +353,7 @@ public class SharedInput {
// ── Fluss-Werkzeug ─────────────────────────────────────────────────────────
public record RiverClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<RiverClick> riverClickQueue = new ConcurrentLinkedQueue<>();
public volatile float riverNewWidth = 4.0f; // Breite des nächsten Punktes
public volatile float riverNewWidth = 8.0f; // Breite des nächsten Punktes (Minimum 8m)
public volatile float riverNewSpeed = 0.4f; // UV-Geschwindigkeit (0.4=Fluss, 3.0=Wasserfall)
public volatile boolean undoRiverPointRequested = false;
public volatile String riverHint = null;

View File

@@ -105,6 +105,10 @@ public class RiverEditorState extends BaseAppState {
public void setTerrainEditor(TerrainEditorState te) {
this.terrainEditor = te;
// Beim ersten Setzen sofort alle geladenen Flüsse ins Terrain graben
if (te != null && !rivers.isEmpty()) {
te.reapplyAllRivers(getPlacedRivers());
}
}
// ── Update ────────────────────────────────────────────────────────────────
@@ -189,17 +193,11 @@ public class RiverEditorState extends BaseAppState {
}
List<RiverPoint> current = rivers.get(activeRiver);
if (!current.isEmpty() && terrainEditor != null) {
if (!current.isEmpty()) {
RiverPoint prev = current.get(current.size() - 1);
// Gefälle sicherstellen: neuer Punkt darf nicht höher als vorheriger sein
float newY = Math.min(pt.y(), prev.y() - 0.05f);
pt = new RiverPoint(pt.x(), newY, pt.z(), pt.width(), pt.uvSpeed());
// Flussbett graben
terrainEditor.carveRiverbedSegment(
prev.x(), prev.y(), prev.z(),
pt.x(), pt.y(), pt.z(),
pt.width() * 0.5f
);
}
rivers.get(activeRiver).add(pt);
@@ -214,13 +212,18 @@ public class RiverEditorState extends BaseAppState {
}
private void finalizeActiveRiver() {
// Fluss mit weniger als 2 Punkten verwerfen
if (activeRiver >= 0 && activeRiver < rivers.size()) {
if (rivers.get(activeRiver).size() < 2) {
removeRiver(activeRiver);
removeRiver(activeRiver); // löst intern reapplyAllRivers aus
activeRiver = -1;
return;
}
}
activeRiver = -1;
// Terrain erst jetzt graben — vollständiger Spline, non-destructive
if (terrainEditor != null) {
terrainEditor.reapplyAllRivers(getPlacedRivers());
}
}
private void undoLastPoint() {
@@ -332,6 +335,10 @@ public class RiverEditorState extends BaseAppState {
else if (activeRiver == idx) activeRiver = -1;
if (selectedRiver > idx) selectedRiver--;
else if (selectedRiver == idx) selectedRiver = -1;
// Terrain revertieren und verbleibende Flüsse neu graben
if (terrainEditor != null) {
terrainEditor.reapplyAllRivers(getPlacedRivers());
}
}
private void selectRiver(int idx) {
@@ -393,16 +400,21 @@ public class RiverEditorState extends BaseAppState {
}
private Geometry buildPointGeo(RiverPoint pt) {
Sphere sphere = new Sphere(8, 8, 0.4f);
// Radius = halbe Flussbreite, damit der Marker die Breite sichtbar macht
float r = Math.max(0.4f, pt.width() * 0.5f);
Sphere sphere = new Sphere(10, 10, r);
Geometry geo = new Geometry("riverPoint", sphere);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
if (pt.isWaterfall()) {
mat.setColor("Color", new ColorRGBA(1.0f, 0.45f, 0.0f, 1f));
mat.setColor("Color", new ColorRGBA(1.0f, 0.45f, 0.0f, 0.7f));
} else {
mat.setColor("Color", new ColorRGBA(0.1f, 0.4f, 1.0f, 1f));
mat.setColor("Color", new ColorRGBA(0.1f, 0.4f, 1.0f, 0.7f));
}
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setShadowMode(RenderQueue.ShadowMode.Off);
geo.setMaterial(mat);
geo.setLocalTranslation(pt.x(), pt.y() + 0.4f, pt.z());
geo.setLocalTranslation(pt.x(), pt.y() + r, pt.z());
return geo;
}
@@ -454,7 +466,7 @@ public class RiverEditorState extends BaseAppState {
if (right.lengthSquared() < 1e-6f) right.set(1f, 0f, 0f);
float halfW = pt.width() * 0.5f;
float px = pt.x(), py = pt.y() + 0.05f, pz = pt.z();
float px = pt.x(), py = pt.y() - 0.45f, pz = pt.z(); // 0,5m Absenkung + 0,05m Offset
pos.put(px - right.x * halfW).put(py).put(pz - right.z * halfW);
norm.put(0f).put(1f).put(0f);
@@ -487,6 +499,7 @@ public class RiverEditorState extends BaseAppState {
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setShadowMode(RenderQueue.ShadowMode.Off);
geo.setMaterial(mat);
return geo;
}

View File

@@ -50,22 +50,30 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import de.blight.common.RiverPoint;
import de.blight.common.RiverSpline;
public class TerrainEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(TerrainEditorState.class);
// ── Terrain-Konstanten ────────────────────────────────────────────────────
private static final int TERRAIN_SIZE = 4096;
private static final int TOTAL_SIZE = TERRAIN_SIZE + 1; // 4097
private static final int PATCH_SIZE = 65;
private static final int TERRAIN_SIZE = 4096;
private static final int TOTAL_SIZE = TERRAIN_SIZE + 1; // 4097
private static final float VERTEX_SPACING = (float) TERRAIN_SIZE / (TOTAL_SIZE - 1); // 1.0f
private static final int PATCH_SIZE = 65;
// ── Splatmap-Konstanten ────────────────────────────────────────────────────
private static final int SPLAT_SIZE = MapData.SPLAT_SIZE; // 513
private static final int SPLAT_SIZE = MapData.SPLAT_SIZE; // 2049
private static final float WORLD_HALF = 2048f;
private static final float SPLAT_WE_PER_PX = 4096f / (SPLAT_SIZE - 1); // 8 WE/px
private static final float SPLAT_WE_PER_PX = (float) TERRAIN_SIZE / (SPLAT_SIZE - 1); // 2 WE/px
// ── Kamera ────────────────────────────────────────────────────────────────
private static final float CAM_SPEED = 300f;
@@ -80,7 +88,10 @@ public class TerrainEditorState extends BaseAppState {
private final SharedInput input;
private TerrainQuad terrain;
private float[] cachedHeightMap; // Einmal geladen, danach manuell synchron gehalten
private float[] cachedHeightMap; // Einmal geladen, danach manuell synchron gehalten
private float[] originalHeightMap; // Unveränderter Stand vor allen Fluss-Modifikationen
private byte[] originalSplatR, originalSplatG, originalSplatB, originalSplatA;
private final HashSet<Integer> modifiedVertices = new HashSet<>(); // Alle durch Flüsse veränderten Vertices
private Geometry brushIndicator;
private Geometry livePlayerMarker;
private PlacedObjectState placedObjectState;
@@ -93,6 +104,7 @@ public class TerrainEditorState extends BaseAppState {
private RiverEditorState riverEditorState;
private MapData loadedMapData;
private Node axesGizmo;
private boolean wireframeMode = false;
// ── Splatmap (Slots 1-4) ─────────────────────────────────────────────────
private byte[] splatR, splatG, splatB, splatA;
@@ -193,7 +205,8 @@ public class TerrainEditorState extends BaseAppState {
private void buildScene() {
input.loadingStatus = "Lade Terrain...";
terrain = buildTerrain();
cachedHeightMap = terrain.getHeightMap(); // einmalige 67MB-Allokation; danach nie wieder
cachedHeightMap = terrain.getHeightMap(); // einmalige 67MB-Allokation; danach nie wieder
originalHeightMap = cachedHeightMap.clone(); // Snapshot vor allen Fluss-Modifikationen
rootNode.attachChild(terrain);
input.loadingStatus = "Lade platzierte Objekte...";
@@ -301,7 +314,14 @@ public class TerrainEditorState extends BaseAppState {
private TerrainQuad buildTerrain() {
float[] heights;
if (loadedMapData != null) {
heights = loadedMapData.terrainHeight.clone();
float[] src = loadedMapData.terrainHeight;
heights = new float[TOTAL_SIZE * TOTAL_SIZE];
int srcVerts = MapData.TERRAIN_VERTS; // 16385
if (srcVerts == TOTAL_SIZE) {
System.arraycopy(src, 0, heights, 0, heights.length);
} else {
downsampleHeights(src, srcVerts, heights, TOTAL_SIZE);
}
mergeUpperHeights(heights, loadedMapData);
} else {
heights = new float[TOTAL_SIZE * TOTAL_SIZE];
@@ -309,6 +329,7 @@ public class TerrainEditorState extends BaseAppState {
}
TerrainQuad tq = new TerrainQuad("terrain", PATCH_SIZE, TOTAL_SIZE, heights);
tq.setLocalScale(VERTEX_SPACING, 1f, VERTEX_SPACING);
// Kein scaleTerrainUVs Terrain.j3md nutzt TexNScale direkt
TerrainLodControl lod = new TerrainLodControl(tq, cam);
@@ -320,10 +341,11 @@ public class TerrainEditorState extends BaseAppState {
}
private void mergeUpperHeights(float[] heights, MapData map) {
float scale = (float)(MapData.UPPER_VERTS - 1) / (TOTAL_SIZE - 1);
for (int tz = 0; tz < TOTAL_SIZE; tz++) {
for (int tx = 0; tx < TOTAL_SIZE; tx++) {
float gi = (float) tx / 8f;
float gj = (float) tz / 8f;
float gi = tx * scale;
float gj = tz * scale;
float upperH = bilinearSample(map.upperTop, MapData.UPPER_VERTS, gi, gj);
int idx = tz * TOTAL_SIZE + tx;
if (upperH > 0f && upperH > heights[idx]) heights[idx] = upperH;
@@ -379,6 +401,29 @@ public class TerrainEditorState extends BaseAppState {
upperSplatA = new byte[SPLAT_SIZE * SPLAT_SIZE];
}
// Snapshot vor allen Fluss-Modifikationen
// Fluss-Farbe aus dem A-Kanal rückrechnen: A-Kanal ist ausschließlich für Flüsse.
// Alte Saves können dort nicht-null-Werte haben. Diese jetzt aus dem Original entfernen,
// damit reapplyAllRivers immer von einer sauberen Basis aus malt.
originalSplatR = splatR.clone();
originalSplatG = splatG.clone();
originalSplatB = splatB.clone();
originalSplatA = splatA.clone();
for (int i = 0; i < originalSplatA.length; i++) {
int a = originalSplatA[i] & 0xFF;
if (a > 0) {
float ps = a / 255f;
float denom = 1f - ps;
// R-Kanal zurückrechnen (war gedimmt: R_painted = R_orig * (1-ps))
originalSplatR[i] = denom < 0.01f ? (byte) 255
: (byte) Math.min(255, Math.round((originalSplatR[i] & 0xFF) / denom));
// G/B wurden von alten Fluss-Saves ggf. auf Nicht-Null gesetzt → bereinigen
originalSplatG[i] = 0;
originalSplatB[i] = 0;
originalSplatA[i] = 0;
}
}
splatBuf = BufferUtils.createByteBuffer(SPLAT_SIZE * SPLAT_SIZE * 4);
for (int i = 0; i < SPLAT_SIZE * SPLAT_SIZE; i++) {
splatBuf.put(splatR[i]).put(splatG[i]).put(splatB[i]).put(splatA[i]);
@@ -455,6 +500,7 @@ public class TerrainEditorState extends BaseAppState {
}
mat.setTexture("AlphaMap", splatTex);
if (wireframeMode) mat.getAdditionalRenderState().setWireframe(true);
// Zweite Splatmap → Slots 5-8 (AlphaMap_1), nur wenn Texturen konfiguriert
boolean hasUpperTex = false;
@@ -639,6 +685,15 @@ public class TerrainEditorState extends BaseAppState {
updateAxesGizmo();
updateLivePlayerMarker();
// Wireframe-Modus setzen
int wfReq = input.wireframeRequest;
if (wfReq != 0) {
input.wireframeRequest = 0;
wireframeMode = (wfReq == 1);
if (terrain != null)
terrain.getMaterial().getAdditionalRenderState().setWireframe(wireframeMode);
}
// Terrain-Material neu aufbauen wenn Texturen (Slots 1-8) oder Normal-Maps geändert
if (input.terrainTexturesChanged || input.terrainNormalMapsChanged
|| input.upperTexturesChanged || input.upperNormalMapsChanged) {
@@ -701,6 +756,319 @@ public class TerrainEditorState extends BaseAppState {
// ── Flussbett graben ──────────────────────────────────────────────────────
// Wasser liegt 50 cm unter den Kontrollpunkten
private static final float WATER_SINK = 0.5f;
// Tiefste Stelle des Flussbetts: 1 m unter Wasseroberfläche
private static final float BED_DEPTH = 1.0f;
// Bettbreite = Flussbreite × (1 + 2×0.25) = 1.5× — je 25 % der Breite als Böschung
private static final float BED_EXTRA = 0.25f;
// Mindest-Halbbreite 4 m (= 8 m gesamt)
private static final float MIN_HALF_WIDTH = 4.0f;
/**
* Setzt das Terrain auf den Original-Zustand zurück und gräbt ALLE übergebenen
* Flüsse in einem einzigen terrain.adjustHeight()-Aufruf neu.
* Aufzurufen wenn ein Fluss fertiggestellt oder gelöscht wird.
*/
public void reapplyAllRivers(List<List<RiverPoint>> allRivers) {
if (terrain == null || cachedHeightMap == null) return;
// 1. Alle zuvor durch Flüsse veränderten Vertices auf Original zurücksetzen
if (!modifiedVertices.isEmpty()) {
List<Vector2f> resetLocs = new ArrayList<>(modifiedVertices.size());
List<Float> resetDeltas = new ArrayList<>(modifiedVertices.size());
for (int vidx : modifiedVertices) {
float orig = originalHeightMap[vidx];
float curH = cachedHeightMap[vidx];
if (curH != orig) {
int vx = vidx % TOTAL_SIZE, vz = vidx / TOTAL_SIZE;
resetLocs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f,
vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f));
resetDeltas.add(orig - curH);
}
cachedHeightMap[vidx] = orig;
}
modifiedVertices.clear();
if (!resetLocs.isEmpty()) {
terrain.adjustHeight(resetLocs, resetDeltas);
terrain.updateModelBound();
}
}
// 2. Splatmap auf Original zurücksetzen
if (splatR != null) {
System.arraycopy(originalSplatR, 0, splatR, 0, splatR.length);
System.arraycopy(originalSplatG, 0, splatG, 0, splatG.length);
System.arraycopy(originalSplatB, 0, splatB, 0, splatB.length);
System.arraycopy(originalSplatA, 0, splatA, 0, splatA.length);
for (int i = 0; i < splatR.length; i++) {
int bi = i * 4;
splatBuf.put(bi, splatR[i]);
splatBuf.put(bi + 1, splatG[i]);
splatBuf.put(bi + 2, splatB[i]);
splatBuf.put(bi + 3, splatA[i]);
}
splatBuf.rewind();
splatImage.setUpdateNeeded();
}
if (allRivers == null || allRivers.isEmpty()) return;
// 3. Alle Flüsse in einem Batch berechnen und anwenden
HashMap<Integer, Float> channelTargets = new HashMap<>();
HashMap<Integer, Float> bankTargets = new HashMap<>();
List<List<RiverPoint>> allSplined = new ArrayList<>(allRivers.size());
for (List<RiverPoint> river : allRivers) {
if (river == null || river.size() < 2) continue;
List<RiverPoint> splined = RiverSpline.subdivide(river);
allSplined.add(splined);
collectRiverTargets(splined, channelTargets, bankTargets);
}
applyTargets(channelTargets);
applyBankTargets(bankTargets, channelTargets.keySet());
// 4. Splatmap für alle Flüsse neu malen
if (splatR != null) {
for (List<RiverPoint> splined : allSplined) paintRiverSplat(splined);
}
}
/**
* Berechnet Tiefenziele für alle Vertices entlang eines gesplineten Flusses.
*
* Profil (gemessen von Wasseroberfläche = waterY WATER_SINK):
* dist ≤ halfWidth → flacher Kanalboden, BED_DEPTH tief
* halfWidth < dist ≤ bedHW → linearer Anstieg von BED_DEPTH → 0
* dist > bedHW → kein Eingriff
*/
private void collectRiverTargets(List<RiverPoint> splined,
HashMap<Integer, Float> channelTargets,
HashMap<Integer, Float> bankTargets) {
for (int si = 1; si < splined.size(); si++) {
RiverPoint pa = splined.get(si - 1);
RiverPoint pb = splined.get(si);
float ax = pa.x(), ay = pa.y(), az = pa.z();
float bx = pb.x(), by = pb.y(), bz = pb.z();
float halfWidth = Math.max(MIN_HALF_WIDTH, (pa.width() + pb.width()) * 0.25f);
float bedHW = halfWidth * (1f + 2f * BED_EXTRA); // 1.5 × halfWidth
float paintHW = bedHW + SPLAT_WE_PER_PX; // Texturrand (+2 m)
float segDx = bx - ax, segDz = bz - az;
float segLen2 = segDx*segDx + segDz*segDz;
if (segLen2 < 0.001f) continue;
float scanHW = paintHW + 1f;
int vxMin = Math.max(0, (int)((Math.min(ax, bx) - scanHW + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
int vxMax = Math.min(TOTAL_SIZE - 1,(int)((Math.max(ax, bx) + scanHW + 1 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
int vzMin = Math.max(0, (int)((Math.min(az, bz) - scanHW + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
int vzMax = Math.min(TOTAL_SIZE - 1,(int)((Math.max(az, bz) + scanHW + 1 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
for (int vz = vzMin; vz <= vzMax; vz++) {
for (int vx = vxMin; vx <= vxMax; vx++) {
float worldX = vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f;
float worldZ = vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f;
float t = FastMath.clamp(
((worldX - ax)*segDx + (worldZ - az)*segDz) / segLen2, 0f, 1f);
float projX = ax + t*segDx, projZ = az + t*segDz;
float dist = FastMath.sqrt(
(worldX - projX)*(worldX - projX) + (worldZ - projZ)*(worldZ - projZ));
if (dist > paintHW) continue;
float waterY = ay + t * (by - ay);
int vidx = vz * TOTAL_SIZE + vx;
if (dist <= bedHW) {
float depth;
if (dist <= halfWidth) {
depth = BED_DEPTH;
} else {
depth = BED_DEPTH * (1f - (dist - halfWidth) / (bedHW - halfWidth));
}
float target = waterY - WATER_SINK - depth;
if (originalHeightMap != null && originalHeightMap[vidx] - target > 20f) continue;
channelTargets.merge(vidx, target, Math::min);
} else {
// Uferzone: Höhe exakt auf Wasseroberfläche setzen
bankTargets.merge(vidx, waterY - WATER_SINK, Math::min);
}
}
}
}
}
/** Wendet vorberechnete Kanalziele in einem adjustHeight()-Aufruf an. */
private void applyTargets(HashMap<Integer, Float> channelTargets) {
List<Vector2f> locs = new ArrayList<>(channelTargets.size());
List<Float> deltas = new ArrayList<>(channelTargets.size());
for (Map.Entry<Integer, Float> e : channelTargets.entrySet()) {
int vidx = e.getKey();
float target = e.getValue();
float curH = cachedHeightMap[vidx];
if (curH > target) {
int vx = vidx % TOTAL_SIZE, vz = vidx / TOTAL_SIZE;
locs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f,
vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f));
deltas.add(target - curH);
cachedHeightMap[vidx] = target;
modifiedVertices.add(vidx);
}
}
if (!locs.isEmpty()) {
terrain.adjustHeight(locs, deltas);
terrain.updateModelBound();
}
}
/** Setzt Ufer-Vertices exakt auf die Wasseroberfläche (hebt und senkt). Channel-Vertices haben Vorrang. */
private void applyBankTargets(HashMap<Integer, Float> bankTargets, Set<Integer> channelVerts) {
List<Vector2f> locs = new ArrayList<>(bankTargets.size());
List<Float> deltas = new ArrayList<>(bankTargets.size());
for (Map.Entry<Integer, Float> e : bankTargets.entrySet()) {
int vidx = e.getKey();
if (channelVerts.contains(vidx)) continue;
float target = e.getValue();
float curH = cachedHeightMap[vidx];
float delta = target - curH;
if (Math.abs(delta) < 0.01f) continue;
int vx = vidx % TOTAL_SIZE, vz = vidx / TOTAL_SIZE;
locs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f,
vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f));
deltas.add(delta);
cachedHeightMap[vidx] = target;
modifiedVertices.add(vidx);
}
if (!locs.isEmpty()) {
terrain.adjustHeight(locs, deltas);
terrain.updateModelBound();
}
}
/**
* Einzelnen Fluss graben (ohne Reset). Nur für isolierte Fälle —
* für Editor-Finalisierung/Löschen immer reapplyAllRivers() verwenden.
*/
public void carveRiver(List<RiverPoint> controlPts) {
if (terrain == null || cachedHeightMap == null || controlPts.size() < 2) return;
List<RiverPoint> splined = RiverSpline.subdivide(controlPts);
HashMap<Integer, Float> channelTargets = new HashMap<>();
HashMap<Integer, Float> bankTargets = new HashMap<>();
collectRiverTargets(splined, channelTargets, bankTargets);
applyTargets(channelTargets);
applyBankTargets(bankTargets, channelTargets.keySet());
if (splatR != null) paintRiverSplat(splined);
}
/**
* Malt Textur 4 (Kanal A) entlang des gesplineten Flusses.
*
* Für jeden Splatmap-Pixel im AABB des Flusses wird der minimale Abstand
* zur gesamten Pfadlinie berechnet — kein per-Segment-Clipping, daher
* folgt die Bemalung exakt dem Kurvenverlauf.
*
* Stärke maximal 70 % (0,3 × Gras bleibt immer), damit kein dunkler
* Schatten-Effekt entsteht wenn die Textur dunkler als Gras ist.
*/
private void paintRiverSplat(List<RiverPoint> splined) {
if (splined.size() < 2) return;
// Globales AABB über alle Punkte inkl. Clearing-Zone (+16 m über Bettkante)
// Die Clearing-Zone entfernt G/B-Reste älterer Algorithmen auch außerhalb der Bemalung.
final float CLEAR_EXTRA = 16f;
float globalMinX = Float.MAX_VALUE, globalMaxX = -Float.MAX_VALUE;
float globalMinZ = Float.MAX_VALUE, globalMaxZ = -Float.MAX_VALUE;
for (RiverPoint pt : splined) {
float hw = Math.max(MIN_HALF_WIDTH, pt.width() * 0.5f);
float pad = hw * (1f + 2f * BED_EXTRA) + CLEAR_EXTRA + SPLAT_WE_PER_PX;
if (pt.x() - pad < globalMinX) globalMinX = pt.x() - pad;
if (pt.x() + pad > globalMaxX) globalMaxX = pt.x() + pad;
if (pt.z() - pad < globalMinZ) globalMinZ = pt.z() - pad;
if (pt.z() + pad > globalMaxZ) globalMaxZ = pt.z() + pad;
}
int pxMin = Math.max(0, (int)((globalMinX + WORLD_HALF) / SPLAT_WE_PER_PX) - 1);
int pxMax = Math.min(SPLAT_SIZE-1, (int)((globalMaxX + WORLD_HALF) / SPLAT_WE_PER_PX) + 1);
int pzMin = Math.max(0, (SPLAT_SIZE-1) - (int)((globalMaxZ + WORLD_HALF) / SPLAT_WE_PER_PX) - 1);
int pzMax = Math.min(SPLAT_SIZE-1, (SPLAT_SIZE-1) - (int)((globalMinZ + WORLD_HALF) / SPLAT_WE_PER_PX) + 1);
boolean changed = false;
for (int pz = pzMin; pz <= pzMax; pz++) {
// Pixel-Position = Vertex-Raster (kein +0,5 Offset)
float worldZ = (SPLAT_SIZE - 1 - pz) * SPLAT_WE_PER_PX - WORLD_HALF;
for (int px = pxMin; px <= pxMax; px++) {
float worldX = px * SPLAT_WE_PER_PX - WORLD_HALF;
// Minimalen Abstand zur gesamten Pfadlinie bestimmen
float minDist = Float.MAX_VALUE;
float nearHalfW = MIN_HALF_WIDTH;
float nearBedHW = MIN_HALF_WIDTH * (1f + 2f * BED_EXTRA);
for (int si = 1; si < splined.size(); si++) {
RiverPoint pa = splined.get(si - 1);
RiverPoint pb = splined.get(si);
float ax = pa.x(), az = pa.z();
float segDx = pb.x() - ax, segDz = pb.z() - az;
float segLen2 = segDx*segDx + segDz*segDz;
if (segLen2 < 0.001f) continue;
float t = FastMath.clamp(((worldX-ax)*segDx + (worldZ-az)*segDz) / segLen2, 0f, 1f);
float projX = ax + t*segDx, projZ = az + t*segDz;
float d = FastMath.sqrt((worldX-projX)*(worldX-projX) + (worldZ-projZ)*(worldZ-projZ));
if (d < minDist) {
minDist = d;
float hw = Math.max(MIN_HALF_WIDTH, (pa.width() + pb.width()) * 0.25f);
nearHalfW = hw;
nearBedHW = hw * (1f + 2f * BED_EXTRA);
}
}
float paintHW = nearBedHW + SPLAT_WE_PER_PX;
float clearHW = nearBedHW + CLEAR_EXTRA;
if (minDist > clearHW) continue;
int sidx = pz * SPLAT_SIZE + px;
int bi = sidx * 4;
// Immer G/B in der gesamten Clearing-Zone nullen (entfernt Reste alter Algorithmen)
splatG[sidx] = 0;
splatB[sidx] = 0;
splatBuf.put(bi + 1, (byte) 0);
splatBuf.put(bi + 2, (byte) 0);
changed = true;
if (minDist > paintHW) continue; // nur räumen, nicht bemalen
float strength;
if (minDist <= nearHalfW) {
strength = 1.0f;
} else if (minDist <= nearBedHW) {
strength = 1.0f - (minDist - nearHalfW) / (nearBedHW - nearHalfW);
} else {
strength = (paintHW - minDist) / SPLAT_WE_PER_PX * 0.15f;
}
if (strength <= 0f) continue;
// Maximal 70 % River-Textur → immer 30 % Gras → kein Schatten-Effekt
float ps = Math.min(strength, 0.7f);
splatR[sidx] = (byte) Math.round((originalSplatR[sidx] & 0xFF) * (1f - ps));
splatA[sidx] = (byte) Math.round(ps * 255);
splatBuf.put(bi, splatR[sidx]);
splatBuf.put(bi + 3, splatA[sidx]);
}
}
if (changed) {
splatBuf.rewind();
splatImage.setUpdateNeeded();
}
}
/**
* Gräbt ein Flussbett zwischen zwei Wasseroberflächenpunkten A und B.
* Alle Terrain-Vertices innerhalb von halfWidth werden auf die linear
@@ -717,22 +1085,26 @@ public class TerrainEditorState extends BaseAppState {
if (segLen2 < 0.001f) return;
// Tiefe proportional zur Breite: 0,5m bei 4m Breite, 1,0m bei 10m Breite
float width = halfWidth * 2f;
float maxDepth = Math.max(0.5f, Math.min(1.0f, 0.5f + (width - 4f) / 12f));
// + 0,5m Wasserabsenkung (Wasseroberfläche sitzt 0,5m unter Kontrollpunkten)
float width = halfWidth * 2f;
float maxDepth = Math.max(0.5f, Math.min(1.0f, 0.5f + (width - 4f) / 12f));
float WATER_SINK = 0.5f;
float BANK_WIDTH = 1.5f; // Breite der Böschungszone außerhalb des Kanals
float BANK_HEIGHT = 0.5f; // Höhe der Böschung über Wasseroberfläche
// ── Terrain-Vertices graben ──────────────────────────────────────────
int vxMin = Math.max(0, (int)((Math.min(ax, bx) - halfWidth - 1) + TERRAIN_SIZE * 0.5f));
int vxMax = Math.min(TOTAL_SIZE - 1, (int)((Math.max(ax, bx) + halfWidth + 2) + TERRAIN_SIZE * 0.5f));
int vzMin = Math.max(0, (int)((Math.min(az, bz) - halfWidth - 1) + TERRAIN_SIZE * 0.5f));
int vzMax = Math.min(TOTAL_SIZE - 1, (int)((Math.max(az, bz) + halfWidth + 2) + TERRAIN_SIZE * 0.5f));
// Scan-Bereich inklusive Böschungszone erweitern
int vxMin = Math.max(0, (int)((Math.min(ax, bx) - halfWidth - BANK_WIDTH - 1 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
int vxMax = Math.min(TOTAL_SIZE - 1,(int)((Math.max(ax, bx) + halfWidth + BANK_WIDTH + 2 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
int vzMin = Math.max(0, (int)((Math.min(az, bz) - halfWidth - BANK_WIDTH - 1 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
int vzMax = Math.min(TOTAL_SIZE - 1,(int)((Math.max(az, bz) + halfWidth + BANK_WIDTH + 2 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
List<Vector2f> locs = new ArrayList<>();
List<Float> deltas = new ArrayList<>();
for (int vz = vzMin; vz <= vzMax; vz++) {
for (int vx = vxMin; vx <= vxMax; vx++) {
float worldX = vx - TERRAIN_SIZE * 0.5f;
float worldZ = vz - TERRAIN_SIZE * 0.5f;
float worldX = vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f;
float worldZ = vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f;
float t = ((worldX - ax) * segDx + (worldZ - az) * segDz) / segLen2;
t = FastMath.clamp(t, 0f, 1f);
@@ -740,21 +1112,31 @@ public class TerrainEditorState extends BaseAppState {
float projX = ax + t * segDx, projZ = az + t * segDz;
float dist = FastMath.sqrt((worldX - projX) * (worldX - projX)
+ (worldZ - projZ) * (worldZ - projZ));
if (dist > halfWidth) continue;
float waterY = ay + t * (by - ay);
// U-Form: 60% des Kanals als flacher Boden, danach linearer Anstieg
float norm = dist / halfWidth;
float uShape = 1.0f - FastMath.clamp((norm - 0.6f) / 0.4f, 0f, 1f);
float depth = maxDepth * uShape;
float target = waterY - depth;
int idx = vz * TOTAL_SIZE + vx;
float curH = cachedHeightMap[idx];
int idx = vz * TOTAL_SIZE + vx;
float curH = cachedHeightMap[idx];
if (curH > target) {
deltas.add(target - curH);
locs.add(new Vector2f(worldX, worldZ));
cachedHeightMap[idx] = target;
if (dist <= halfWidth) {
// ── Kanal graben (Wasseroberfläche + U-förmiges Bett) ──────
float norm = dist / halfWidth;
float uShape = 1.0f - FastMath.clamp((norm - 0.6f) / 0.4f, 0f, 1f);
float target = waterY - WATER_SINK - maxDepth * uShape;
if (curH > target) {
deltas.add(target - curH);
locs.add(new Vector2f(worldX, worldZ));
cachedHeightMap[idx] = target;
}
} else if (dist <= halfWidth + BANK_WIDTH) {
// ── Böschung aufschütten ──────────────────────────────────
// Quadratischer Abfall: volle Höhe am Kanalrand, 0 am Außenrand
float bankT = 1.0f - (dist - halfWidth) / BANK_WIDTH;
float target = waterY + BANK_HEIGHT * bankT * bankT;
if (curH < target) {
deltas.add(target - curH);
locs.add(new Vector2f(worldX, worldZ));
cachedHeightMap[idx] = target;
}
}
}
}
@@ -819,12 +1201,25 @@ public class TerrainEditorState extends BaseAppState {
try {
MapData data = new MapData();
if (cachedHeightMap != null) {
System.arraycopy(cachedHeightMap, 0, data.terrainHeight, 0,
Math.min(cachedHeightMap.length, data.terrainHeight.length));
// Post-River-Terrain speichern: Das Spiel erhält korrekt eingegrabene Flussbetten.
// originalHeightMap bleibt in-session als Rücksetz-Basis erhalten.
float[] saveHeight = (cachedHeightMap != null) ? cachedHeightMap : originalHeightMap;
if (saveHeight != null) {
if (saveHeight.length == data.terrainHeight.length) {
System.arraycopy(saveHeight, 0, data.terrainHeight, 0, saveHeight.length);
} else {
upsampleHeights(saveHeight, TOTAL_SIZE, data.terrainHeight, MapData.TERRAIN_VERTS);
}
}
// Uferkante mit 0,25 m Präzision exakt auf Wasseroberfläche setzen
if (riverEditorState != null) {
applyHighResBankLeveling(data.terrainHeight, riverEditorState.getPlacedRivers());
}
if (splatR != null) {
// Bemalte Splatmap speichern (mit Fluss-Textur) → Spiel zeigt Fluss-Textur korrekt.
// Beim nächsten Laden strippt initSplatmap() die Fluss-Farbe für originalSplatX,
// sodass reapplyAllRivers ohne doppeltes Malen neu aufträgt.
System.arraycopy(splatR, 0, data.splatR, 0, data.splatR.length);
System.arraycopy(splatG, 0, data.splatG, 0, data.splatG.length);
System.arraycopy(splatB, 0, data.splatB, 0, data.splatB.length);
@@ -941,8 +1336,8 @@ public class TerrainEditorState extends BaseAppState {
if (cachedHeightMap == null) return;
for (int i = 0; i < locs.size(); i++) {
Vector2f loc = locs.get(i);
int vx = Math.round(loc.x + TERRAIN_SIZE * 0.5f);
int vz = Math.round(loc.y + TERRAIN_SIZE * 0.5f);
int vx = Math.round((loc.x + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING);
int vz = Math.round((loc.y + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING);
if (vx >= 0 && vx < TOTAL_SIZE && vz >= 0 && vz < TOTAL_SIZE)
cachedHeightMap[vz * TOTAL_SIZE + vx] += deltas.get(i);
}
@@ -1000,9 +1395,9 @@ public class TerrainEditorState extends BaseAppState {
private void modifyHeight(Vector3f worldContact, float delta, int mode) {
float radius = (float) input.heightTool.brushRadius.getValue();
int r = (int) Math.ceil(radius);
int cx = Math.round(worldContact.x + TERRAIN_SIZE * 0.5f);
int cz = Math.round(worldContact.z + TERRAIN_SIZE * 0.5f);
int r = (int) Math.ceil(radius / VERTEX_SPACING);
int cx = Math.round((worldContact.x + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING);
int cz = Math.round((worldContact.z + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING);
List<Vector2f> locs = new ArrayList<>();
List<Float> deltas = new ArrayList<>();
@@ -1012,7 +1407,7 @@ public class TerrainEditorState extends BaseAppState {
int vx = cx + dx, vz = cz + dz;
if (vx < 0 || vx >= TOTAL_SIZE || vz < 0 || vz >= TOTAL_SIZE) continue;
float dist = FastMath.sqrt(dx * dx + dz * dz);
float dist = FastMath.sqrt(dx * dx + dz * dz) * VERTEX_SPACING;
if (dist >= radius) continue;
float t = dist / radius;
@@ -1035,7 +1430,8 @@ public class TerrainEditorState extends BaseAppState {
}
}
locs.add(new Vector2f(vx - TERRAIN_SIZE * 0.5f, vz - TERRAIN_SIZE * 0.5f));
locs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f,
vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f));
deltas.add(delta * falloff);
}
}
@@ -1051,9 +1447,9 @@ public class TerrainEditorState extends BaseAppState {
private void smoothHeight(Vector3f worldContact) {
float radius = (float) input.heightTool.brushRadius.getValue();
float strength = (float) input.heightTool.brushStrength.getValue();
int r = (int) Math.ceil(radius);
int cx = Math.round(worldContact.x + TERRAIN_SIZE * 0.5f);
int cz = Math.round(worldContact.z + TERRAIN_SIZE * 0.5f);
int r = (int) Math.ceil(radius / VERTEX_SPACING);
int cx = Math.round((worldContact.x + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING);
int cz = Math.round((worldContact.z + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING);
if (cachedHeightMap == null) return;
float[] hmap = cachedHeightMap;
@@ -1066,12 +1462,13 @@ public class TerrainEditorState extends BaseAppState {
for (int dx = -r; dx <= r; dx++) {
int vx = cx + dx, vz = cz + dz;
if (vx < 0 || vx >= TOTAL_SIZE || vz < 0 || vz >= TOTAL_SIZE) continue;
float dist = FastMath.sqrt(dx * dx + dz * dz);
float dist = FastMath.sqrt(dx * dx + dz * dz) * VERTEX_SPACING;
if (dist >= radius) continue;
int idx = vz * TOTAL_SIZE + vx;
float h = hmap[idx];
if (!Float.isFinite(h)) continue;
locs.add(new Vector2f(vx - TERRAIN_SIZE * 0.5f, vz - TERRAIN_SIZE * 0.5f));
locs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f,
vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f));
heights.add(h);
dists.add(dist);
}
@@ -1100,8 +1497,8 @@ public class TerrainEditorState extends BaseAppState {
/** Liest die Terrain-Höhe am nächstgelegenen Vertex zum Kontaktpunkt. */
private float sampleTerrainHeight(Vector3f worldContact) {
if (cachedHeightMap == null) return Float.NaN;
int cx = Math.round(worldContact.x + TERRAIN_SIZE * 0.5f);
int cz = Math.round(worldContact.z + TERRAIN_SIZE * 0.5f);
int cx = Math.round((worldContact.x + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING);
int cz = Math.round((worldContact.z + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING);
if (cx < 0 || cx >= TOTAL_SIZE || cz < 0 || cz >= TOTAL_SIZE) return Float.NaN;
return cachedHeightMap[cz * TOTAL_SIZE + cx];
}
@@ -1111,9 +1508,9 @@ public class TerrainEditorState extends BaseAppState {
float radius = (float) input.heightTool.brushRadius.getValue();
float strength = (float) input.heightTool.brushStrength.getValue();
float target = (float) input.heightTool.plateauHeight.getValue();
int r = (int) Math.ceil(radius);
int cx = Math.round(worldContact.x + TERRAIN_SIZE * 0.5f);
int cz = Math.round(worldContact.z + TERRAIN_SIZE * 0.5f);
int r = (int) Math.ceil(radius / VERTEX_SPACING);
int cx = Math.round((worldContact.x + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING);
int cz = Math.round((worldContact.z + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING);
if (cachedHeightMap == null) return;
@@ -1124,7 +1521,7 @@ public class TerrainEditorState extends BaseAppState {
for (int dx = -r; dx <= r; dx++) {
int vx = cx + dx, vz = cz + dz;
if (vx < 0 || vx >= TOTAL_SIZE || vz < 0 || vz >= TOTAL_SIZE) continue;
float dist = FastMath.sqrt(dx * dx + dz * dz);
float dist = FastMath.sqrt(dx * dx + dz * dz) * VERTEX_SPACING;
if (dist >= radius) continue;
float curH = cachedHeightMap[vz * TOTAL_SIZE + vx];
@@ -1137,7 +1534,8 @@ public class TerrainEditorState extends BaseAppState {
: 1f - FastMath.pow((t - edge) / (1f - edge), 2f);
float blend = FastMath.clamp(falloff * (strength / 50f), 0f, 1f);
locs.add(new Vector2f(vx - TERRAIN_SIZE * 0.5f, vz - TERRAIN_SIZE * 0.5f));
locs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f,
vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f));
deltas.add((target - curH) * blend);
}
}
@@ -1402,4 +1800,103 @@ public class TerrainEditorState extends BaseAppState {
livePlayerMarker.setLocalTranslation(x, input.livePlayerY + 0.9f, input.livePlayerZ);
}
}
// Nearest-Neighbor-Downsampling (src 16385 → dst 4097)
private static void downsampleHeights(float[] src, int srcSize, float[] dst, int dstSize) {
float step = (float)(srcSize - 1) / (dstSize - 1);
for (int dz = 0; dz < dstSize; dz++) {
int sz = Math.round(dz * step);
for (int dx = 0; dx < dstSize; dx++) {
dst[dz * dstSize + dx] = src[sz * srcSize + Math.round(dx * step)];
}
}
}
// Bilinear-Upsampling (src 4097 → dst 16385)
private static void upsampleHeights(float[] src, int srcSize, float[] dst, int dstSize) {
float scale = (float)(srcSize - 1) / (dstSize - 1);
for (int dz = 0; dz < dstSize; dz++) {
float sz = dz * scale;
int sz0 = Math.min((int)sz, srcSize - 2), sz1 = sz0 + 1;
float fz = sz - sz0;
for (int dx = 0; dx < dstSize; dx++) {
float sx = dx * scale;
int sx0 = Math.min((int)sx, srcSize - 2), sx1 = sx0 + 1;
float fx = sx - sx0;
dst[dz * dstSize + dx] =
(src[sz0 * srcSize + sx0] * (1 - fx) + src[sz0 * srcSize + sx1] * fx) * (1 - fz)
+ (src[sz1 * srcSize + sx0] * (1 - fx) + src[sz1 * srcSize + sx1] * fx) * fz;
}
}
}
/**
* Setzt Uferkanten-Vertices (bedHW < dist ≤ paintHW) in der 16385²-Heightmap
* exakt auf die Wasseroberfläche (0,25 m Präzision).
* Wird nach dem Hochskalieren in performSave() aufgerufen.
*/
private static void applyHighResBankLeveling(float[] heights, List<List<RiverPoint>> rivers) {
if (heights == null || rivers == null || rivers.isEmpty()) return;
final int HR_VERTS = MapData.TERRAIN_VERTS; // 16385
final float HR_SPACING = 4096f / (HR_VERTS - 1); // 0.25 m/Vertex
final float HR_HALF = 2048f;
for (List<RiverPoint> controlPts : rivers) {
if (controlPts == null || controlPts.size() < 2) continue;
List<RiverPoint> splined = RiverSpline.subdivide(controlPts);
if (splined.size() < 2) continue;
// AABB (inkl. paintHW-Rand)
float minX = Float.MAX_VALUE, maxX = -Float.MAX_VALUE;
float minZ = Float.MAX_VALUE, maxZ = -Float.MAX_VALUE;
for (RiverPoint pt : splined) {
float hw = Math.max(MIN_HALF_WIDTH, pt.width() * 0.5f);
float pad = hw * (1f + 2f * BED_EXTRA) + SPLAT_WE_PER_PX + 1f;
if (pt.x() - pad < minX) minX = pt.x() - pad;
if (pt.x() + pad > maxX) maxX = pt.x() + pad;
if (pt.z() - pad < minZ) minZ = pt.z() - pad;
if (pt.z() + pad > maxZ) maxZ = pt.z() + pad;
}
int vxMin = Math.max(0, (int)((minX + HR_HALF) / HR_SPACING));
int vxMax = Math.min(HR_VERTS-1, (int)((maxX + HR_HALF) / HR_SPACING) + 1);
int vzMin = Math.max(0, (int)((minZ + HR_HALF) / HR_SPACING));
int vzMax = Math.min(HR_VERTS-1, (int)((maxZ + HR_HALF) / HR_SPACING) + 1);
for (int vz = vzMin; vz <= vzMax; vz++) {
for (int vx = vxMin; vx <= vxMax; vx++) {
float worldX = vx * HR_SPACING - HR_HALF;
float worldZ = vz * HR_SPACING - HR_HALF;
float minDist = Float.MAX_VALUE;
float bestWaterY = 0f;
float bestBedHW = 0f;
float bestPaintHW = 0f;
for (int si = 1; si < splined.size(); si++) {
RiverPoint pa = splined.get(si - 1);
RiverPoint pb = splined.get(si);
float ax = pa.x(), ay = pa.y(), az = pa.z();
float bx = pb.x(), by = pb.y(), bz = pb.z();
float segDx = bx - ax, segDz = bz - az;
float segLen2 = segDx*segDx + segDz*segDz;
if (segLen2 < 0.001f) continue;
float t = FastMath.clamp(((worldX-ax)*segDx + (worldZ-az)*segDz) / segLen2, 0f, 1f);
float projX = ax + t*segDx, projZ = az + t*segDz;
float d = FastMath.sqrt((worldX-projX)*(worldX-projX) + (worldZ-projZ)*(worldZ-projZ));
if (d < minDist) {
minDist = d;
float hw = Math.max(MIN_HALF_WIDTH, (pa.width() + pb.width()) * 0.25f);
bestBedHW = hw * (1f + 2f * BED_EXTRA);
bestPaintHW = bestBedHW + SPLAT_WE_PER_PX;
bestWaterY = ay + t * (by - ay);
}
}
if (minDist > bestBedHW && minDist <= bestPaintHW) {
heights[vz * HR_VERTS + vx] = bestWaterY - WATER_SINK;
}
}
}
}
}
}