Imposter Generation angepassst

This commit is contained in:
2026-07-19 10:37:10 +02:00
parent 0b1cb4d0e9
commit 0913da2f93
14 changed files with 1093 additions and 474 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,28 @@
#Sat Jul 18 13:44:33 CEST 2026
attachedEmitters.count=0
attachedLights.count=0
castShadow=true
category=
cullDistance=120.0
footstepSurface=
interactableOffsetX=0.0
interactableOffsetY=0.5
interactableOffsetZ=0.0
interactableRotY=0.0
interactableType=NONE
lod1Distance=30.0
lod1Path=
lod2Distance=80.0
lod2Path=
name=pine_medium_20260706_190939
pivotOffsetY=0.0
placementOffsetY=0.0
randomScaleMax=1.0
randomScaleMin=1.0
receiveShadow=true
scaleX=1.0
scaleY=1.0
scaleZ=1.0
solid=false
tags=
uniformScale=true

View File

@@ -0,0 +1,28 @@
#Sat Jul 18 11:14:25 CEST 2026
attachedEmitters.count=0
attachedLights.count=0
castShadow=true
category=
cullDistance=120.0
footstepSurface=
interactableOffsetX=0.0
interactableOffsetY=0.5
interactableOffsetZ=0.0
interactableRotY=0.0
interactableType=NONE
lod1Distance=30.0
lod1Path=
lod2Distance=80.0
lod2Path=
name=pine_medium_20260706_190953
pivotOffsetY=0.0
placementOffsetY=0.0
randomScaleMax=1.0
randomScaleMin=1.0
receiveShadow=true
scaleX=1.0
scaleY=1.0
scaleZ=1.0
solid=false
tags=
uniformScale=true

View File

@@ -256,8 +256,14 @@ public class EditorApp extends Application {
private Label modelEditorLod2Label; private Label modelEditorLod2Label;
private ToggleButton modelEditorLod1Btn; private ToggleButton modelEditorLod1Btn;
private ToggleButton modelEditorLod2Btn; private ToggleButton modelEditorLod2Btn;
private Label modelEditorLod1StatusLabel;
private Label modelEditorLod2StatusLabel;
private Button modelEditorLod1GenBtn;
private Button modelEditorLod2GenBtn;
private ToggleButton importLod1Btn; private ToggleButton importLod1Btn;
private ToggleButton importLod2Btn; private ToggleButton importLod2Btn;
private Button modelImportLod1ClearBtn;
private Button modelImportLod2ClearBtn;
// Anhänge (veränderliche Listen, werden bei Änderung an JME übertragen) // Anhänge (veränderliche Listen, werden bei Änderung an JME übertragen)
private final java.util.List<de.blight.common.ModelMeta.AttachedLight> modelEditorLights = new java.util.ArrayList<>(); private final java.util.List<de.blight.common.ModelMeta.AttachedLight> modelEditorLights = new java.util.ArrayList<>();
private final java.util.List<de.blight.common.ModelMeta.AttachedEmitter> modelEditorEmitters = new java.util.ArrayList<>(); private final java.util.List<de.blight.common.ModelMeta.AttachedEmitter> modelEditorEmitters = new java.util.ArrayList<>();
@@ -663,35 +669,45 @@ public class EditorApp extends Application {
} }
// Modell-Editor: eingebettete LODs erkannt → Labels aktualisieren // Modell-Editor: eingebettete LODs erkannt → Labels aktualisieren
if (input.modelEditorHasEmbeddedLods) { if (input.modelEditorHasEmbeddedLod1 && modelEditorLod1Label != null
if (modelEditorLod1Label != null && "(kein Pfad)".equals(modelEditorLod1Label.getText())) {
&& "(nicht gesetzt)".equals(modelEditorLod1Label.getText())) { modelEditorLod1Label.setText("(eingebettet im j3o)");
modelEditorLod1Label.setText("(eingebettet im j3o)"); modelEditorLod1Label.setStyle("-fx-text-fill:#6aaa6a; -fx-font-size:11;");
modelEditorLod1Label.setStyle("-fx-text-fill:#6aaa6a; -fx-font-size:11;");
}
if (modelEditorLod2Label != null
&& "(nicht gesetzt)".equals(modelEditorLod2Label.getText())) {
modelEditorLod2Label.setText("(eingebettet im j3o)");
modelEditorLod2Label.setStyle("-fx-text-fill:#6aaa6a; -fx-font-size:11;");
}
} }
// Model-Editor LOD-Buttons freischalten/sperren if (input.modelEditorHasEmbeddedLod2 && modelEditorLod2Label != null
&& "(kein Pfad)".equals(modelEditorLod2Label.getText())) {
modelEditorLod2Label.setText("(eingebettet im j3o)");
modelEditorLod2Label.setStyle("-fx-text-fill:#6aaa6a; -fx-font-size:11;");
}
// Model-Editor LOD-Buttons freischalten/sperren (per Level)
if (modelEditorLod1Btn != null) if (modelEditorLod1Btn != null)
modelEditorLod1Btn.setDisable(!input.modelEditorHasEmbeddedLods && modelEditorLod1Path.isBlank()); modelEditorLod1Btn.setDisable(!input.modelEditorHasEmbeddedLod1 && modelEditorLod1Path.isBlank() && !input.modelImportHasLod1);
if (modelEditorLod2Btn != null) if (modelEditorLod2Btn != null)
modelEditorLod2Btn.setDisable(!input.modelEditorHasEmbeddedLods && modelEditorLod2Path.isBlank()); modelEditorLod2Btn.setDisable(!input.modelEditorHasEmbeddedLod2 && modelEditorLod2Path.isBlank() && !input.modelImportHasLod2);
// Generieren-Buttons: nur aktiv wenn genau dieses LOD-Level noch nicht gesetzt
if (modelEditorLod1GenBtn != null)
modelEditorLod1GenBtn.setDisable(input.modelEditorHasEmbeddedLod1 || !modelEditorLod1Path.isBlank() || input.modelImportHasLod1);
if (modelEditorLod2GenBtn != null)
modelEditorLod2GenBtn.setDisable(input.modelEditorHasEmbeddedLod2 || !modelEditorLod2Path.isBlank() || input.modelImportHasLod2);
// Importer LOD-Buttons freischalten sobald generiert // Importer LOD-Buttons freischalten sobald generiert
if (importLod1Btn != null) importLod1Btn.setDisable(!input.modelImportHasLod1); if (importLod1Btn != null) importLod1Btn.setDisable(!input.modelImportHasLod1);
if (importLod2Btn != null) importLod2Btn.setDisable(!input.modelImportHasLod2); if (importLod2Btn != null) importLod2Btn.setDisable(!input.modelImportHasLod2);
if (modelImportLod1ClearBtn != null) modelImportLod1ClearBtn.setDisable(!input.modelImportHasLod1);
if (modelImportLod2ClearBtn != null) modelImportLod2ClearBtn.setDisable(!input.modelImportHasLod2);
// Modell-Import: LOD-Status // Modell-Import/Editor: LOD-Status
String lodStatus = input.modelLodGenStatus; String lodStatus = input.modelLodGenStatus;
if (lodStatus != null) { if (lodStatus != null) {
input.modelLodGenStatus = null; input.modelLodGenStatus = null;
if (modelImportLod1StatusLabel != null && lodStatus.startsWith("LOD1")) boolean isLod1 = lodStatus.startsWith("LOD1");
modelImportLod1StatusLabel.setText(lodStatus); boolean isLod2 = lodStatus.startsWith("LOD2") || lodStatus.contains("Impostor");
else if (modelImportLod2StatusLabel != null && (lodStatus.startsWith("LOD2") || lodStatus.contains("Impostor"))) if (isLod1) {
modelImportLod2StatusLabel.setText(lodStatus); if (modelImportLod1StatusLabel != null) modelImportLod1StatusLabel.setText(lodStatus);
if (modelEditorLod1StatusLabel != null) modelEditorLod1StatusLabel.setText(lodStatus);
} else if (isLod2) {
if (modelImportLod2StatusLabel != null) modelImportLod2StatusLabel.setText(lodStatus);
if (modelEditorLod2StatusLabel != null) modelEditorLod2StatusLabel.setText(lodStatus);
}
setStatus(lodStatus); setStatus(lodStatus);
} }
// Modell-Import: Export-Ergebnis // Modell-Import: Export-Ergebnis
@@ -867,14 +883,15 @@ public class EditorApp extends Application {
modelEditorLod1Btn.setOnAction(e -> { input.modelEditorLodPreview = 1; input.modelEditorLodChanged = true; }); modelEditorLod1Btn.setOnAction(e -> { input.modelEditorLodPreview = 1; input.modelEditorLodChanged = true; });
modelEditorLod2Btn.setOnAction(e -> { input.modelEditorLodPreview = 2; input.modelEditorLodChanged = true; }); modelEditorLod2Btn.setOnAction(e -> { input.modelEditorLodPreview = 2; input.modelEditorLodChanged = true; });
// Initial deaktiviert werden freigegeben sobald LOD-Pfade/eingebettete LODs bekannt sind // Initial deaktiviert werden freigegeben sobald LOD-Pfade/eingebettete LODs bekannt sind
boolean hasLod1 = input.modelEditorHasEmbeddedLods || !modelEditorLod1Path.isBlank(); boolean hasLod1 = input.modelEditorHasEmbeddedLod1 || !modelEditorLod1Path.isBlank() || input.modelImportHasLod1;
boolean hasLod2 = input.modelEditorHasEmbeddedLods || !modelEditorLod2Path.isBlank(); boolean hasLod2 = input.modelEditorHasEmbeddedLod2 || !modelEditorLod2Path.isBlank() || input.modelImportHasLod2;
modelEditorLod1Btn.setDisable(!hasLod1); modelEditorLod1Btn.setDisable(!hasLod1);
modelEditorLod2Btn.setDisable(!hasLod2); modelEditorLod2Btn.setDisable(!hasLod2);
ToolBar tb = new ToolBar(); ToolBar tb = new ToolBar();
tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label, tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label,
new Separator(Orientation.VERTICAL), lod0Btn, modelEditorLod1Btn, modelEditorLod2Btn); new Separator(Orientation.VERTICAL), lod0Btn,
modelEditorLod1Btn, modelEditorLod2Btn);
return tb; return tb;
} }
@@ -4051,6 +4068,10 @@ public class EditorApp extends Application {
} }
if (cat == modelsNode) { if (cat == modelsNode) {
if ("modeleditor".equals(currentTool) && relPath.endsWith(".j3o")) {
openModelEditor(relPath, p);
return;
}
input.pendingModelPath = relPath; input.pendingModelPath = relPath;
input.activeLayer = SharedInput.LAYER_OBJECTS; input.activeLayer = SharedInput.LAYER_OBJECTS;
if (objPlaceBtn != null) objPlaceBtn.setSelected(true); if (objPlaceBtn != null) objPlaceBtn.setSelected(true);
@@ -5059,7 +5080,7 @@ public class EditorApp extends Application {
/** Gibt zurück, ob das aktive Tool den aufgeklappten Zustand des Asset-Baums behalten soll. */ /** Gibt zurück, ob das aktive Tool den aufgeklappten Zustand des Asset-Baums behalten soll. */
private boolean shouldPreserveExpansion() { private boolean shouldPreserveExpansion() {
return "vegetation".equals(currentTool) || "eztree".equals(currentTool) return "vegetation".equals(currentTool) || "eztree".equals(currentTool)
|| "objecteditor".equals(currentTool) || "objecteditor".equals(currentTool) || "modeleditor".equals(currentTool)
|| ("world".equals(currentTool) || ("world".equals(currentTool)
&& (input.activeLayer == SharedInput.LAYER_OBJECTS && (input.activeLayer == SharedInput.LAYER_OBJECTS
|| input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT)); || input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT));
@@ -5812,8 +5833,8 @@ public class EditorApp extends Application {
topBar.getChildren().set(1, buildModelEditorToolBar()); topBar.getChildren().set(1, buildModelEditorToolBar());
// Kein Modell vorausgewählt Platzhalter zeigen // Kein Modell vorausgewählt Platzhalter zeigen
VBox placeholder = new VBox(12); VBox placeholder = new VBox(12);
placeholder.setPadding(new javafx.geometry.Insets(16)); placeholder.setPadding(new javafx.geometry.Insets(12));
placeholder.setPrefWidth(300); placeholder.setPrefWidth(280);
placeholder.setStyle("-fx-background-color: #2a2a3e;"); placeholder.setStyle("-fx-background-color: #2a2a3e;");
Label title = new Label("Modell-Editor"); Label title = new Label("Modell-Editor");
title.setStyle("-fx-font-weight:bold; -fx-font-size:14; -fx-text-fill:#ddd;"); title.setStyle("-fx-font-weight:bold; -fx-font-size:14; -fx-text-fill:#ddd;");
@@ -5854,7 +5875,8 @@ public class EditorApp extends Application {
modelEditorScroll.setStyle("-fx-background-color:transparent;-fx-background:transparent;"); modelEditorScroll.setStyle("-fx-background-color:transparent;-fx-background:transparent;");
VBox modelEditorOuter = new VBox(modelEditorScroll); VBox modelEditorOuter = new VBox(modelEditorScroll);
VBox.setVgrow(modelEditorScroll, Priority.ALWAYS); VBox.setVgrow(modelEditorScroll, Priority.ALWAYS);
modelEditorOuter.setPrefWidth(300); modelEditorOuter.setPrefWidth(280);
modelEditorOuter.setMaxWidth(280);
root.setRight(modelEditorOuter); root.setRight(modelEditorOuter);
setStatus("Modell-Editor: " + relPath); setStatus("Modell-Editor: " + relPath);
} }
@@ -5862,9 +5884,9 @@ public class EditorApp extends Application {
private VBox buildModelEditorPanel(String relPath, private VBox buildModelEditorPanel(String relPath,
java.nio.file.Path absolutePath, java.nio.file.Path absolutePath,
de.blight.common.ModelMeta meta) { de.blight.common.ModelMeta meta) {
VBox panel = new VBox(8); VBox panel = new VBox(6);
panel.setPadding(new javafx.geometry.Insets(10)); panel.setPadding(new javafx.geometry.Insets(8));
panel.setPrefWidth(300); panel.setPrefWidth(280);
panel.setStyle("-fx-background-color: #2a2a3e;"); panel.setStyle("-fx-background-color: #2a2a3e;");
// ── Titel ──────────────────────────────────────────────────────────── // ── Titel ────────────────────────────────────────────────────────────
@@ -5958,8 +5980,10 @@ public class EditorApp extends Application {
}); });
// ── Maße (read-only, von JME aktualisiert) ─────────────────────────── // ── Maße (read-only, von JME aktualisiert) ───────────────────────────
modelEditorDimLabel = new Label("Maße: × × m"); modelEditorDimLabel = new Label("B × H × T m");
modelEditorDimLabel.setStyle("-fx-text-fill:#8cf; -fx-font-family:monospace;"); modelEditorDimLabel.setStyle("-fx-text-fill:#8cf; -fx-font-family:monospace;");
modelEditorDimLabel.setMaxWidth(Double.MAX_VALUE);
modelEditorDimLabel.setWrapText(true);
// ── Pivot & Platzierungs-Versatz ───────────────────────────────────── // ── Pivot & Platzierungs-Versatz ─────────────────────────────────────
Label offsetTitle = new Label("Versatz:"); Label offsetTitle = new Label("Versatz:");
@@ -6007,38 +6031,79 @@ public class EditorApp extends Application {
Label lodTitle = new Label("Level of Detail"); Label lodTitle = new Label("Level of Detail");
lodTitle.setStyle("-fx-font-weight:bold; -fx-text-fill:#ccc;"); lodTitle.setStyle("-fx-font-weight:bold; -fx-text-fill:#ccc;");
modelEditorLod1Label = new Label(modelEditorLod1Path.isBlank() ? "(nicht gesetzt)" : modelEditorLod1Path); // ── LOD 1 ─────────────────────────────────────────────────────────────
modelEditorLod1GenBtn = new Button("LOD 1 generieren");
modelEditorLod1GenBtn.setMaxWidth(Double.MAX_VALUE);
boolean lod1Exists = input.modelEditorHasEmbeddedLod1 || !modelEditorLod1Path.isBlank() || input.modelImportHasLod1;
modelEditorLod1GenBtn.setDisable(lod1Exists);
modelEditorLod1GenBtn.setOnAction(e -> showLodGenDialog(1, 50));
modelEditorLod1StatusLabel = new Label("");
modelEditorLod1StatusLabel.setStyle("-fx-text-fill:#888; -fx-font-size:11;");
modelEditorLod1StatusLabel.setWrapText(true);
modelEditorLod1Label = new Label(modelEditorLod1Path.isBlank() ? "(kein Pfad)" : modelEditorLod1Path);
modelEditorLod1Label.setStyle("-fx-text-fill:#888; -fx-font-size:11;"); modelEditorLod1Label.setStyle("-fx-text-fill:#888; -fx-font-size:11;");
modelEditorLod1Label.setWrapText(true); modelEditorLod1Label.setWrapText(true);
Button lod1ImportBtn = new Button("LOD 1 importieren…");
Button lod1ClearBtn = new Button(""); Button lod1ClearBtn = new Button("");
lod1ClearBtn.setStyle("-fx-text-fill:#c0392b;"); lod1ClearBtn.setStyle("-fx-text-fill:#c0392b;");
lod1ImportBtn.setMaxWidth(Double.MAX_VALUE);
lod1ImportBtn.setOnAction(e -> importLodFile(1));
lod1ClearBtn.setOnAction(e -> { lod1ClearBtn.setOnAction(e -> {
if (input.modelEditorHasEmbeddedLod1) {
input.modelEditorRemoveEmbeddedLod = 1;
}
if (input.modelImportHasLod1) {
input.modelImportClearLodLevel = 1;
if (modelEditorLod1StatusLabel != null) modelEditorLod1StatusLabel.setText("");
}
modelEditorLod1Path = ""; modelEditorLod1Path = "";
input.modelEditorLod1Path = ""; input.modelEditorLod1Path = "";
modelEditorLod1Label.setText("(nicht gesetzt)"); modelEditorLod1Label.setText("(kein Pfad)");
if (input.modelEditorLodPreview == 1) {
input.modelEditorLodPreview = 0;
input.modelEditorLodChanged = true;
}
}); });
modelEditorLod2Label = new Label(modelEditorLod2Path.isBlank() ? "(nicht gesetzt)" : modelEditorLod2Path); HBox lod1Row = new HBox(4, modelEditorLod1GenBtn, lod1ClearBtn);
HBox.setHgrow(modelEditorLod1GenBtn, javafx.scene.layout.Priority.ALWAYS);
// ── LOD 2 ─────────────────────────────────────────────────────────────
modelEditorLod2GenBtn = new Button("LOD 2 generieren");
modelEditorLod2GenBtn.setMaxWidth(Double.MAX_VALUE);
boolean lod2Exists = input.modelEditorHasEmbeddedLod2 || !modelEditorLod2Path.isBlank() || input.modelImportHasLod2;
modelEditorLod2GenBtn.setDisable(lod2Exists);
modelEditorLod2GenBtn.setOnAction(e -> showLodGenDialog(2, 85));
modelEditorLod2StatusLabel = new Label("");
modelEditorLod2StatusLabel.setStyle("-fx-text-fill:#888; -fx-font-size:11;");
modelEditorLod2StatusLabel.setWrapText(true);
modelEditorLod2Label = new Label(modelEditorLod2Path.isBlank() ? "(kein Pfad)" : modelEditorLod2Path);
modelEditorLod2Label.setStyle("-fx-text-fill:#888; -fx-font-size:11;"); modelEditorLod2Label.setStyle("-fx-text-fill:#888; -fx-font-size:11;");
modelEditorLod2Label.setWrapText(true); modelEditorLod2Label.setWrapText(true);
Button lod2ImportBtn = new Button("LOD 2 importieren…");
Button lod2ClearBtn = new Button(""); Button lod2ClearBtn = new Button("");
lod2ClearBtn.setStyle("-fx-text-fill:#c0392b;"); lod2ClearBtn.setStyle("-fx-text-fill:#c0392b;");
lod2ImportBtn.setMaxWidth(Double.MAX_VALUE);
lod2ImportBtn.setOnAction(e -> importLodFile(2));
lod2ClearBtn.setOnAction(e -> { lod2ClearBtn.setOnAction(e -> {
if (input.modelEditorHasEmbeddedLod2) {
input.modelEditorRemoveEmbeddedLod = 2;
}
if (input.modelImportHasLod2) {
input.modelImportClearLodLevel = 2;
if (modelEditorLod2StatusLabel != null) modelEditorLod2StatusLabel.setText("");
}
modelEditorLod2Path = ""; modelEditorLod2Path = "";
input.modelEditorLod2Path = ""; input.modelEditorLod2Path = "";
modelEditorLod2Label.setText("(nicht gesetzt)"); modelEditorLod2Label.setText("(kein Pfad)");
if (input.modelEditorLodPreview == 2) {
input.modelEditorLodPreview = 0;
input.modelEditorLodChanged = true;
}
}); });
HBox lod1Row = new HBox(4, lod1ImportBtn, lod1ClearBtn); HBox lod2Row = new HBox(4, modelEditorLod2GenBtn, lod2ClearBtn);
HBox lod2Row = new HBox(4, lod2ImportBtn, lod2ClearBtn); HBox.setHgrow(modelEditorLod2GenBtn, javafx.scene.layout.Priority.ALWAYS);
HBox.setHgrow(lod1ImportBtn, javafx.scene.layout.Priority.ALWAYS);
HBox.setHgrow(lod2ImportBtn, javafx.scene.layout.Priority.ALWAYS);
// ── Anhänge: Lichter & Partikel ─────────────────────────────────────── // ── Anhänge: Lichter & Partikel ───────────────────────────────────────
Label attachTitle = new Label("Anhänge: Lichter & Partikel"); Label attachTitle = new Label("Anhänge: Lichter & Partikel");
@@ -6134,7 +6199,7 @@ public class EditorApp extends Application {
new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-5.0, 5.0, new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-5.0, 5.0,
Math.round(meta.interactableOffsetX() * 100.0) / 100.0, 0.05)); Math.round(meta.interactableOffsetX() * 100.0) / 100.0, 0.05));
modelEditorInteractableXSpin.setEditable(true); modelEditorInteractableXSpin.setEditable(true);
modelEditorInteractableXSpin.setPrefWidth(100); modelEditorInteractableXSpin.setMaxWidth(Double.MAX_VALUE);
modelEditorInteractableXSpin.valueProperty().addListener((obs, ov, nv) -> { modelEditorInteractableXSpin.valueProperty().addListener((obs, ov, nv) -> {
if (updatingInteractableSpinnersFromJme) return; if (updatingInteractableSpinnersFromJme) return;
input.modelInteractableOffsetX = nv.floatValue(); input.modelInteractableOffsetX = nv.floatValue();
@@ -6145,7 +6210,7 @@ public class EditorApp extends Application {
new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-1.0, 4.0, new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-1.0, 4.0,
Math.round(meta.interactableOffsetY() * 100.0) / 100.0, 0.05)); Math.round(meta.interactableOffsetY() * 100.0) / 100.0, 0.05));
modelEditorInteractableYSpin.setEditable(true); modelEditorInteractableYSpin.setEditable(true);
modelEditorInteractableYSpin.setPrefWidth(100); modelEditorInteractableYSpin.setMaxWidth(Double.MAX_VALUE);
modelEditorInteractableYSpin.valueProperty().addListener((obs, ov, nv) -> { modelEditorInteractableYSpin.valueProperty().addListener((obs, ov, nv) -> {
if (updatingInteractableSpinnersFromJme) return; if (updatingInteractableSpinnersFromJme) return;
input.modelInteractableOffsetY = nv.floatValue(); input.modelInteractableOffsetY = nv.floatValue();
@@ -6156,7 +6221,7 @@ public class EditorApp extends Application {
new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-5.0, 5.0, new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-5.0, 5.0,
Math.round(meta.interactableOffsetZ() * 100.0) / 100.0, 0.05)); Math.round(meta.interactableOffsetZ() * 100.0) / 100.0, 0.05));
modelEditorInteractableZSpin.setEditable(true); modelEditorInteractableZSpin.setEditable(true);
modelEditorInteractableZSpin.setPrefWidth(100); modelEditorInteractableZSpin.setMaxWidth(Double.MAX_VALUE);
modelEditorInteractableZSpin.valueProperty().addListener((obs, ov, nv) -> { modelEditorInteractableZSpin.valueProperty().addListener((obs, ov, nv) -> {
if (updatingInteractableSpinnersFromJme) return; if (updatingInteractableSpinnersFromJme) return;
input.modelInteractableOffsetZ = nv.floatValue(); input.modelInteractableOffsetZ = nv.floatValue();
@@ -6165,6 +6230,11 @@ public class EditorApp extends Application {
javafx.scene.layout.GridPane posGrid = new javafx.scene.layout.GridPane(); javafx.scene.layout.GridPane posGrid = new javafx.scene.layout.GridPane();
posGrid.setHgap(4); posGrid.setVgap(2); posGrid.setHgap(4); posGrid.setVgap(2);
javafx.scene.layout.ColumnConstraints posCC0 = new javafx.scene.layout.ColumnConstraints();
posCC0.setMinWidth(20); posCC0.setHgrow(javafx.scene.layout.Priority.NEVER);
javafx.scene.layout.ColumnConstraints posCC1 = new javafx.scene.layout.ColumnConstraints();
posCC1.setHgrow(javafx.scene.layout.Priority.ALWAYS); posCC1.setFillWidth(true);
posGrid.getColumnConstraints().addAll(posCC0, posCC1);
posGrid.add(new Label("X:"), 0, 0); posGrid.add(modelEditorInteractableXSpin, 1, 0); posGrid.add(new Label("X:"), 0, 0); posGrid.add(modelEditorInteractableXSpin, 1, 0);
posGrid.add(new Label("Y:"), 0, 1); posGrid.add(modelEditorInteractableYSpin, 1, 1); posGrid.add(new Label("Y:"), 0, 1); posGrid.add(modelEditorInteractableYSpin, 1, 1);
posGrid.add(new Label("Z:"), 0, 2); posGrid.add(modelEditorInteractableZSpin, 1, 2); posGrid.add(new Label("Z:"), 0, 2); posGrid.add(modelEditorInteractableZSpin, 1, 2);
@@ -6179,7 +6249,7 @@ public class EditorApp extends Application {
Slider rotSlider = new Slider(0, 360, initDeg); Slider rotSlider = new Slider(0, 360, initDeg);
rotSlider.setShowTickMarks(true); rotSlider.setShowTickMarks(true);
rotSlider.setMajorTickUnit(90); rotSlider.setMajorTickUnit(90);
rotSlider.setPrefWidth(180); rotSlider.setPrefWidth(150);
TextField rotField = new TextField(String.format("%.1f", initDeg)); TextField rotField = new TextField(String.format("%.1f", initDeg));
rotField.setPrefWidth(60); rotField.setPrefWidth(60);
@@ -6329,8 +6399,8 @@ public class EditorApp extends Application {
rndTitle, rndGrid, rndTitle, rndGrid,
new Separator(), new Separator(),
lodTitle, lodTitle,
modelEditorLod1Label, lod1Row, lod1Row, modelEditorLod1StatusLabel, modelEditorLod1Label,
modelEditorLod2Label, lod2Row, lod2Row, modelEditorLod2StatusLabel, modelEditorLod2Label,
new Separator(), new Separator(),
attachTitle, attachTitle,
lightSectionLbl, modelEditorLightBox, addLightBtn, lightSectionLbl, modelEditorLightBox, addLightBtn,
@@ -6409,6 +6479,8 @@ public class EditorApp extends Application {
modelImportCurrentPath = relPath; modelImportCurrentPath = relPath;
modelImportLod1StatusLabel = null; modelImportLod1StatusLabel = null;
modelImportLod2StatusLabel = null; modelImportLod2StatusLabel = null;
modelImportLod1ClearBtn = null;
modelImportLod2ClearBtn = null;
modelImportExportStatusLabel = null; modelImportExportStatusLabel = null;
input.modelImportHasLod1 = false; input.modelImportHasLod1 = false;
input.modelImportHasLod2 = false; input.modelImportHasLod2 = false;
@@ -6567,8 +6639,10 @@ public class EditorApp extends Application {
} }
}); });
modelEditorDimLabel = new Label("Maße: × × m"); modelEditorDimLabel = new Label("B × H × T m");
modelEditorDimLabel.setStyle("-fx-text-fill:#8cf; -fx-font-family:monospace;"); modelEditorDimLabel.setStyle("-fx-text-fill:#8cf; -fx-font-family:monospace;");
modelEditorDimLabel.setMaxWidth(Double.MAX_VALUE);
modelEditorDimLabel.setWrapText(true);
// ── LOD-Algorithmus ─────────────────────────────────────────────────── // ── LOD-Algorithmus ───────────────────────────────────────────────────
Label algoTitle = new Label("Reduktions-Algorithmus"); Label algoTitle = new Label("Reduktions-Algorithmus");
@@ -6624,6 +6698,14 @@ public class EditorApp extends Application {
modelImportLod1StatusLabel = new Label("(nicht gesetzt)"); modelImportLod1StatusLabel = new Label("(nicht gesetzt)");
modelImportLod1StatusLabel.setStyle("-fx-text-fill:#888; -fx-font-size:11;"); modelImportLod1StatusLabel.setStyle("-fx-text-fill:#888; -fx-font-size:11;");
modelImportLod1StatusLabel.setWrapText(true); modelImportLod1StatusLabel.setWrapText(true);
modelImportLod1ClearBtn = new Button("✕ LOD 1 entfernen");
modelImportLod1ClearBtn.setStyle("-fx-text-fill:#c0392b;");
modelImportLod1ClearBtn.setMaxWidth(Double.MAX_VALUE);
modelImportLod1ClearBtn.setDisable(true);
modelImportLod1ClearBtn.setOnAction(e -> {
input.modelImportClearLodLevel = 1;
modelImportLod1StatusLabel.setText("(nicht gesetzt)");
});
// ── LOD 2 ───────────────────────────────────────────────────────────── // ── LOD 2 ─────────────────────────────────────────────────────────────
Label lod2Title = new Label("LOD 2"); Label lod2Title = new Label("LOD 2");
@@ -6665,6 +6747,14 @@ public class EditorApp extends Application {
modelImportLod2StatusLabel = new Label("(nicht gesetzt)"); modelImportLod2StatusLabel = new Label("(nicht gesetzt)");
modelImportLod2StatusLabel.setStyle("-fx-text-fill:#888; -fx-font-size:11;"); modelImportLod2StatusLabel.setStyle("-fx-text-fill:#888; -fx-font-size:11;");
modelImportLod2StatusLabel.setWrapText(true); modelImportLod2StatusLabel.setWrapText(true);
modelImportLod2ClearBtn = new Button("✕ LOD 2 entfernen");
modelImportLod2ClearBtn.setStyle("-fx-text-fill:#c0392b;");
modelImportLod2ClearBtn.setMaxWidth(Double.MAX_VALUE);
modelImportLod2ClearBtn.setDisable(true);
modelImportLod2ClearBtn.setOnAction(e -> {
input.modelImportClearLodLevel = 2;
modelImportLod2StatusLabel.setText("(nicht gesetzt)");
});
// ── Export ──────────────────────────────────────────────────────────── // ── Export ────────────────────────────────────────────────────────────
Label exportTitle = new Label("Export"); Label exportTitle = new Label("Export");
@@ -6705,9 +6795,9 @@ public class EditorApp extends Application {
new Separator(), new Separator(),
algoTitle, algoBlightRB, algoJmeRB, algoTitle, algoBlightRB, algoJmeRB,
new Separator(), new Separator(),
lod1Title, lod1AutoRB, lod1FileRB, lod1GenBtn, modelImportLod1StatusLabel, lod1Title, lod1AutoRB, lod1FileRB, lod1GenBtn, modelImportLod1StatusLabel, modelImportLod1ClearBtn,
new Separator(), new Separator(),
lod2Title, lod2MeshRB, lod2FileRB, lod2GenBtn, modelImportLod2StatusLabel, lod2Title, lod2MeshRB, lod2FileRB, lod2GenBtn, modelImportLod2StatusLabel, modelImportLod2ClearBtn,
new Separator(), new Separator(),
exportTitle, assetNameLabel, nameTF, modelImportExportStatusLabel, exportTitle, assetNameLabel, nameTF, modelImportExportStatusLabel,
exportBtn, cancelBtn exportBtn, cancelBtn
@@ -6758,12 +6848,19 @@ public class EditorApp extends Application {
} }
private void addLabeledRow(GridPane grid, int row, String labelText, javafx.scene.Node ctrl) { private void addLabeledRow(GridPane grid, int row, String labelText, javafx.scene.Node ctrl) {
if (grid.getColumnConstraints().isEmpty()) {
javafx.scene.layout.ColumnConstraints cc0 = new javafx.scene.layout.ColumnConstraints();
cc0.setMinWidth(80);
cc0.setHgrow(javafx.scene.layout.Priority.NEVER);
javafx.scene.layout.ColumnConstraints cc1 = new javafx.scene.layout.ColumnConstraints();
cc1.setHgrow(javafx.scene.layout.Priority.ALWAYS);
cc1.setFillWidth(true);
grid.getColumnConstraints().addAll(cc0, cc1);
}
Label l = new Label(labelText); Label l = new Label(labelText);
l.setStyle("-fx-text-fill:#aaa;"); l.setStyle("-fx-text-fill:#aaa;");
l.setMinWidth(90);
grid.add(l, 0, row); grid.add(l, 0, row);
grid.add(ctrl, 1, row); grid.add(ctrl, 1, row);
javafx.scene.layout.GridPane.setHgrow(ctrl, javafx.scene.layout.Priority.ALWAYS);
} }
private void pushScaleToJme() { private void pushScaleToJme() {
@@ -6776,8 +6873,7 @@ public class EditorApp extends Application {
private void updateModelEditorDimensions(float w, float h, float d) { private void updateModelEditorDimensions(float w, float h, float d) {
if (modelEditorDimLabel != null) if (modelEditorDimLabel != null)
modelEditorDimLabel.setText(String.format( modelEditorDimLabel.setText(String.format("B %.2f × H %.2f × T %.2f m", w, h, d));
"Maße: B %.2f m × H %.2f m × T %.2f m", w, h, d));
} }
private void saveModelMeta(String relPath, private void saveModelMeta(String relPath,
@@ -7044,6 +7140,56 @@ public class EditorApp extends Application {
} }
} }
private void showLodGenDialog(int level, int defaultPct) {
javafx.scene.control.Dialog<javafx.util.Pair<String, Integer>> dlg = new javafx.scene.control.Dialog<>();
dlg.setTitle("LOD " + level + " generieren");
dlg.setHeaderText("Methode und Reduktion für LOD " + level + " wählen");
RadioButton jmeRB = new RadioButton("JME (Progressive Mesh)");
RadioButton blightRB = new RadioButton("Blight (Edge Collapse)");
RadioButton impostorRB = new RadioButton("Impostor Image (Billboard)");
jmeRB.setSelected(true);
javafx.scene.control.ToggleGroup tg = new javafx.scene.control.ToggleGroup();
jmeRB.setToggleGroup(tg);
blightRB.setToggleGroup(tg);
impostorRB.setToggleGroup(tg);
Label pctLabel = new Label("Reduktion (%):");
Spinner<Integer> pctSpinner = new Spinner<>(
new javafx.scene.control.SpinnerValueFactory.IntegerSpinnerValueFactory(5, 95, defaultPct, 5));
pctSpinner.setEditable(true);
pctSpinner.setPrefWidth(90);
impostorRB.selectedProperty().addListener((obs, ov, nv) -> {
pctLabel.setDisable(nv);
pctSpinner.setDisable(nv);
});
javafx.scene.layout.VBox content = new javafx.scene.layout.VBox(8,
jmeRB, blightRB, impostorRB,
new Separator(),
new HBox(8, pctLabel, pctSpinner));
content.setPadding(new javafx.geometry.Insets(12));
dlg.getDialogPane().setContent(content);
dlg.getDialogPane().getButtonTypes().addAll(
javafx.scene.control.ButtonType.OK, javafx.scene.control.ButtonType.CANCEL);
dlg.setResultConverter(bt -> {
if (bt != javafx.scene.control.ButtonType.OK) return null;
String method = impostorRB.isSelected() ? "impostor"
: blightRB.isSelected() ? "blight_auto"
: "jme_auto";
return new javafx.util.Pair<>(method, pctSpinner.getValue());
});
dlg.showAndWait().ifPresent(result -> {
String method = result.getKey();
float reductionPct = result.getValue() / 100f;
input.modelLodGenRequest.set(
new SharedInput.ModelLodGenRequest(level, method, null, reductionPct));
});
}
// ── Zentraler Bereich: JME3-Viewport ──────────────────────────────────── // ── Zentraler Bereich: JME3-Viewport ────────────────────────────────────
private StackPane buildViewport() { private StackPane buildViewport() {

View File

@@ -709,12 +709,16 @@ public class SharedInput {
/** JME → JFX: true wenn das geladene Modell eingebettete LOD-Kinder hat (kein separater Pfad nötig). */ /** JME → JFX: true wenn das geladene Modell eingebettete LOD-Kinder hat (kein separater Pfad nötig). */
public volatile boolean modelEditorHasEmbeddedLods = false; public volatile boolean modelEditorHasEmbeddedLods = false;
public volatile boolean modelEditorHasEmbeddedLod1 = false;
public volatile boolean modelEditorHasEmbeddedLod2 = false;
/** JFX → JME: welche LOD-Stufe anzeigen (0=LOD0, 1=LOD1, 2=LOD2). */ /** JFX → JME: welche LOD-Stufe anzeigen (0=LOD0, 1=LOD1, 2=LOD2). */
public volatile int modelEditorLodPreview = 0; public volatile int modelEditorLodPreview = 0;
public volatile String modelEditorLod1Path = ""; public volatile String modelEditorLod1Path = "";
public volatile String modelEditorLod2Path = ""; public volatile String modelEditorLod2Path = "";
public volatile boolean modelEditorLodChanged = false; public volatile boolean modelEditorLodChanged = false;
/** JFX → JME: Eingebettetes LOD aus j3o entfernen und speichern (1=lod1, 2=lod2, -1=kein Auftrag). */
public volatile int modelEditorRemoveEmbeddedLod = -1;
/** JFX → JME: Richtung der Hauptlichtquelle in der Vorschau. /** JFX → JME: Richtung der Hauptlichtquelle in der Vorschau.
* Azimut 0360° (Kompassrichtung), Elevation 090° (Höhe über Horizont). */ * Azimut 0360° (Kompassrichtung), Elevation 090° (Höhe über Horizont). */
@@ -790,9 +794,15 @@ public class SharedInput {
* JFX → JME: LOD-Generierungsanfrage. * JFX → JME: LOD-Generierungsanfrage.
* level: 1 oder 2 * level: 1 oder 2
* method: "auto_mesh" | "file" | "auto_impostor" * method: "auto_mesh" | "file" | "auto_impostor"
* fileAssetPath: nur bei method=="file" relevant * fileAssetPath: nur bei method=="file" relevant.
* reductionPct: 0..1 (z.B. 0.5 = 50 % der Dreiecke entfernen), ignoriert bei "impostor".
*/ */
public record ModelLodGenRequest(int level, String method, String fileAssetPath) {} public record ModelLodGenRequest(int level, String method, String fileAssetPath, float reductionPct) {
/** Kompatibler Konstruktor für Code der keine Prozentzahl übergibt (benutzt j3md-Defaults). */
public ModelLodGenRequest(int level, String method, String fileAssetPath) {
this(level, method, fileAssetPath, -1f);
}
}
public final AtomicReference<ModelLodGenRequest> modelLodGenRequest = public final AtomicReference<ModelLodGenRequest> modelLodGenRequest =
new AtomicReference<>(); new AtomicReference<>();
@@ -821,6 +831,8 @@ public class SharedInput {
/** JME → JFX: LOD-Stufen vorhanden. */ /** JME → JFX: LOD-Stufen vorhanden. */
public volatile boolean modelImportHasLod1 = false; public volatile boolean modelImportHasLod1 = false;
public volatile boolean modelImportHasLod2 = false; public volatile boolean modelImportHasLod2 = false;
/** JFX → JME: Generierten Import-LOD löschen (1=lod1, 2=lod2, -1=kein Auftrag). */
public volatile int modelImportClearLodLevel = -1;
/** JFX → JME: Dateiname (ohne Erweiterung) für den Export-Auftrag. null = kein Auftrag. */ /** JFX → JME: Dateiname (ohne Erweiterung) für den Export-Auftrag. null = kein Auftrag. */
public volatile String modelImportExportName = null; public volatile String modelImportExportName = null;

View File

@@ -8,16 +8,11 @@ import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager; import com.jme3.asset.AssetManager;
import com.jme3.bounding.BoundingBox; import com.jme3.bounding.BoundingBox;
import com.jme3.export.binary.BinaryExporter; import com.jme3.export.binary.BinaryExporter;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material; import com.jme3.material.Material;
import com.jme3.material.RenderState; import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA; import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector3f; import com.jme3.math.Vector3f;
import com.jme3.post.SceneProcessor;
import com.jme3.profile.AppProfiler;
import com.jme3.renderer.Camera; import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort; import com.jme3.renderer.ViewPort;
import com.jme3.renderer.queue.RenderQueue; import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry; import com.jme3.scene.Geometry;
@@ -31,6 +26,7 @@ import com.jme3.texture.Texture;
import com.jme3.texture.Texture2D; import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils; import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput; import de.blight.editor.SharedInput;
import de.blight.editor.util.ImpostorUtil;
import de.blight.eztree.Tree; import de.blight.eztree.Tree;
import de.blight.eztree.TreeOptions; import de.blight.eztree.TreeOptions;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -59,10 +55,6 @@ public class EzTreeState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(EzTreeState.class); private static final Logger log = LoggerFactory.getLogger(EzTreeState.class);
private static final int IMPOSTOR_SIZE = 512;
private static final int ATLAS_DIRS = 4;
private static final int ATLAS_W = IMPOSTOR_SIZE * ATLAS_DIRS;
private static final int ATLAS_H = IMPOSTOR_SIZE;
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("blight-assets", "src", "main", "resources"); private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("blight-assets", "src", "main", "resources");
private static final Path TOOLS_DIR = de.blight.editor.ProjectRoot.resolve("tools"); private static final Path TOOLS_DIR = de.blight.editor.ProjectRoot.resolve("tools");
private static final Gson GSON = new Gson(); private static final Gson GSON = new Gson();
@@ -79,9 +71,9 @@ public class EzTreeState extends BaseAppState {
private BoundingBox pendingBb = null; private BoundingBox pendingBb = null;
private ViewPort captureVP = null; private ViewPort captureVP = null;
private FrameBuffer captureFB = null; private FrameBuffer captureFB = null;
private volatile boolean captureReady = false; private boolean[] captureReady = new boolean[]{false};
private int capturePass = 0; private int capturePass = 0;
private ByteBuffer[] capturePixels = new ByteBuffer[ATLAS_DIRS]; private ByteBuffer[] capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
public EzTreeState(SharedInput input) { this.input = input; } public EzTreeState(SharedInput input) { this.input = input; }
@@ -106,7 +98,7 @@ public class EzTreeState extends BaseAppState {
if (previewHost == null) return; if (previewHost == null) return;
} }
if (pendingRequest != null && captureReady) { if (pendingRequest != null && captureReady[0]) {
finishCapture(); finishCapture();
} else if (pendingRequest == null) { } else if (pendingRequest == null) {
SharedInput.EzTreeGenRequest req = input.ezTreeGenQueue.poll(); SharedInput.EzTreeGenRequest req = input.ezTreeGenQueue.poll();
@@ -150,9 +142,9 @@ public class EzTreeState extends BaseAppState {
pendingLd1Node = finalLd1; pendingLd1Node = finalLd1;
pendingBb = finalBb; pendingBb = finalBb;
capturePass = 0; capturePass = 0;
capturePixels = new ByteBuffer[ATLAS_DIRS]; capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
startCapturePass(0, finalHd, finalBb); startCapturePass(0);
input.treeGenStatusMsg = "EZ-Tree: rendere Impostor (1/" + ATLAS_DIRS + ")…"; input.treeGenStatusMsg = "EZ-Tree: rendere Impostor (1/" + ImpostorUtil.DIRS + ")…";
} }
}); });
@@ -384,27 +376,29 @@ public class EzTreeState extends BaseAppState {
// ── Phase 2: Impostor-Capture (4-Richtungen) ───────────────────────────── // ── Phase 2: Impostor-Capture (4-Richtungen) ─────────────────────────────
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
private void startCapturePass(int pass, Node treeNode, BoundingBox bb) { private void startCapturePass(int pass) {
BoundingBox safeBb = bb != null ? bb : new BoundingBox(Vector3f.ZERO, 5f, 10f, 5f); BoundingBox safeBb = pendingBb != null ? pendingBb : new BoundingBox(Vector3f.ZERO, 5f, 10f, 5f);
Texture2D capTex = new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.RGBA8); com.jme3.texture.Texture2D capTex = new com.jme3.texture.Texture2D(
captureFB = new FrameBuffer(IMPOSTOR_SIZE, IMPOSTOR_SIZE, 1); ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.RGBA8);
captureFB = new FrameBuffer(ImpostorUtil.SIZE, ImpostorUtil.SIZE, 1);
captureFB.addColorTexture(capTex); captureFB.addColorTexture(capTex);
captureFB.setDepthTexture(new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.Depth)); captureFB.setDepthTexture(new com.jme3.texture.Texture2D(
float angle = pass * com.jme3.math.FastMath.HALF_PI; ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.Depth));
captureVP = buildCaptureViewPort(treeNode, safeBb, captureFB, angle); captureReady[0] = false;
captureReady = false; captureVP = ImpostorUtil.startCapturePass(app, pendingHdNode, safeBb, captureFB, pass, captureReady);
} }
private void finishCapture() { private void finishCapture() {
ByteBuffer pixels = BufferUtils.createByteBuffer(IMPOSTOR_SIZE * IMPOSTOR_SIZE * 4); capturePixels[capturePass] = ImpostorUtil.readPixels(app, captureFB);
app.getRenderer().readFrameBuffer(captureFB, pixels); ImpostorUtil.cleanup(app, captureVP, captureFB);
capturePixels[capturePass] = pixels; captureVP = null;
cleanupCapture(); captureFB = null;
captureReady[0] = false;
if (capturePass < ATLAS_DIRS - 1) { if (capturePass < ImpostorUtil.DIRS - 1) {
capturePass++; capturePass++;
input.treeGenStatusMsg = "EZ-Tree: rendere Impostor (" + (capturePass + 1) + "/" + ATLAS_DIRS + ")…"; input.treeGenStatusMsg = "EZ-Tree: rendere Impostor (" + (capturePass + 1) + "/" + ImpostorUtil.DIRS + ")…";
startCapturePass(capturePass, pendingHdNode, pendingBb); startCapturePass(capturePass);
return; return;
} }
@@ -418,7 +412,7 @@ public class EzTreeState extends BaseAppState {
pendingHdNode = null; pendingHdNode = null;
pendingLd1Node = null; pendingLd1Node = null;
pendingBb = null; pendingBb = null;
capturePixels = new ByteBuffer[ATLAS_DIRS]; capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
String subPath = resolveSubPath(req.presetName()); String subPath = resolveSubPath(req.presetName());
String namePart = req.presetName() != null String namePart = req.presetName() != null
@@ -427,8 +421,8 @@ public class EzTreeState extends BaseAppState {
String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
String exportName = namePart + "_" + timestamp; String exportName = namePart + "_" + timestamp;
ByteBuffer atlas = combineAtlas(savedPixels); ByteBuffer atlas = ImpostorUtil.combineAtlas(savedPixels);
Texture2D atlasT2d = saveImpostor(atlas, "ez_impostor_" + exportName, ATLAS_W, ATLAS_H); com.jme3.texture.Texture2D atlasT2d = ImpostorUtil.buildAtlasTexture(atlas);
Node lodRoot = assembleLodNode(req.presetName(), hdNode, ld1Node, bb, atlasT2d); Node lodRoot = assembleLodNode(req.presetName(), hdNode, ld1Node, bb, atlasT2d);
exportTree(lodRoot, exportName, subPath); exportTree(lodRoot, exportName, subPath);
@@ -503,80 +497,6 @@ public class EzTreeState extends BaseAppState {
} }
} }
// ── Offscreen-Viewport für Impostor ───────────────────────────────────────
private ViewPort buildCaptureViewPort(Node src, BoundingBox bb, FrameBuffer fb, float angle) {
Vector3f center = bb.getCenter();
float yExt = bb.getYExtent();
float hExt = Math.max(bb.getXExtent(), bb.getZExtent());
float extent = Math.max(yExt, hExt);
float dist = extent * 3.5f;
float camX = com.jme3.math.FastMath.sin(angle) * dist;
float camZ = com.jme3.math.FastMath.cos(angle) * dist;
Camera cam = new Camera(IMPOSTOR_SIZE, IMPOSTOR_SIZE);
cam.setLocation(center.add(camX, 0f, camZ));
cam.lookAt(center, Vector3f.UNIT_Y);
// FOV groß genug, um den vollen Baum (+ 15 % Rand) zu erfassen
float fovY = (float) Math.toDegrees(2.0 * Math.atan2(yExt * 1.15, dist));
float fovH = (float) Math.toDegrees(2.0 * Math.atan2(hExt * 1.15, dist));
cam.setFrustumPerspective(Math.max(fovY, fovH), 1f, 0.1f, dist * 4f);
ViewPort vp = app.getRenderManager()
.createPostView("ezCapture_" + System.nanoTime(), cam);
vp.setOutputFrameBuffer(fb);
vp.setBackgroundColor(new ColorRGBA(0f, 0f, 0f, 0f));
vp.setClearFlags(true, true, true);
Node scene = new Node("ezCapScene");
scene.addLight(new DirectionalLight(
new Vector3f(-0.4f, -1f, -0.5f).normalizeLocal(), new ColorRGBA(2.0f, 1.85f, 1.5f, 1f)));
scene.addLight(new AmbientLight(new ColorRGBA(0.60f, 0.60f, 0.60f, 1f)));
scene.attachChild(cloneForCapture(src));
vp.attachScene(scene);
scene.updateGeometricState();
vp.addProcessor(new SceneProcessor() {
@Override public void initialize(RenderManager rm, ViewPort v) {}
@Override public void reshape(ViewPort v, int w, int h) {}
@Override public boolean isInitialized() { return true; }
@Override public void preFrame(float t) {}
@Override public void postQueue(RenderQueue rq) {}
@Override public void cleanup() {}
@Override public void setProfiler(AppProfiler p) {}
@Override
public void postFrame(FrameBuffer out) {
vp.removeProcessor(this);
captureReady = true;
}
});
return vp;
}
private static Node cloneForCapture(Node src) {
Node copy = new Node("ezCap");
copy.setLocalTranslation(src.getLocalTranslation());
copy.setLocalScale(src.getLocalScale());
for (Spatial child : src.getChildren()) {
if (child instanceof Geometry g) {
Geometry gc = new Geometry(g.getName(), g.getMesh());
gc.setMaterial(g.getMaterial().clone());
copy.attachChild(gc);
} else if (child instanceof Node n) {
Node nc = new Node(n.getName());
for (Spatial ns : n.getChildren()) {
if (ns instanceof Geometry ng) {
Geometry ngc = new Geometry(ng.getName(), ng.getMesh());
ngc.setMaterial(ng.getMaterial().clone());
nc.attachChild(ngc);
}
}
copy.attachChild(nc);
}
}
return copy;
}
// ── Hilfsmethoden ───────────────────────────────────────────────────────── // ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static BoundingBox boundsOf(Node node) { private static BoundingBox boundsOf(Node node) {
@@ -586,48 +506,17 @@ public class EzTreeState extends BaseAppState {
} }
private void cleanupCapture() { private void cleanupCapture() {
if (captureVP != null) { ImpostorUtil.cleanup(app, captureVP, captureFB);
app.getRenderManager().removePostView(captureVP); captureVP = null;
captureVP = null; captureFB = null;
} captureReady[0] = false;
if (captureFB != null) {
try { captureFB.dispose(); } catch (Exception ignored) {}
captureFB = null;
}
captureReady = false;
} }
private Texture2D saveImpostor(ByteBuffer pixels, String name, int width, int height) { private Node assembleLodNode(String name, Node hd, Node ld1, BoundingBox bb, com.jme3.texture.Texture2D atlasTex) {
pixels.rewind();
Image jmeImg = new Image(Image.Format.RGBA8, width, height, pixels, null,
com.jme3.texture.image.ColorSpace.sRGB);
return new Texture2D(jmeImg);
}
private ByteBuffer combineAtlas(ByteBuffer[] passes) {
ByteBuffer atlas = BufferUtils.createByteBuffer(ATLAS_W * ATLAS_H * 4);
for (int d = 0; d < ATLAS_DIRS; d++) {
ByteBuffer src = passes[d];
src.rewind();
for (int y = 0; y < IMPOSTOR_SIZE; y++) {
for (int x = 0; x < IMPOSTOR_SIZE; x++) {
int srcOff = (y * IMPOSTOR_SIZE + x) * 4;
int dstOff = (y * ATLAS_W + d * IMPOSTOR_SIZE + x) * 4;
atlas.put(dstOff, src.get(srcOff));
atlas.put(dstOff + 1, src.get(srcOff + 1));
atlas.put(dstOff + 2, src.get(srcOff + 2));
atlas.put(dstOff + 3, src.get(srcOff + 3));
}
}
}
return atlas;
}
private Node assembleLodNode(String name, Node hd, Node ld1, BoundingBox bb, Texture2D atlasTex) {
Node root = new Node(name != null ? name : "ez_tree"); Node root = new Node(name != null ? name : "ez_tree");
root.attachChild(hd); root.attachChild(hd);
root.attachChild(ld1); root.attachChild(ld1);
Node lod2 = makeImpostorNode(bb, atlasTex); Node lod2 = ImpostorUtil.makeImpostorNode(bb, atlasTex, assets);
root.attachChild(lod2); root.attachChild(lod2);
hd.setCullHint(Spatial.CullHint.Inherit); hd.setCullHint(Spatial.CullHint.Inherit);
@@ -635,56 +524,11 @@ public class EzTreeState extends BaseAppState {
lod2.setCullHint(Spatial.CullHint.Always); lod2.setCullHint(Spatial.CullHint.Always);
lod2.setShadowMode(RenderQueue.ShadowMode.Off); lod2.setShadowMode(RenderQueue.ShadowMode.Off);
ImpostorUtil.addViewControl(lod2, app.getCamera());
root.addControl(new EzTreeLodControl(app.getCamera(), hd, ld1, lod2, 40f, 120f)); root.addControl(new EzTreeLodControl(app.getCamera(), hd, ld1, lod2, 40f, 120f));
return root; return root;
} }
private Node makeImpostorNode(BoundingBox bb, Texture2D tex) {
float h = bb != null ? bb.getYExtent() * 2f : 10f;
float w = bb != null ? Math.max(bb.getXExtent(), bb.getZExtent()) * 2f : 4f;
float size = Math.max(h, w);
float yOff = bb != null ? bb.getCenter().y : 5f;
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
if (tex != null) mat.setTexture("ColorMap", tex);
else mat.setColor("Color", new ColorRGBA(0.18f, 0.5f, 0.1f, 0.9f));
mat.getAdditionalRenderState().setBlendMode(com.jme3.material.RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(com.jme3.material.RenderState.FaceCullMode.Off);
Node n = new Node("lod2");
for (int d = 0; d < ATLAS_DIRS; d++) {
float angle = d * com.jme3.math.FastMath.HALF_PI;
float uMin = (float) d / ATLAS_DIRS;
float uMax = (float)(d + 1) / ATLAS_DIRS;
n.attachChild(buildBillboardQuad("quad_" + d, angle, yOff, size, mat.clone(), uMin, uMax));
}
n.setQueueBucket(RenderQueue.Bucket.Transparent);
return n;
}
private Geometry buildBillboardQuad(String name, float yRot, float yCent,
float size, Material mat, float uMin, float uMax) {
float hw = size * 0.5f;
float hh = size * 0.5f;
float cos = com.jme3.math.FastMath.cos(yRot);
float sin = com.jme3.math.FastMath.sin(yRot);
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, new float[]{
-hw*cos, yCent-hh, -hw*sin,
hw*cos, yCent-hh, hw*sin,
hw*cos, yCent+hh, hw*sin,
-hw*cos, yCent+hh, -hw*sin
});
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, new float[]{
uMin, 0f, uMax, 0f, uMax, 1f, uMin, 1f
});
mesh.setBuffer(VertexBuffer.Type.Index, 3, new int[]{0,1,2, 0,2,3, 2,1,0, 3,2,0});
mesh.updateBound();
Geometry g = new Geometry(name, mesh);
g.setMaterial(mat);
return g;
}
/** /**
* Setzt Vertex-Farben (R = Wind-Gewicht) für alle Geometrien eines Baum-Knotens. * Setzt Vertex-Farben (R = Wind-Gewicht) für alle Geometrien eines Baum-Knotens.
* Blätter bekommen Gewicht 1.0 (volle Animation); Äste werden per Y-Position * Blätter bekommen Gewicht 1.0 (volle Animation); Äste werden per Y-Position
@@ -778,10 +622,17 @@ public class EzTreeState extends BaseAppState {
@Override @Override
protected void controlUpdate(float tpf) { protected void controlUpdate(float tpf) {
float dSq = cam.getLocation().distanceSquared(spatial.getWorldTranslation()); float dSq = cam.getLocation().distanceSquared(spatial.getWorldTranslation());
lod0.setCullHint(dSq < d01sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); boolean atL2 = dSq >= d12sq;
lod1.setCullHint(dSq >= d01sq && dSq < d12sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); setLod(lod0, dSq < d01sq, !atL2);
lod2.setCullHint(dSq >= d12sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); setLod(lod1, dSq >= d01sq && !atL2, !atL2);
setLod(lod2, atL2, false);
}
private static void setLod(Node node, boolean visible, boolean castShadow) {
node.setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
node.setShadowMode(visible && castShadow
? RenderQueue.ShadowMode.CastAndReceive : RenderQueue.ShadowMode.Off);
} }
@Override protected void controlRender(com.jme3.renderer.RenderManager rm, @Override protected void controlRender(com.jme3.renderer.RenderManager rm,

View File

@@ -13,6 +13,9 @@ import com.jme3.light.PointLight;
import com.jme3.material.Material; import com.jme3.material.Material;
import com.jme3.math.*; import com.jme3.math.*;
import com.jme3.renderer.Camera; import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*; import com.jme3.scene.*;
import com.jme3.collision.CollisionResults; import com.jme3.collision.CollisionResults;
import com.jme3.math.Ray; import com.jme3.math.Ray;
@@ -23,6 +26,7 @@ import com.jme3.scene.shape.Sphere;
import com.jme3.anim.SkinningControl; import com.jme3.anim.SkinningControl;
import com.jme3.util.BufferUtils; import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput; import de.blight.editor.SharedInput;
import de.blight.editor.util.ImpostorUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -73,6 +77,8 @@ public class ModelEditorState extends BaseAppState {
/** Hauptlichtquelle der Vorschau (per UI steuerbar). */ /** Hauptlichtquelle der Vorschau (per UI steuerbar). */
private DirectionalLight mainLight = null; private DirectionalLight mainLight = null;
/** Kamera-folgendes Füll-Licht immer aus Sicht der Kamera. */
private DirectionalLight camLight = null;
// gespeicherter Kamerazustand aus dem Editor-Modus // gespeicherter Kamerazustand aus dem Editor-Modus
private Vector3f savedCamPos; private Vector3f savedCamPos;
@@ -98,6 +104,11 @@ public class ModelEditorState extends BaseAppState {
private boolean hasEmbeddedLods = false; private boolean hasEmbeddedLods = false;
private Spatial[] embeddedLodSpatials = null; private Spatial[] embeddedLodSpatials = null;
// Materialien mit eigenem m_LightDir (Tree.j3md / TreeLeaf.j3md) werden jedes Frame aktualisiert
private final java.util.List<Material> treeLightMaterials = new java.util.ArrayList<>();
// PBR-Materialien (PBRLighting) brauchen EmissivePower=1 als IBL-Fallback
private final java.util.List<Material> pbrMaterials = new java.util.ArrayList<>();
// Mittelpunkt für Orbit (Bounding-Box-Zentrum des Modells) // Mittelpunkt für Orbit (Bounding-Box-Zentrum des Modells)
private Vector3f orbitCenter = Vector3f.ZERO.clone(); private Vector3f orbitCenter = Vector3f.ZERO.clone();
@@ -134,6 +145,9 @@ public class ModelEditorState extends BaseAppState {
return; return;
} }
// Welt sofort ausblenden, auch wenn noch kein Modell geladen ist
if (previewRoot == null) enterPreview();
// Modell laden // Modell laden
String openPath = input.modelEditorOpenPath; String openPath = input.modelEditorOpenPath;
if (openPath != null) { if (openPath != null) {
@@ -145,37 +159,54 @@ public class ModelEditorState extends BaseAppState {
loadModel(openPath); loadModel(openPath);
} }
int removeLod = input.modelEditorRemoveEmbeddedLod;
if (removeLod > 0) {
input.modelEditorRemoveEmbeddedLod = -1;
if (hasEmbeddedLods && embeddedLodSpatials != null) {
removeEmbeddedLod(removeLod);
}
}
if (input.modelEditorLodChanged) { if (input.modelEditorLodChanged) {
input.modelEditorLodChanged = false; input.modelEditorLodChanged = false;
if (hasEmbeddedLods && embeddedLodSpatials != null) {
// Generierte In-Memory-Previews haben immer Vorrang (auch über eingebettete LODs)
Spatial genPreview = null;
if (input.modelEditorLodPreview == 1)
genPreview = input.modelImportLod1PreviewNode.get();
else if (input.modelEditorLodPreview == 2)
genPreview = input.modelImportLod2PreviewNode.get();
if (genPreview != null) {
if (hasEmbeddedLods && embeddedLodSpatials != null) {
// Eingebettete LODs alle ausblenden, damit das generierte Preview sichtbar ist
for (Spatial s : embeddedLodSpatials) {
if (s != null) s.setCullHint(Spatial.CullHint.Always);
}
}
showSpatialDirectly(genPreview.clone());
} else if (hasEmbeddedLods && embeddedLodSpatials != null) {
// Eingebettete LOD-Kinder direkt ein-/ausblenden // Eingebettete LOD-Kinder direkt ein-/ausblenden
int lodIdx = Math.min(input.modelEditorLodPreview, embeddedLodSpatials.length - 1); int lodIdx = Math.min(input.modelEditorLodPreview, embeddedLodSpatials.length - 1);
for (int i = 0; i < embeddedLodSpatials.length; i++) { for (int i = 0; i < embeddedLodSpatials.length; i++) {
embeddedLodSpatials[i].setCullHint( if (embeddedLodSpatials[i] != null) {
i == lodIdx ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); boolean show = (i == lodIdx);
embeddedLodSpatials[i].setCullHint(show ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
embeddedLodSpatials[i].setShadowMode(RenderQueue.ShadowMode.Off);
}
} }
} else { } else {
// In-memory Preview-Node aus Importer hat Vorrang vor Dateipfaden // Pfad-basierter Fallback
Spatial previewNode = null; String path = switch (input.modelEditorLodPreview) {
if (input.modelEditorLodPreview == 1) case 1 -> input.modelEditorLod1Path;
previewNode = input.modelImportLod1PreviewNode.get(); case 2 -> input.modelEditorLod2Path;
else if (input.modelEditorLodPreview == 2) default -> mainModelPath;
previewNode = input.modelImportLod2PreviewNode.get(); };
if (path == null || path.isBlank()) {
if (previewNode != null) { path = mainModelPath;
showSpatialDirectly(previewNode.clone()); input.modelEditorLodPreview = 0;
} else {
String path = switch (input.modelEditorLodPreview) {
case 1 -> input.modelEditorLod1Path;
case 2 -> input.modelEditorLod2Path;
default -> mainModelPath;
};
if (path == null || path.isBlank()) {
path = mainModelPath;
input.modelEditorLodPreview = 0;
}
if (path != null) loadModel(path);
} }
if (path != null) loadModel(path);
} }
} }
@@ -305,6 +336,15 @@ public class ModelEditorState extends BaseAppState {
} }
applyOrbitCamera(); applyOrbitCamera();
if (camLight != null) {
Vector3f camDir = cam.getDirection();
camLight.setDirection(camDir);
// Tree.j3md erwartet Richtung VON Oberfläche ZUR Lichtquelle = camDir
Vector3f treeLightDir = camDir.negate();
for (Material m : treeLightMaterials) {
m.setVector3("LightDir", treeLightDir);
}
}
previewRoot.updateLogicalState(tpf); previewRoot.updateLogicalState(tpf);
previewRoot.updateGeometricState(); previewRoot.updateGeometricState();
@@ -327,6 +367,8 @@ public class ModelEditorState extends BaseAppState {
hasEmbeddedLods = false; hasEmbeddedLods = false;
embeddedLodSpatials = null; embeddedLodSpatials = null;
input.modelEditorHasEmbeddedLods = false; input.modelEditorHasEmbeddedLods = false;
input.modelEditorHasEmbeddedLod1 = false;
input.modelEditorHasEmbeddedLod2 = false;
try { try {
// Cache vor dem Laden löschen verhindert, dass zwischengespeicherte // Cache vor dem Laden löschen verhindert, dass zwischengespeicherte
// Materialzustände (z.B. aus vorherigen Thumbnail-Renders) das erste // Materialzustände (z.B. aus vorherigen Thumbnail-Renders) das erste
@@ -335,6 +377,7 @@ public class ModelEditorState extends BaseAppState {
catch (Exception ignored) {} catch (Exception ignored) {}
Spatial model = app.getAssetManager().loadModel(assetPath); Spatial model = app.getAssetManager().loadModel(assetPath);
stripControls(model); stripControls(model);
initImpostorControls(model);
recalcNormalsIfMissing(model); recalcNormalsIfMissing(model);
markBuffersUpdateNeeded(model); markBuffersUpdateNeeded(model);
originalSpatial = model; originalSpatial = model;
@@ -350,6 +393,8 @@ public class ModelEditorState extends BaseAppState {
embeddedLodSpatials = children.toArray(new Spatial[0]); embeddedLodSpatials = children.toArray(new Spatial[0]);
hasEmbeddedLods = true; hasEmbeddedLods = true;
input.modelEditorHasEmbeddedLods = true; input.modelEditorHasEmbeddedLods = true;
input.modelEditorHasEmbeddedLod1 = embeddedLodSpatials.length > 1;
input.modelEditorHasEmbeddedLod2 = embeddedLodSpatials.length > 2;
} }
} }
} catch (Exception e) { } catch (Exception e) {
@@ -375,6 +420,22 @@ public class ModelEditorState extends BaseAppState {
input.modelEditorBakedScaleDetected = true; input.modelEditorBakedScaleDetected = true;
} }
// Materialien klassifizieren und sofort für den Editor initialisieren
treeLightMaterials.clear();
pbrMaterials.clear();
collectEditorMaterials(originalSpatial);
Vector3f initialLightDir = cam.getDirection().negate();
for (Material m : treeLightMaterials) {
m.setVector3("LightDir", initialLightDir);
m.setVector3("SunColor", new Vector3f(1.1f, 1.0f, 0.9f));
m.setVector3("AmbientColor", new Vector3f(0.30f, 0.30f, 0.32f));
}
for (Material m : pbrMaterials) {
m.setFloat("EmissivePower", 1f);
m.setFloat("EmissiveIntensity", 1f);
m.setColor("Emissive", new ColorRGBA(0.25f, 0.25f, 0.28f, 1f));
}
// Skalierung aus SharedInput anwenden // Skalierung aus SharedInput anwenden
applyScale(input.modelEditorScaleX, input.modelEditorScaleY, input.modelEditorScaleZ); applyScale(input.modelEditorScaleX, input.modelEditorScaleY, input.modelEditorScaleZ);
applyPivot(input.modelEditorPivotY); applyPivot(input.modelEditorPivotY);
@@ -391,6 +452,18 @@ public class ModelEditorState extends BaseAppState {
orbitCenter = bb.getCenter().clone(); orbitCenter = bb.getCenter().clone();
} }
// Kamera sofort positionieren und Baum-LightDir initialisieren
// damit der allererste Render bereits korrekte Beleuchtung zeigt.
applyOrbitCamera();
if (camLight != null) {
Vector3f camDir = cam.getDirection();
camLight.setDirection(camDir);
Vector3f treeLightDir = camDir.negate();
for (Material m : treeLightMaterials) {
m.setVector3("LightDir", treeLightDir);
}
}
// Vergleichszylinder neu positionieren // Vergleichszylinder neu positionieren
repositionCompCylinder(); repositionCompCylinder();
@@ -409,6 +482,21 @@ public class ModelEditorState extends BaseAppState {
if (modelWrapper != null) previewRoot.detachChild(modelWrapper); if (modelWrapper != null) previewRoot.detachChild(modelWrapper);
modelWrapper = new Node("model_wrapper"); modelWrapper = new Node("model_wrapper");
stripControls(spatial); stripControls(spatial);
treeLightMaterials.clear();
pbrMaterials.clear();
collectEditorMaterials(spatial);
Vector3f lodLightDir = cam.getDirection().negate();
for (Material m : treeLightMaterials) {
m.setVector3("LightDir", lodLightDir);
m.setVector3("SunColor", new Vector3f(1.1f, 1.0f, 0.9f));
m.setVector3("AmbientColor", new Vector3f(0.30f, 0.30f, 0.32f));
}
for (Material m : pbrMaterials) {
m.setFloat("EmissivePower", 1f);
m.setFloat("EmissiveIntensity", 1f);
m.setColor("Emissive", new ColorRGBA(0.25f, 0.25f, 0.28f, 1f));
}
spatial.setShadowMode(RenderQueue.ShadowMode.Off);
modelWrapper.attachChild(spatial); modelWrapper.attachChild(spatial);
applyScale(input.modelEditorScaleX, input.modelEditorScaleY, input.modelEditorScaleZ); applyScale(input.modelEditorScaleX, input.modelEditorScaleY, input.modelEditorScaleZ);
applyPivot(input.modelEditorPivotY); applyPivot(input.modelEditorPivotY);
@@ -469,6 +557,8 @@ public class ModelEditorState extends BaseAppState {
computeLightDirection(input.modelEditorLightAzimuth, input.modelEditorLightElevation), computeLightDirection(input.modelEditorLightAzimuth, input.modelEditorLightElevation),
new ColorRGBA(1.8f, 1.7f, 1.4f, 1f)); new ColorRGBA(1.8f, 1.7f, 1.4f, 1f));
previewRoot.addLight(mainLight); previewRoot.addLight(mainLight);
camLight = new DirectionalLight(Vector3f.UNIT_Z.negate(), new ColorRGBA(1.4f, 1.3f, 1.1f, 1f));
previewRoot.addLight(camLight);
previewRoot.addLight(new DirectionalLight( previewRoot.addLight(new DirectionalLight(
new Vector3f(0.6f, -0.4f, 0.7f).normalizeLocal(), new Vector3f(0.6f, -0.4f, 0.7f).normalizeLocal(),
new ColorRGBA(0.5f, 0.55f, 0.75f, 1f))); new ColorRGBA(0.5f, 0.55f, 0.75f, 1f)));
@@ -479,6 +569,10 @@ public class ModelEditorState extends BaseAppState {
app.getViewPort().setBackgroundColor(new ColorRGBA(0.15f, 0.15f, 0.18f, 1f)); app.getViewPort().setBackgroundColor(new ColorRGBA(0.15f, 0.15f, 0.18f, 1f));
// Sofort am Viewport registrieren auch ohne Modell sichtbar (leerer, dunkler Viewport)
app.getViewPort().attachScene(previewRoot);
previewSceneAttached = true;
interactableArrowNode = new Node("interactableArrow"); interactableArrowNode = new Node("interactableArrow");
interactableArrowNode.setCullHint(Spatial.CullHint.Always); interactableArrowNode.setCullHint(Spatial.CullHint.Always);
previewRoot.attachChild(interactableArrowNode); previewRoot.attachChild(interactableArrowNode);
@@ -510,7 +604,13 @@ public class ModelEditorState extends BaseAppState {
embeddedLodSpatials = null; embeddedLodSpatials = null;
originalSpatial = null; originalSpatial = null;
interactableArrowNode = null; interactableArrowNode = null;
camLight = null;
mainLight = null;
treeLightMaterials.clear();
pbrMaterials.clear();
input.modelEditorHasEmbeddedLods = false; input.modelEditorHasEmbeddedLods = false;
input.modelEditorHasEmbeddedLod1 = false;
input.modelEditorHasEmbeddedLod2 = false;
app.getRootNode().setCullHint(Spatial.CullHint.Inherit); app.getRootNode().setCullHint(Spatial.CullHint.Inherit);
if (savedCamPos != null) { if (savedCamPos != null) {
@@ -521,6 +621,65 @@ public class ModelEditorState extends BaseAppState {
currentPath = null; currentPath = null;
} }
// ── Eingebettetes LOD entfernen ───────────────────────────────────────────
private void removeEmbeddedLod(int lodIdx) {
if (lodIdx < 0 || lodIdx >= embeddedLodSpatials.length) return;
Spatial toRemove = embeddedLodSpatials[lodIdx];
if (toRemove == null) return;
if (toRemove.getParent() != null) toRemove.getParent().detachChild(toRemove);
embeddedLodSpatials[lodIdx] = null;
if (lodIdx == 1) input.modelEditorHasEmbeddedLod1 = false;
else if (lodIdx == 2) input.modelEditorHasEmbeddedLod2 = false;
if (input.modelEditorLodPreview == lodIdx) {
input.modelEditorLodPreview = 0;
if (embeddedLodSpatials[0] != null)
embeddedLodSpatials[0].setCullHint(Spatial.CullHint.Inherit);
}
boolean anyLodLeft = false;
for (int i = 1; i < embeddedLodSpatials.length; i++) {
if (embeddedLodSpatials[i] != null) { anyLodLeft = true; break; }
}
if (!anyLodLeft) {
hasEmbeddedLods = false;
embeddedLodSpatials = null;
input.modelEditorHasEmbeddedLods = false;
}
if (currentPath != null && !modelWrapper.getChildren().isEmpty()) {
try {
java.nio.file.Path absPath = ASSET_ROOT.resolve(currentPath);
Spatial root = modelWrapper.getChild(0);
BinaryExporter.getInstance().save(root, absPath.toFile());
app.getAssetManager().clearCache();
log.info("[ModelEditor] LOD {} entfernt, Datei gespeichert: {}", lodIdx, currentPath);
} catch (Exception ex) {
log.error("[ModelEditor] Speichern nach LOD-Entfernung fehlgeschlagen: {}", ex.getMessage(), ex);
}
}
}
private void collectEditorMaterials(Spatial s) {
if (s == null) return;
if (s instanceof Geometry g) {
Material mat = g.getMaterial();
if (mat == null) return;
String defName = mat.getMaterialDef().getName();
if ("Tree".equals(defName) || "TreeLeaf".equals(defName)) {
treeLightMaterials.add(mat);
} else if ("PBRLighting".equals(defName)) {
pbrMaterials.add(mat);
}
} else if (s instanceof Node n) {
for (Spatial child : n.getChildren()) {
collectEditorMaterials(child);
}
}
}
// ── Gitter ──────────────────────────────────────────────────────────────── // ── Gitter ────────────────────────────────────────────────────────────────
private void rebuildGrid() { private void rebuildGrid() {
@@ -732,10 +891,23 @@ public class ModelEditorState extends BaseAppState {
} }
private static void stripControls(Spatial s) { private static void stripControls(Spatial s) {
while (s.getNumControls() > 0) s.removeControl(s.getControl(0)); int i = 0;
while (i < s.getNumControls()) {
com.jme3.scene.control.Control c = s.getControl(i);
if (c instanceof ImpostorUtil.ImpostorViewControl) { i++; }
else { s.removeControl(c); }
}
if (s instanceof Node n) n.getChildren().forEach(ModelEditorState::stripControls); if (s instanceof Node n) n.getChildren().forEach(ModelEditorState::stripControls);
} }
private void initImpostorControls(Spatial s) {
ImpostorUtil.ImpostorViewControl ctrl = s.getControl(ImpostorUtil.ImpostorViewControl.class);
if (ctrl != null) ctrl.setCamera(cam);
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) initImpostorControls(child);
}
}
/** /**
* Berechnet Smooth-Normals immer neu aus der Geometrie. * Berechnet Smooth-Normals immer neu aus der Geometrie.
* Behebt TripoAI-Modelle mit fehlenden, null oder nach innen zeigenden Normals. * Behebt TripoAI-Modelle mit fehlenden, null oder nach innen zeigenden Normals.

View File

@@ -12,11 +12,15 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.nio.ByteBuffer;
import com.jme3.app.Application; import com.jme3.app.Application;
import com.jme3.app.SimpleApplication; import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState; import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager; import com.jme3.asset.AssetManager;
import com.jme3.export.binary.BinaryExporter; import com.jme3.export.binary.BinaryExporter;
import com.jme3.bounding.BoundingBox;
import com.jme3.material.Material;
import com.jme3.math.*; import com.jme3.math.*;
import com.jme3.renderer.Camera; import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager; import com.jme3.renderer.RenderManager;
@@ -25,9 +29,13 @@ import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*; import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer; import com.jme3.scene.VertexBuffer;
import com.jme3.scene.control.AbstractControl; import com.jme3.scene.control.AbstractControl;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils; import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput; import de.blight.editor.SharedInput;
import de.blight.editor.util.ImpostorUtil;
import de.blight.editor.util.MeshUtils; import de.blight.editor.util.MeshUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -55,6 +63,16 @@ public class ModelImportState extends BaseAppState {
private Node lod1Node = null; private Node lod1Node = null;
private Node lod2Node = null; private Node lod2Node = null;
// ── Impostor-Capture-State ────────────────────────────────────────────────
private int impostorLevel = -1;
private int impostorPass = 0;
private boolean[] impostorReady = new boolean[]{false};
private FrameBuffer impostorFB = null;
private ViewPort impostorVP = null;
private BoundingBox impostorBb = null;
private Node impostorSrcNode = null;
private ByteBuffer[] impostorPixels = new ByteBuffer[ImpostorUtil.DIRS];
public ModelImportState(SharedInput input) { public ModelImportState(SharedInput input) {
this.input = input; this.input = input;
} }
@@ -78,21 +96,51 @@ public class ModelImportState extends BaseAppState {
if (modelEditorState == null) return; if (modelEditorState == null) return;
} }
int clearLod = input.modelImportClearLodLevel;
if (clearLod != -1) {
input.modelImportClearLodLevel = -1;
if (clearLod == 1) {
lod1Node = null;
input.modelImportHasLod1 = false;
input.modelImportLod1PreviewNode.set(null);
input.modelLodGenStatus = "LOD1: (entfernt)";
if (input.modelEditorLodPreview == 1) {
input.modelEditorLodPreview = 0;
input.modelEditorLodChanged = true;
}
} else if (clearLod == 2) {
lod2Node = null;
input.modelImportHasLod2 = false;
input.modelImportLod2PreviewNode.set(null);
input.modelLodGenStatus = "LOD2: (entfernt)";
if (input.modelEditorLodPreview == 2) {
input.modelEditorLodPreview = 0;
input.modelEditorLodChanged = true;
}
}
}
SharedInput.ModelLodGenRequest req = input.modelLodGenRequest.getAndSet(null); SharedInput.ModelLodGenRequest req = input.modelLodGenRequest.getAndSet(null);
if (req != null) { if (req != null) {
switch (req.level()) { float pct = req.reductionPct() > 0 ? req.reductionPct()
case 1 -> { : (req.level() == 1 ? 0.50f : 0.85f);
if ("auto_mesh".equals(req.method())) generateLodAuto(1, 0.5f); switch (req.method()) {
else if ("file".equals(req.method())) generateLodFromFile(1, req.fileAssetPath()); case "auto_mesh", "blight_auto", "jme_auto" -> {
input.modelLodAlgorithm = req.method().startsWith("jme") ? "jme" : "blight";
generateLodAuto(req.level(), pct);
} }
case 2 -> { case "auto_mesh_heavy" -> generateLodAuto(req.level(), pct);
if ("auto_mesh_heavy".equals(req.method())) generateLodAuto(2, 0.85f); case "file" -> generateLodFromFile(req.level(), req.fileAssetPath());
else if ("file".equals(req.method())) generateLodFromFile(2, req.fileAssetPath()); case "impostor" -> generateImpostorLod(req.level());
} default -> log.warn("[ModelImport] Unbekannte Methode: {}", req.method());
default -> log.warn("[ModelImport] Unbekannte LOD-Stufe: {}", req.level());
} }
} }
// Impostor: nächsten Frame abwarten und Pixel lesen
if (impostorLevel >= 0 && impostorReady[0] && impostorFB != null) {
finishImpostorPass();
}
String exportName = input.modelImportExportName; String exportName = input.modelImportExportName;
if (exportName != null) { if (exportName != null) {
performExport(exportName); performExport(exportName);
@@ -189,6 +237,88 @@ public class ModelImportState extends BaseAppState {
} }
} }
// ── Impostor-Generierung (Modell-Editor) ──────────────────────────────────
private void generateImpostorLod(int level) {
Spatial src = modelEditorState.getOriginalSpatial();
if (src == null) {
input.modelLodGenStatus = "FEHLER: Kein Modell geladen";
return;
}
// Sauberer eigenständiger Clone bei Nulltranslation + Editor-Scale:
// originalSpatial kann nach showSpatialDirectly() aus dem Scenegraph entfernt worden
// sein, dann wäre getWorldBound() veraltet. Ein frischer Clone mit Identity-Transform
// garantiert korrekte lokale Bounds für die Kamera-Rahmung.
Spatial cleanClone = src.clone();
// Controls entfernen, damit z.B. ein ImportLodControl den CullHint
// nicht auf lod0 zurücksetzt und die Geometrie im Capture unsichtbar macht.
stripControls(cleanClone);
cleanClone.setLocalTranslation(Vector3f.ZERO);
cleanClone.setLocalScale(input.modelEditorScaleX, input.modelEditorScaleY, input.modelEditorScaleZ);
cleanClone.setLocalRotation(Quaternion.IDENTITY);
cleanClone.updateGeometricState();
cleanClone.updateModelBound();
impostorBb = cleanClone.getWorldBound() instanceof BoundingBox bb ? bb
: new BoundingBox(new Vector3f(0f, 2f, 0f), 2f, 2f, 2f);
impostorSrcNode = cleanClone instanceof Node n ? n : wrapInNode(cleanClone, "imp_src");
input.modelLodGenStatus = "Impostor wird gerendert (1/" + ImpostorUtil.DIRS + ")…";
impostorLevel = level;
impostorPass = 0;
impostorPixels = new ByteBuffer[ImpostorUtil.DIRS];
startImpostorPass(0);
}
private void startImpostorPass(int pass) {
Texture2D capTex = new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.RGBA8);
impostorFB = new FrameBuffer(ImpostorUtil.SIZE, ImpostorUtil.SIZE, 1);
impostorFB.addColorTexture(capTex);
impostorFB.setDepthTexture(new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.Depth));
impostorReady[0] = false;
impostorVP = ImpostorUtil.startCapturePass(app, impostorSrcNode, impostorBb, impostorFB, pass, impostorReady);
}
private void finishImpostorPass() {
impostorPixels[impostorPass] = ImpostorUtil.readPixels(app, impostorFB);
ImpostorUtil.cleanup(app, impostorVP, impostorFB);
impostorVP = null;
impostorFB = null;
impostorReady[0] = false;
if (impostorPass < ImpostorUtil.DIRS - 1) {
impostorPass++;
input.modelLodGenStatus = "Impostor wird gerendert ("
+ (impostorPass + 1) + "/" + ImpostorUtil.DIRS + ")…";
startImpostorPass(impostorPass);
return;
}
// Alle Richtungen fertig
int level = impostorLevel;
impostorLevel = -1;
ByteBuffer atlas = ImpostorUtil.combineAtlas(impostorPixels);
ImpostorUtil.saveDebugImages(impostorPixels, atlas); // DEBUG kann nach Diagnose entfernt werden
Texture2D atlasTex = ImpostorUtil.buildAtlasTexture(atlas);
Node impNode = ImpostorUtil.makeImpostorNode(impostorBb, atlasTex, assets);
impNode.setName("lod" + level);
impNode.setShadowMode(RenderQueue.ShadowMode.Off);
ImpostorUtil.addViewControl(impNode, app.getCamera());
if (level == 1) {
lod1Node = impNode;
input.modelImportLod1PreviewNode.set(impNode);
input.modelImportHasLod1 = true;
} else {
lod2Node = impNode;
input.modelImportLod2PreviewNode.set(impNode);
input.modelImportHasLod2 = true;
}
input.modelLodGenStatus = "LOD" + level + " Impostor generiert (" + ImpostorUtil.DIRS + " Richtungen)";
input.modelEditorLodPreview = level;
input.modelEditorLodChanged = true;
}
// ── Export ──────────────────────────────────────────────────────────────── // ── Export ────────────────────────────────────────────────────────────────
private void performExport(String fileName) { private void performExport(String fileName) {
@@ -708,12 +838,17 @@ public class ModelImportState extends BaseAppState {
@Override @Override
protected void controlUpdate(float tpf) { protected void controlUpdate(float tpf) {
float dSq = cam.getLocation().distanceSquared(spatial.getWorldTranslation()); float dSq = cam.getLocation().distanceSquared(spatial.getWorldTranslation());
lod0.setCullHint(dSq < d01sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); boolean atLod2 = (lod2 != null && dSq >= d12sq);
if (lod1 != null) setLod(lod0, dSq < d01sq, !atLod2, RenderQueue.ShadowMode.CastAndReceive);
lod1.setCullHint(dSq >= d01sq && dSq < d12sq setLod(lod1, dSq >= d01sq && dSq < d12sq, !atLod2, RenderQueue.ShadowMode.CastAndReceive);
? Spatial.CullHint.Inherit : Spatial.CullHint.Always); setLod(lod2, atLod2, false, RenderQueue.ShadowMode.Off);
if (lod2 != null) }
lod2.setCullHint(dSq >= d12sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
private static void setLod(Node node, boolean visible,
boolean castShadow, RenderQueue.ShadowMode shadowMode) {
if (node == null) return;
node.setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
node.setShadowMode(visible && castShadow ? shadowMode : RenderQueue.ShadowMode.Off);
} }
@Override protected void controlRender(RenderManager rm, ViewPort vp) {} @Override protected void controlRender(RenderManager rm, ViewPort vp) {}

View File

@@ -46,6 +46,7 @@ import com.jme3.util.SkyFactory;
import de.blight.editor.FrameTransfer; import de.blight.editor.FrameTransfer;
import de.blight.editor.SharedInput; import de.blight.editor.SharedInput;
import de.blight.editor.util.ImpostorUtil;
import de.blight.editor.tree.TreeMeshBuilder; import de.blight.editor.tree.TreeMeshBuilder;
import de.blight.editor.tree.TreeParams; import de.blight.editor.tree.TreeParams;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -64,10 +65,6 @@ public class TreeGeneratorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(TreeGeneratorState.class); private static final Logger log = LoggerFactory.getLogger(TreeGeneratorState.class);
private static final int IMPOSTOR_SIZE = 512;
private static final int ATLAS_DIRS = 4;
private static final int ATLAS_W = IMPOSTOR_SIZE * ATLAS_DIRS; // 2048
private static final int ATLAS_H = IMPOSTOR_SIZE; // 512
private static final int PREVIEW_SIZE = 1024; private static final int PREVIEW_SIZE = 1024;
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("blight-assets", "src", "main", "resources"); private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("blight-assets", "src", "main", "resources");
@@ -99,9 +96,9 @@ public class TreeGeneratorState extends BaseAppState {
private ViewPort captureVP = null; private ViewPort captureVP = null;
private FrameBuffer captureFB = null; private FrameBuffer captureFB = null;
private Texture2D captureTex = null; private Texture2D captureTex = null;
private volatile boolean captureReady = false; private boolean[] captureReady = new boolean[]{false};
private int capturePass = 0; private int capturePass = 0;
private ByteBuffer[] capturePixels = new ByteBuffer[ATLAS_DIRS]; private ByteBuffer[] capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
public TreeGeneratorState(SharedInput input) { this.input = input; } public TreeGeneratorState(SharedInput input) { this.input = input; }
@@ -191,7 +188,7 @@ public class TreeGeneratorState extends BaseAppState {
@Override @Override
public void update(float tpf) { public void update(float tpf) {
// 1. Szenen-Änderungen zuerst (bevor updateGeometricState) // 1. Szenen-Änderungen zuerst (bevor updateGeometricState)
if (pendingRequest != null && captureReady) { if (pendingRequest != null && captureReady[0]) {
finishCapture(); finishCapture();
} else if (pendingRequest == null) { } else if (pendingRequest == null) {
SharedInput.TreeGenRequest req = input.treeGenQueue.poll(); SharedInput.TreeGenRequest req = input.treeGenQueue.poll();
@@ -286,23 +283,25 @@ public class TreeGeneratorState extends BaseAppState {
pendingBarkMat = barkMat; pendingBarkMat = barkMat;
pendingLeafMat = leafMat; pendingLeafMat = leafMat;
capturePass = 0; capturePass = 0;
capturePixels = new ByteBuffer[ATLAS_DIRS]; capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
startCapturePass(0); startCapturePass(0);
input.treeGenStatusMsg = "Rendere Impostor (1/" + ATLAS_DIRS + ")…"; input.treeGenStatusMsg = "Rendere Impostor (1/" + ImpostorUtil.DIRS + ")…";
} }
// ── Phase 2: Capture abschließen ────────────────────────────────────────── // ── Phase 2: Capture abschließen ──────────────────────────────────────────
private void finishCapture() { private void finishCapture() {
ByteBuffer pixels = BufferUtils.createByteBuffer(IMPOSTOR_SIZE * IMPOSTOR_SIZE * 4); capturePixels[capturePass] = ImpostorUtil.readPixels(app, captureFB);
app.getRenderer().readFrameBuffer(captureFB, pixels); ImpostorUtil.cleanup(app, captureVP, captureFB);
capturePixels[capturePass] = pixels; captureVP = null;
cleanupCapture(); captureFB = null;
captureTex = null;
captureReady[0] = false;
if (capturePass < ATLAS_DIRS - 1) { if (capturePass < ImpostorUtil.DIRS - 1) {
capturePass++; capturePass++;
input.treeGenStatusMsg = "Rendere Impostor (" + (capturePass + 1) + "/" + ATLAS_DIRS + ")…"; input.treeGenStatusMsg = "Rendere Impostor (" + (capturePass + 1) + "/" + ImpostorUtil.DIRS + ")…";
startCapturePass(capturePass); startCapturePass(capturePass);
return; return;
} }
@@ -311,9 +310,8 @@ public class TreeGeneratorState extends BaseAppState {
String treeType = pendingRequest.treeType(); String treeType = pendingRequest.treeType();
String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
ByteBuffer atlas = combineAtlas(capturePixels); ByteBuffer atlas = ImpostorUtil.combineAtlas(capturePixels);
Texture2D impostorTex = saveImpostor(atlas, "impostor_" + treeType + "_" + timestamp, Texture2D impostorTex = ImpostorUtil.buildAtlasTexture(atlas);
ATLAS_W, ATLAS_H);
Node previewTree = makeTreeNode(pendingHdResult, Node previewTree = makeTreeNode(pendingHdResult,
pendingBarkMat.clone(), pendingLeafMat.clone(), "prev"); pendingBarkMat.clone(), pendingLeafMat.clone(), "prev");
@@ -339,7 +337,7 @@ public class TreeGeneratorState extends BaseAppState {
pendingHdResult = null; pendingHdResult = null;
pendingBarkMat = null; pendingBarkMat = null;
pendingLeafMat = null; pendingLeafMat = null;
capturePixels = new ByteBuffer[ATLAS_DIRS]; capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
} }
// ── LOD-Aufbau ──────────────────────────────────────────────────────────── // ── LOD-Aufbau ────────────────────────────────────────────────────────────
@@ -349,7 +347,7 @@ public class TreeGeneratorState extends BaseAppState {
root.attachChild(pendingHdNode); root.attachChild(pendingHdNode);
root.attachChild(pendingLdNode); root.attachChild(pendingLdNode);
Node lod2 = makeImpostorNode(pendingHdResult.bounds(), impostorTex); Node lod2 = ImpostorUtil.makeImpostorNode(pendingHdResult.bounds(), impostorTex, assets);
root.attachChild(lod2); root.attachChild(lod2);
// Nur LOD0 initial sichtbar; Control steuert je nach Distanz // Nur LOD0 initial sichtbar; Control steuert je nach Distanz
@@ -357,88 +355,21 @@ public class TreeGeneratorState extends BaseAppState {
lod2.setCullHint(Spatial.CullHint.Always); lod2.setCullHint(Spatial.CullHint.Always);
lod2.setShadowMode(RenderQueue.ShadowMode.Off); lod2.setShadowMode(RenderQueue.ShadowMode.Off);
ImpostorUtil.addViewControl(lod2, app.getCamera());
root.addControl(new TreeLodControl(app.getCamera(), root.addControl(new TreeLodControl(app.getCamera(),
pendingHdNode, pendingLdNode, lod2, 60f, 200f)); pendingHdNode, pendingLdNode, lod2, 60f, 200f));
return root; return root;
} }
private Node makeImpostorNode(BoundingBox bb, Texture2D tex) {
float h = bb.getYExtent() * 2f;
float w = Math.max(bb.getXExtent(), bb.getZExtent()) * 2f;
float size = Math.max(h, w);
float yOff = bb.getCenter().y + 2f;
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
if (tex != null) mat.setTexture("ColorMap", tex);
else mat.setColor("Color", new ColorRGBA(0.18f, 0.5f, 0.1f, 0.9f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
Node n = new Node("lod2");
for (int d = 0; d < ATLAS_DIRS; d++) {
float angle = d * FastMath.HALF_PI;
float uMin = (float) d / ATLAS_DIRS;
float uMax = (float)(d + 1) / ATLAS_DIRS;
n.attachChild(buildBillboardQuad("quad_" + d, angle, yOff, size, mat.clone(), uMin, uMax));
}
n.setQueueBucket(RenderQueue.Bucket.Transparent);
return n;
}
private Geometry buildBillboardQuad(String name, float yRot, float yCent,
float size, Material mat, float uMin, float uMax) {
float hw = size * 0.5f;
float hh = size * 0.5f;
float cos = FastMath.cos(yRot);
float sin = FastMath.sin(yRot);
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, new float[]{
-hw*cos, yCent-hh, -hw*sin,
hw*cos, yCent-hh, hw*sin,
hw*cos, yCent+hh, hw*sin,
-hw*cos, yCent+hh, -hw*sin
});
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, new float[]{
uMin, 0, uMax, 0, uMax, 1, uMin, 1
});
mesh.setBuffer(VertexBuffer.Type.Index, 3, new int[]{0,1,2, 0,2,3, 2,1,0, 3,2,0});
mesh.updateBound();
Geometry g = new Geometry(name, mesh);
g.setMaterial(mat);
return g;
}
/** Kombiniert 4 einzelne 512×512-Puffer zu einem horizontalen 2048×512-Atlas. */
private ByteBuffer combineAtlas(ByteBuffer[] passes) {
ByteBuffer atlas = BufferUtils.createByteBuffer(ATLAS_W * ATLAS_H * 4);
for (int d = 0; d < ATLAS_DIRS; d++) {
ByteBuffer src = passes[d];
src.rewind();
for (int y = 0; y < IMPOSTOR_SIZE; y++) {
for (int x = 0; x < IMPOSTOR_SIZE; x++) {
int srcOff = (y * IMPOSTOR_SIZE + x) * 4;
int dstOff = (y * ATLAS_W + d * IMPOSTOR_SIZE + x) * 4;
atlas.put(dstOff, src.get(srcOff));
atlas.put(dstOff + 1, src.get(srcOff + 1));
atlas.put(dstOff + 2, src.get(srcOff + 2));
atlas.put(dstOff + 3, src.get(srcOff + 3));
}
}
}
return atlas;
}
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
private void startCapturePass(int pass) { private void startCapturePass(int pass) {
captureTex = new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.RGBA8); captureTex = new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.RGBA8);
captureFB = new FrameBuffer(IMPOSTOR_SIZE, IMPOSTOR_SIZE, 1); captureFB = new FrameBuffer(ImpostorUtil.SIZE, ImpostorUtil.SIZE, 1);
captureFB.addColorTexture(captureTex); captureFB.addColorTexture(captureTex);
captureFB.setDepthTexture(new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.Depth)); captureFB.setDepthTexture(new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.Depth));
float angle = pass * FastMath.HALF_PI; captureReady[0] = false;
captureVP = buildCaptureViewPort(pendingHdNode, pendingHdResult.bounds(), captureFB, angle); captureVP = ImpostorUtil.startCapturePass(app, pendingHdNode, pendingHdResult.bounds(),
captureReady = false; captureFB, pass, captureReady);
} }
// ── Material-Factories ──────────────────────────────────────────────────── // ── Material-Factories ────────────────────────────────────────────────────
@@ -513,79 +444,6 @@ public class TreeGeneratorState extends BaseAppState {
return n; return n;
} }
// ── Offscreen-ViewPort ────────────────────────────────────────────────────
private ViewPort buildCaptureViewPort(Node treeNode, BoundingBox bb, FrameBuffer fb, float angle) {
Camera cam = new Camera(IMPOSTOR_SIZE, IMPOSTOR_SIZE);
Vector3f center = bb.getCenter().add(0f, 2f, 0f);
float extent = Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent()));
float dist = extent * 3.0f;
float camX = FastMath.sin(angle) * dist;
float camZ = FastMath.cos(angle) * dist;
cam.setLocation(center.add(camX, 0f, camZ));
cam.lookAt(center, Vector3f.UNIT_Y);
cam.setFrustumPerspective(35f, 1f, 0.1f, dist * 4f);
ViewPort vp = app.getRenderManager()
.createPostView("impostorCap_" + System.nanoTime(), cam);
vp.setOutputFrameBuffer(fb);
vp.setBackgroundColor(new ColorRGBA(0f, 0f, 0f, 0f));
vp.setClearFlags(true, true, true);
// Capture-Szene: Kopien der Geometrien + Beleuchtung
Node scene = new Node("captureScene");
scene.addLight(new DirectionalLight(
new Vector3f(-0.4f, -1f, -0.5f).normalizeLocal(), new ColorRGBA(2.0f, 1.85f, 1.5f, 1f)));
scene.addLight(new AmbientLight(new ColorRGBA(0.60f, 0.60f, 0.60f, 1f)));
Node capTree = cloneForCapture(treeNode);
scene.attachChild(capTree);
vp.attachScene(scene);
scene.updateGeometricState();
// Einmaliger SceneProcessor signalisiert Fertigstellung
vp.addProcessor(new SceneProcessor() {
@Override public void initialize(RenderManager rm, ViewPort v) {}
@Override public void reshape(ViewPort v, int w, int h) {}
@Override public boolean isInitialized() { return true; }
@Override public void preFrame(float t) {}
@Override public void postQueue(RenderQueue rq) {}
@Override public void cleanup() {}
@Override public void setProfiler(AppProfiler profiler) {}
@Override
public void postFrame(FrameBuffer out) {
vp.removeProcessor(this);
captureReady = true;
}
});
return vp;
}
private Node cloneForCapture(Node src) {
Node copy = new Node(src.getName() + "_cap");
copy.setLocalTranslation(src.getLocalTranslation());
copy.setLocalScale(src.getLocalScale());
for (Spatial child : src.getChildren()) {
if (child instanceof Geometry g) {
Geometry gc = new Geometry(g.getName() + "_c", g.getMesh());
gc.setMaterial(g.getMaterial().clone());
copy.attachChild(gc);
}
}
return copy;
}
// ── Impostor-Textur in-memory erzeugen ────────────────────────────────────
private Texture2D saveImpostor(ByteBuffer pixels, String name, int width, int height) {
pixels.rewind();
Image jmeImg = new Image(Image.Format.RGBA8, width, height,
pixels, null, com.jme3.texture.image.ColorSpace.sRGB);
return new Texture2D(jmeImg);
}
// ── .j3o-Export ─────────────────────────────────────────────────────────── // ── .j3o-Export ───────────────────────────────────────────────────────────
private void exportTree(Node treeNode, String treeType, String timestamp, float height) { private void exportTree(Node treeNode, String treeType, String timestamp, float height) {
@@ -611,16 +469,11 @@ public class TreeGeneratorState extends BaseAppState {
// ── Aufräumen ───────────────────────────────────────────────────────────── // ── Aufräumen ─────────────────────────────────────────────────────────────
private void cleanupCapture() { private void cleanupCapture() {
if (captureVP != null) { ImpostorUtil.cleanup(app, captureVP, captureFB);
app.getRenderManager().removePostView(captureVP); captureVP = null;
captureVP = null; captureFB = null;
}
if (captureFB != null) {
try { captureFB.dispose(); } catch (Exception ignored) {}
captureFB = null;
}
captureTex = null; captureTex = null;
captureReady = false; captureReady[0] = false;
} }
// ── LOD-Control ─────────────────────────────────────────────────────────── // ── LOD-Control ───────────────────────────────────────────────────────────
@@ -639,10 +492,17 @@ public class TreeGeneratorState extends BaseAppState {
@Override @Override
protected void controlUpdate(float tpf) { protected void controlUpdate(float tpf) {
float dSq = cam.getLocation().distanceSquared(spatial.getWorldTranslation()); float dSq = cam.getLocation().distanceSquared(spatial.getWorldTranslation());
lod0.setCullHint(dSq < d01sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); boolean atL2 = dSq >= d12sq;
lod1.setCullHint(dSq>=d01sq && dSq<d12sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); setLod(lod0, dSq < d01sq, !atL2);
lod2.setCullHint(dSq >= d12sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); setLod(lod1, dSq >= d01sq && !atL2, !atL2);
setLod(lod2, atL2, false);
}
private static void setLod(Node node, boolean visible, boolean castShadow) {
node.setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
node.setShadowMode(visible && castShadow
? RenderQueue.ShadowMode.CastAndReceive : RenderQueue.ShadowMode.Off);
} }
@Override protected void controlRender(RenderManager rm, ViewPort vp) {} @Override protected void controlRender(RenderManager rm, ViewPort vp) {}

View File

@@ -0,0 +1,387 @@
package de.blight.editor.util;
import com.jme3.app.Application;
import com.jme3.asset.AssetManager;
import com.jme3.bounding.BoundingBox;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.*;
import com.jme3.post.SceneProcessor;
import com.jme3.profile.AppProfiler;
import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.control.AbstractControl;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils;
import java.awt.image.BufferedImage;
import java.io.File;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
import java.util.logging.Logger;
/**
* Gemeinsame Impostor-Generierung für EzTreeState, TreeGeneratorState und ModelImportState.
*
* Erzeugt 8 Offscreen-Ansichten eines Modells (45°-Schritte), kombiniert diese zu einem
* horizontalen 4096×512-Atlas und baut daraus einen Billboard-Node aus 8 Quads (4 Ebenen).
* Korrekt in Bezug auf CullHint: Kinder mit Always werden beim Klonen übersprungen,
* sodass nur die sichtbare LOD0-Geometrie im Capture landet.
*/
public final class ImpostorUtil {
public static final int SIZE = 512;
public static final int DIRS = 8;
public static final int ATL_W = SIZE * DIRS;
public static final int ATL_H = SIZE;
/** Sicherheitspuffer um die BBox gilt für Capture-FOV UND Quad-Größe. */
private static final float MARGIN = 1.02f;
private ImpostorUtil() {}
// ── Offscreen-Capture ─────────────────────────────────────────────────────
/**
* Startet einen einzelnen Capture-Pass (ein Winkel).
* Setzt {@code ready[0] = true} nachdem der Frame gerendert wurde.
*
* @param app JME3-Application
* @param src Quell-Node (wird geklont; nur Kinder mit CullHint ≠ Always)
* @param bb Bounding-Box des Modells (für Kamera-Rahmung)
* @param fb Ziel-FrameBuffer (512×512, RGBA8)
* @param pass Pass-Index 0..7 (entspricht 0°, 45°, 90°, 135°, 180°, 225°, 270°, 315°)
* @param ready Ein-Element-Array; wird auf {@code true} gesetzt wenn fertig
*/
@SuppressWarnings("deprecation")
public static ViewPort startCapturePass(Application app, Node src, BoundingBox bb,
FrameBuffer fb, int pass, boolean[] ready) {
float angle = pass * FastMath.TWO_PI / DIRS;
Vector3f center = bb.getCenter();
float yExt = bb.getYExtent();
float hExt = Math.max(bb.getXExtent(), bb.getZExtent());
float dist = Math.max(yExt, hExt) * 3.5f;
float camX = FastMath.sin(angle) * dist;
float camZ = FastMath.cos(angle) * dist;
Camera capCam = new Camera(SIZE, SIZE);
capCam.setLocation(center.add(camX, 0f, camZ));
capCam.lookAt(center, Vector3f.UNIT_Y);
float fovY = (float) Math.toDegrees(2.0 * Math.atan2(yExt * MARGIN, dist));
float fovH = (float) Math.toDegrees(2.0 * Math.atan2(hExt * MARGIN, dist));
capCam.setFrustumPerspective(Math.max(fovY, fovH), 1f, 0.1f, dist * 4f);
// Hauptlicht aus Kamera-Richtung (Richtungsvektor zeigt VOM Licht ZUM Objekt)
Vector3f toLight = new Vector3f(camX, dist * 0.5f, camZ).normalizeLocal();
Vector3f fromLight = toLight.negate();
Node capNode = cloneForCapture(src);
applyTreeLighting(capNode, toLight);
Node scene = new Node("impCapScene");
// Hauptlicht (warm, von Kamera-Seite)
scene.addLight(new DirectionalLight(fromLight,
new ColorRGBA(1.2f, 1.1f, 0.9f, 1f)));
// Fülllicht (kühler, von gegenüberliegender Seite)
scene.addLight(new DirectionalLight(toLight.mult(-0.7f).addLocal(0f, 0.3f, 0f).normalizeLocal(),
new ColorRGBA(0.35f, 0.35f, 0.45f, 1f)));
// Oberlicht (Himmelslicht von oben)
scene.addLight(new DirectionalLight(new Vector3f(0f, -1f, 0f),
new ColorRGBA(0.25f, 0.30f, 0.35f, 1f)));
// Starkes Ambient: verhindert vollständig schwarze Stellen bei Lighting.j3md
scene.addLight(new AmbientLight(new ColorRGBA(0.55f, 0.55f, 0.57f, 1f)));
scene.attachChild(capNode);
ViewPort vp = app.getRenderManager().createPostView("impCap_" + System.nanoTime(), capCam);
vp.setOutputFrameBuffer(fb);
vp.setBackgroundColor(new ColorRGBA(0f, 0f, 0f, 0f));
vp.setClearFlags(true, true, true);
vp.attachScene(scene);
scene.updateGeometricState();
vp.addProcessor(new SceneProcessor() {
@Override public void initialize(RenderManager rm, ViewPort v) {}
@Override public void reshape(ViewPort v, int w, int h) {}
@Override public boolean isInitialized() { return true; }
@Override public void preFrame(float t) {}
@Override public void postQueue(RenderQueue rq) {}
@Override public void cleanup() {}
@Override public void setProfiler(AppProfiler p) {}
@Override public void postFrame(FrameBuffer out) {
vp.removeProcessor(this);
ready[0] = true;
}
});
return vp;
}
/** Liest den aktuellen FrameBuffer in einen neuen ByteBuffer. */
public static ByteBuffer readPixels(Application app, FrameBuffer fb) {
ByteBuffer pixels = BufferUtils.createByteBuffer(SIZE * SIZE * 4);
app.getRenderer().readFrameBuffer(fb, pixels);
return pixels;
}
/** Räumt ViewPort und FrameBuffer auf. */
public static void cleanup(Application app, ViewPort vp, FrameBuffer fb) {
if (vp != null) app.getRenderManager().removePostView(vp);
if (fb != null) { try { fb.dispose(); } catch (Exception ignored) {} }
}
// ── Atlas-Aufbau ──────────────────────────────────────────────────────────
/** Kombiniert {@code DIRS} 512×512-Puffer zu einem horizontalen 4096×512-Atlas. */
public static ByteBuffer combineAtlas(ByteBuffer[] passes) {
ByteBuffer atlas = BufferUtils.createByteBuffer(ATL_W * ATL_H * 4);
for (int d = 0; d < DIRS; d++) {
ByteBuffer src = passes[d];
src.rewind();
for (int y = 0; y < SIZE; y++) {
for (int x = 0; x < SIZE; x++) {
int srcOff = (y * SIZE + x) * 4;
int dstOff = (y * ATL_W + d * SIZE + x) * 4;
atlas.put(dstOff, src.get(srcOff));
atlas.put(dstOff + 1, src.get(srcOff + 1));
atlas.put(dstOff + 2, src.get(srcOff + 2));
atlas.put(dstOff + 3, src.get(srcOff + 3));
}
}
}
return atlas;
}
/** Baut eine Texture2D aus dem Atlas-ByteBuffer. */
public static Texture2D buildAtlasTexture(ByteBuffer atlas) {
atlas.rewind();
Image img = new Image(Image.Format.RGBA8, ATL_W, ATL_H, atlas, null,
com.jme3.texture.image.ColorSpace.Linear);
return new Texture2D(img);
}
// ── Billboard-Node ────────────────────────────────────────────────────────
/**
* Baut einen Impostor-Billboard-Node aus {@code DIRS} gekreuzten Quads.
* Die Quads nutzen vorberechnete, rotierte Vertex-Positionen (kein Transform-Rotation).
*/
public static Node makeImpostorNode(BoundingBox bb, Texture2D tex, AssetManager assets) {
float h = bb != null ? bb.getYExtent() * 2f * MARGIN : 10f;
float w = bb != null ? Math.max(bb.getXExtent(), bb.getZExtent()) * 2f * MARGIN : 4f;
float size = Math.max(h, w);
float yOff = bb != null ? bb.getCenter().y : size * 0.5f;
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
if (tex != null) mat.setTexture("ColorMap", tex);
else mat.setColor("Color", new ColorRGBA(0.18f, 0.5f, 0.1f, 0.9f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Back);
Node n = new Node("lod2");
for (int d = 0; d < DIRS; d++) {
float angle = d * FastMath.TWO_PI / DIRS;
float uMin = (float) d / DIRS;
float uMax = (float)(d + 1) / DIRS;
n.attachChild(buildBillboardQuad("quad_" + d, angle, yOff, size, mat.clone(), uMin, uMax));
}
n.setQueueBucket(RenderQueue.Bucket.Transparent);
return n;
}
/** Erzeugt einen einzelnen Billboard-Quad mit vorberechneten rotierten Vertex-Positionen. */
public static Geometry buildBillboardQuad(String name, float yRot, float yCent,
float size, Material mat, float uMin, float uMax) {
float hw = size * 0.5f;
float hh = size * 0.5f;
float cos = FastMath.cos(yRot);
float sin = FastMath.sin(yRot);
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, new float[]{
-hw*cos, yCent - hh, hw*sin,
hw*cos, yCent - hh, -hw*sin,
hw*cos, yCent + hh, -hw*sin,
-hw*cos, yCent + hh, hw*sin
});
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, new float[]{
uMin, 0f, uMax, 0f, uMax, 1f, uMin, 1f
});
mesh.setBuffer(VertexBuffer.Type.Index, 3, new int[]{0,1,2, 0,2,3});
mesh.updateBound();
Geometry g = new Geometry(name, mesh);
g.setMaterial(mat);
return g;
}
// ── Klon & Beleuchtung ────────────────────────────────────────────────────
/**
* Klont einen Node rekursiv mit eigenen Material-Instanzen.
* Eingebettete LOD-Knoten ("lod1", "lod2") werden per Name übersprungen,
* damit sie nicht im Impostor-Capture landen.
* CullHint wird auf allen geklonten Knoten auf Inherit zurückgesetzt,
* um einen LOD-Control-Zustand aus dem Spiel-Thread zu ignorieren.
*/
public static Node cloneForCapture(Node src) {
Node copy = new Node(src.getName());
copy.setLocalTranslation(src.getLocalTranslation());
copy.setLocalScale(src.getLocalScale());
copy.setLocalRotation(src.getLocalRotation());
for (Spatial child : src.getChildren()) {
String name = child.getName();
if ("lod1".equals(name) || "lod2".equals(name)) continue;
Spatial childCopy = cloneSpatial(child);
if (childCopy != null) copy.attachChild(childCopy);
}
return copy;
}
private static Spatial cloneSpatial(Spatial s) {
if (s instanceof Geometry g) {
Geometry gc = new Geometry(g.getName(), g.getMesh());
gc.setMaterial(g.getMaterial().clone());
gc.setLocalTranslation(g.getLocalTranslation());
gc.setLocalScale(g.getLocalScale());
gc.setLocalRotation(g.getLocalRotation());
gc.setCullHint(Spatial.CullHint.Inherit);
return gc;
} else if (s instanceof Node n) {
Node nc = new Node(n.getName());
nc.setLocalTranslation(n.getLocalTranslation());
nc.setLocalScale(n.getLocalScale());
nc.setLocalRotation(n.getLocalRotation());
nc.setCullHint(Spatial.CullHint.Inherit);
for (Spatial child : n.getChildren()) {
String childName = child.getName();
if ("lod1".equals(childName) || "lod2".equals(childName)) continue;
Spatial cc = cloneSpatial(child);
if (cc != null) nc.attachChild(cc);
}
return nc;
}
return null;
}
// ── Debug: Impostor-Bilder auf Disk ablegen ───────────────────────────────
/**
* Speichert alle Capture-Passes und den kombinierten Atlas als PNG nach ~/blight_impostor_debug/.
* Nur für Debug-Zwecke kann nach Diagnose entfernt werden.
*/
public static void saveDebugImages(ByteBuffer[] passes, ByteBuffer atlas) {
try {
java.nio.file.Path dir = Paths.get(System.getProperty("user.home"), "blight_impostor_debug");
Files.createDirectories(dir);
long ts = System.currentTimeMillis();
for (int i = 0; i < passes.length; i++) {
File f = dir.resolve("pass_" + i + "_" + ts + ".png").toFile();
saveRgba(passes[i], SIZE, SIZE, f);
}
File atlasFile = dir.resolve("atlas_" + ts + ".png").toFile();
saveRgba(atlas, ATL_W, ATL_H, atlasFile);
Logger.getLogger(ImpostorUtil.class.getName())
.info("[ImpostorUtil] Debug-Bilder gespeichert in: " + dir.toAbsolutePath());
} catch (Exception e) {
Logger.getLogger(ImpostorUtil.class.getName())
.warning("[ImpostorUtil] Debug-Save fehlgeschlagen: " + e);
}
}
private static void saveRgba(ByteBuffer buf, int w, int h, File file) throws Exception {
buf.rewind();
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int off = (y * w + x) * 4;
int r = buf.get(off) & 0xFF;
int g = buf.get(off + 1) & 0xFF;
int b = buf.get(off + 2) & 0xFF;
int a = buf.get(off + 3) & 0xFF;
img.setRGB(x, y, (a << 24) | (r << 16) | (g << 8) | b);
}
}
javax.imageio.ImageIO.write(img, "PNG", file);
}
// ── View-Control ──────────────────────────────────────────────────────────
/**
* Hängt einen {@link ImpostorViewControl} an den Impostor-Node.
* Der Control zeigt pro Frame nur das Quad, dessen Capture-Richtung
* dem aktuellen Kamerawinkel am nächsten liegt.
* Die Camera-Referenz wird NICHT serialisiert; nach j3o-Laden ggf.
* via {@code setCamera()} neu setzen.
*/
public static ImpostorViewControl addViewControl(Node impostorNode, Camera cam) {
ImpostorViewControl ctrl = new ImpostorViewControl(cam);
impostorNode.addControl(ctrl);
return ctrl;
}
/**
* Wählt per Kamerawinkel das relevante Quad-Paar des Impostor-Node.
* Pass d wurde mit Kamera bei sin(d·2π/DIRS), cos(d·2π/DIRS) aufgenommen.
* Es werden immer zwei gegenüberliegende Quads (eine Ebene) angezeigt;
* die anderen 6 werden per CullHint.Always ausgeblendet.
* Wechsel erfolgt alle 360°/DIRS = 45° → max. Winkelabweichung 22,5°.
*/
public static final class ImpostorViewControl extends AbstractControl {
private Camera cam;
public ImpostorViewControl() {}
public ImpostorViewControl(Camera cam) { this.cam = cam; }
public void setCamera(Camera cam) { this.cam = cam; }
@Override
protected void controlUpdate(float tpf) {
if (cam == null || !(spatial instanceof Node n)) return;
List<Spatial> quads = n.getChildren();
if (quads.size() < DIRS) return;
Vector3f toCamera = cam.getLocation().subtract(spatial.getWorldTranslation());
float dx = toCamera.x, dz = toCamera.z;
if (Math.abs(dx) < 0.001f && Math.abs(dz) < 0.001f) return;
// Winkelindex: t in Einheiten von 2π/DIRS; nächstes Capture = round(t) % DIRS
float angle = (float) Math.atan2(dx, dz); // −π … +π
float t = angle * DIRS / FastMath.TWO_PI;
int best = ((int) Math.round(t) % DIRS + DIRS) % DIRS;
int opp = (best + DIRS / 2) % DIRS; // gegenüberliegendes Quad
for (int i = 0; i < quads.size(); i++) {
quads.get(i).setCullHint(i == best || i == opp
? Spatial.CullHint.Inherit
: Spatial.CullHint.Always);
}
}
@Override protected void controlRender(RenderManager rm, ViewPort vp) {}
}
/** Setzt LightDir/SunColor/AmbientColor auf Tree.j3md-Materialien (rekursiv). */
public static void applyTreeLighting(Node root, Vector3f lightDir) {
for (Spatial s : root.getChildren()) {
if (s instanceof Geometry g) {
Material mat = g.getMaterial();
if (mat.getMaterialDef().getMaterialParam("LightDir") != null) {
mat.setVector3("LightDir", lightDir);
mat.setVector3("SunColor", new Vector3f(1.3f, 1.2f, 1.0f));
mat.setVector3("AmbientColor", new Vector3f(0.55f, 0.55f, 0.57f));
}
} else if (s instanceof Node n) {
applyTreeLighting(n, lightDir);
}
}
}
}