Compare commits

..

4 Commits

21 changed files with 477 additions and 64 deletions

View File

@@ -58,11 +58,10 @@ void main() {
vec2 uvY = vWorldPos.xz / m_TexScale;
vec2 uvZ = vWorldPos.xy / m_TexScale;
// Flach bis ~22,5° Gefälle (deckt sich mit der Bake-Schwelle in VoxelEditorState,
// die Rampen bis 22,5° glättet vorher lag diese Grenze bei ~15° und sorgte dafür,
// dass geometrisch glatt gebackene, aber 15-22,5° steile Rampen trotzdem komplett
// mit der Klippen-Textur eingefärbt wurden statt mit der Flach-Textur.
float flatBlend = smoothstep(0.90, 0.95, vNormal.y);
// Flach bis ~45° Gefälle. LOD0-Smoothing (3 Passes, 0.3 Stärke) kann die Normalen
// an Rampenfüßen (14°-Rampe, ideal normal.y≈0.97) auf 0.70-0.85 herunterziehen.
// Untere Grenze 0.70 (≈45°) echte Klippen (normal.y<0.70) zeigen weiter Gebirge.
float flatBlend = smoothstep(0.70, 0.90, vNormal.y);
float steepBlend = 1.0 - flatBlend;
// Flat: reines XZ-UV wie das Terrain (uvY = worldPos.xz / texScale), kein Triplanar.

View File

@@ -3582,6 +3582,27 @@ public class EditorApp extends Application {
bakeBarLabel.setVisible(false);
bakeBarLabel.setManaged(false);
// ── Bake-Parameter ────────────────────────────────────────────────
Label smoothLbl = new Label(String.format("Glättung: %.1f", input.bakeSmoothStrength));
smoothLbl.setStyle("-fx-font-size: 11;");
javafx.scene.control.Slider smoothSlider =
new javafx.scene.control.Slider(0, 1, input.bakeSmoothStrength);
smoothSlider.setShowTickMarks(false);
smoothSlider.valueProperty().addListener((obs, o, n) -> {
input.bakeSmoothStrength = n.floatValue();
smoothLbl.setText(String.format("Glättung: %.1f", n.floatValue()));
});
Label cliffLbl = new Label(String.format("Klippen-Noise: %.1f", input.bakeCliffNoiseStrength));
cliffLbl.setStyle("-fx-font-size: 11;");
javafx.scene.control.Slider cliffSlider =
new javafx.scene.control.Slider(0, 1, input.bakeCliffNoiseStrength);
cliffSlider.setShowTickMarks(false);
cliffSlider.valueProperty().addListener((obs, o, n) -> {
input.bakeCliffNoiseStrength = n.floatValue();
cliffLbl.setText(String.format("Klippen-Noise: %.1f", n.floatValue()));
});
Button bakeBtn = new Button("Voxels backen (J3O)");
bakeBtn.setMaxWidth(Double.MAX_VALUE);
bakeBtn.setStyle("-fx-background-color: #4a7eba; -fx-text-fill: white;");
@@ -3697,8 +3718,9 @@ public class EditorApp extends Application {
cleanupPoller.setCycleCount(javafx.animation.Animation.INDEFINITE);
cleanupPoller.play();
panel.getChildren().addAll(new Separator(), bakeBtn,
bakeBar, bakeBarLabel, bakeStatus,
panel.getChildren().addAll(new Separator(),
smoothLbl, smoothSlider, cliffLbl, cliffSlider,
bakeBtn, bakeBar, bakeBarLabel, bakeStatus,
new Separator(), cleanupBtn, cleanupStatus,
new Separator(), selInfoLabel, selHintLabel, deleteBtn, revertBtn);
}

View File

@@ -778,7 +778,11 @@ public class SharedInput {
public volatile boolean voxelRedoRequested = false;
/** JFX → JME: alle Voxel-Chunks als geglättete J3O-Meshes backen. */
public volatile boolean bakeVoxelsRequested = false;
public volatile boolean bakeVoxelsRequested = false;
/** JFX → JME: Stärke des Post-Bake-Laplacian-Smooth (0 = kein Extra-Pass, 1 = stark). */
public volatile float bakeSmoothStrength = 0.3f;
/** JFX → JME: Amplitude der horizontalen Klippen-Noise-Verschiebung (0 = kein, 1 = ~2 m). */
public volatile float bakeCliffNoiseStrength = 0.2f;
/** JME → JFX: Anzahl bereits gebackener Chunks (0 = nicht gestartet). */
public volatile int bakeDone = 0;
/** JME → JFX: Gesamtzahl der zu backenden Chunks (0 = nicht gestartet). */

View File

@@ -218,6 +218,8 @@ public class GrassVertexState extends BaseAppState {
float jmeY = (float) (edit.screenY() * input.viewportScaleY);
Vector3f hit = raycastSurface(jmeX, jmeY, getApplication().getCamera());
if (hit == null) continue;
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
if (ves != null && ves.terrainTypeAt(hit.x, hit.z) == VoxelEditorState.TerrainType.VOXEL_UNBAKED) continue;
if (edit.action() > 0) addBlades(hit);
else removeBlades(hit);
}
@@ -294,14 +296,22 @@ public class GrassVertexState extends BaseAppState {
if (changed) dirtyChunks[ci] = true;
}
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
// Neue Halme mit Falloff und Gleichmäßigkeits-Variation setzen
for (int i = 0; i < density; i++) {
float angle = rng.nextFloat() * (float) (Math.PI * 2);
float r = rng.nextFloat() * radius;
float bx = center.x + (float) Math.cos(angle) * r;
float bz = center.z + (float) Math.sin(angle) * r;
float by = terrain.getHeight(new Vector2f(bx, bz));
if (Float.isNaN(by)) continue;
float by;
if (ves != null) {
by = ves.heightAt(bx, bz);
if (ves.isTooSteep(bx, bz)) continue;
} else {
by = terrain.getHeight(new Vector2f(bx, bz));
if (Float.isNaN(by)) continue;
}
float distRatio = r / radius;
float h = height * brushFalloff(distRatio)
* (1f + variation * (rng.nextFloat() * 2f - 1f));

View File

@@ -255,6 +255,8 @@ public class PlacedObjectState extends BaseAppState {
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
Vector3f contact = raycastSurface(ray);
if (contact == null) continue;
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
if (ves != null && ves.terrainTypeAt(contact.x, contact.z) == VoxelEditorState.TerrainType.VOXEL_UNBAKED) continue;
float radius = (float) input.grassTool.brushRadius.getValue();
if (edit.action() > 0) paintGrass(contact.x, contact.z, radius);
else eraseGrass(contact.x, contact.z, radius);
@@ -292,6 +294,7 @@ public class PlacedObjectState extends BaseAppState {
float baseH = (float) input.grassTool.grassHeight.getValue();
int slot = input.grassActiveSlot;
Random rng = new Random();
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
for (int i = 0; i < n; i++) {
float angle = rng.nextFloat() * FastMath.TWO_PI;
float dist = FastMath.sqrt(rng.nextFloat()) * radius;
@@ -299,6 +302,7 @@ public class PlacedObjectState extends BaseAppState {
float bz = cz + dist * FastMath.sin(angle);
if (bx < -TERRAIN_HALF || bx > TERRAIN_HALF
|| bz < -TERRAIN_HALF || bz > TERRAIN_HALF) continue;
if (ves != null && ves.isTooSteep(bx, bz)) continue;
float h = baseH * (0.7f + rng.nextFloat() * 0.6f);
int ci = chunkIndex(bx, bz);
if (ci >= 0) {
@@ -356,6 +360,8 @@ public class PlacedObjectState extends BaseAppState {
}
if (chunkTufts[idx].isEmpty()) return;
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
Map<Integer, List<float[]>> bySlot = new LinkedHashMap<>();
for (GrassTuft t : chunkTufts[idx]) {
long seed = (long) Float.floatToRawIntBits(t.x()) * 0x9E3779B9L
@@ -367,8 +373,13 @@ public class PlacedObjectState extends BaseAppState {
float oz = (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
float bx = t.x() + ox;
float bz = t.z() + oz;
float th = terrain.getHeight(new Vector2f(bx, bz));
if (Float.isNaN(th)) continue;
float th;
if (ves != null) {
th = ves.heightAt(bx, bz);
} else {
th = terrain.getHeight(new Vector2f(bx, bz));
if (Float.isNaN(th)) continue;
}
float bh = t.height() * (0.7f + rng.nextFloat() * 0.6f);
blades.add(new float[]{bx, th, bz, bh});
}

View File

@@ -1392,11 +1392,19 @@ public class TerrainEditorState extends BaseAppState {
contactPoint = raycastSurface(ray);
if (contactPoint != null) {
brushRadius = (float) input.grassTool.brushRadius.getValue();
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
if (ves != null && ves.terrainTypeAt(contactPoint.x, contactPoint.z) == VoxelEditorState.TerrainType.VOXEL_UNBAKED) {
contactPoint = null;
}
}
} else if (layer == SharedInput.LAYER_GRASS_VERTEX) {
contactPoint = raycastSurface(ray);
if (contactPoint != null) {
brushRadius = (float) input.grassVertexTool.brushRadius.getValue();
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
if (ves != null && ves.terrainTypeAt(contactPoint.x, contactPoint.z) == VoxelEditorState.TerrainType.VOXEL_UNBAKED) {
contactPoint = null;
}
}
}

View File

@@ -1154,6 +1154,87 @@ public class VoxelEditorState extends BaseAppState {
return 0f;
}
// ── Zentrale Terrain-Abfragen ─────────────────────────────────────────────
/** Terrain-Typ an einer Weltposition. */
public enum TerrainType { BASE, VOXEL_BAKED, VOXEL_UNBAKED }
/**
* Liefert den Terrain-Typ an einer Weltposition.
* BASE = nur Basis-Terrain; VOXEL_BAKED = mind. ein gebackener Chunk in der Spalte;
* VOXEL_UNBAKED = Voxel-Daten vorhanden, aber kein Bake-Ergebnis auf der Festplatte.
*/
public TerrainType terrainTypeAt(float worldX, float worldZ) {
int cx = VoxelChunk.worldXToCx(worldX);
int cz = VoxelChunk.worldZToCz(worldZ);
boolean hasVoxel = false;
boolean hasBaked = false;
for (int cy = -2; cy <= 10; cy++) {
VoxelChunk chunk = chunks.get(chunkKey(cx, cy, cz));
if (chunk != null && !chunk.isEmpty()) {
hasVoxel = true;
if (VoxelChunkIO.bakedExists(cx, cy, cz)) {
hasBaked = true;
}
}
}
if (!hasVoxel) return TerrainType.BASE;
return hasBaked ? TerrainType.VOXEL_BAKED : TerrainType.VOXEL_UNBAKED;
}
/**
* Nächster Oberflächentreffer für den gegebenen Ray (Terrain + Voxel-Geo + Baked Sculpt).
* Zentrale Alternative zu den lokalen raycastSurface()-Methoden in den einzelnen States.
*/
public Vector3f clickAt(Ray ray) {
Vector3f best = null;
float bestDistSq = Float.MAX_VALUE;
if (terrainNode != null) {
CollisionResults hits = new CollisionResults();
terrainNode.collideWith(ray, hits);
if (hits.size() > 0) {
best = hits.getClosestCollision().getContactPoint();
bestDistSq = ray.getOrigin().distanceSquared(best);
}
}
Vector3f vp = raycastVoxelGeometry(ray);
if (vp != null) {
float d = ray.getOrigin().distanceSquared(vp);
if (d < bestDistSq) { best = vp; bestDistSq = d; }
}
SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class);
if (smes != null) {
Vector3f sp = smes.raycastGeometry(ray);
if (sp != null) {
float d = ray.getOrigin().distanceSquared(sp);
if (d < bestDistSq) { best = sp; }
}
}
return best;
}
/** true wenn das Gefälle an (worldX, worldZ) 45° überschreitet. */
public boolean isTooSteep(float worldX, float worldZ) {
// Gebackenes Mesh: Flächennormale direkt aus dem Mesh — genauer als Höhen-Sampling.
SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class);
if (smes != null) {
Ray r = new Ray(new Vector3f(worldX, 500f, worldZ), new Vector3f(0f, -1f, 0f));
Vector3f[] hit = smes.raycastGeometryWithNormal(r);
if (hit != null) {
return hit[1].y < 0.7071f; // cos(45°)
}
}
// Fallback für Basis-Terrain und rohes Voxel: Höhen-Sampling mit 1 WE Abstand.
float h0 = heightAt(worldX, worldZ);
float dhx = heightAt(worldX + 1f, worldZ) - h0;
float dhz = heightAt(worldX, worldZ + 1f) - h0;
return dhx * dhx + dhz * dhz > 1.0f; // tan²(45°) = 1
}
/** Höchste bekannte Oberfläche an (worldX, worldZ): max(Terrain, Voxel, Baked). */
public float heightAt(float worldX, float worldZ) {
float h = terrainH(worldX, worldZ);
@@ -2111,78 +2192,168 @@ public class VoxelEditorState extends BaseAppState {
// Fix v6 (aktuell): Steigungs-Fortsetzung komplett gestrichen. Reiner Distanz-Blend
// von der (ring-gemittelten) Kanten-Höhe zur tatsächlichen Basisterrain-Höhe. Folgt
// nicht mehr exakt dem Rampen-Winkel, ist aber garantiert monoton/glatt.
// 4:1-Abwärtsrampe bei Stufenabbrüchen (inter-terrace + Basisterrain-Übergang).
// 4:1-Abwärtsrampe bei Stufenabbrüchen.
//
// Schritt 2 lässt Spalten mit hUp=null flach (smoothH=h0), auch wenn direkt
// daneben eine niedrigere Terasse oder das Basisterrain-Tal liegt. Das erzeugt
// vertikale Klippen an allen Plateau-Kanten.
// Zwei getrennte Passes wegen unterschiedlicher Schutz-Logik:
//
// Fix: Für jede Spalte den nächsten Nachbarn mit Höhe < h0 suchen
// (Voxel-Spalte aus hfMap ODER Basisterrain außerhalb). Wenn gefunden bei
// Chebyshev-Distanz d mit mittlerer Höhe avgLow:
// rampH = avgLow + d / 4.0
// Spalte wird auf min(smoothH, rampH) abgesenkt nie angehoben.
// Entspricht 4 Schritte vor / 1 Schritt runter, deckt beide Bedingungen ab:
// „Tal darunter" (avgLow << h0) und „<1m über Basisterrain" (kleines dH).
// Ringsuche je Spalte unabhängig von anderen Schritt-2b-Ergebnissen →
// keine Kettenfortpflanzung, keine Divergenz.
// Pass A Basisterrain-Übergang:
// Sucht nur Nicht-hfMap-Zellen (echtes Basisterrain). Voxel-Spalten in
// hfMap blocken die Sicht natürlich (Kliffwände sind in hfMap → werden
// übersprungen). MAX_TERRAIN_DIFF verhindert tiefe Klippen.
// Kein Ring-Vorfilter nötig der Schutz kommt durch hfMap-Blocking.
//
// Pass B Voxel-zu-Voxel-Übergang:
// Sucht nur hfMap-Zellen. Ring-1-Vorfilter: alle Voxel auf gleicher Höhe
// → Plateau-Innenzelle → überspringen.
{
final float RAMP_RATIO = 4.0f;
final int MAX_RAMP_R = 20;
final float RAMP_RATIO = 4.0f;
final int MAX_RAMP_R = 20;
final int MAX_RAMP_R_TERRAIN = 4;
final float MAX_TERRAIN_DIFF = 1.0f;
final float MAX_VOXEL_DIFF = 1.05f;
// Terrain-Cache: vermeidet wiederholte terrainH()-Aufrufe bei Überlappungen
Map<Long, Float> terrainCache2b = new HashMap<>();
// ── Loop 1: Pass A Basisterrain (LOWER + RAISE) ───────────────────
// Muss als eigenständiger Loop VOR Pass B laufen: Pass B liest smoothMap
// der Nachbarn. Wären beide Passes im selben Loop, sähe Pass B manchmal
// noch den vergrabenen h0raw einer Nachbarzelle, die von PassA-RAISE noch
// nicht angehoben wurde (HashMap-Reihenfolge unbestimmt) → Graben.
for (Map.Entry<Long, Float> entry : hfMap.entrySet()) {
long hk0 = entry.getKey();
float h0raw = entry.getValue(); // Original-Voxelhöhe
float h0sm = smoothMap.getOrDefault(hk0, h0raw); // Schritt-2-Ausgabe
long hk0 = entry.getKey();
float h0raw = entry.getValue();
float h0sm = smoothMap.getOrDefault(hk0, h0raw);
int colX2b = (int)(hk0 / 60001L) - 30000;
int colZ2b = (int)(hk0 % 60001L) - 30000;
// Ringsuche nach nächstem niedrigeren Nachbarn (Voxel oder Terrain)
float sumLowH = 0f;
int lowCount = 0;
int lowDist = -1;
float minRampH = h0sm;
float minRaiseH = Float.POSITIVE_INFINITY;
float passA_maxThAbove = Float.NEGATIVE_INFINITY;
outer2b:
for (int r = 1; r <= MAX_RAMP_R; r++) {
for (int r = 1; r <= MAX_RAMP_R_TERRAIN; r++) {
float maxThAtR = Float.NEGATIVE_INFINITY;
float maxThAboveAtR = Float.NEGATIVE_INFINITY;
for (int dz2b = -r; dz2b <= r; dz2b++) {
for (int dx2b = -r; dx2b <= r; dx2b++) {
if (Math.max(Math.abs(dx2b), Math.abs(dz2b)) != r) continue;
long hkN = (long)(colX2b + dx2b + 30000) * 60001L
+ (colZ2b + dz2b + 30000);
Float hN = hfMap.get(hkN);
float neighborH;
if (hN != null) {
neighborH = hN; // Voxel-Spalte: Originalhöhe
} else {
Float cached = terrainCache2b.get(hkN);
if (cached == null) {
cached = terrainH((float)(colX2b + dx2b),
(float)(colZ2b + dz2b));
terrainCache2b.put(hkN, cached);
}
neighborH = cached; // Basisterrain
if (hfMap.containsKey(hkN)) continue;
Float cached = terrainCache2b.get(hkN);
if (cached == null) {
cached = terrainH((float)(colX2b + dx2b),
(float)(colZ2b + dz2b));
terrainCache2b.put(hkN, cached);
}
if (neighborH < h0raw) {
sumLowH += neighborH;
lowCount++;
float th = cached;
if (th >= h0sm) {
if (r == 1 && th > passA_maxThAbove) {
passA_maxThAbove = th;
}
if ((th - h0sm) <= MAX_TERRAIN_DIFF && th > maxThAboveAtR) {
maxThAboveAtR = th;
}
continue;
}
float diff = h0sm - th;
if (diff > MAX_TERRAIN_DIFF) continue;
if (th > maxThAtR) {
maxThAtR = th;
}
}
}
if (lowCount > 0) {
lowDist = r;
break outer2b;
if (maxThAtR > Float.NEGATIVE_INFINITY) {
float candidate = maxThAtR + 0.1f + (r - 1) / RAMP_RATIO;
if (candidate < minRampH) {
minRampH = candidate;
}
}
if (maxThAboveAtR > Float.NEGATIVE_INFINITY) {
float raiseCandidate = maxThAboveAtR + (r - 1) / RAMP_RATIO;
if (raiseCandidate < minRaiseH) {
minRaiseH = raiseCandidate;
}
}
}
if (log.isTraceEnabled() && passA_maxThAbove > Float.NEGATIVE_INFINITY
&& minRampH >= h0sm) {
if (minRaiseH < Float.POSITIVE_INFINITY) {
log.trace(String.format(
"PassA-RAISE (%d,%d) h=%.2f → %.2f (terrainR1=%.2f)",
colX2b, colZ2b, h0sm, minRaiseH, passA_maxThAbove));
} else {
log.trace(String.format(
"PassA-UNTEN (%d,%d) h0sm=%.3f h0raw=%.3f terrainR1=%.3f",
colX2b, colZ2b, h0sm, h0raw, passA_maxThAbove));
}
}
if (minRampH < h0sm) {
if (log.isTraceEnabled()) {
log.trace(String.format("Schritt2b-A (%d,%d) h=%.2f → %.2f",
colX2b, colZ2b, h0sm, minRampH));
}
smoothMap.put(hk0, minRampH);
} else if (minRaiseH > h0sm && minRaiseH < Float.POSITIVE_INFINITY) {
if (log.isTraceEnabled()) {
log.trace(String.format("Schritt2b-RAISE (%d,%d) h=%.2f → %.2f",
colX2b, colZ2b, h0sm, minRaiseH));
}
smoothMap.put(hk0, minRaiseH);
}
}
if (lowDist < 0) continue; // kein niedrigerer Nachbar im Suchradius
// ── Loop 2: Pass B Voxel-zu-Voxel ─────────────────────────────────
// Liest smoothMap aus Loop 1 → RAISE-Zellen haben ihre angehobene Höhe.
// Effektive Nachbarhöhe: max(raw, smooth).
// RAISE-Nachbar (smooth > raw): smooth gewinnt → kein Kandidat unter Terrain.
// LOWER-Nachbar (smooth < raw): raw gewinnt → Kaskaden-Schutz bleibt.
for (Map.Entry<Long, Float> entry : hfMap.entrySet()) {
long hk0 = entry.getKey();
float h0raw = entry.getValue();
if (h0raw > 4.25f) continue;
float h0sm = smoothMap.getOrDefault(hk0, h0raw);
int colX2b = (int)(hk0 / 60001L) - 30000;
int colZ2b = (int)(hk0 % 60001L) - 30000;
float avgLowH = sumLowH / lowCount;
float rampH = avgLowH + lowDist / RAMP_RATIO;
if (rampH < h0sm) {
smoothMap.put(hk0, rampH);
float minRampH = h0sm;
boolean hadAnyLower = false;
boolean hadNonVoxelR1R3 = false;
voxelPassB:
for (int r = 1; r <= MAX_RAMP_R; r++) {
for (int dz2b = -r; dz2b <= r; dz2b++) {
for (int dx2b = -r; dx2b <= r; dx2b++) {
if (Math.max(Math.abs(dx2b), Math.abs(dz2b)) != r) continue;
long hkN = (long)(colX2b + dx2b + 30000) * 60001L
+ (colZ2b + dz2b + 30000);
Float hNraw = hfMap.get(hkN);
if (hNraw == null) {
if (r <= 3) { hadNonVoxelR1R3 = true; }
continue;
}
float hN = Math.max(hNraw, smoothMap.getOrDefault(hkN, hNraw));
if (hN >= h0raw) continue;
if ((h0raw - hN) >= MAX_VOXEL_DIFF) continue;
hadAnyLower = true;
if ((h0raw - hN) > (float) r) continue;
float candidate = hN + r / RAMP_RATIO;
if (candidate < minRampH) {
minRampH = candidate;
}
}
}
if (r == 3 && !hadAnyLower && !hadNonVoxelR1R3) {
break voxelPassB;
}
if (hadAnyLower) {
break voxelPassB;
}
}
if (minRampH < h0sm) {
if (log.isTraceEnabled()) {
log.trace(String.format("Schritt2b-B (%d,%d) h=%.2f → %.2f",
colX2b, colZ2b, h0sm, minRampH));
}
smoothMap.put(hk0, minRampH);
}
}
}
@@ -2432,8 +2603,10 @@ public class VoxelEditorState extends BaseAppState {
// Nur erfolgreich gebackene Chunks werden gelöscht
List<VoxelChunk> successfullyBaked = new java.util.ArrayList<>();
int baked = 0;
float bakeSmoothStr = input.bakeSmoothStrength;
float bakeCliffStr = input.bakeCliffNoiseStrength;
for (VoxelChunk chunk : nonEmpty) {
if (bakeChunk(chunk, blurredMap)) {
if (bakeChunk(chunk, blurredMap, bakeSmoothStr, bakeCliffStr)) {
successfullyBaked.add(chunk);
} else {
log.warn("Chunk ({},{},{}) nicht gebacken Voxel-Daten bleiben erhalten.",
@@ -2549,7 +2722,8 @@ public class VoxelEditorState extends BaseAppState {
}
/** Bäckt einen einzelnen Chunk. Gibt true zurück wenn erfolgreich, false bei Fehler. */
private boolean bakeChunk(VoxelChunk original, Map<Long, VoxelChunk> blurredMap) {
private boolean bakeChunk(VoxelChunk original, Map<Long, VoxelChunk> blurredMap,
float smoothStrength, float cliffNoiseStrength) {
try {
VoxelChunk blurred = blurredMap.get(chunkKey(original.cx, original.cy, original.cz));
if (blurred == null) return false;
@@ -2557,9 +2731,25 @@ public class VoxelEditorState extends BaseAppState {
// Geblurrte Nachbarn für nahtlose Chunk-Grenzen im MC
VoxelChunk[] nb = getNeighbors(original.cx, original.cy, original.cz, blurredMap);
float wx = (float)(original.cx * VoxelChunk.CELLS);
float wz = (float)(original.cz * VoxelChunk.CELLS);
Mesh lod0 = MarchingCubes.smooth(MarchingCubes.build(blurred, 1, nb), 1, 0.3f);
if (smoothStrength > 0.01f) {
lod0 = MarchingCubes.smooth(lod0, 1, smoothStrength);
}
if (cliffNoiseStrength > 0.01f) {
lod0 = MarchingCubes.perturb(lod0, cliffNoiseStrength, wx, wz);
}
Mesh lod1 = MarchingCubes.smooth(MarchingCubes.build(blurred, 4, nb), 3, 0.4f);
if (cliffNoiseStrength > 0.01f) {
lod1 = MarchingCubes.perturb(lod1, cliffNoiseStrength * 0.6f, wx, wz);
}
Mesh[] meshes = {
MarchingCubes.smooth(MarchingCubes.build(blurred, 1, nb), 1, 0.3f),
MarchingCubes.smooth(MarchingCubes.build(blurred, 4, nb), 3, 0.4f),
lod0,
lod1,
MarchingCubes.smooth(MarchingCubes.build(blurred, 16, nb), 2, 0.4f),
};

View File

@@ -26,6 +26,7 @@
<!-- Material warnt bei linear-color-space Texturen ohne passenden Parameter bekannt, kein Fehler -->
<logger name="com.jme3.material.Material" level="ERROR"/>
<logger name="de.blight.game.animation.FootIKControl" level="DEBUG"/>
<logger name="de.blight.editor.state.VoxelEditorState" level="TRACE"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>

View File

@@ -682,4 +682,172 @@ public final class MarchingCubes {
return chunk.getDensity(x, y, z);
}
/**
* Verschiebt Vertices auf steilen Flächen (normal.y < 0.707 ≈ 45°) horizontal (XZ)
* per kohärentem Value-Noise. Macht Klippen natürlicher und felsiger.
* Rand-Vertices (Chunk-Grenzen) bleiben fixiert, Nahtlosigkeit bleibt erhalten.
*
* @param strength Amplitude: 0 = kein Effekt, 1 = bis ~2 m maximale Verschiebung
* @param worldX Weltkoordinate X des Chunk-Ursprungs (cx * CELLS)
* @param worldZ Weltkoordinate Z des Chunk-Ursprungs (cz * CELLS)
*/
public static Mesh perturb(Mesh mesh, float strength, float worldX, float worldZ) {
if (mesh == null || strength < 0.001f) return mesh;
FloatBuffer posF = mesh.getFloatBuffer(VertexBuffer.Type.Position);
if (posF == null) return mesh;
int vertCount = posF.capacity() / 3;
if (vertCount < 3) return mesh;
int triCount = vertCount / 3;
float[] pos = new float[vertCount * 3];
posF.rewind(); posF.get(pos);
// Vertex-Gruppen aufbauen (gleiche Position → gleiche Gruppe)
HashMap<Long, Integer> keyToGroup = new HashMap<>(vertCount / 3 + 16);
int[] vertGroup = new int[vertCount];
int[] groupFirst = new int[vertCount];
int groupCount = 0;
for (int v = 0; v < vertCount; v++) {
long key = posKey(pos, v);
Integer g = keyToGroup.get(key);
if (g == null) {
keyToGroup.put(key, groupCount);
groupFirst[groupCount] = v;
vertGroup[v] = groupCount++;
} else {
vertGroup[v] = g;
}
}
float[] gx = new float[groupCount];
float[] gy = new float[groupCount];
float[] gz = new float[groupCount];
for (int g = 0; g < groupCount; g++) {
int v = groupFirst[g];
gx[g] = pos[v*3]; gy[g] = pos[v*3+1]; gz[g] = pos[v*3+2];
}
// Rand-Vertices einfrieren (Chunk-Nahtlosigkeit)
float bound = VoxelChunk.CELLS;
boolean[] pinned = new boolean[groupCount];
for (int g = 0; g < groupCount; g++) {
float x = gx[g], y = gy[g], z = gz[g];
if (x < 0.01f || x > bound - 0.01f ||
y < 0.01f || y > bound - 0.01f ||
z < 0.01f || z > bound - 0.01f) {
pinned[g] = true;
}
}
// Flächennormalen berechnen und pro Gruppe akkumulieren
float[] gnx = new float[groupCount];
float[] gny = new float[groupCount];
float[] gnz = new float[groupCount];
for (int t = 0; t < triCount; t++) {
int g0=vertGroup[t*3], g1=vertGroup[t*3+1], g2=vertGroup[t*3+2];
float p0x=pos[t*9], p0y=pos[t*9+1], p0z=pos[t*9+2];
float p1x=pos[t*9+3], p1y=pos[t*9+4], p1z=pos[t*9+5];
float p2x=pos[t*9+6], p2y=pos[t*9+7], p2z=pos[t*9+8];
float ex=p1x-p0x, ey=p1y-p0y, ez=p1z-p0z;
float fx=p2x-p0x, fy=p2y-p0y, fz=p2z-p0z;
float nx=ey*fz-ez*fy, ny=ez*fx-ex*fz, nz=ex*fy-ey*fx;
gnx[g0]+=nx; gny[g0]+=ny; gnz[g0]+=nz;
gnx[g1]+=nx; gny[g1]+=ny; gnz[g1]+=nz;
gnx[g2]+=nx; gny[g2]+=ny; gnz[g2]+=nz;
}
for (int g = 0; g < groupCount; g++) {
float len=(float)Math.sqrt(gnx[g]*gnx[g]+gny[g]*gny[g]+gnz[g]*gnz[g]);
if (len > 1e-6f) { gnx[g]/=len; gny[g]/=len; gnz[g]/=len; }
else { gny[g] = 1f; }
}
// Horizontale Verschiebung auf steile Gruppen anwenden.
// fBm (Fractal Brownian Motion) mit 4 Oktaven: gleiche Frequenz auf allen
// drei Achsen → jedes Höhenniveau bekommt eigene, unabhängige Verschiebung.
// Zwei unkorrelierte fBm-Felder für X und Z (unterschiedliche Offsets).
final float STEEP = 0.707f; // cos(45°)
final float MAX_D = 4.0f; // m maximale Verschiebung bei strength=1, senkrechter Fläche
for (int g = 0; g < groupCount; g++) {
if (pinned[g]) continue;
float ny = gny[g];
if (ny >= STEEP) continue;
float steepness = 1f - ny / STEEP; // 0 bei 45°, 1 bei senkrecht
float wx = worldX + gx[g];
float wz = worldZ + gz[g];
float wy = gy[g];
float noiseX = perturbFbm(wx * 0.08f, wy * 0.08f, wz * 0.08f);
float noiseZ = perturbFbm(wx * 0.08f + 31.7f, wy * 0.08f + 11.3f, wz * 0.08f + 67.1f);
float disp = steepness * strength * MAX_D;
gx[g] += noiseX * disp;
gz[g] += noiseZ * disp;
}
// Positionen in Buffer schreiben
for (int v = 0; v < vertCount; v++) {
int g = vertGroup[v];
pos[v*3] = gx[g]; pos[v*3+2] = gz[g];
}
posF.rewind(); posF.put(pos); posF.rewind();
// Normalen aus geänderter Geometrie neu berechnen
java.util.Arrays.fill(gnx, 0f); java.util.Arrays.fill(gny, 0f); java.util.Arrays.fill(gnz, 0f);
for (int t = 0; t < triCount; t++) {
int g0=vertGroup[t*3], g1=vertGroup[t*3+1], g2=vertGroup[t*3+2];
float p0x=pos[t*9], p0y=pos[t*9+1], p0z=pos[t*9+2];
float p1x=pos[t*9+3], p1y=pos[t*9+4], p1z=pos[t*9+5];
float p2x=pos[t*9+6], p2y=pos[t*9+7], p2z=pos[t*9+8];
float ex=p1x-p0x, ey=p1y-p0y, ez=p1z-p0z;
float fx=p2x-p0x, fy=p2y-p0y, fz=p2z-p0z;
float nx=ey*fz-ez*fy, ny=ez*fx-ex*fz, nz=ex*fy-ey*fx;
gnx[g0]+=nx; gny[g0]+=ny; gnz[g0]+=nz;
gnx[g1]+=nx; gny[g1]+=ny; gnz[g1]+=nz;
gnx[g2]+=nx; gny[g2]+=ny; gnz[g2]+=nz;
}
FloatBuffer normF = mesh.getFloatBuffer(VertexBuffer.Type.Normal);
if (normF != null) {
normF.rewind();
for (int v = 0; v < vertCount; v++) {
int g = vertGroup[v];
float len=(float)Math.sqrt(gnx[g]*gnx[g]+gny[g]*gny[g]+gnz[g]*gnz[g]);
if (len > 1e-6f) normF.put(gnx[g]/len).put(gny[g]/len).put(gnz[g]/len);
else normF.put(0f).put(1f).put(0f);
}
normF.rewind();
}
mesh.updateBound();
return mesh;
}
/** fBm (Fractal Brownian Motion), 4 Oktaven, Ergebnis in [-1, 1]. */
private static float perturbFbm(float x, float y, float z) {
float v = 0f, amp = 1f, sumAmp = 0f;
for (int i = 0; i < 4; i++) {
v += perturbNoise(x, y, z) * amp;
sumAmp += amp;
x *= 2.1f; y *= 2.1f; z *= 2.1f;
amp *= 0.5f;
}
return v / sumAmp;
}
/** Trilinear interpoliertes Value-Noise in [-1, 1]. Kohärent, deterministisch. */
private static float perturbNoise(float x, float y, float z) {
int ix=(int)Math.floor(x), iy=(int)Math.floor(y), iz=(int)Math.floor(z);
float fx=x-ix, fy=y-iy, fz=z-iz;
fx=fx*fx*(3-2*fx); fy=fy*fy*(3-2*fy); fz=fz*fz*(3-2*fz);
float v00 = perturbHash(ix,iy,iz) + fx*(perturbHash(ix+1,iy,iz) - perturbHash(ix,iy,iz));
float v10 = perturbHash(ix,iy+1,iz) + fx*(perturbHash(ix+1,iy+1,iz) - perturbHash(ix,iy+1,iz));
float v01 = perturbHash(ix,iy,iz+1) + fx*(perturbHash(ix+1,iy,iz+1) - perturbHash(ix,iy,iz+1));
float v11 = perturbHash(ix,iy+1,iz+1)+fx*(perturbHash(ix+1,iy+1,iz+1)-perturbHash(ix,iy+1,iz+1));
return (v00 + fy*(v10-v00)) + fz*((v01 + fy*(v11-v01)) - (v00 + fy*(v10-v00)));
}
private static float perturbHash(int x, int y, int z) {
int h = x * 374761393 + y * 1103515245 + z * -2012135261;
h ^= (h >>> 15); h *= 0x9e3779b9; h ^= (h >>> 12);
return ((h >>> 1) & 0xFFFF) * (2f / 65535f) - 1f;
}
}