Malen von Gras erweitert

This commit is contained in:
2026-08-21 15:03:23 +02:00
parent 5d8e4db468
commit 03dcfb4187
15 changed files with 1055 additions and 41 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -0,0 +1,66 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
/**
* Liest und schreibt Terrain-Farb-Gras-Halme als komprimierte Binärdatei
* ({@code blight_grass_terrain.blgv}) neben der Kartendatei.
*
* Format v1: int MAGIC, int VERSION, int count, N × (float x, float y, float z, float height, byte seedIdx)
* Kein Trockenheitswert Farbe kommt ausschließlich vom Terrain.
*/
public final class TerrainColorGrassIO {
private static final int MAGIC = 0x54434758; // "TCGX"
private static final int VERSION = 1;
private TerrainColorGrassIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_grass_terrain.blgv");
}
public static void save(List<GrassVertexBlade> blades) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new GZIPOutputStream(Files.newOutputStream(p))))) {
out.writeInt(MAGIC);
out.writeInt(VERSION);
out.writeInt(blades.size());
for (GrassVertexBlade b : blades) {
out.writeFloat(b.x());
out.writeFloat(b.y());
out.writeFloat(b.z());
out.writeFloat(b.height());
out.writeByte(Math.max(-1, Math.min(126, b.seedIdx())));
}
}
}
public static List<GrassVertexBlade> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
try (DataInputStream in = new DataInputStream(
new BufferedInputStream(new GZIPInputStream(Files.newInputStream(p))))) {
int magic = in.readInt();
if (magic != MAGIC) throw new IOException("Ungültiges Dateiformat (MAGIC)");
int version = in.readInt();
if (version < 1 || version > VERSION) throw new IOException("Unbekannte Version: " + version);
int count = in.readInt();
List<GrassVertexBlade> list = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
float x = in.readFloat();
float y = in.readFloat();
float z = in.readFloat();
float h = in.readFloat();
int si = in.readByte();
list.add(new GrassVertexBlade(x, y, z, h, 0f, si));
}
return list;
}
}
}

View File

@@ -364,8 +364,9 @@ public class EditorApp extends Application {
// Toolbar-Buttons (müssen vom Status-Poller erreichbar sein)
private ToggleButton baseBtn;
private ToggleButton grassBtn; // einziger Toolbar-Button für beide Gras-Tools
private ToggleButton grassSubTexBtn; // Sub-Toggle im Panel: Textur-Gras
private ToggleButton grassSubVertBtn; // Sub-Toggle im Panel: Vertex-Gras
private ToggleButton grassSubTexBtn; // Sub-Toggle im Panel: Textur-Gras
private ToggleButton grassSubVertBtn; // Sub-Toggle im Panel: Vertex-Gras
private ToggleButton grassSubTerrainBtn; // Sub-Toggle im Panel: Terrain-Farb-Gras
private ToggleButton textureBtn;
private ToggleButton stoneBtn;
private ToggleButton objPlaceBtn; // Unified Objekte-Button (Place + Edit)
@@ -3571,28 +3572,33 @@ public class EditorApp extends Application {
panel.getChildren().addAll(title, new Separator());
// Sub-Tool-Auswahl
grassSubTexBtn = new ToggleButton(":: Gras (Textur)");
grassSubVertBtn = new ToggleButton("|| Gras (Vertices)");
grassSubTexBtn = new ToggleButton(":: Textur");
grassSubVertBtn = new ToggleButton("|| Vertex");
grassSubTerrainBtn = new ToggleButton("~~ Terrain");
grassSubTexBtn.setMaxWidth(Double.MAX_VALUE);
grassSubVertBtn.setMaxWidth(Double.MAX_VALUE);
grassSubTerrainBtn.setMaxWidth(Double.MAX_VALUE);
ToggleGroup subGroup = new ToggleGroup();
grassSubTexBtn.setToggleGroup(subGroup);
grassSubVertBtn.setToggleGroup(subGroup);
grassSubTerrainBtn.setToggleGroup(subGroup);
subGroup.selectedToggleProperty().addListener((obs, o, n) -> { if (n == null) subGroup.selectToggle(o); });
HBox subRow = new HBox(4, grassSubTexBtn, grassSubVertBtn);
HBox.setHgrow(grassSubTexBtn, Priority.ALWAYS);
HBox.setHgrow(grassSubVertBtn, Priority.ALWAYS);
HBox subRow = new HBox(4, grassSubTexBtn, grassSubVertBtn, grassSubTerrainBtn);
HBox.setHgrow(grassSubTexBtn, Priority.ALWAYS);
HBox.setHgrow(grassSubVertBtn, Priority.ALWAYS);
HBox.setHgrow(grassSubTerrainBtn, Priority.ALWAYS);
panel.getChildren().add(subRow);
// Parameter-Bereich (wird beim Sub-Tool-Wechsel ersetzt)
VBox paramsBox = new VBox(10);
panel.getChildren().add(paramsBox);
// Globale Farben
panel.getChildren().add(new Separator());
// Globale Farben nur für Textur-Gras und Vertex-Gras sichtbar
VBox colorSection = new VBox(6);
colorSection.getChildren().add(new Separator());
Label colorTitle = new Label("Globale Farben");
colorTitle.setStyle("-fx-font-weight: bold; -fx-font-size: 11; -fx-text-fill: #333;");
panel.getChildren().add(colorTitle);
colorSection.getChildren().add(colorTitle);
javafx.scene.control.ColorPicker freshPicker = new javafx.scene.control.ColorPicker(
javafx.scene.paint.Color.color(input.grassFreshR, input.grassFreshG, input.grassFreshB));
@@ -3616,9 +3622,36 @@ public class EditorApp extends Application {
input.grassColorsChanged = true;
});
panel.getChildren().addAll(
new Label("Unvertrocknet:"), freshPicker,
new Label("Vertrocknet:"), dryPicker);
Button pickFromTerrainBtn = new Button("[ ] Farbe vom Boden picken");
pickFromTerrainBtn.setMaxWidth(Double.MAX_VALUE);
pickFromTerrainBtn.setOnAction(e -> {
pickFromTerrainBtn.setText("[ ] Boden anklicken...");
pickFromTerrainBtn.setDisable(true);
input.grassTerrainPickCallback = color -> {
freshPicker.setValue(javafx.scene.paint.Color.color(
Math.min(1.0, color[0]), Math.min(1.0, color[1]), Math.min(1.0, color[2])));
pickFromTerrainBtn.setText("[ ] Farbe vom Boden picken");
pickFromTerrainBtn.setDisable(false);
};
// Nächsten Klick auf die JME-View abfangen
javafx.event.EventHandler<javafx.scene.input.MouseEvent> handler =
new javafx.event.EventHandler<javafx.scene.input.MouseEvent>() {
@Override public void handle(javafx.scene.input.MouseEvent ev) {
if (ev.getButton() == javafx.scene.input.MouseButton.PRIMARY) {
input.grassTerrainPickQueue.offer(
new SharedInput.GrassVertexEdit((float) ev.getX(), (float) ev.getY(), +1));
viewport.removeEventHandler(javafx.scene.input.MouseEvent.MOUSE_PRESSED, this);
ev.consume();
}
}
};
viewport.addEventHandler(javafx.scene.input.MouseEvent.MOUSE_PRESSED, handler);
});
colorSection.getChildren().addAll(new Label("Unvertrocknet:"), freshPicker,
pickFromTerrainBtn,
new Label("Vertrocknet:"), dryPicker);
panel.getChildren().add(colorSection);
// Sub-Tool-Handler
Runnable activateTex = () -> {
@@ -3628,6 +3661,8 @@ public class EditorApp extends Application {
VBox inner = new VBox(10);
showToolParameters(inner, input.activeTool);
paramsBox.getChildren().add(inner);
colorSection.setVisible(true);
colorSection.setManaged(true);
};
Runnable activateVert = () -> {
input.activeLayer = SharedInput.LAYER_GRASS_VERTEX;
@@ -3636,15 +3671,31 @@ public class EditorApp extends Application {
VBox inner = new VBox(10);
showToolParameters(inner, input.activeTool);
paramsBox.getChildren().add(inner);
colorSection.setVisible(true);
colorSection.setManaged(true);
};
Runnable activateTerrain = () -> {
input.activeLayer = SharedInput.LAYER_TERRAIN_GRASS;
input.activeTool = input.terrainGrassTool;
paramsBox.getChildren().clear();
VBox inner = new VBox(10);
showToolParameters(inner, input.activeTool);
paramsBox.getChildren().add(inner);
colorSection.setVisible(false);
colorSection.setManaged(false);
};
grassSubTexBtn.setOnAction(e -> activateTex.run());
grassSubVertBtn.setOnAction(e -> activateVert.run());
grassSubTexBtn.setOnAction(e -> activateTex.run());
grassSubVertBtn.setOnAction(e -> activateVert.run());
grassSubTerrainBtn.setOnAction(e -> activateTerrain.run());
// Standard: zuletzt aktives Sub-Tool
if (input.activeLayer == SharedInput.LAYER_GRASS_VERTEX) {
grassSubVertBtn.setSelected(true);
activateVert.run();
} else if (input.activeLayer == SharedInput.LAYER_TERRAIN_GRASS) {
grassSubTerrainBtn.setSelected(true);
activateTerrain.run();
} else {
grassSubTexBtn.setSelected(true);
activateTex.run();
@@ -8822,8 +8873,14 @@ public class EditorApp extends Application {
switch (input.activeLayer) {
case 0 -> input.editQueue.offer(new SharedInput.TerrainEdit((float) x, (float) y, action));
case 3 -> input.grassEditQueue.offer(new SharedInput.GrassEdit((float) x, (float) y, action));
case SharedInput.LAYER_GRASS_VERTEX ->
case SharedInput.LAYER_GRASS_VERTEX -> {
input.grassVertexEditQueue.offer(new SharedInput.GrassVertexEdit((float) x, (float) y, action));
if (action < 0) input.terrainGrassEditQueue.offer(new SharedInput.GrassVertexEdit((float) x, (float) y, action));
}
case SharedInput.LAYER_TERRAIN_GRASS -> {
input.terrainGrassEditQueue.offer(new SharedInput.GrassVertexEdit((float) x, (float) y, action));
if (action < 0) input.grassVertexEditQueue.offer(new SharedInput.GrassVertexEdit((float) x, (float) y, action));
}
case 4 -> {
SharedInput.TextureEdit te = new SharedInput.TextureEdit((float) x, (float) y, action);
input.textureEditQueue.offer(te);
@@ -9434,7 +9491,7 @@ public class EditorApp extends Application {
case F9 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> { if (wasserMergedBtn != null) wasserMergedBtn.fire(); }); }
case F10 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> { if (wegeBtn != null) wegeBtn.fire(); }); }
case F11 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> { if (zonenBtn != null) zonenBtn.fire(); }); }
case F12 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> { if (playToolBtn != null) playToolBtn.fire(); }); }
case F12 -> { if (pressed && !input.ctrlHeld && "world".equals(currentTool)) Platform.runLater(() -> { if (playToolBtn != null) playToolBtn.fire(); }); }
}
}

View File

@@ -3,6 +3,7 @@ package de.blight.editor;
import de.blight.editor.tool.EditorTool;
import de.blight.editor.tool.GrassTool;
import de.blight.editor.tool.GrassVertexTool;
import de.blight.editor.tool.TerrainColorGrassTool;
import de.blight.editor.tool.HeightTool;
import de.blight.editor.tool.HoleTool;
import de.blight.editor.tool.StoneTool;
@@ -25,7 +26,8 @@ public class SharedInput {
public final HeightTool heightTool = new HeightTool();
public final UpperHeightTool upperHeightTool = new UpperHeightTool();
public final GrassTool grassTool = new GrassTool();
public final GrassVertexTool grassVertexTool = new GrassVertexTool();
public final GrassVertexTool grassVertexTool = new GrassVertexTool();
public final TerrainColorGrassTool terrainGrassTool = new TerrainColorGrassTool();
public final TextureTool textureTool = new TextureTool();
public final HoleTool holeTool = new HoleTool();
public final VoxelTool voxelTool = new VoxelTool();
@@ -92,10 +94,16 @@ public class SharedInput {
// ── Gras (Vertices) ───────────────────────────────────────────────────────
/** activeLayer==15 → Vertex-Gras (X-Quad-Halme) platzieren und entfernen */
public static final int LAYER_GRASS_VERTEX = 15;
public static final int LAYER_GRASS_VERTEX = 15;
public static final int LAYER_TERRAIN_GRASS = 34;
public record GrassVertexEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<GrassVertexEdit> grassVertexEditQueue = new ConcurrentLinkedQueue<>();
public final ConcurrentLinkedQueue<GrassVertexEdit> grassVertexEditQueue = new ConcurrentLinkedQueue<>();
public final ConcurrentLinkedQueue<GrassVertexEdit> terrainGrassEditQueue = new ConcurrentLinkedQueue<>();
/** Einzelner Klick zum Terrain-Farb-Sampling (Dropper) für Vertex-Gras Tool 1 */
public final ConcurrentLinkedQueue<GrassVertexEdit> grassTerrainPickQueue = new ConcurrentLinkedQueue<>();
/** Callback: JME-Thread setzt Farbe, EditorApp leitet sie via Platform.runLater an den Picker weiter */
public volatile java.util.function.Consumer<float[]> grassTerrainPickCallback = null;
/** Globale Grasfarbe frisches Gras (Spitze; Wurzel wird intern abgeleitet). Defaults: TIP_COLOR */
public volatile float grassFreshR = 0.26f, grassFreshG = 0.72f, grassFreshB = 0.11f;

View File

@@ -22,6 +22,7 @@ import com.jme3.texture.Texture;
import com.jme3.util.BufferUtils;
import de.blight.common.GrassVertexBlade;
import de.blight.common.GrassVertexIO;
import de.blight.common.MapData;
import de.blight.editor.SharedInput;
import org.slf4j.Logger;
@@ -52,8 +53,7 @@ public class GrassVertexState extends BaseAppState {
static final int BLADES_PER_TUFT = 3; // Halme pro Büschel
static final int SEGMENTS = 5; // Segmente pro Halm (5 → 6 Reihen)
static final float WIDTH_FACTOR = 0.10f; // Basis-Halbbreite = Höhe × WIDTH_FACTOR
static final float BEND_FACTOR = 0.15f; // max. Krümmungsversatz an der Spitze
static final float BEND_FACTOR = 0.15f;
static final ColorRGBA ROOT_COLOR = new ColorRGBA(0.08f, 0.34f, 0.04f, 1f);
static final ColorRGBA TIP_COLOR = new ColorRGBA(0.26f, 0.72f, 0.11f, 1f);
// 050 % Trockenheit: Grün → Goldgelb
@@ -95,11 +95,15 @@ public class GrassVertexState extends BaseAppState {
private float freshTipR = TIP_COLOR.r, freshTipG = TIP_COLOR.g, freshTipB = TIP_COLOR.b;
private float dryTipR = DRY_TIP_COLOR.r, dryTipG = DRY_TIP_COLOR.g, dryTipB = DRY_TIP_COLOR.b;
private static final float[] NO_TINT = {0f, 0f, 0f, 0f, 0f};
public GrassVertexState(SharedInput input) {
this.input = input;
for (int i = 0; i < CHUNK_COUNT; i++) chunkBlades[i] = new ArrayList<>();
}
public void setMapData(MapData mapData) { /* Farbe kommt nicht aus Terrain kein Terrain-Tint für Tool 1 */ }
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
public List<GrassVertexBlade> getAllBlades() {
@@ -417,7 +421,7 @@ public class GrassVertexState extends BaseAppState {
for (GrassVertexBlade blade : blades) {
buildTuft(positions, normals, colors, texCoords, indices, vi, ii, blade,
frR, frG, frB, freshTipR, freshTipG, freshTipB,
drR, drG, drB, dryTipR, dryTipG, dryTipB);
drR, drG, drB, dryTipR, dryTipG, dryTipB, NO_TINT);
vi += BLADES_PER_TUFT * (SEGMENTS + 1) * 2;
ii += BLADES_PER_TUFT * SEGMENTS * 6;
}
@@ -580,7 +584,8 @@ public class GrassVertexState extends BaseAppState {
float frshRootR, float frshRootG, float frshRootB,
float frshTipR, float frshTipG, float frshTipB,
float dryRootR, float dryRootG, float dryRootB,
float dryTipR, float dryTipG, float dryTipB) {
float dryTipR, float dryTipG, float dryTipB,
float[] tint) {
float x = blade.x(), y = blade.y(), z = blade.z(), h = blade.height();
float baseHW = h * WIDTH_FACTOR * 0.5f;
float tAngle = (float) (((x * 127.1f + z * 311.7f) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2));
@@ -648,10 +653,10 @@ public class GrassVertexState extends BaseAppState {
float dry = blade.dryness();
setV(pos, nrm, col, tex, svi, spX - cosA * hw, spY, spZ - sinA * hw, nx, ny, nz, t, dry,
frshRootR, frshRootG, frshRootB, frshTipR, frshTipG, frshTipB,
dryRootR, dryRootG, dryRootB, dryTipR, dryTipG, dryTipB);
dryRootR, dryRootG, dryRootB, dryTipR, dryTipG, dryTipB, tint);
setV(pos, nrm, col, tex, svi+1, spX + cosA * hw, spY, spZ + sinA * hw, nx, ny, nz, t, dry,
frshRootR, frshRootG, frshRootB, frshTipR, frshTipG, frshTipB,
dryRootR, dryRootG, dryRootB, dryTipR, dryTipG, dryTipB);
dryRootR, dryRootG, dryRootB, dryTipR, dryTipG, dryTipB, tint);
if (s < SEGMENTS) {
int sii = bladeIi + s * 6;
@@ -679,7 +684,8 @@ public class GrassVertexState extends BaseAppState {
float frshRootR, float frshRootG, float frshRootB,
float frshTipR, float frshTipG, float frshTipB,
float dryRootR, float dryRootG, float dryRootB,
float dryTipR, float dryTipG, float dryTipB) {
float dryTipR, float dryTipG, float dryTipB,
float[] tint) {
int pi = vi * 3;
pos[pi] = x; pos[pi+1] = y; pos[pi+2] = z;
@@ -696,18 +702,29 @@ public class GrassVertexState extends BaseAppState {
float vr = VERY_DRY_ROOT_COLOR.r + (VERY_DRY_TIP_COLOR.r - VERY_DRY_ROOT_COLOR.r) * wf;
float vg = VERY_DRY_ROOT_COLOR.g + (VERY_DRY_TIP_COLOR.g - VERY_DRY_ROOT_COLOR.g) * wf;
float vb = VERY_DRY_ROOT_COLOR.b + (VERY_DRY_TIP_COLOR.b - VERY_DRY_ROOT_COLOR.b) * wf;
float effectiveDryness = Math.min(1f, dryness + tint[4]);
float fr, fg, fb;
if (dryness <= 0.5f) {
float t = dryness * 2f;
if (effectiveDryness <= 0.5f) {
float t = effectiveDryness * 2f;
fr = gr + (dr - gr) * t;
fg = gg + (dg - gg) * t;
fb = gb + (db - gb) * t;
} else {
float t = (dryness - 0.5f) * 2f;
float t = (effectiveDryness - 0.5f) * 2f;
fr = dr + (vr - dr) * t;
fg = dg + (vg - dg) * t;
fb = db + (vb - db) * t;
}
if (tint[3] > 0f) {
// Terrain-Farbe vollständig übernehmen; Gradient: 40% an Wurzel, 115% an Spitze
float factor = 0.40f + wf * 0.75f;
fr = Math.min(1f, tint[0] * factor);
fg = Math.min(1f, tint[1] * factor);
fb = Math.min(1f, tint[2] * factor);
}
fr = Math.max(0f, Math.min(1f, fr));
fg = Math.max(0f, Math.min(1f, fg));
fb = Math.max(0f, Math.min(1f, fb));
col[ci] = fr; col[ci+1] = fg; col[ci+2] = fb; col[ci+3] = 1f;
int ti = vi * 2;

View File

@@ -0,0 +1,650 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.collision.CollisionResults;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Ray;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.texture.Texture;
import com.jme3.util.BufferUtils;
import de.blight.common.GrassVertexBlade;
import de.blight.common.MapData;
import de.blight.common.TerrainColorGrassIO;
import de.blight.editor.SharedInput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
/**
* Rendert Terrain-Farb-Gras im Editor: Halmfarbe kommt pixel-genau von der
* Terrain-Textur an der Bladeposition. Keine Nutzer-Farbauswahl, kein Trockenheitsgrad.
*/
public class TerrainColorGrassState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(TerrainColorGrassState.class);
// ── Chunks ────────────────────────────────────────────────────────────────
private static final int TERRAIN_HALF = 2048;
private static final int CHUNK_SIZE = 128;
private static final int CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE;
private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS;
private static final int MAX_REBUILDS_PER_FRAME = 3;
// ── Geometrie (identisch zu GrassVertexState) ─────────────────────────────
static final int BLADES_PER_TUFT = 3;
static final int SEGMENTS = 5;
static final float WIDTH_FACTOR = 0.10f;
static final float BEND_FACTOR = 0.15f;
// ── LOD ───────────────────────────────────────────────────────────────────
private static final float CULL_DIST = 150f;
private static final float CULL_DIST_SQ = CULL_DIST * CULL_DIST;
// ── Samen ─────────────────────────────────────────────────────────────────
private static final String SEED_TEX_BASE = "Textures/internal/gras/seeds/seeds";
private static final String SEED_TEX_EXT = ".png";
private static final float SEED_SIZE_FACTOR = 0.45f;
private static final float SEED_Y_FACTOR = 0.78f;
// ── Zustand ───────────────────────────────────────────────────────────────
private final SharedInput input;
private AssetManager assetManager;
private com.jme3.renderer.Camera cam;
private TerrainQuad terrain;
private Node grassNode;
private Material material;
private Material[] seedMaterials = new Material[0];
private Material seedStalkMaterial;
@SuppressWarnings({"unchecked", "rawtypes"})
private final List<GrassVertexBlade>[] chunkBlades = new List[CHUNK_COUNT];
private final Node[] chunkNodes = new Node[CHUNK_COUNT];
private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT];
// ── Terrain-Farb-Sampling ─────────────────────────────────────────────────
private BufferedImage[] slotImages; // [12] geladene Texturbilder pro Slot
private MapData cachedMapData;
public TerrainColorGrassState(SharedInput input) {
this.input = input;
for (int i = 0; i < CHUNK_COUNT; i++) chunkBlades[i] = new ArrayList<>();
}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
public void setMapData(MapData mapData) {
this.cachedMapData = mapData;
this.slotImages = null;
initSlotImages();
}
public List<GrassVertexBlade> getAllBlades() {
List<GrassVertexBlade> all = new ArrayList<>();
for (List<GrassVertexBlade> list : chunkBlades) all.addAll(list);
return all;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.assetManager = app.getAssetManager();
this.cam = app.getCamera();
grassNode = new Node("terrainColorGrassNode");
((SimpleApplication) app).getRootNode().attachChild(grassNode);
material = buildMaterial();
seedMaterials = loadSeedMaterials();
seedStalkMaterial = buildSeedStalkMaterial();
try {
for (GrassVertexBlade b : TerrainColorGrassIO.load()) {
int ci = chunkIndex(b.x(), b.z());
if (ci >= 0) { chunkBlades[ci].add(b); dirtyChunks[ci] = true; }
}
} catch (Exception e) {
log.warn("[TerrainColorGrassState] Daten nicht ladbar: {}", e.getMessage());
}
}
@Override
protected void cleanup(Application app) {
((SimpleApplication) app).getRootNode().detachChild(grassNode);
}
@Override protected void onEnable() { grassNode.setCullHint(Spatial.CullHint.Inherit); }
@Override protected void onDisable() { grassNode.setCullHint(Spatial.CullHint.Always); }
@Override
public void update(float tpf) {
if (slotImages == null && cachedMapData != null) initSlotImages();
processBrushEdits();
rebuildDirtyChunks();
updateChunkVisibility();
}
// ── Terrain-Farb-Sampling ─────────────────────────────────────────────────
private void initSlotImages() {
if (cachedMapData == null) return;
slotImages = new BufferedImage[12];
String[] paths = new String[12];
for (int i = 0; i < 4; i++) {
paths[i] = (cachedMapData.terrainTextures != null && cachedMapData.terrainTextures.length > i) ? cachedMapData.terrainTextures[i] : "";
paths[i+4] = (cachedMapData.upperTextures != null && cachedMapData.upperTextures.length > i) ? cachedMapData.upperTextures[i] : "";
paths[i+8] = (cachedMapData.thirdTextures != null && cachedMapData.thirdTextures.length > i) ? cachedMapData.thirdTextures[i] : "";
}
for (int s = 0; s < 12; s++) {
if (paths[s] == null || paths[s].isEmpty()) continue;
try (java.io.InputStream is = getClass().getClassLoader().getResourceAsStream(paths[s])) {
if (is == null) continue;
slotImages[s] = javax.imageio.ImageIO.read(is);
if (slotImages[s] != null) {
log.info("[TerrainColorGrassState] Slot {} geladen: {} ({}x{})", s, paths[s],
slotImages[s].getWidth(), slotImages[s].getHeight());
}
} catch (Exception e) {
log.warn("[TerrainColorGrassState] Slot {} nicht ladbar '{}': {}", s, paths[s], e.getMessage());
}
}
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
if (!chunkBlades[ci].isEmpty()) dirtyChunks[ci] = true;
}
log.info("[TerrainColorGrassState] Slot-Bilder geladen, Chunks als dirty markiert");
}
/**
* Gibt die pixel-genaue Terrain-Texturfarbe an der Weltposition zurück.
* UV = worldXZ / diffuseScale (identisch zum BakedTerrain-Shader).
*/
public float[] sampleTerrainColorAt(float worldX, float worldZ) {
return sampleTerrainColor(worldX, worldZ);
}
private float[] sampleTerrainColor(float worldX, float worldZ) {
float[] fallback = {0.2f, 0.5f, 0.1f};
if (cachedMapData == null || slotImages == null) return fallback;
MapData md = cachedMapData;
int size = MapData.SPLAT_SIZE;
int px = Math.max(0, Math.min(size - 1, Math.round((worldX + 1024f) / 2f)));
int pz = Math.max(0, Math.min(size - 1, (size - 1) - Math.round((worldZ + 1024f) / 2f)));
int idx = pz * size + px;
int maxOverlay = Math.max(
Math.max(md.upperSplatR[idx] & 0xFF, md.upperSplatG[idx] & 0xFF),
Math.max(
Math.max(md.upperSplatB[idx] & 0xFF, md.upperSplatA[idx] & 0xFF),
Math.max(
Math.max(md.thirdSplatR[idx] & 0xFF, md.thirdSplatG[idx] & 0xFF),
Math.max(md.thirdSplatB[idx] & 0xFF, md.thirdSplatA[idx] & 0xFF)
)
)
);
float baseScale = Math.max(0f, (255 - maxOverlay) / 255f);
int splatR = md.splatR[idx] & 0xFF;
if (splatR == 0) splatR = 255;
float[] slotW = {
splatR * baseScale / 255f,
(md.splatG[idx] & 0xFF) * baseScale / 255f,
(md.splatB[idx] & 0xFF) * baseScale / 255f,
(md.splatA[idx] & 0xFF) * baseScale / 255f,
(md.upperSplatR[idx] & 0xFF) / 255f,
(md.upperSplatG[idx] & 0xFF) / 255f,
(md.upperSplatB[idx] & 0xFF) / 255f,
(md.upperSplatA[idx] & 0xFF) / 255f,
(md.thirdSplatR[idx] & 0xFF) / 255f,
(md.thirdSplatG[idx] & 0xFF) / 255f,
(md.thirdSplatB[idx] & 0xFF) / 255f,
(md.thirdSplatA[idx] & 0xFF) / 255f,
};
float[] scales = input.diffuseScales; // volatile Shader-UV: worldXZ / scale
float rSum = 0, gSum = 0, bSum = 0, wSum = 0;
for (int s = 0; s < 12; s++) {
if (slotW[s] <= 0f || slotImages[s] == null) continue;
float sc = (scales != null && s < scales.length && scales[s] > 0f) ? scales[s] : 8f;
float u = (worldX / sc) % 1f; if (u < 0f) u += 1f;
float v = (worldZ / sc) % 1f; if (v < 0f) v += 1f;
int imgW = slotImages[s].getWidth();
int imgH = slotImages[s].getHeight();
int texPx = Math.min(imgW - 1, (int)(u * imgW));
int texPz = Math.min(imgH - 1, (int)(v * imgH));
int rgb = slotImages[s].getRGB(texPx, texPz);
float r = ((rgb >> 16) & 0xFF) / 255f;
float g = ((rgb >> 8) & 0xFF) / 255f;
float b = ( rgb & 0xFF) / 255f;
rSum += r * slotW[s];
gSum += g * slotW[s];
bSum += b * slotW[s];
wSum += slotW[s];
}
if (wSum <= 0f) return fallback;
return new float[]{rSum / wSum, gSum / wSum, bSum / wSum};
}
// ── Material ──────────────────────────────────────────────────────────────
private Material buildMaterial() {
try {
Material mat = new Material(assetManager, "MatDefs/GrassVertex.j3md");
mat.setFloat("WindSpeed", 1.0f);
mat.setFloat("WindStrength", 0.15f);
mat.setVector3("SunDir", new Vector3f(0.35f, 0.8f, 0.45f).normalizeLocal());
mat.setColor("SunColor", new ColorRGBA(0.95f, 0.90f, 0.75f, 1.0f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat;
} catch (Exception e) {
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.22f, 0.68f, 0.12f, 1f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat;
}
}
private Material buildSeedStalkMaterial() {
Material mat = new Material(assetManager, "MatDefs/GrassVertex.j3md");
mat.setFloat("WindSpeed", 1.0f);
mat.setFloat("WindStrength", 0.15f);
mat.setVector3("SunDir", new Vector3f(0.35f, 0.8f, 0.45f).normalizeLocal());
mat.setColor("SunColor", new ColorRGBA(0.95f, 0.90f, 0.75f, 1.0f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat;
}
private Material[] loadSeedMaterials() {
List<Material> mats = new ArrayList<>();
for (int i = 1; i <= 99; i++) {
String path = SEED_TEX_BASE + i + SEED_TEX_EXT;
try {
Texture tex = assetManager.loadTexture(path);
Material mat = new Material(assetManager, "MatDefs/GrassSeed.j3md");
mat.setTexture("ColorMap", tex);
mat.setFloat("WindSpeed", 1.0f);
mat.setFloat("WindStrength", 0.15f);
mat.setVector3("SunDir", new Vector3f(0.35f, 0.8f, 0.45f).normalizeLocal());
mat.setColor("SunColor", new ColorRGBA(0.95f, 0.90f, 0.75f, 1.0f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
mats.add(mat);
} catch (Exception e) {
break;
}
}
return mats.toArray(new Material[0]);
}
// ── Pinsel-Interaktion ────────────────────────────────────────────────────
private void processBrushEdits() {
SharedInput.GrassVertexEdit edit;
while ((edit = input.terrainGrassEditQueue.poll()) != null) {
if (terrain == null) continue;
float jmeX = (float)(edit.screenX() * input.viewportScaleX);
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);
}
}
private Vector3f raycastSurface(float screenX, float screenY, com.jme3.renderer.Camera cam) {
float flippedY = cam.getHeight() - screenY;
Vector3f origin = cam.getWorldCoordinates(new Vector2f(screenX, flippedY), 0f);
Vector3f dir = cam.getWorldCoordinates(new Vector2f(screenX, flippedY), 1f).subtractLocal(origin).normalizeLocal();
Ray ray = new Ray(origin, dir);
Vector3f best = null;
float bestDistSq = Float.MAX_VALUE;
CollisionResults cr = new CollisionResults();
terrain.collideWith(ray, cr);
if (cr.size() > 0) {
best = cr.getClosestCollision().getContactPoint().clone();
bestDistSq = origin.distanceSquared(best);
}
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
if (ves != null) {
Vector3f vp = ves.raycastVoxelGeometry(ray);
if (vp != null) {
float d = origin.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 = origin.distanceSquared(sp);
if (d < bestDistSq) { best = sp; }
}
}
return best;
}
private final Random rng = new Random();
private static float brushFalloff(float distRatio) {
return 1f - 0.5f * distRatio * distRatio;
}
private void addBlades(Vector3f center) {
float radius = (float) input.terrainGrassTool.brushRadius.getValue();
float height = (float) input.terrainGrassTool.bladeHeight.getValue();
int density = (int) input.terrainGrassTool.density.getValue();
float uniformity = (float) input.terrainGrassTool.uniformity.getValue();
float variation = (1f - uniformity) * 0.25f;
float radSq = radius * radius;
float seedPct = (float) input.terrainGrassTool.seedDensity.getValue() / 100f;
// Zu kleine Halme im Pinselbereich durch größere ersetzen
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
List<GrassVertexBlade> list = chunkBlades[ci];
boolean changed = false;
for (int i = 0; i < list.size(); i++) {
GrassVertexBlade b = list.get(i);
float dx = b.x() - center.x, dz = b.z() - center.z;
float distSq = dx*dx + dz*dz;
if (distSq > radSq) continue;
float distRatio = (float) Math.sqrt(distSq) / radius;
float idealH = height * brushFalloff(distRatio);
if (b.height() < idealH * 0.88f) {
float newH = idealH * (1f + variation * (rng.nextFloat() * 2f - 1f));
list.set(i, new GrassVertexBlade(b.x(), b.y(), b.z(), Math.max(0.05f, newH), 0f, b.seedIdx()));
changed = true;
}
}
if (changed) dirtyChunks[ci] = true;
}
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
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;
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));
h = Math.max(0.05f, h);
int seedIdx = rng.nextFloat() < seedPct ? rng.nextInt(Math.max(1, seedMaterials.length)) : -1;
int ci = chunkIndex(bx, bz);
if (ci >= 0) {
chunkBlades[ci].add(new GrassVertexBlade(bx, by, bz, h, 0f, seedIdx));
dirtyChunks[ci] = true;
}
}
}
public void adjustBladeHeights(Vector3f center, float radius) {
if (terrain == null) return;
float radSq = radius * radius;
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
List<GrassVertexBlade> list = chunkBlades[ci];
boolean changed = false;
for (int i = 0; i < list.size(); i++) {
GrassVertexBlade b = list.get(i);
float dx = b.x() - center.x, dz = b.z() - center.z;
if (dx*dx + dz*dz > radSq) continue;
float newY = terrain.getHeight(new Vector2f(b.x(), b.z()));
if (!Float.isNaN(newY)) {
list.set(i, new GrassVertexBlade(b.x(), newY, b.z(), b.height(), 0f, b.seedIdx()));
changed = true;
}
}
if (changed) dirtyChunks[ci] = true;
}
}
private void removeBlades(Vector3f center) {
float radSq = (float) Math.pow(input.terrainGrassTool.brushRadius.getValue(), 2);
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
List<GrassVertexBlade> list = chunkBlades[ci];
int before = list.size();
list.removeIf(b -> {
float dx = b.x() - center.x, dz = b.z() - center.z;
return dx*dx + dz*dz <= radSq;
});
if (list.size() != before) dirtyChunks[ci] = true;
}
}
// ── Chunk-Verwaltung ──────────────────────────────────────────────────────
private int chunkIndex(float x, float z) {
int cx = (int)((x + TERRAIN_HALF) / CHUNK_SIZE);
int cz = (int)((z + TERRAIN_HALF) / CHUNK_SIZE);
if (cx < 0 || cx >= CHUNKS_PER_AXIS || cz < 0 || cz >= CHUNKS_PER_AXIS) return -1;
return cz * CHUNKS_PER_AXIS + cx;
}
private void rebuildDirtyChunks() {
int rebuilt = 0;
for (int ci = 0; ci < CHUNK_COUNT && rebuilt < MAX_REBUILDS_PER_FRAME; ci++) {
if (!dirtyChunks[ci]) continue;
dirtyChunks[ci] = false;
rebuilt++;
rebuildChunk(ci);
}
}
private void rebuildChunk(int ci) {
if (chunkNodes[ci] != null) {
grassNode.detachChild(chunkNodes[ci]);
chunkNodes[ci] = null;
}
List<GrassVertexBlade> blades = chunkBlades[ci];
if (blades.isEmpty()) return;
int bladeCount = blades.size();
int vertCount = bladeCount * BLADES_PER_TUFT * (SEGMENTS + 1) * 2;
int indexCount = bladeCount * BLADES_PER_TUFT * SEGMENTS * 6;
float[] positions = new float[vertCount * 3];
float[] normals = new float[vertCount * 3];
float[] colors = new float[vertCount * 4];
float[] texCoords = new float[vertCount * 2];
int[] indices = new int[indexCount];
int vi = 0, ii = 0;
for (GrassVertexBlade blade : blades) {
// Pixel-genaue Terrain-Farbe an der Halmposition
float[] col = sampleTerrainColor(blade.x(), blade.z());
// Als vollständiger Tint übergeben (tint[3]=1.0 → GrassVertexState.setV übernimmt Farbe komplett)
float[] tint = {col[0], col[1], col[2], 1.0f, 0f};
// Farbparameter sind egal werden durch tint[3]=1.0 komplett überschrieben
GrassVertexState.buildTuft(positions, normals, colors, texCoords, indices, vi, ii, blade,
0f, 0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 0f, 0f, 0f, tint);
vi += BLADES_PER_TUFT * (SEGMENTS + 1) * 2;
ii += BLADES_PER_TUFT * SEGMENTS * 6;
}
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(positions));
mesh.setBuffer(VertexBuffer.Type.Normal, 3, BufferUtils.createFloatBuffer(normals));
mesh.setBuffer(VertexBuffer.Type.Color, 4, BufferUtils.createFloatBuffer(colors));
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(texCoords));
mesh.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createIntBuffer(indices));
mesh.updateBound();
Geometry geo = new Geometry("tcg_" + ci, mesh);
geo.setMaterial(material);
Node node = new Node("tcgc_" + ci);
node.attachChild(geo);
// Samen
if (seedMaterials.length > 0) {
List<GrassVertexBlade> seededAll = new ArrayList<>();
Map<Integer, List<GrassVertexBlade>> byTex = new HashMap<>();
for (GrassVertexBlade b : blades) {
if (b.seedIdx() >= 0 && b.seedIdx() < seedMaterials.length) {
seededAll.add(b);
byTex.computeIfAbsent(b.seedIdx(), k -> new ArrayList<>()).add(b);
}
}
if (!seededAll.isEmpty()) {
Geometry stalkGeo = buildSeedStalkMesh("stalk_" + ci, seededAll);
stalkGeo.setMaterial(seedStalkMaterial);
node.attachChild(stalkGeo);
}
for (Map.Entry<Integer, List<GrassVertexBlade>> e : byTex.entrySet()) {
Geometry seedGeo = buildSeedCrossMesh("seed_" + ci + "_" + e.getKey(), e.getValue());
seedGeo.setMaterial(seedMaterials[e.getKey()]);
node.attachChild(seedGeo);
}
}
chunkNodes[ci] = node;
grassNode.attachChild(node);
}
private void updateChunkVisibility() {
if (cam == null) return;
Vector3f camPos = cam.getLocation();
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
if (chunkNodes[ci] == null) continue;
int cx = ci % CHUNKS_PER_AXIS;
int cz = ci / CHUNKS_PER_AXIS;
float wx = cx * CHUNK_SIZE - TERRAIN_HALF + CHUNK_SIZE * 0.5f;
float wz = cz * CHUNK_SIZE - TERRAIN_HALF + CHUNK_SIZE * 0.5f;
float dx = camPos.x - wx, dz = camPos.z - wz;
boolean visible = dx*dx + dz*dz <= CULL_DIST_SQ;
chunkNodes[ci].setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
}
}
// ── Samen-Mesh-Generierung (analog zu GrassVertexState) ───────────────────
private static Geometry buildSeedStalkMesh(String name, List<GrassVertexBlade> blades) {
final int SEG = 4;
final float R = 0xbb / 255f, G = 0x90 / 255f, B = 0x59 / 255f;
int n = blades.size();
int vTotal = n * (SEG + 1) * 2;
float[] pos = new float[vTotal * 3];
float[] nrm = new float[vTotal * 3];
float[] col = new float[vTotal * 4];
float[] tex = new float[vTotal * 2];
int[] idx = new int [n * SEG * 6];
int vi = 0, ii = 0;
for (GrassVertexBlade blade : blades) {
float x = blade.x(), y = blade.y(), z = blade.z(), h = blade.height();
float hw = h * 0.018f;
float ang = (float)(((x * 127.1f + z * 311.7f) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2));
float cA = (float) Math.cos(ang), sA = (float) Math.sin(ang);
float nx = -sA, ny = 0f, nz = cA;
float blend = 0.30f;
nx *= (1f - blend); ny = blend; nz *= (1f - blend);
float nLen = (float) Math.sqrt(nx*nx + ny*ny + nz*nz);
nx /= nLen; ny /= nLen; nz /= nLen;
for (int s = 0; s <= SEG; s++) {
float t = (float) s / SEG;
float curHW = hw * (float) Math.pow(1.0 - t, 1.4);
float py = y + h * t;
int sviL = vi + s * 2;
int sviR = sviL + 1;
pos[sviL*3] = x - cA*curHW; pos[sviL*3+1] = py; pos[sviL*3+2] = z - sA*curHW;
nrm[sviL*3] = nx; nrm[sviL*3+1] = ny; nrm[sviL*3+2] = nz;
col[sviL*4] = R; col[sviL*4+1] = G; col[sviL*4+2] = B; col[sviL*4+3] = 1f;
tex[sviL*2] = t; tex[sviL*2+1] = 0f;
pos[sviR*3] = x + cA*curHW; pos[sviR*3+1] = py; pos[sviR*3+2] = z + sA*curHW;
nrm[sviR*3] = nx; nrm[sviR*3+1] = ny; nrm[sviR*3+2] = nz;
col[sviR*4] = R; col[sviR*4+1] = G; col[sviR*4+2] = B; col[sviR*4+3] = 1f;
tex[sviR*2] = t; tex[sviR*2+1] = 0f;
}
for (int s = 0; s < SEG; s++) {
int b0 = vi + s * 2;
idx[ii] = b0; idx[ii+1] = b0+1; idx[ii+2] = b0+3;
idx[ii+3] = b0; idx[ii+4] = b0+3; idx[ii+5] = b0+2;
ii += 6;
}
vi += (SEG + 1) * 2;
}
Mesh m = new Mesh();
m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(pos));
m.setBuffer(VertexBuffer.Type.Normal, 3, BufferUtils.createFloatBuffer(nrm));
m.setBuffer(VertexBuffer.Type.Color, 4, BufferUtils.createFloatBuffer(col));
m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(tex));
m.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createIntBuffer(idx));
m.updateBound();
return new Geometry(name, m);
}
private static Geometry buildSeedCrossMesh(String name, List<GrassVertexBlade> blades) {
int n = blades.size();
float[] pos = new float[n * 8 * 3];
float[] tex = new float[n * 8 * 2];
int[] idx = new int [n * 12];
int vi = 0, ii = 0;
for (GrassVertexBlade b : blades) {
float x = b.x();
float yBot = b.y() + b.height() * SEED_Y_FACTOR;
float z = b.z();
float size = b.height() * SEED_SIZE_FACTOR;
float hw = size * 0.5f;
setSeedV(pos, tex, vi+0, x-hw, yBot, z, 0,0);
setSeedV(pos, tex, vi+1, x+hw, yBot, z, 1,0);
setSeedV(pos, tex, vi+2, x+hw, yBot+size, z, 1,1);
setSeedV(pos, tex, vi+3, x-hw, yBot+size, z, 0,1);
setSeedV(pos, tex, vi+4, x, yBot, z-hw, 0,0);
setSeedV(pos, tex, vi+5, x, yBot, z+hw, 1,0);
setSeedV(pos, tex, vi+6, x, yBot+size, z+hw, 1,1);
setSeedV(pos, tex, vi+7, x, yBot+size, z-hw, 0,1);
idx[ii] = vi; idx[ii+1] = vi+1; idx[ii+2] = vi+2;
idx[ii+3] = vi; idx[ii+4] = vi+2; idx[ii+5] = vi+3;
idx[ii+6] = vi+4; idx[ii+7] = vi+5; idx[ii+8] = vi+6;
idx[ii+9] = vi+4; idx[ii+10] = vi+6; idx[ii+11] = vi+7;
vi += 8; ii += 12;
}
Mesh m = new Mesh();
m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(pos));
m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(tex));
m.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createIntBuffer(idx));
m.updateBound();
return new Geometry(name, m);
}
private static void setSeedV(float[] pos, float[] tex, int vi,
float x, float y, float z, float u, float v) {
pos[vi*3] = x; pos[vi*3+1] = y; pos[vi*3+2] = z;
tex[vi*2] = u; tex[vi*2+1] = v;
}
}

View File

@@ -98,7 +98,8 @@ public class TerrainEditorState extends BaseAppState {
private Geometry brushIndicator;
private Geometry waterGeo;
private PlacedObjectState placedObjectState;
private GrassVertexState grassVertexState;
private GrassVertexState grassVertexState;
private TerrainColorGrassState terrainColorGrassState;
private StoneEditorState stoneEditorState;
private SceneObjectState sceneObjState;
private ItemPlacementState itemPlacementState;
@@ -253,6 +254,7 @@ public class TerrainEditorState extends BaseAppState {
// ── Szene aufbauen ────────────────────────────────────────────────────────
private void buildScene() {
MapData mapDataForGrass = loadedMapData; // sichern bevor buildTerrain()/initSplatmap() es auf null setzt
input.loadingStatus = "Lade Terrain...";
terrain = buildTerrain();
cachedHeightMap = terrain.getHeightMap();
@@ -266,8 +268,15 @@ public class TerrainEditorState extends BaseAppState {
input.loadingStatus = "Lade Vertex-Gras...";
grassVertexState = new GrassVertexState(input);
grassVertexState.setTerrain(terrain);
grassVertexState.setMapData(mapDataForGrass);
app.getStateManager().attach(grassVertexState);
input.loadingStatus = "Lade Terrain-Gras...";
terrainColorGrassState = new TerrainColorGrassState(input);
terrainColorGrassState.setTerrain(terrain);
terrainColorGrassState.setMapData(mapDataForGrass);
app.getStateManager().attach(terrainColorGrassState);
input.loadingStatus = "Lade Steine...";
stoneEditorState = new StoneEditorState(input);
stoneEditorState.setTerrain(terrain);
@@ -1222,6 +1231,7 @@ public class TerrainEditorState extends BaseAppState {
processEdits();
processTextureEdits();
updateBrushIndicator();
processGrassTerrainPick();
updateAxesGizmo();
// Debug: Strg+F8 — Raw-Texture-Modus (kein Lighting) ein/aus
if (input.debugNoLightToggle) {
@@ -1411,6 +1421,8 @@ public class TerrainEditorState extends BaseAppState {
/** Gibt den TerrainQuad-Node zurück (z.B. für Voxel-Raycasts). */
public TerrainQuad getTerrainNode() { return terrain; }
public MapData getMapData() { return loadedMapData; }
/** Gibt das Terrain-Material zurück (für BakedTerrain-Material-Aufbau in anderen States). */
public com.jme3.material.Material getTerrainMaterial() { return terrainMat; }
@@ -1534,7 +1546,8 @@ public class TerrainEditorState extends BaseAppState {
final GrassTuftIO.GrassData grassData = placedObjectState != null
? new GrassTuftIO.GrassData(placedObjectState.getSlotPaths(), placedObjectState.getAllTufts())
: null;
final var grassVertexBlades = grassVertexState != null ? grassVertexState.getAllBlades() : null;
final var grassVertexBlades = grassVertexState != null ? grassVertexState.getAllBlades() : null;
final var terrainColorGrassBlades = terrainColorGrassState != null ? terrainColorGrassState.getAllBlades() : null;
final List<PlacedModel> models = sceneObjState != null ? sceneObjState.getPlacedModels() : null;
final List<PlacedLight> lights = lightState != null ? lightState.getPlacedLights() : null;
final List<PlacedEmitter> emitters = emitterState != null ? emitterState.getPlacedEmitters() : null;
@@ -1635,6 +1648,10 @@ public class TerrainEditorState extends BaseAppState {
try { GrassVertexIO.save(grassVertexBlades); }
catch (IOException e) { log.error("Vertex-Gras nicht speicherbar", e); }
}
if (terrainColorGrassBlades != null) {
try { de.blight.common.TerrainColorGrassIO.save(terrainColorGrassBlades); }
catch (IOException e) { log.error("Terrain-Gras nicht speicherbar", e); }
}
MapIO.save(data);
if (heightSnap != null) {
try {
@@ -1661,6 +1678,30 @@ public class TerrainEditorState extends BaseAppState {
// ── Brush-Indikator ───────────────────────────────────────────────────────
private void processGrassTerrainPick() {
SharedInput.GrassVertexEdit req;
while ((req = input.grassTerrainPickQueue.poll()) != null) {
if (terrainColorGrassState == null) continue;
float jmeX = req.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - req.screenY() * (float) input.viewportScaleY;
Vector3f near = cam.getWorldCoordinates(new com.jme3.math.Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new com.jme3.math.Vector2f(jmeX, jmeY), 1f);
com.jme3.math.Ray ray = new com.jme3.math.Ray(near, far.subtract(near).normalizeLocal());
Vector3f hit = raycastSurface(ray);
if (hit == null) continue;
float[] color = terrainColorGrassState.sampleTerrainColorAt(hit.x, hit.z);
input.grassFreshR = color[0];
input.grassFreshG = color[1];
input.grassFreshB = color[2];
input.grassColorsChanged = true;
java.util.function.Consumer<float[]> cb = input.grassTerrainPickCallback;
if (cb != null) {
input.grassTerrainPickCallback = null;
javafx.application.Platform.runLater(() -> cb.accept(color));
}
}
}
private void updateBrushIndicator() {
float mx = input.mouseScreenX;
float my = input.mouseScreenY;
@@ -1720,6 +1761,15 @@ public class TerrainEditorState extends BaseAppState {
contactPoint = null;
}
}
} else if (layer == SharedInput.LAYER_TERRAIN_GRASS) {
contactPoint = raycastSurface(ray);
if (contactPoint != null) {
brushRadius = (float) input.terrainGrassTool.brushRadius.getValue();
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
if (ves != null && ves.terrainTypeAt(contactPoint.x, contactPoint.z) == VoxelEditorState.TerrainType.VOXEL_UNBAKED) {
contactPoint = null;
}
}
}
if (contactPoint != null) {

View File

@@ -0,0 +1,20 @@
package de.blight.editor.tool;
import java.util.List;
public class TerrainColorGrassTool extends EditorTool {
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 1.0, 50.0);
public final ToolParameter bladeHeight = new ToolParameter("Halmhöhe", 0.6, 0.1, 2.0);
public final ToolParameter density = new ToolParameter("Dichte", 50.0, 1.0, 100.0);
public final ToolParameter uniformity = new ToolParameter("Gleichmäßigkeit", 1.0, 0.0, 1.0);
public final ToolParameter seedDensity = new ToolParameter("Samen %", 0.0, 0.0, 100.0);
@Override public String getName() { return "Terrain-Gras"; }
@Override public List<ChoiceToolParameter> getChoiceParameters() { return List.of(); }
@Override public List<ToolParameter> getParameters() {
return List.of(brushRadius, bladeHeight, density, uniformity, seedDensity);
}
}

View File

@@ -20,6 +20,7 @@ import com.jme3.texture.Texture;
import com.jme3.util.BufferUtils;
import de.blight.common.GrassVertexBlade;
import de.blight.common.GrassVertexIO;
import de.blight.common.MapData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -50,7 +51,8 @@ public class GrassVertexRenderState extends BaseAppState
private static final int BLADES_PER_TUFT = 3;
private static final int SEGMENTS = 5;
private static final float WIDTH_FACTOR = 0.10f;
private static final float BEND_FACTOR = 0.15f;
private static final float BEND_FACTOR = 0.15f;
private static final float TERRAIN_DRYNESS_MAX = 0.7f; // max Dryness-Boost bei nicht-grünem Untergrund
private static final ColorRGBA ROOT_COLOR = new ColorRGBA(0.08f, 0.34f, 0.04f, 1f);
private static final ColorRGBA TIP_COLOR = new ColorRGBA(0.26f, 0.72f, 0.11f, 1f);
// 050 % Trockenheit: Grün → Goldgelb
@@ -69,6 +71,7 @@ public class GrassVertexRenderState extends BaseAppState
// ── Zustand ───────────────────────────────────────────────────────────────
private final TerrainChunkState terrainChunkState;
private AssetManager assetManager;
private float[][] slotAvgColors; // [12][3] — gemittelte RGB (0..1) pro Terrain-Textur-Slot
private Node grassNode;
private Material material;
private Material[] seedMaterials = new Material[0];
@@ -104,6 +107,7 @@ public class GrassVertexRenderState extends BaseAppState
material = buildMaterial();
seedMaterials = loadSeedMaterials();
seedStalkMaterial = buildSeedStalkMaterial();
initSlotColors();
}
private Material buildSeedStalkMaterial() {
@@ -238,6 +242,131 @@ public class GrassVertexRenderState extends BaseAppState
return cz * CHUNKS_PER_AXIS + cx;
}
// ── Terrain-Tint ──────────────────────────────────────────────────────────
private void initSlotColors() {
slotAvgColors = new float[12][3];
MapData md = terrainChunkState.getMapData();
if (md == null) {
for (float[] c : slotAvgColors) { c[0] = 1f; c[1] = 1f; c[2] = 1f; }
return;
}
String[] paths = new String[12];
for (int i = 0; i < 4; i++) {
paths[i] = (md.terrainTextures != null && md.terrainTextures.length > i) ? md.terrainTextures[i] : "";
paths[i+4] = (md.upperTextures != null && md.upperTextures.length > i) ? md.upperTextures[i] : "";
paths[i+8] = (md.thirdTextures != null && md.thirdTextures.length > i) ? md.thirdTextures[i] : "";
}
for (int s = 0; s < 12; s++) {
slotAvgColors[s] = sampleAvgColor(paths[s]);
}
}
private float[] sampleAvgColor(String path) {
float[] neutral = {1f, 1f, 1f};
if (path == null || path.isEmpty()) return neutral;
try (java.io.InputStream is = getClass().getClassLoader().getResourceAsStream(path)) {
if (is == null) return neutral;
java.awt.image.BufferedImage bi = javax.imageio.ImageIO.read(is);
if (bi == null) return neutral;
int w = bi.getWidth(), h = bi.getHeight();
float rSum = 0, gSum = 0, bSum = 0;
int samples = 0;
for (int gx = 0; gx < 4; gx++) {
for (int gz = 0; gz < 4; gz++) {
int px = (int)((gx + 0.5f) / 4f * w);
int pz = (int)((gz + 0.5f) / 4f * h);
int rgb = bi.getRGB(px, pz);
rSum += ((rgb >> 16) & 0xFF) / 255f;
gSum += ((rgb >> 8) & 0xFF) / 255f;
bSum += ( rgb & 0xFF) / 255f;
samples++;
}
}
if (samples == 0) return neutral;
return new float[]{rSum / samples, gSum / samples, bSum / samples};
} catch (Exception e) {
log.warn("[GrassVertexRenderState] Textur-Sampling fehlgeschlagen für '{}': {}", path, e.getMessage());
return neutral;
}
}
private float[] computeTerrainTint(float worldX, float worldZ) {
// tint[5]: [r, g, b, colorBlend, drynessBoost]
// colorBlend > 0: grüner Untergrund → Farbton übernehmen
// drynessBoost > 0: nicht-grüner Untergrund → Gras trockener darstellen
float[] noTint = {0f, 0f, 0f, 0f, 0f};
MapData md = terrainChunkState.getMapData();
if (md == null) return noTint;
int size = MapData.SPLAT_SIZE;
int px = Math.max(0, Math.min(size - 1, Math.round((worldX + 1024f) / 2f)));
int pz = Math.max(0, Math.min(size - 1, (size - 1) - Math.round((worldZ + 1024f) / 2f)));
int idx = pz * size + px;
int maxOverlay = Math.max(
Math.max(md.upperSplatR[idx] & 0xFF, md.upperSplatG[idx] & 0xFF),
Math.max(
Math.max(md.upperSplatB[idx] & 0xFF, md.upperSplatA[idx] & 0xFF),
Math.max(
Math.max(md.thirdSplatR[idx] & 0xFF, md.thirdSplatG[idx] & 0xFF),
Math.max(md.thirdSplatB[idx] & 0xFF, md.thirdSplatA[idx] & 0xFF)
)
)
);
float baseScale = Math.max(0f, (255 - maxOverlay) / 255f);
int splatR = md.splatR[idx] & 0xFF;
if (splatR == 0) splatR = 255;
float[] slotW = {
splatR * baseScale / 255f,
(md.splatG[idx] & 0xFF) * baseScale / 255f,
(md.splatB[idx] & 0xFF) * baseScale / 255f,
(md.splatA[idx] & 0xFF) * baseScale / 255f,
(md.upperSplatR[idx] & 0xFF) / 255f,
(md.upperSplatG[idx] & 0xFF) / 255f,
(md.upperSplatB[idx] & 0xFF) / 255f,
(md.upperSplatA[idx] & 0xFF) / 255f,
(md.thirdSplatR[idx] & 0xFF) / 255f,
(md.thirdSplatG[idx] & 0xFF) / 255f,
(md.thirdSplatB[idx] & 0xFF) / 255f,
(md.thirdSplatA[idx] & 0xFF) / 255f,
};
float rSum = 0, gSum = 0, bSum = 0, wSum = 0;
for (int s = 0; s < 12; s++) {
if (slotW[s] <= 0f) continue;
rSum += slotAvgColors[s][0] * slotW[s];
gSum += slotAvgColors[s][1] * slotW[s];
bSum += slotAvgColors[s][2] * slotW[s];
wSum += slotW[s];
}
if (wSum <= 0f) return noTint;
float r = rSum / wSum, g = gSum / wSum, b = bSum / wSum;
// Hue berechnen
float cmax = Math.max(r, Math.max(g, b));
float cmin = Math.min(r, Math.min(g, b));
float delta = cmax - cmin;
if (delta < 1e-6f || cmax < 0.05f) return noTint;
float sat = delta / cmax;
if (sat < 0.08f) return noTint;
float hue;
if (cmax == r) hue = 60f * (((g - b) / delta) % 6f);
else if (cmax == g) hue = 60f * ((b - r) / delta + 2f);
else hue = 60f * ((r - g) / delta + 4f);
if (hue < 0f) hue += 360f;
if (hue >= 80f && hue <= 155f) {
// Grüner Untergrund → Farbton des Terrains übernehmen
return new float[]{r, g, b, 0.45f, 0f};
} else if (hue >= 20f && hue < 80f) {
// Gelb-/Braunton → je weiter von Grün entfernt, desto trockener
float drynessBoost = (80f - hue) / 60f * TERRAIN_DRYNESS_MAX;
return new float[]{0f, 0f, 0f, 0f, drynessBoost};
}
return noTint;
}
private void buildChunk(int ci) {
List<GrassVertexBlade> blades = chunkBlades[ci];
if (blades.isEmpty()) return;
@@ -254,7 +383,10 @@ public class GrassVertexRenderState extends BaseAppState
int vi = 0, ii = 0;
for (GrassVertexBlade blade : blades) {
buildTuft(positions, normals, colors, texCoords, indices, vi, ii, blade);
float[] tint = (slotAvgColors != null)
? computeTerrainTint(blade.x(), blade.z())
: new float[]{0f, 0f, 0f, 0f, 0f};
buildTuft(positions, normals, colors, texCoords, indices, vi, ii, blade, tint);
vi += BLADES_PER_TUFT * (SEGMENTS + 1) * 2;
ii += BLADES_PER_TUFT * SEGMENTS * 6;
}
@@ -434,7 +566,7 @@ public class GrassVertexRenderState extends BaseAppState
// ── Mesh-Logik (gespiegelt zu GrassVertexState) ───────────────────────────
private static void buildTuft(float[] pos, float[] nrm, float[] col, float[] tex,
int[] idx, int vi, int ii, GrassVertexBlade blade) {
int[] idx, int vi, int ii, GrassVertexBlade blade, float[] tint) {
float x = blade.x(), y = blade.y(), z = blade.z(), h = blade.height();
float baseHW = h * WIDTH_FACTOR * 0.5f;
float tAngle = (float) (((x * 127.1f + z * 311.7f) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2));
@@ -496,8 +628,8 @@ public class GrassVertexRenderState extends BaseAppState
int svi = bladeVi + s * 2;
float dry = blade.dryness();
float bladePhase = hash(bx, bz) * 6.2832f;
setV(pos, nrm, col, tex, svi, spX - cosA * hw, spY, spZ - sinA * hw, nx, ny, nz, t, bladePhase, dry);
setV(pos, nrm, col, tex, svi+1, spX + cosA * hw, spY, spZ + sinA * hw, nx, ny, nz, t, bladePhase, dry);
setV(pos, nrm, col, tex, svi, spX - cosA * hw, spY, spZ - sinA * hw, nx, ny, nz, t, bladePhase, dry, tint);
setV(pos, nrm, col, tex, svi+1, spX + cosA * hw, spY, spZ + sinA * hw, nx, ny, nz, t, bladePhase, dry, tint);
if (s < SEGMENTS) {
int sii = bladeIi + s * 6;
@@ -520,7 +652,7 @@ public class GrassVertexRenderState extends BaseAppState
private static void setV(float[] pos, float[] nrm, float[] col, float[] tex, int vi,
float x, float y, float z, float nx, float ny, float nz,
float wf, float bladePhase, float dryness) {
float wf, float bladePhase, float dryness, float[] tint) {
int pi = vi * 3;
pos[pi] = x; pos[pi+1] = y; pos[pi+2] = z;
@@ -528,6 +660,8 @@ public class GrassVertexRenderState extends BaseAppState
nrm[ni] = nx; nrm[ni+1] = ny; nrm[ni+2] = nz;
int ci = vi * 4;
// Effektive Dryness: manueller Wert + Terrain-Boost (nicht-grüner Untergrund)
float effectiveDryness = Math.min(1f, dryness + tint[4]);
float gr = ROOT_COLOR.r + (TIP_COLOR.r - ROOT_COLOR.r) * wf;
float gg = ROOT_COLOR.g + (TIP_COLOR.g - ROOT_COLOR.g) * wf;
float gb = ROOT_COLOR.b + (TIP_COLOR.b - ROOT_COLOR.b) * wf;
@@ -539,17 +673,27 @@ public class GrassVertexRenderState extends BaseAppState
float vb = VERY_DRY_ROOT_COLOR.b + (VERY_DRY_TIP_COLOR.b - VERY_DRY_ROOT_COLOR.b) * wf;
// Zwei-Segment-Gradient: 0→0.5 = grün→goldgelb, 0.5→1.0 = goldgelb→dunkelbraun
float fr, fg, fb;
if (dryness <= 0.5f) {
float t = dryness * 2f;
if (effectiveDryness <= 0.5f) {
float t = effectiveDryness * 2f;
fr = gr + (dr - gr) * t;
fg = gg + (dg - gg) * t;
fb = gb + (db - gb) * t;
} else {
float t = (dryness - 0.5f) * 2f;
float t = (effectiveDryness - 0.5f) * 2f;
fr = dr + (vr - dr) * t;
fg = dg + (vg - dg) * t;
fb = db + (vb - db) * t;
}
// Grüner Untergrund: Farbton des Terrains übernehmen (stärker an Wurzel)
if (tint[3] > 0f) {
float ts = tint[3] * (1f - wf * 0.5f);
fr = fr + (tint[0] - fr) * ts;
fg = fg + (tint[1] - fg) * ts;
fb = fb + (tint[2] - fb) * ts;
}
fr = Math.max(0f, Math.min(1f, fr));
fg = Math.max(0f, Math.min(1f, fg));
fb = Math.max(0f, Math.min(1f, fb));
col[ci] = fr; col[ci+1] = fg; col[ci+2] = fb; col[ci+3] = 1f;
int ti = vi * 2;

View File

@@ -98,6 +98,8 @@ public class TerrainChunkState extends BaseAppState {
this.mapData = mapData;
}
public MapData getMapData() { return mapData; }
// ── Lifecycle ─────────────────────────────────────────────────────────────
public void loadChunkHeights() {

Binary file not shown.