diff --git a/blight-assets/src/main/resources/.thumbnails/Models/trees/pine/medium/pine_medium_20260706_190939.j3o.thumb.png b/blight-assets/src/main/resources/.thumbnails/Models/trees/pine/medium/pine_medium_20260706_190939.j3o.thumb.png new file mode 100644 index 0000000..4a9ce4c Binary files /dev/null and b/blight-assets/src/main/resources/.thumbnails/Models/trees/pine/medium/pine_medium_20260706_190939.j3o.thumb.png differ diff --git a/blight-assets/src/main/resources/.thumbnails/Models/trees/pine/medium/pine_medium_20260706_190953.j3o.thumb.png b/blight-assets/src/main/resources/.thumbnails/Models/trees/pine/medium/pine_medium_20260706_190953.j3o.thumb.png new file mode 100644 index 0000000..ac112f4 Binary files /dev/null and b/blight-assets/src/main/resources/.thumbnails/Models/trees/pine/medium/pine_medium_20260706_190953.j3o.thumb.png differ diff --git a/blight-assets/src/main/resources/Models/imported/wooden+cabin+3d+model.j3o b/blight-assets/src/main/resources/Models/imported/wooden+cabin+3d+model.j3o index 84ffdee..78117c6 100644 Binary files a/blight-assets/src/main/resources/Models/imported/wooden+cabin+3d+model.j3o and b/blight-assets/src/main/resources/Models/imported/wooden+cabin+3d+model.j3o differ diff --git a/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190939.j3o b/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190939.j3o index d4df79f..7cc0a63 100644 Binary files a/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190939.j3o and b/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190939.j3o differ diff --git a/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190939.j3o.meta b/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190939.j3o.meta new file mode 100644 index 0000000..415d7f6 --- /dev/null +++ b/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190939.j3o.meta @@ -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 diff --git a/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190953.j3o b/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190953.j3o index 8a54591..29776a0 100644 Binary files a/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190953.j3o and b/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190953.j3o differ diff --git a/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190953.j3o.meta b/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190953.j3o.meta new file mode 100644 index 0000000..4fe207d --- /dev/null +++ b/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190953.j3o.meta @@ -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 diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index cb6be11..02edd36 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -256,8 +256,14 @@ public class EditorApp extends Application { private Label modelEditorLod2Label; private ToggleButton modelEditorLod1Btn; private ToggleButton modelEditorLod2Btn; + private Label modelEditorLod1StatusLabel; + private Label modelEditorLod2StatusLabel; + private Button modelEditorLod1GenBtn; + private Button modelEditorLod2GenBtn; private ToggleButton importLod1Btn; private ToggleButton importLod2Btn; + private Button modelImportLod1ClearBtn; + private Button modelImportLod2ClearBtn; // Anhänge (veränderliche Listen, werden bei Änderung an JME übertragen) private final java.util.List modelEditorLights = new java.util.ArrayList<>(); private final java.util.List modelEditorEmitters = new java.util.ArrayList<>(); @@ -663,35 +669,45 @@ public class EditorApp extends Application { } // Modell-Editor: eingebettete LODs erkannt → Labels aktualisieren - if (input.modelEditorHasEmbeddedLods) { - if (modelEditorLod1Label != null - && "(nicht gesetzt)".equals(modelEditorLod1Label.getText())) { - modelEditorLod1Label.setText("(eingebettet im j3o)"); - 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;"); - } + if (input.modelEditorHasEmbeddedLod1 && modelEditorLod1Label != null + && "(kein Pfad)".equals(modelEditorLod1Label.getText())) { + modelEditorLod1Label.setText("(eingebettet im j3o)"); + modelEditorLod1Label.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) - modelEditorLod1Btn.setDisable(!input.modelEditorHasEmbeddedLods && modelEditorLod1Path.isBlank()); + modelEditorLod1Btn.setDisable(!input.modelEditorHasEmbeddedLod1 && modelEditorLod1Path.isBlank() && !input.modelImportHasLod1); 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 if (importLod1Btn != null) importLod1Btn.setDisable(!input.modelImportHasLod1); 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; if (lodStatus != null) { input.modelLodGenStatus = null; - if (modelImportLod1StatusLabel != null && lodStatus.startsWith("LOD1")) - modelImportLod1StatusLabel.setText(lodStatus); - else if (modelImportLod2StatusLabel != null && (lodStatus.startsWith("LOD2") || lodStatus.contains("Impostor"))) - modelImportLod2StatusLabel.setText(lodStatus); + boolean isLod1 = lodStatus.startsWith("LOD1"); + boolean isLod2 = lodStatus.startsWith("LOD2") || lodStatus.contains("Impostor"); + if (isLod1) { + 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); } // Modell-Import: Export-Ergebnis @@ -867,14 +883,15 @@ public class EditorApp extends Application { modelEditorLod1Btn.setOnAction(e -> { input.modelEditorLodPreview = 1; input.modelEditorLodChanged = true; }); modelEditorLod2Btn.setOnAction(e -> { input.modelEditorLodPreview = 2; input.modelEditorLodChanged = true; }); // Initial deaktiviert – werden freigegeben sobald LOD-Pfade/eingebettete LODs bekannt sind - boolean hasLod1 = input.modelEditorHasEmbeddedLods || !modelEditorLod1Path.isBlank(); - boolean hasLod2 = input.modelEditorHasEmbeddedLods || !modelEditorLod2Path.isBlank(); + boolean hasLod1 = input.modelEditorHasEmbeddedLod1 || !modelEditorLod1Path.isBlank() || input.modelImportHasLod1; + boolean hasLod2 = input.modelEditorHasEmbeddedLod2 || !modelEditorLod2Path.isBlank() || input.modelImportHasLod2; modelEditorLod1Btn.setDisable(!hasLod1); modelEditorLod2Btn.setDisable(!hasLod2); ToolBar tb = new ToolBar(); 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; } @@ -4051,6 +4068,10 @@ public class EditorApp extends Application { } if (cat == modelsNode) { + if ("modeleditor".equals(currentTool) && relPath.endsWith(".j3o")) { + openModelEditor(relPath, p); + return; + } input.pendingModelPath = relPath; input.activeLayer = SharedInput.LAYER_OBJECTS; 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. */ private boolean shouldPreserveExpansion() { return "vegetation".equals(currentTool) || "eztree".equals(currentTool) - || "objecteditor".equals(currentTool) + || "objecteditor".equals(currentTool) || "modeleditor".equals(currentTool) || ("world".equals(currentTool) && (input.activeLayer == SharedInput.LAYER_OBJECTS || input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT)); @@ -5812,8 +5833,8 @@ public class EditorApp extends Application { topBar.getChildren().set(1, buildModelEditorToolBar()); // Kein Modell vorausgewählt – Platzhalter zeigen VBox placeholder = new VBox(12); - placeholder.setPadding(new javafx.geometry.Insets(16)); - placeholder.setPrefWidth(300); + placeholder.setPadding(new javafx.geometry.Insets(12)); + placeholder.setPrefWidth(280); placeholder.setStyle("-fx-background-color: #2a2a3e;"); Label title = new Label("Modell-Editor"); 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;"); VBox modelEditorOuter = new VBox(modelEditorScroll); VBox.setVgrow(modelEditorScroll, Priority.ALWAYS); - modelEditorOuter.setPrefWidth(300); + modelEditorOuter.setPrefWidth(280); + modelEditorOuter.setMaxWidth(280); root.setRight(modelEditorOuter); setStatus("Modell-Editor: " + relPath); } @@ -5862,9 +5884,9 @@ public class EditorApp extends Application { private VBox buildModelEditorPanel(String relPath, java.nio.file.Path absolutePath, de.blight.common.ModelMeta meta) { - VBox panel = new VBox(8); - panel.setPadding(new javafx.geometry.Insets(10)); - panel.setPrefWidth(300); + VBox panel = new VBox(6); + panel.setPadding(new javafx.geometry.Insets(8)); + panel.setPrefWidth(280); panel.setStyle("-fx-background-color: #2a2a3e;"); // ── Titel ──────────────────────────────────────────────────────────── @@ -5958,8 +5980,10 @@ public class EditorApp extends Application { }); // ── 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.setMaxWidth(Double.MAX_VALUE); + modelEditorDimLabel.setWrapText(true); // ── Pivot & Platzierungs-Versatz ───────────────────────────────────── Label offsetTitle = new Label("Versatz:"); @@ -6007,38 +6031,79 @@ public class EditorApp extends Application { Label lodTitle = new Label("Level of Detail"); 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.setWrapText(true); - Button lod1ImportBtn = new Button("LOD 1 importieren…"); - Button lod1ClearBtn = new Button("✕"); + + Button lod1ClearBtn = new Button("✕"); lod1ClearBtn.setStyle("-fx-text-fill:#c0392b;"); - lod1ImportBtn.setMaxWidth(Double.MAX_VALUE); - lod1ImportBtn.setOnAction(e -> importLodFile(1)); lod1ClearBtn.setOnAction(e -> { + if (input.modelEditorHasEmbeddedLod1) { + input.modelEditorRemoveEmbeddedLod = 1; + } + if (input.modelImportHasLod1) { + input.modelImportClearLodLevel = 1; + if (modelEditorLod1StatusLabel != null) modelEditorLod1StatusLabel.setText("–"); + } 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.setWrapText(true); - Button lod2ImportBtn = new Button("LOD 2 importieren…"); - Button lod2ClearBtn = new Button("✕"); + + Button lod2ClearBtn = new Button("✕"); lod2ClearBtn.setStyle("-fx-text-fill:#c0392b;"); - lod2ImportBtn.setMaxWidth(Double.MAX_VALUE); - lod2ImportBtn.setOnAction(e -> importLodFile(2)); lod2ClearBtn.setOnAction(e -> { + if (input.modelEditorHasEmbeddedLod2) { + input.modelEditorRemoveEmbeddedLod = 2; + } + if (input.modelImportHasLod2) { + input.modelImportClearLodLevel = 2; + if (modelEditorLod2StatusLabel != null) modelEditorLod2StatusLabel.setText("–"); + } 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, lod2ImportBtn, lod2ClearBtn); - HBox.setHgrow(lod1ImportBtn, javafx.scene.layout.Priority.ALWAYS); - HBox.setHgrow(lod2ImportBtn, javafx.scene.layout.Priority.ALWAYS); + HBox lod2Row = new HBox(4, modelEditorLod2GenBtn, lod2ClearBtn); + HBox.setHgrow(modelEditorLod2GenBtn, javafx.scene.layout.Priority.ALWAYS); // ── 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, Math.round(meta.interactableOffsetX() * 100.0) / 100.0, 0.05)); modelEditorInteractableXSpin.setEditable(true); - modelEditorInteractableXSpin.setPrefWidth(100); + modelEditorInteractableXSpin.setMaxWidth(Double.MAX_VALUE); modelEditorInteractableXSpin.valueProperty().addListener((obs, ov, nv) -> { if (updatingInteractableSpinnersFromJme) return; input.modelInteractableOffsetX = nv.floatValue(); @@ -6145,7 +6210,7 @@ public class EditorApp extends Application { new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-1.0, 4.0, Math.round(meta.interactableOffsetY() * 100.0) / 100.0, 0.05)); modelEditorInteractableYSpin.setEditable(true); - modelEditorInteractableYSpin.setPrefWidth(100); + modelEditorInteractableYSpin.setMaxWidth(Double.MAX_VALUE); modelEditorInteractableYSpin.valueProperty().addListener((obs, ov, nv) -> { if (updatingInteractableSpinnersFromJme) return; input.modelInteractableOffsetY = nv.floatValue(); @@ -6156,7 +6221,7 @@ public class EditorApp extends Application { new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-5.0, 5.0, Math.round(meta.interactableOffsetZ() * 100.0) / 100.0, 0.05)); modelEditorInteractableZSpin.setEditable(true); - modelEditorInteractableZSpin.setPrefWidth(100); + modelEditorInteractableZSpin.setMaxWidth(Double.MAX_VALUE); modelEditorInteractableZSpin.valueProperty().addListener((obs, ov, nv) -> { if (updatingInteractableSpinnersFromJme) return; input.modelInteractableOffsetZ = nv.floatValue(); @@ -6165,6 +6230,11 @@ public class EditorApp extends Application { javafx.scene.layout.GridPane posGrid = new javafx.scene.layout.GridPane(); 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("Y:"), 0, 1); posGrid.add(modelEditorInteractableYSpin, 1, 1); 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); rotSlider.setShowTickMarks(true); rotSlider.setMajorTickUnit(90); - rotSlider.setPrefWidth(180); + rotSlider.setPrefWidth(150); TextField rotField = new TextField(String.format("%.1f", initDeg)); rotField.setPrefWidth(60); @@ -6329,8 +6399,8 @@ public class EditorApp extends Application { rndTitle, rndGrid, new Separator(), lodTitle, - modelEditorLod1Label, lod1Row, - modelEditorLod2Label, lod2Row, + lod1Row, modelEditorLod1StatusLabel, modelEditorLod1Label, + lod2Row, modelEditorLod2StatusLabel, modelEditorLod2Label, new Separator(), attachTitle, lightSectionLbl, modelEditorLightBox, addLightBtn, @@ -6409,6 +6479,8 @@ public class EditorApp extends Application { modelImportCurrentPath = relPath; modelImportLod1StatusLabel = null; modelImportLod2StatusLabel = null; + modelImportLod1ClearBtn = null; + modelImportLod2ClearBtn = null; modelImportExportStatusLabel = null; input.modelImportHasLod1 = 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.setMaxWidth(Double.MAX_VALUE); + modelEditorDimLabel.setWrapText(true); // ── LOD-Algorithmus ─────────────────────────────────────────────────── Label algoTitle = new Label("Reduktions-Algorithmus"); @@ -6624,6 +6698,14 @@ public class EditorApp extends Application { modelImportLod1StatusLabel = new Label("(nicht gesetzt)"); modelImportLod1StatusLabel.setStyle("-fx-text-fill:#888; -fx-font-size:11;"); 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 ───────────────────────────────────────────────────────────── Label lod2Title = new Label("LOD 2"); @@ -6665,6 +6747,14 @@ public class EditorApp extends Application { modelImportLod2StatusLabel = new Label("(nicht gesetzt)"); modelImportLod2StatusLabel.setStyle("-fx-text-fill:#888; -fx-font-size:11;"); 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 ──────────────────────────────────────────────────────────── Label exportTitle = new Label("Export"); @@ -6705,9 +6795,9 @@ public class EditorApp extends Application { new Separator(), algoTitle, algoBlightRB, algoJmeRB, new Separator(), - lod1Title, lod1AutoRB, lod1FileRB, lod1GenBtn, modelImportLod1StatusLabel, + lod1Title, lod1AutoRB, lod1FileRB, lod1GenBtn, modelImportLod1StatusLabel, modelImportLod1ClearBtn, new Separator(), - lod2Title, lod2MeshRB, lod2FileRB, lod2GenBtn, modelImportLod2StatusLabel, + lod2Title, lod2MeshRB, lod2FileRB, lod2GenBtn, modelImportLod2StatusLabel, modelImportLod2ClearBtn, new Separator(), exportTitle, assetNameLabel, nameTF, modelImportExportStatusLabel, exportBtn, cancelBtn @@ -6758,12 +6848,19 @@ public class EditorApp extends Application { } 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); l.setStyle("-fx-text-fill:#aaa;"); - l.setMinWidth(90); grid.add(l, 0, row); grid.add(ctrl, 1, row); - javafx.scene.layout.GridPane.setHgrow(ctrl, javafx.scene.layout.Priority.ALWAYS); } private void pushScaleToJme() { @@ -6776,8 +6873,7 @@ public class EditorApp extends Application { private void updateModelEditorDimensions(float w, float h, float d) { if (modelEditorDimLabel != null) - modelEditorDimLabel.setText(String.format( - "Maße: B %.2f m × H %.2f m × T %.2f m", w, h, d)); + modelEditorDimLabel.setText(String.format("B %.2f × H %.2f × T %.2f m", w, h, d)); } 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> 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 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 ──────────────────────────────────── private StackPane buildViewport() { diff --git a/blight-editor/src/main/java/de/blight/editor/SharedInput.java b/blight-editor/src/main/java/de/blight/editor/SharedInput.java index 801f001..d53a798 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -709,12 +709,16 @@ public class SharedInput { /** JME → JFX: true wenn das geladene Modell eingebettete LOD-Kinder hat (kein separater Pfad nötig). */ 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). */ public volatile int modelEditorLodPreview = 0; public volatile String modelEditorLod1Path = ""; public volatile String modelEditorLod2Path = ""; 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. * Azimut 0–360° (Kompassrichtung), Elevation 0–90° (Höhe über Horizont). */ @@ -790,9 +794,15 @@ public class SharedInput { * JFX → JME: LOD-Generierungsanfrage. * level: 1 oder 2 * 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 = new AtomicReference<>(); @@ -821,6 +831,8 @@ public class SharedInput { /** JME → JFX: LOD-Stufen vorhanden. */ public volatile boolean modelImportHasLod1 = 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. */ public volatile String modelImportExportName = null; diff --git a/blight-editor/src/main/java/de/blight/editor/state/EzTreeState.java b/blight-editor/src/main/java/de/blight/editor/state/EzTreeState.java index d0e1303..8dbff8e 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/EzTreeState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/EzTreeState.java @@ -8,16 +8,11 @@ import com.jme3.app.state.BaseAppState; import com.jme3.asset.AssetManager; import com.jme3.bounding.BoundingBox; 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.RenderState; import com.jme3.math.ColorRGBA; import com.jme3.math.Vector3f; -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.Geometry; @@ -31,6 +26,7 @@ import com.jme3.texture.Texture; import com.jme3.texture.Texture2D; import com.jme3.util.BufferUtils; import de.blight.editor.SharedInput; +import de.blight.editor.util.ImpostorUtil; import de.blight.eztree.Tree; import de.blight.eztree.TreeOptions; 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 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 TOOLS_DIR = de.blight.editor.ProjectRoot.resolve("tools"); private static final Gson GSON = new Gson(); @@ -79,9 +71,9 @@ public class EzTreeState extends BaseAppState { private BoundingBox pendingBb = null; private ViewPort captureVP = null; private FrameBuffer captureFB = null; - private volatile boolean captureReady = false; + private boolean[] captureReady = new boolean[]{false}; 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; } @@ -106,7 +98,7 @@ public class EzTreeState extends BaseAppState { if (previewHost == null) return; } - if (pendingRequest != null && captureReady) { + if (pendingRequest != null && captureReady[0]) { finishCapture(); } else if (pendingRequest == null) { SharedInput.EzTreeGenRequest req = input.ezTreeGenQueue.poll(); @@ -150,9 +142,9 @@ public class EzTreeState extends BaseAppState { pendingLd1Node = finalLd1; pendingBb = finalBb; capturePass = 0; - capturePixels = new ByteBuffer[ATLAS_DIRS]; - startCapturePass(0, finalHd, finalBb); - input.treeGenStatusMsg = "EZ-Tree: rendere Impostor (1/" + ATLAS_DIRS + ")…"; + capturePixels = new ByteBuffer[ImpostorUtil.DIRS]; + startCapturePass(0); + input.treeGenStatusMsg = "EZ-Tree: rendere Impostor (1/" + ImpostorUtil.DIRS + ")…"; } }); @@ -384,27 +376,29 @@ public class EzTreeState extends BaseAppState { // ── Phase 2: Impostor-Capture (4-Richtungen) ───────────────────────────── @SuppressWarnings("deprecation") - private void startCapturePass(int pass, Node treeNode, BoundingBox bb) { - BoundingBox safeBb = bb != null ? bb : new BoundingBox(Vector3f.ZERO, 5f, 10f, 5f); - Texture2D capTex = new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.RGBA8); - captureFB = new FrameBuffer(IMPOSTOR_SIZE, IMPOSTOR_SIZE, 1); + private void startCapturePass(int pass) { + BoundingBox safeBb = pendingBb != null ? pendingBb : new BoundingBox(Vector3f.ZERO, 5f, 10f, 5f); + com.jme3.texture.Texture2D capTex = new com.jme3.texture.Texture2D( + ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.RGBA8); + captureFB = new FrameBuffer(ImpostorUtil.SIZE, ImpostorUtil.SIZE, 1); captureFB.addColorTexture(capTex); - captureFB.setDepthTexture(new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.Depth)); - float angle = pass * com.jme3.math.FastMath.HALF_PI; - captureVP = buildCaptureViewPort(treeNode, safeBb, captureFB, angle); - captureReady = false; + captureFB.setDepthTexture(new com.jme3.texture.Texture2D( + ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.Depth)); + captureReady[0] = false; + captureVP = ImpostorUtil.startCapturePass(app, pendingHdNode, safeBb, captureFB, pass, captureReady); } private void finishCapture() { - ByteBuffer pixels = BufferUtils.createByteBuffer(IMPOSTOR_SIZE * IMPOSTOR_SIZE * 4); - app.getRenderer().readFrameBuffer(captureFB, pixels); - capturePixels[capturePass] = pixels; - cleanupCapture(); + capturePixels[capturePass] = ImpostorUtil.readPixels(app, captureFB); + ImpostorUtil.cleanup(app, captureVP, captureFB); + captureVP = null; + captureFB = null; + captureReady[0] = false; - if (capturePass < ATLAS_DIRS - 1) { + if (capturePass < ImpostorUtil.DIRS - 1) { capturePass++; - input.treeGenStatusMsg = "EZ-Tree: rendere Impostor (" + (capturePass + 1) + "/" + ATLAS_DIRS + ")…"; - startCapturePass(capturePass, pendingHdNode, pendingBb); + input.treeGenStatusMsg = "EZ-Tree: rendere Impostor (" + (capturePass + 1) + "/" + ImpostorUtil.DIRS + ")…"; + startCapturePass(capturePass); return; } @@ -418,7 +412,7 @@ public class EzTreeState extends BaseAppState { pendingHdNode = null; pendingLd1Node = null; pendingBb = null; - capturePixels = new ByteBuffer[ATLAS_DIRS]; + capturePixels = new ByteBuffer[ImpostorUtil.DIRS]; String subPath = resolveSubPath(req.presetName()); String namePart = req.presetName() != null @@ -427,8 +421,8 @@ public class EzTreeState extends BaseAppState { String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); String exportName = namePart + "_" + timestamp; - ByteBuffer atlas = combineAtlas(savedPixels); - Texture2D atlasT2d = saveImpostor(atlas, "ez_impostor_" + exportName, ATLAS_W, ATLAS_H); + ByteBuffer atlas = ImpostorUtil.combineAtlas(savedPixels); + com.jme3.texture.Texture2D atlasT2d = ImpostorUtil.buildAtlasTexture(atlas); Node lodRoot = assembleLodNode(req.presetName(), hdNode, ld1Node, bb, atlasT2d); 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 ───────────────────────────────────────────────────────── private static BoundingBox boundsOf(Node node) { @@ -586,48 +506,17 @@ public class EzTreeState extends BaseAppState { } private void cleanupCapture() { - if (captureVP != null) { - app.getRenderManager().removePostView(captureVP); - captureVP = null; - } - if (captureFB != null) { - try { captureFB.dispose(); } catch (Exception ignored) {} - captureFB = null; - } - captureReady = false; + ImpostorUtil.cleanup(app, captureVP, captureFB); + captureVP = null; + captureFB = null; + captureReady[0] = false; } - 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); - } - - 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) { + private Node assembleLodNode(String name, Node hd, Node ld1, BoundingBox bb, com.jme3.texture.Texture2D atlasTex) { Node root = new Node(name != null ? name : "ez_tree"); root.attachChild(hd); root.attachChild(ld1); - Node lod2 = makeImpostorNode(bb, atlasTex); + Node lod2 = ImpostorUtil.makeImpostorNode(bb, atlasTex, assets); root.attachChild(lod2); hd.setCullHint(Spatial.CullHint.Inherit); @@ -635,56 +524,11 @@ public class EzTreeState extends BaseAppState { lod2.setCullHint(Spatial.CullHint.Always); lod2.setShadowMode(RenderQueue.ShadowMode.Off); + ImpostorUtil.addViewControl(lod2, app.getCamera()); root.addControl(new EzTreeLodControl(app.getCamera(), hd, ld1, lod2, 40f, 120f)); 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. * Blätter bekommen Gewicht 1.0 (volle Animation); Äste werden per Y-Position @@ -778,10 +622,17 @@ public class EzTreeState extends BaseAppState { @Override protected void controlUpdate(float tpf) { - float dSq = cam.getLocation().distanceSquared(spatial.getWorldTranslation()); - lod0.setCullHint(dSq < d01sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); - lod1.setCullHint(dSq >= d01sq && dSq < d12sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); - lod2.setCullHint(dSq >= d12sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + float dSq = cam.getLocation().distanceSquared(spatial.getWorldTranslation()); + boolean atL2 = dSq >= d12sq; + setLod(lod0, dSq < d01sq, !atL2); + 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, diff --git a/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java index 28311a9..4cd18d1 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java @@ -13,6 +13,9 @@ import com.jme3.light.PointLight; import com.jme3.material.Material; import com.jme3.math.*; 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.collision.CollisionResults; import com.jme3.math.Ray; @@ -23,6 +26,7 @@ import com.jme3.scene.shape.Sphere; import com.jme3.anim.SkinningControl; import com.jme3.util.BufferUtils; import de.blight.editor.SharedInput; +import de.blight.editor.util.ImpostorUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -73,6 +77,8 @@ public class ModelEditorState extends BaseAppState { /** Hauptlichtquelle der Vorschau (per UI steuerbar). */ private DirectionalLight mainLight = null; + /** Kamera-folgendes Füll-Licht – immer aus Sicht der Kamera. */ + private DirectionalLight camLight = null; // gespeicherter Kamerazustand aus dem Editor-Modus private Vector3f savedCamPos; @@ -98,6 +104,11 @@ public class ModelEditorState extends BaseAppState { private boolean hasEmbeddedLods = false; private Spatial[] embeddedLodSpatials = null; + // Materialien mit eigenem m_LightDir (Tree.j3md / TreeLeaf.j3md) – werden jedes Frame aktualisiert + private final java.util.List treeLightMaterials = new java.util.ArrayList<>(); + // PBR-Materialien (PBRLighting) – brauchen EmissivePower=1 als IBL-Fallback + private final java.util.List pbrMaterials = new java.util.ArrayList<>(); + // Mittelpunkt für Orbit (Bounding-Box-Zentrum des Modells) private Vector3f orbitCenter = Vector3f.ZERO.clone(); @@ -134,6 +145,9 @@ public class ModelEditorState extends BaseAppState { return; } + // Welt sofort ausblenden, auch wenn noch kein Modell geladen ist + if (previewRoot == null) enterPreview(); + // Modell laden String openPath = input.modelEditorOpenPath; if (openPath != null) { @@ -145,37 +159,54 @@ public class ModelEditorState extends BaseAppState { loadModel(openPath); } + int removeLod = input.modelEditorRemoveEmbeddedLod; + if (removeLod > 0) { + input.modelEditorRemoveEmbeddedLod = -1; + if (hasEmbeddedLods && embeddedLodSpatials != null) { + removeEmbeddedLod(removeLod); + } + } + if (input.modelEditorLodChanged) { 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 int lodIdx = Math.min(input.modelEditorLodPreview, embeddedLodSpatials.length - 1); for (int i = 0; i < embeddedLodSpatials.length; i++) { - embeddedLodSpatials[i].setCullHint( - i == lodIdx ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + if (embeddedLodSpatials[i] != null) { + boolean show = (i == lodIdx); + embeddedLodSpatials[i].setCullHint(show ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + embeddedLodSpatials[i].setShadowMode(RenderQueue.ShadowMode.Off); + } } } else { - // In-memory Preview-Node aus Importer hat Vorrang vor Dateipfaden - Spatial previewNode = null; - if (input.modelEditorLodPreview == 1) - previewNode = input.modelImportLod1PreviewNode.get(); - else if (input.modelEditorLodPreview == 2) - previewNode = input.modelImportLod2PreviewNode.get(); - - if (previewNode != null) { - showSpatialDirectly(previewNode.clone()); - } 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); + // Pfad-basierter Fallback + 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); } } @@ -305,6 +336,15 @@ public class ModelEditorState extends BaseAppState { } 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.updateGeometricState(); @@ -327,6 +367,8 @@ public class ModelEditorState extends BaseAppState { hasEmbeddedLods = false; embeddedLodSpatials = null; input.modelEditorHasEmbeddedLods = false; + input.modelEditorHasEmbeddedLod1 = false; + input.modelEditorHasEmbeddedLod2 = false; try { // Cache vor dem Laden löschen – verhindert, dass zwischengespeicherte // Materialzustände (z.B. aus vorherigen Thumbnail-Renders) das erste @@ -335,6 +377,7 @@ public class ModelEditorState extends BaseAppState { catch (Exception ignored) {} Spatial model = app.getAssetManager().loadModel(assetPath); stripControls(model); + initImpostorControls(model); recalcNormalsIfMissing(model); markBuffersUpdateNeeded(model); originalSpatial = model; @@ -350,6 +393,8 @@ public class ModelEditorState extends BaseAppState { embeddedLodSpatials = children.toArray(new Spatial[0]); hasEmbeddedLods = true; input.modelEditorHasEmbeddedLods = true; + input.modelEditorHasEmbeddedLod1 = embeddedLodSpatials.length > 1; + input.modelEditorHasEmbeddedLod2 = embeddedLodSpatials.length > 2; } } } catch (Exception e) { @@ -375,6 +420,22 @@ public class ModelEditorState extends BaseAppState { 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 applyScale(input.modelEditorScaleX, input.modelEditorScaleY, input.modelEditorScaleZ); applyPivot(input.modelEditorPivotY); @@ -391,6 +452,18 @@ public class ModelEditorState extends BaseAppState { 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 repositionCompCylinder(); @@ -409,6 +482,21 @@ public class ModelEditorState extends BaseAppState { if (modelWrapper != null) previewRoot.detachChild(modelWrapper); modelWrapper = new Node("model_wrapper"); 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); applyScale(input.modelEditorScaleX, input.modelEditorScaleY, input.modelEditorScaleZ); applyPivot(input.modelEditorPivotY); @@ -469,6 +557,8 @@ public class ModelEditorState extends BaseAppState { computeLightDirection(input.modelEditorLightAzimuth, input.modelEditorLightElevation), new ColorRGBA(1.8f, 1.7f, 1.4f, 1f)); 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( new Vector3f(0.6f, -0.4f, 0.7f).normalizeLocal(), 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)); + // Sofort am Viewport registrieren – auch ohne Modell sichtbar (leerer, dunkler Viewport) + app.getViewPort().attachScene(previewRoot); + previewSceneAttached = true; + interactableArrowNode = new Node("interactableArrow"); interactableArrowNode.setCullHint(Spatial.CullHint.Always); previewRoot.attachChild(interactableArrowNode); @@ -510,7 +604,13 @@ public class ModelEditorState extends BaseAppState { embeddedLodSpatials = null; originalSpatial = null; interactableArrowNode = null; + camLight = null; + mainLight = null; + treeLightMaterials.clear(); + pbrMaterials.clear(); input.modelEditorHasEmbeddedLods = false; + input.modelEditorHasEmbeddedLod1 = false; + input.modelEditorHasEmbeddedLod2 = false; app.getRootNode().setCullHint(Spatial.CullHint.Inherit); if (savedCamPos != null) { @@ -521,6 +621,65 @@ public class ModelEditorState extends BaseAppState { 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 ──────────────────────────────────────────────────────────────── private void rebuildGrid() { @@ -732,10 +891,23 @@ public class ModelEditorState extends BaseAppState { } 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); } + 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. * Behebt TripoAI-Modelle mit fehlenden, null oder nach innen zeigenden Normals. diff --git a/blight-editor/src/main/java/de/blight/editor/state/ModelImportState.java b/blight-editor/src/main/java/de/blight/editor/state/ModelImportState.java index 7eb436c..e082212 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/ModelImportState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/ModelImportState.java @@ -12,11 +12,15 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.nio.ByteBuffer; + import com.jme3.app.Application; import com.jme3.app.SimpleApplication; import com.jme3.app.state.BaseAppState; import com.jme3.asset.AssetManager; import com.jme3.export.binary.BinaryExporter; +import com.jme3.bounding.BoundingBox; +import com.jme3.material.Material; import com.jme3.math.*; import com.jme3.renderer.Camera; import com.jme3.renderer.RenderManager; @@ -25,9 +29,13 @@ import com.jme3.renderer.queue.RenderQueue; import com.jme3.scene.*; import com.jme3.scene.VertexBuffer; 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 de.blight.editor.SharedInput; +import de.blight.editor.util.ImpostorUtil; import de.blight.editor.util.MeshUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,6 +63,16 @@ public class ModelImportState extends BaseAppState { private Node lod1Node = 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) { this.input = input; } @@ -78,21 +96,51 @@ public class ModelImportState extends BaseAppState { 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); if (req != null) { - switch (req.level()) { - case 1 -> { - if ("auto_mesh".equals(req.method())) generateLodAuto(1, 0.5f); - else if ("file".equals(req.method())) generateLodFromFile(1, req.fileAssetPath()); + float pct = req.reductionPct() > 0 ? req.reductionPct() + : (req.level() == 1 ? 0.50f : 0.85f); + switch (req.method()) { + case "auto_mesh", "blight_auto", "jme_auto" -> { + input.modelLodAlgorithm = req.method().startsWith("jme") ? "jme" : "blight"; + generateLodAuto(req.level(), pct); } - case 2 -> { - if ("auto_mesh_heavy".equals(req.method())) generateLodAuto(2, 0.85f); - else if ("file".equals(req.method())) generateLodFromFile(2, req.fileAssetPath()); - } - default -> log.warn("[ModelImport] Unbekannte LOD-Stufe: {}", req.level()); + case "auto_mesh_heavy" -> generateLodAuto(req.level(), pct); + case "file" -> generateLodFromFile(req.level(), req.fileAssetPath()); + case "impostor" -> generateImpostorLod(req.level()); + default -> log.warn("[ModelImport] Unbekannte Methode: {}", req.method()); } } + // Impostor: nächsten Frame abwarten und Pixel lesen + if (impostorLevel >= 0 && impostorReady[0] && impostorFB != null) { + finishImpostorPass(); + } + String exportName = input.modelImportExportName; if (exportName != null) { 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 ──────────────────────────────────────────────────────────────── private void performExport(String fileName) { @@ -708,12 +838,17 @@ public class ModelImportState extends BaseAppState { @Override protected void controlUpdate(float tpf) { float dSq = cam.getLocation().distanceSquared(spatial.getWorldTranslation()); - lod0.setCullHint(dSq < d01sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); - if (lod1 != null) - lod1.setCullHint(dSq >= d01sq && dSq < d12sq - ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); - if (lod2 != null) - lod2.setCullHint(dSq >= d12sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + boolean atLod2 = (lod2 != null && dSq >= d12sq); + setLod(lod0, dSq < d01sq, !atLod2, RenderQueue.ShadowMode.CastAndReceive); + setLod(lod1, dSq >= d01sq && dSq < d12sq, !atLod2, RenderQueue.ShadowMode.CastAndReceive); + setLod(lod2, atLod2, false, RenderQueue.ShadowMode.Off); + } + + 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) {} diff --git a/blight-editor/src/main/java/de/blight/editor/state/TreeGeneratorState.java b/blight-editor/src/main/java/de/blight/editor/state/TreeGeneratorState.java index 0d5a071..5379eeb 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/TreeGeneratorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/TreeGeneratorState.java @@ -46,6 +46,7 @@ import com.jme3.util.SkyFactory; import de.blight.editor.FrameTransfer; import de.blight.editor.SharedInput; +import de.blight.editor.util.ImpostorUtil; import de.blight.editor.tree.TreeMeshBuilder; import de.blight.editor.tree.TreeParams; 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 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 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 FrameBuffer captureFB = null; private Texture2D captureTex = null; - private volatile boolean captureReady = false; + private boolean[] captureReady = new boolean[]{false}; 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; } @@ -191,7 +188,7 @@ public class TreeGeneratorState extends BaseAppState { @Override public void update(float tpf) { // 1. Szenen-Änderungen zuerst (bevor updateGeometricState) - if (pendingRequest != null && captureReady) { + if (pendingRequest != null && captureReady[0]) { finishCapture(); } else if (pendingRequest == null) { SharedInput.TreeGenRequest req = input.treeGenQueue.poll(); @@ -286,23 +283,25 @@ public class TreeGeneratorState extends BaseAppState { pendingBarkMat = barkMat; pendingLeafMat = leafMat; capturePass = 0; - capturePixels = new ByteBuffer[ATLAS_DIRS]; + capturePixels = new ByteBuffer[ImpostorUtil.DIRS]; startCapturePass(0); - input.treeGenStatusMsg = "Rendere Impostor (1/" + ATLAS_DIRS + ")…"; + input.treeGenStatusMsg = "Rendere Impostor (1/" + ImpostorUtil.DIRS + ")…"; } // ── Phase 2: Capture abschließen ────────────────────────────────────────── private void finishCapture() { - ByteBuffer pixels = BufferUtils.createByteBuffer(IMPOSTOR_SIZE * IMPOSTOR_SIZE * 4); - app.getRenderer().readFrameBuffer(captureFB, pixels); - capturePixels[capturePass] = pixels; - cleanupCapture(); + capturePixels[capturePass] = ImpostorUtil.readPixels(app, captureFB); + ImpostorUtil.cleanup(app, captureVP, captureFB); + captureVP = null; + captureFB = null; + captureTex = null; + captureReady[0] = false; - if (capturePass < ATLAS_DIRS - 1) { + if (capturePass < ImpostorUtil.DIRS - 1) { capturePass++; - input.treeGenStatusMsg = "Rendere Impostor (" + (capturePass + 1) + "/" + ATLAS_DIRS + ")…"; + input.treeGenStatusMsg = "Rendere Impostor (" + (capturePass + 1) + "/" + ImpostorUtil.DIRS + ")…"; startCapturePass(capturePass); return; } @@ -311,9 +310,8 @@ public class TreeGeneratorState extends BaseAppState { String treeType = pendingRequest.treeType(); String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); - ByteBuffer atlas = combineAtlas(capturePixels); - Texture2D impostorTex = saveImpostor(atlas, "impostor_" + treeType + "_" + timestamp, - ATLAS_W, ATLAS_H); + ByteBuffer atlas = ImpostorUtil.combineAtlas(capturePixels); + Texture2D impostorTex = ImpostorUtil.buildAtlasTexture(atlas); Node previewTree = makeTreeNode(pendingHdResult, pendingBarkMat.clone(), pendingLeafMat.clone(), "prev"); @@ -339,7 +337,7 @@ public class TreeGeneratorState extends BaseAppState { pendingHdResult = null; pendingBarkMat = null; pendingLeafMat = null; - capturePixels = new ByteBuffer[ATLAS_DIRS]; + capturePixels = new ByteBuffer[ImpostorUtil.DIRS]; } // ── LOD-Aufbau ──────────────────────────────────────────────────────────── @@ -349,7 +347,7 @@ public class TreeGeneratorState extends BaseAppState { root.attachChild(pendingHdNode); root.attachChild(pendingLdNode); - Node lod2 = makeImpostorNode(pendingHdResult.bounds(), impostorTex); + Node lod2 = ImpostorUtil.makeImpostorNode(pendingHdResult.bounds(), impostorTex, assets); root.attachChild(lod2); // Nur LOD0 initial sichtbar; Control steuert je nach Distanz @@ -357,88 +355,21 @@ public class TreeGeneratorState extends BaseAppState { lod2.setCullHint(Spatial.CullHint.Always); lod2.setShadowMode(RenderQueue.ShadowMode.Off); + ImpostorUtil.addViewControl(lod2, app.getCamera()); root.addControl(new TreeLodControl(app.getCamera(), pendingHdNode, pendingLdNode, lod2, 60f, 200f)); 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") private void startCapturePass(int pass) { - captureTex = new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.RGBA8); - captureFB = new FrameBuffer(IMPOSTOR_SIZE, IMPOSTOR_SIZE, 1); + captureTex = new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.RGBA8); + captureFB = new FrameBuffer(ImpostorUtil.SIZE, ImpostorUtil.SIZE, 1); captureFB.addColorTexture(captureTex); - captureFB.setDepthTexture(new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.Depth)); - float angle = pass * FastMath.HALF_PI; - captureVP = buildCaptureViewPort(pendingHdNode, pendingHdResult.bounds(), captureFB, angle); - captureReady = false; + captureFB.setDepthTexture(new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.Depth)); + captureReady[0] = false; + captureVP = ImpostorUtil.startCapturePass(app, pendingHdNode, pendingHdResult.bounds(), + captureFB, pass, captureReady); } // ── Material-Factories ──────────────────────────────────────────────────── @@ -513,79 +444,6 @@ public class TreeGeneratorState extends BaseAppState { 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 ─────────────────────────────────────────────────────────── private void exportTree(Node treeNode, String treeType, String timestamp, float height) { @@ -611,16 +469,11 @@ public class TreeGeneratorState extends BaseAppState { // ── Aufräumen ───────────────────────────────────────────────────────────── private void cleanupCapture() { - if (captureVP != null) { - app.getRenderManager().removePostView(captureVP); - captureVP = null; - } - if (captureFB != null) { - try { captureFB.dispose(); } catch (Exception ignored) {} - captureFB = null; - } + ImpostorUtil.cleanup(app, captureVP, captureFB); + captureVP = null; + captureFB = null; captureTex = null; - captureReady = false; + captureReady[0] = false; } // ── LOD-Control ─────────────────────────────────────────────────────────── @@ -639,10 +492,17 @@ public class TreeGeneratorState extends BaseAppState { @Override protected void controlUpdate(float tpf) { - float dSq = cam.getLocation().distanceSquared(spatial.getWorldTranslation()); - lod0.setCullHint(dSq < d01sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); - lod1.setCullHint(dSq>=d01sq && dSq= d12sq ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + float dSq = cam.getLocation().distanceSquared(spatial.getWorldTranslation()); + boolean atL2 = dSq >= d12sq; + setLod(lod0, dSq < d01sq, !atL2); + 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) {} diff --git a/blight-editor/src/main/java/de/blight/editor/util/ImpostorUtil.java b/blight-editor/src/main/java/de/blight/editor/util/ImpostorUtil.java new file mode 100644 index 0000000..8e17308 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/util/ImpostorUtil.java @@ -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 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); + } + } + } +}