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

@@ -2,11 +2,13 @@ MaterialDef Flowing Water {
MaterialParameters {
Texture2D NormalMap
Texture2D FoamMap
Texture2D DiffuseMap
Color Tint
Float UVScale : 6.0
Float Time : 0.0
Float FlowSpeed : 1.0
Float FoamAmount : 0.0
Float UVScale : 6.0
Float NormalUVScale : 0.5
Float Time : 0.0
Float FlowSpeed : 1.0
Float FoamAmount : 0.0
}
Technique {
VertexShader GLSL150: Shaders/FlowingWater.vert
@@ -15,8 +17,9 @@ MaterialDef Flowing Water {
WorldViewProjectionMatrix
}
Defines {
HAS_NORMALMAP : NormalMap
HAS_FOAMMAP : FoamMap
HAS_NORMALMAP : NormalMap
HAS_FOAMMAP : FoamMap
HAS_DIFFUSEMAP : DiffuseMap
}
}
}

View File

@@ -4,6 +4,9 @@ uniform sampler2D m_NormalMap;
#ifdef HAS_FOAMMAP
uniform sampler2D m_FoamMap;
#endif
#ifdef HAS_DIFFUSEMAP
uniform sampler2D m_DiffuseMap;
#endif
uniform vec4 m_Tint;
uniform float m_FoamAmount;
@@ -27,15 +30,18 @@ void main() {
float specular = 0.0;
float ripple = 0.0;
#ifdef HAS_NORMALMAP
vec3 n1 = texture(m_NormalMap, vTex1).rgb * 2.0 - 1.0;
vec3 n2 = texture(m_NormalMap, vTex2).rgb * 2.0 - 1.0;
vec3 raw1 = texture(m_NormalMap, vTex1).rgb * 2.0 - 1.0;
vec3 raw2 = texture(m_NormalMap, vTex2).rgb * 2.0 - 1.0;
// Tangent-Space → World-Space für horizontales Mesh (T=X, B=Z, N=Y):
// raw.xyz = (tangent-X, tangent-Y, depth) → world = (raw.x, raw.z, raw.y)
vec3 n1 = normalize(vec3(raw1.x, raw1.z, raw1.y));
vec3 n2 = normalize(vec3(raw2.x, raw2.z, raw2.y));
vec3 n = normalize(n1 + n2);
// Feste Sonnenrichtung gut für Bachlauf-Optik
vec3 sunDir = normalize(vec3(0.5, 1.0, 0.3));
specular = pow(max(0.0, dot(n, sunDir)), 22.0);
// Wellenhelligkeit variiert den Basiston leicht
ripple = dot(n, vec3(0.0, 1.0, 0.0)) * 0.10;
specular = pow(max(0.0, dot(n, sunDir)), 8.0);
// Ripple: Bereiche mit starker Oberflächenneigung (Wellenkämme) heller
ripple = (abs(n.x) + abs(n.z)) * 0.30;
#endif
// ── Schaum ────────────────────────────────────────────────────────────────
@@ -54,9 +60,26 @@ void main() {
#endif
// ── Zusammenführen ────────────────────────────────────────────────────────
vec3 waterColor = baseColor + ripple + vec3(0.35, 0.45, 0.55) * specular;
// specular-Anteil erhöht (0.55→0.75) damit Glanzlichter deutlich sichtbar sind
vec3 waterColor = baseColor + ripple + vec3(0.55, 0.65, 0.75) * specular;
vec3 finalColor = mix(waterColor, vec3(1.0), foamMask);
float finalAlpha = max(baseAlpha, foamMask * 0.9);
// ── Diffuse-Map: Fluss = Farbmodulation in Fließrichtung, Wasserfall = Gischt ──
#ifdef HAS_DIFFUSEMAP
float d1 = texture(m_DiffuseMap, vTex1 * 0.55).r;
float d2 = texture(m_DiffuseMap, vTex2 * 0.40).r;
float diffuseBlend = max(d1, d2 * 0.6);
// Fluss (FoamAmount=0): Textur moduliert die Wasserfarbe (dunkle/helle Strömungsmuster)
float riverMod = (diffuseBlend - 0.5) * 0.4 * (1.0 - m_FoamAmount);
finalColor = clamp(finalColor + riverMod, 0.0, 1.0);
// Wasserfall (FoamAmount>0): weiße Gischt-Überlagerung
float foamStr = diffuseBlend * m_FoamAmount * 0.85;
finalColor = mix(finalColor, vec3(1.0), foamStr);
finalAlpha = max(finalAlpha, foamStr * 0.9);
#endif
outFragColor = vec4(clamp(finalColor, 0.0, 1.0), finalAlpha);
}

View File

@@ -1,6 +1,7 @@
uniform mat4 g_WorldViewProjectionMatrix;
uniform float m_Time;
uniform float m_UVScale;
uniform float m_NormalUVScale;
uniform float m_FlowSpeed;
in vec3 inPosition;
@@ -12,10 +13,11 @@ out vec2 vTex2; // scrollende Normal-Map-UV (diagonal, gegenläufig)
void main() {
vUV = inTexCoord;
float t = m_Time * m_FlowSpeed;
vTex1 = vec2(inTexCoord.x * m_UVScale,
inTexCoord.y * m_UVScale - t);
vTex2 = vec2(inTexCoord.x * m_UVScale * 0.7 - t * 0.25,
inTexCoord.y * m_UVScale * 0.7 + t * 0.55);
float ns = m_UVScale * m_NormalUVScale; // Normal-Map UV-Dichte (0.5 = 2x größeres Muster)
float t = m_Time * m_FlowSpeed * m_NormalUVScale; // Scroll-Geschwindigkeit skaliert mit
vTex1 = vec2(inTexCoord.x * ns,
inTexCoord.y * ns - t);
vTex2 = vec2(inTexCoord.x * ns * 0.7 - t * 0.25,
inTexCoord.y * ns * 0.7 + t * 0.55);
gl_Position = g_WorldViewProjectionMatrix * vec4(inPosition, 1.0);
}

View File

@@ -3,10 +3,10 @@ package de.blight.common;
/**
* Serialisierbarer Zustand einer Blight-Weltkarte.
*
* Basis-Terrain : 4097 × 4097 Vertices (= 4096 × 4096 Zellen),
* 8 Welteinheiten pro Zelle → Welt 2048 .. +2048.
* Basis-Terrain : 16385 × 16385 Vertices (= 16384 × 16384 Zellen, 0,25 m/Vertex),
* Welt 2048 .. +2048 m.
* Obere Schicht : 513 × 513 Vertices (= 512 × 512 Zellen), gleiche Weltausdehnung.
* Splatmap : 513 × 513 Pixel (1:1 zu beiden Terrain-Grids).
* Splatmap : 2049 × 2049 Pixel (≈ 2 m/Pixel).
* Kanäle R/G/B/A = Gewicht für Tex1-Helligkeit / Tex2 / Tex3 / Tex4.
*/
public final class MapData {
@@ -14,7 +14,7 @@ public final class MapData {
// ── Terrain-Konstanten ────────────────────────────────────────────────────
/** Vertices pro Achse des Basis-Terrains (muss 2^n + 1 sein). */
public static final int TERRAIN_VERTS = 4097;
public static final int TERRAIN_VERTS = 16385;
// ── Upper-Layer-Konstanten ────────────────────────────────────────────────
@@ -26,8 +26,8 @@ public final class MapData {
// ── Splatmap-Konstanten ────────────────────────────────────────────────────
/** Pixel pro Achse der Splatmap (entspricht UPPER_VERTS = Spiel-Terrain-Auflösung). */
public static final int SPLAT_SIZE = 513;
/** Pixel pro Achse der Splatmap (≈ 2 m/Pixel bei 4096 m Weltkante). */
public static final int SPLAT_SIZE = 2049;
/** Anzahl konfigurierbarer Textur-Slots pro Terrain-Layer. */
public static final int TEXTURE_SLOTS = 4;

View File

@@ -22,6 +22,8 @@ import java.util.zip.*;
* 7 wie 6 + Gras-Texturpfad (UTF-String) + Gras-Standardhöhe (float)
* 8 wie 7 + Gras-Höhen-Map (SPLAT_SIZE² Bytes, 0=ungesetzt 1-255=0.1-10m)
* 9 wie 8 + Gras-Textur-Map (SPLAT_SIZE² Bytes) + Slots (N×UTF)
* 10 wie 9, aber TERRAIN_VERTS=16385 (0,25 m/Vertex) + SPLAT_SIZE=2049 (2 m/Pixel)
* Altes Format (v≤9) wird beim Laden bilinear hochskaliert.
*/
public final class MapIO {
@@ -53,7 +55,11 @@ public final class MapIO {
}
private static final int MAGIC = 0x424C4947; // "BLIG"
private static final int VERSION = 9;
private static final int VERSION = 10;
// Größen älterer Saves (v≤9) für Migrations-Upsampling
private static final int OLD_TERRAIN_VERTS = 4097;
private static final int OLD_SPLAT_SIZE = 513;
private MapIO() {}
@@ -134,36 +140,76 @@ public final class MapIO {
if (version < 1 || version > VERSION)
throw new IOException("Unbekannte Map-Version: " + version);
readFloats(in, data.terrainHeight);
readFloats(in, data.upperTop);
readFloats(in, data.upperBottom);
if (version >= 10) {
readFloats(in, data.terrainHeight);
readFloats(in, data.upperTop);
readFloats(in, data.upperBottom);
} else {
// v≤9: alte Größen lesen und bilinear hochskalieren
float[] oldH = new float[OLD_TERRAIN_VERTS * OLD_TERRAIN_VERTS];
float[] oldUT = new float[MapData.UPPER_VERTS * MapData.UPPER_VERTS];
float[] oldUB = new float[MapData.UPPER_VERTS * MapData.UPPER_VERTS];
readFloats(in, oldH);
upsampleFloats(oldH, OLD_TERRAIN_VERTS, data.terrainHeight, MapData.TERRAIN_VERTS);
readFloats(in, oldUT); System.arraycopy(oldUT, 0, data.upperTop, 0, oldUT.length);
readFloats(in, oldUB); System.arraycopy(oldUB, 0, data.upperBottom, 0, oldUB.length);
}
if (version <= 5) {
// v5 had upperHole[UPPER_CELLS²]; read and discard
in.skip((long) MapData.UPPER_CELLS * MapData.UPPER_CELLS);
}
if (version >= 2) {
in.readFully(data.splatR);
in.readFully(data.splatG);
in.readFully(data.splatB);
if (version >= 10) {
in.readFully(data.splatR); in.readFully(data.splatG); in.readFully(data.splatB);
} else {
byte[] oR = new byte[OLD_SPLAT_SIZE*OLD_SPLAT_SIZE];
byte[] oG = new byte[OLD_SPLAT_SIZE*OLD_SPLAT_SIZE];
byte[] oB = new byte[OLD_SPLAT_SIZE*OLD_SPLAT_SIZE];
in.readFully(oR); in.readFully(oG); in.readFully(oB);
upsampleBytes(oR, OLD_SPLAT_SIZE, data.splatR, MapData.SPLAT_SIZE);
upsampleBytes(oG, OLD_SPLAT_SIZE, data.splatG, MapData.SPLAT_SIZE);
upsampleBytes(oB, OLD_SPLAT_SIZE, data.splatB, MapData.SPLAT_SIZE);
}
}
if (version >= 3) {
in.readFully(data.grassDensity);
if (version >= 10) {
in.readFully(data.grassDensity);
} else {
byte[] old = new byte[OLD_SPLAT_SIZE*OLD_SPLAT_SIZE];
in.readFully(old);
upsampleBytes(old, OLD_SPLAT_SIZE, data.grassDensity, MapData.SPLAT_SIZE);
}
}
if (version >= 4) {
data.spawnX = in.readFloat();
data.spawnZ = in.readFloat();
}
if (version >= 5) {
in.readFully(data.splatA);
readStrings(in, data.terrainTextures);
in.readFully(data.upperSplatR);
in.readFully(data.upperSplatG);
in.readFully(data.upperSplatB);
in.readFully(data.upperSplatA);
readStrings(in, data.upperTextures);
if (version >= 10) {
in.readFully(data.splatA);
readStrings(in, data.terrainTextures);
in.readFully(data.upperSplatR); in.readFully(data.upperSplatG);
in.readFully(data.upperSplatB); in.readFully(data.upperSplatA);
readStrings(in, data.upperTextures);
} else {
byte[] oA = new byte[OLD_SPLAT_SIZE*OLD_SPLAT_SIZE];
byte[] oUR = new byte[OLD_SPLAT_SIZE*OLD_SPLAT_SIZE];
byte[] oUG = new byte[OLD_SPLAT_SIZE*OLD_SPLAT_SIZE];
byte[] oUB = new byte[OLD_SPLAT_SIZE*OLD_SPLAT_SIZE];
byte[] oUA = new byte[OLD_SPLAT_SIZE*OLD_SPLAT_SIZE];
in.readFully(oA);
readStrings(in, data.terrainTextures);
in.readFully(oUR); in.readFully(oUG); in.readFully(oUB); in.readFully(oUA);
readStrings(in, data.upperTextures);
upsampleBytes(oA, OLD_SPLAT_SIZE, data.splatA, MapData.SPLAT_SIZE);
upsampleBytes(oUR, OLD_SPLAT_SIZE, data.upperSplatR, MapData.SPLAT_SIZE);
upsampleBytes(oUG, OLD_SPLAT_SIZE, data.upperSplatG, MapData.SPLAT_SIZE);
upsampleBytes(oUB, OLD_SPLAT_SIZE, data.upperSplatB, MapData.SPLAT_SIZE);
upsampleBytes(oUA, OLD_SPLAT_SIZE, data.upperSplatA, MapData.SPLAT_SIZE);
}
} else {
// Ältere Maps: upperSplatR auf 255 initialisieren (Tex1 immer sichtbar)
java.util.Arrays.fill(data.upperSplatR, (byte) 255);
}
if (version >= 7) {
@@ -171,13 +217,25 @@ public final class MapIO {
data.grassDefaultHeight = in.readFloat();
}
if (version >= 8) {
in.readFully(data.grassHeightMap);
if (version >= 10) {
in.readFully(data.grassHeightMap);
} else {
byte[] old = new byte[OLD_SPLAT_SIZE*OLD_SPLAT_SIZE];
in.readFully(old);
upsampleBytes(old, OLD_SPLAT_SIZE, data.grassHeightMap, MapData.SPLAT_SIZE);
}
}
if (version >= 9) {
int n = in.readInt();
data.grassTextureSlots = new String[n];
for (int i = 0; i < n; i++) data.grassTextureSlots[i] = in.readUTF();
in.readFully(data.grassTextureMap);
if (version >= 10) {
in.readFully(data.grassTextureMap);
} else {
byte[] old = new byte[OLD_SPLAT_SIZE*OLD_SPLAT_SIZE];
in.readFully(old);
upsampleBytes(old, OLD_SPLAT_SIZE, data.grassTextureMap, MapData.SPLAT_SIZE);
}
}
}
return data;
@@ -207,4 +265,39 @@ public final class MapIO {
int len = in.readInt();
for (int i = 0; i < len && i < arr.length; i++) arr[i] = in.readUTF();
}
private static void upsampleFloats(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;
}
}
}
private static void upsampleBytes(byte[] src, int srcSize, byte[] 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;
float v =
((src[sz0*srcSize+sx0]&0xFF)*(1-fx) + (src[sz0*srcSize+sx1]&0xFF)*fx)*(1-fz)
+ ((src[sz1*srcSize+sx0]&0xFF)*(1-fx) + (src[sz1*srcSize+sx1]&0xFF)*fx)*fz;
dst[dz*dstSize+dx] = (byte)Math.round(v);
}
}
}
}

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;
}
}
}
}
}
}

View File

@@ -324,14 +324,14 @@ public class WorldScene extends BaseAppState {
/**
* Erstellt ein Terrain aus der gespeicherten {@link MapData}.
* Die 4097×4097 Editor-Daten werden auf 513×513 heruntergesampelt
* (jeder 8. Vertex), mit Scale (8, 1, 8) auf die gleiche Weltgröße
* Die 16385×16385 Editor-Daten werden auf 513×513 heruntergesampelt
* (jeder 32. Vertex), mit Scale (8, 1, 8) auf die gleiche Weltgröße
* 4096 × 4096 WE gebracht.
*/
private TerrainQuad buildTerrainFromMap(MapData map) {
final int GAME_VERTS = 513; // 512 Zellen à 8 WE = 4096 WE
final int STEP = 8; // 4096 / 512 = 8 Vertices überspringen
final int SRC_VERTS = MapData.TERRAIN_VERTS; // 4097
final int STEP = (MapData.TERRAIN_VERTS - 1) / (GAME_VERTS - 1); // 32
final int SRC_VERTS = MapData.TERRAIN_VERTS; // 16385
float[] heights = new float[GAME_VERTS * GAME_VERTS];
for (int gz = 0; gz < GAME_VERTS; gz++) {

View File

@@ -40,7 +40,8 @@ import java.util.Random;
public class RiverState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(RiverState.class);
private static final float UV_SCALE = 6.0f;
private static final float UV_SCALE = 6.0f;
private static final float WATER_SINK = 0.5f; // Wasser liegt 0,5m unter den Kontrollpunkten
private Node riverNode;
private AssetManager assets;
@@ -118,7 +119,7 @@ public class RiverState extends BaseAppState {
List<RiverPoint> run = RiverSpline.subdivide(pts.subList(i, j + 1));
if (run.size() >= 2) {
buildRibbonSection(run, wf);
if (wf) buildWaterfallParticles(run.get(run.size() - 1));
if (wf) buildWaterfallParticles(run.get(run.size() - 1), computeFlowDir(run));
}
i = j;
}
@@ -175,7 +176,7 @@ public class RiverState 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(), pz = pt.z();
float px = pt.x(), py = pt.y() - WATER_SINK, pz = pt.z();
float vCoord = arcLen[i] / UV_SCALE;
// Linker Rand (U=0), rechter Rand (U=1)
@@ -208,28 +209,44 @@ public class RiverState extends BaseAppState {
private Material buildMaterial(boolean isWaterfall) {
ColorRGBA tint = isWaterfall
? new ColorRGBA(0.65f, 0.82f, 0.95f, 0.80f)
: new ColorRGBA(0.10f, 0.30f, 0.62f, 0.85f);
? new ColorRGBA(0.65f, 0.82f, 0.95f, 0.75f)
: new ColorRGBA(0.10f, 0.30f, 0.62f, 0.68f);
Material mat;
try {
mat = new Material(assets, "MatDefs/FlowingWater.j3md");
try {
Texture nm = assets.loadTexture(
"Common/MatDefs/Water/Textures/water_normalmap.png");
// Normal-Map: erst eigene Texturen versuchen, dann JME-Fallback
Texture nm = loadTextureOr(
isWaterfall ? "Textures/water/waterfall_normal.png"
: "Textures/water/river_normal.jpg",
"Common/MatDefs/Water/Textures/water_normalmap.png");
if (nm != null) {
nm.setWrap(Texture.WrapMode.Repeat);
mat.setTexture("NormalMap", nm);
} catch (Exception e) {
log.warn("Normal-Map nicht ladbar, wird ohne Wellenstruktur gerendert");
} else {
log.warn("Keine Normal-Map geladen Mesh ohne Wellenstruktur");
}
if (foamTexture != null) {
mat.setTexture("FoamMap", foamTexture);
}
mat.setColor("Tint", tint);
mat.setFloat("UVScale", UV_SCALE);
mat.setFloat("FlowSpeed", isWaterfall ? RiverPoint.WATERFALL_SPEED
: RiverPoint.RIVER_SPEED);
mat.setFloat("FoamAmount", isWaterfall ? 1.0f : 0.0f);
// Diffuse-Map: river.jpg für Fluss (Farbmodulation), waterfall_diffuse für Gischt
String diffPath = isWaterfall ? "Textures/water/waterfall_diffuse.png"
: "Textures/water/river.jpg";
Texture diff = loadTextureOr(diffPath, null);
if (diff != null) {
diff.setWrap(Texture.WrapMode.Repeat);
mat.setTexture("DiffuseMap", diff);
}
mat.setColor("Tint", tint);
mat.setFloat("UVScale", UV_SCALE);
mat.setFloat("NormalUVScale", 0.5f); // 2x größeres Wellenmuster
mat.setFloat("FlowSpeed", isWaterfall ? RiverPoint.WATERFALL_SPEED
: RiverPoint.RIVER_SPEED);
mat.setFloat("FoamAmount", isWaterfall ? 1.0f : 0.0f);
} catch (Exception e) {
log.warn("FlowingWater-Material nicht ladbar, Fallback auf Unshaded", e);
mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
@@ -242,6 +259,20 @@ public class RiverState extends BaseAppState {
return mat;
}
/** Versucht primaryPath zu laden; schlägt das fehl, wird fallbackPath versucht (null = kein Fallback). */
private Texture loadTextureOr(String primaryPath, String fallbackPath) {
try {
return assets.loadTexture(primaryPath);
} catch (Exception ignored) {}
if (fallbackPath == null) return null;
try {
return assets.loadTexture(fallbackPath);
} catch (Exception e) {
log.warn("Textur nicht ladbar: {} und {}", primaryPath, fallbackPath);
return null;
}
}
// ── Worley-Noise Schaum-Textur ────────────────────────────────────────────
private Texture2D generateFoamTexture() {
@@ -286,29 +317,58 @@ public class RiverState extends BaseAppState {
// ── Partikel-Emitter ──────────────────────────────────────────────────────
private void buildWaterfallParticles(RiverPoint base) {
/**
* Gischt-Partikel am Fuß des Wasserfalls.
* Die Spray-Richtung ist das Spiegelbild der horizontalen Fließrichtung
* (Wasser prallt auf und spritzt zurück + hoch).
*/
private void buildWaterfallParticles(RiverPoint base, Vector3f flowDir) {
ParticleEmitter emitter = new ParticleEmitter(
"waterfall_particles", ParticleMesh.Type.Triangle, 30);
"waterfall_particles", ParticleMesh.Type.Triangle, 60);
Material pMat = new Material(assets, "Common/MatDefs/Misc/Particle.j3md");
try {
pMat.setTexture("Texture", assets.loadTexture("Effects/Smoke/Smoke.png"));
} catch (Exception e) {
log.warn("Partikel-Textur nicht ladbar", e);
}
Texture pTex = loadTextureOr("Textures/Water/spray.png", "Effects/Smoke/Smoke.png");
if (pTex != null) pMat.setTexture("Texture", pTex);
pMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.AlphaAdditive);
emitter.setMaterial(pMat);
emitter.setImagesX(1);
emitter.setImagesY(1);
emitter.setStartColor(new ColorRGBA(1f, 1f, 1f, 0.5f));
emitter.setEndColor(new ColorRGBA(1f, 1f, 1f, 0f));
emitter.setStartSize(1.2f);
emitter.setEndSize(2.5f);
emitter.setGravity(0f, -0.5f, 0f);
emitter.setLowLife(0.8f);
emitter.setHighLife(1.2f);
emitter.setInitialVelocity(new Vector3f(0f, 3f, 0f));
emitter.setVelocityVariation(0.6f);
emitter.setParticlesPerSec(15);
// Hellweiße, leicht bläuliche Gischt startet fast opak, verblasst komplett
emitter.setStartColor(new ColorRGBA(0.90f, 0.95f, 1.00f, 0.55f));
emitter.setEndColor (new ColorRGBA(1.00f, 1.00f, 1.00f, 0.00f));
// Partikel starten klein, werden als Nebelwolke größer
emitter.setStartSize(0.4f);
emitter.setEndSize (2.8f);
// Leichte Schwerkraft damit Gischt wieder fällt
emitter.setGravity(0f, -1.2f, 0f);
emitter.setLowLife (0.7f);
emitter.setHighLife(1.6f);
// Spray-Richtung: nach oben + horizontal entgegen der Fließrichtung (Aufprall-Bounce)
// flowDir zeigt bergab/in Fließrichtung; horizontale Umkehrung = Rückspritzer
Vector3f sprayVel = new Vector3f(
-flowDir.x * 1.8f,
4.5f,
-flowDir.z * 1.8f);
emitter.setInitialVelocity(sprayVel);
emitter.setVelocityVariation(0.75f); // hohe Variation → aufgefächerte Gischt-Wolke
emitter.setParticlesPerSec(35);
emitter.setLocalTranslation(base.x(), base.y(), base.z());
riverNode.attachChild(emitter);
}
/** Fließrichtung am Ende eines Abschnitts aus den letzten Stützpunkten. */
private Vector3f computeFlowDir(List<RiverPoint> pts) {
int n = pts.size();
RiverPoint a = pts.get(Math.max(0, n - 3));
RiverPoint b = pts.get(n - 1);
Vector3f dir = new Vector3f(b.x() - a.x(), b.y() - a.y(), b.z() - a.z());
if (dir.lengthSquared() < 1e-6f) return new Vector3f(0f, -1f, 0f);
return dir.normalizeLocal();
}
}

View File

@@ -1,3 +1,3 @@
# blight_rivers.blr Flussdaten
# Format: x,y,z,width,uvSpeed | x,y,z,width,uvSpeed | ...
3.71271,1.45984,-299.11252,8.98643,0.40000|11.98857,1.17747,-290.66177,8.98643,0.40000|31.99506,1.01111,-282.99625,8.98643,0.40000|48.89030,0.96111,-289.70255,8.98643,0.40000|63.18369,0.91111,-300.59106,8.98643,0.40000|72.72055,0.86111,-308.32092,8.98643,0.40000|81.47565,0.81111,-310.34119,8.98643,0.40000|88.85998,0.54894,-310.70313,8.98643,0.40000|95.29650,0.01967,-310.18607,8.98643,0.40000
83.70156,11.20734,-377.98270,8.00000,0.40000|53.36767,11.15734,-386.29489,8.00000,0.40000|19.23992,11.10734,-376.33185,8.00000,0.40000|-22.25724,11.05734,-322.99374,8.00000,0.40000

View File

@@ -1,2 +1,3 @@
# polygon soundPath volume crossfade
67.278,-201.343;67.278,-201.343;57.509,-236.901;57.509,-236.901 1.0000 false
-4.282,-291.410;-4.282,-291.410;-4.282,-291.410 1.0000 false