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 index 3d20f4d..4214d6e 100644 Binary files a/blight-assets/src/main/resources/.thumbnails/Models/trees/pine/medium/pine_medium_20260706_190939.j3o.thumb.png 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_190947.j3o.thumb.png b/blight-assets/src/main/resources/.thumbnails/Models/trees/pine/medium/pine_medium_20260706_190947.j3o.thumb.png index bc90de5..0d87ce5 100644 Binary files a/blight-assets/src/main/resources/.thumbnails/Models/trees/pine/medium/pine_medium_20260706_190947.j3o.thumb.png and b/blight-assets/src/main/resources/.thumbnails/Models/trees/pine/medium/pine_medium_20260706_190947.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 index 4e2d719..23bdba8 100644 Binary files a/blight-assets/src/main/resources/.thumbnails/Models/trees/pine/medium/pine_medium_20260706_190953.j3o.thumb.png 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/MatDefs/Tree.j3md b/blight-assets/src/main/resources/MatDefs/Tree.j3md index c358757..b03c915 100644 --- a/blight-assets/src/main/resources/MatDefs/Tree.j3md +++ b/blight-assets/src/main/resources/MatDefs/Tree.j3md @@ -10,6 +10,30 @@ MaterialDef Tree { Vector3 LightDir : 0.6 -0.8 0.4 Vector3 SunColor : 0.68 0.65 0.60 Vector3 AmbientColor : 0.08 0.10 0.16 + + // Vom Shadow-Renderer befüllt (PostShadow-Pass) + Int BoundDrawBuffer + Int FilterMode + Boolean HardwareShadows + Texture2D ShadowMap0 + Texture2D ShadowMap1 + Texture2D ShadowMap2 + Texture2D ShadowMap3 + Texture2D ShadowMap4 + Texture2D ShadowMap5 + Float ShadowIntensity : 1.0 + Vector4 Splits + Vector2 FadeInfo + Matrix4 LightViewProjectionMatrix0 + Matrix4 LightViewProjectionMatrix1 + Matrix4 LightViewProjectionMatrix2 + Matrix4 LightViewProjectionMatrix3 + Matrix4 LightViewProjectionMatrix4 + Matrix4 LightViewProjectionMatrix5 + Vector3 LightPos + Float PCFEdge + Float ShadowMapSize + Boolean BackfaceShadows : false } Technique { @@ -25,4 +49,19 @@ MaterialDef Tree { Defines { } } + + Technique PostShadow { + VertexShader GLSL150 : Shaders/TreePostShadow.vert + FragmentShader GLSL150 : Shaders/TreePostShadow.frag + + WorldParameters { + WorldViewProjectionMatrix + WorldMatrix + } + + ForcedRenderState { + Blend Modulate + DepthWrite Off + } + } } 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 d7b01bc..175d89d 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 index 540baf5..72d58db 100644 --- 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 @@ -1,4 +1,4 @@ -#Mon Jul 20 11:46:10 CEST 2026 +#Mon Jul 20 20:34:07 CEST 2026 attachedEmitters.count=0 attachedLights.count=0 castShadow=true diff --git a/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190947.j3o b/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190947.j3o index 9f78144..7d3443f 100644 Binary files a/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190947.j3o and b/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190947.j3o differ diff --git a/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190947.j3o.meta b/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190947.j3o.meta index 4fba9c1..dfa43b1 100644 --- a/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190947.j3o.meta +++ b/blight-assets/src/main/resources/Models/trees/pine/medium/pine_medium_20260706_190947.j3o.meta @@ -1,4 +1,4 @@ -#Mon Jul 20 13:09:03 CEST 2026 +#Mon Jul 20 20:34:14 CEST 2026 attachedEmitters.count=0 attachedLights.count=0 castShadow=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 37f58e8..07c18e4 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 index ceb8d29..f178145 100644 --- 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 @@ -1,4 +1,4 @@ -#Mon Jul 20 13:25:15 CEST 2026 +#Mon Jul 20 20:34:23 CEST 2026 attachedEmitters.count=0 attachedLights.count=0 castShadow=true diff --git a/blight-assets/src/main/resources/Shaders/TreePostShadow.frag b/blight-assets/src/main/resources/Shaders/TreePostShadow.frag new file mode 100644 index 0000000..05baf2e --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/TreePostShadow.frag @@ -0,0 +1,13 @@ +#import "Common/ShaderLib/GLSLCompat.glsllib" + +uniform sampler2D m_ShadowMap0; +uniform float m_ShadowIntensity; + +in vec4 shadowCoord; + +void main() { + vec3 coord = shadowCoord.xyz / shadowCoord.w; + float mapDepth = texture2D(m_ShadowMap0, coord.xy).r; + float lit = (coord.z > mapDepth + 0.001) ? (1.0 - m_ShadowIntensity) : 1.0; + gl_FragColor = vec4(lit, lit, lit, 1.0); +} diff --git a/blight-assets/src/main/resources/Shaders/TreePostShadow.vert b/blight-assets/src/main/resources/Shaders/TreePostShadow.vert new file mode 100644 index 0000000..55194f8 --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/TreePostShadow.vert @@ -0,0 +1,15 @@ +#import "Common/ShaderLib/GLSLCompat.glsllib" + +uniform mat4 g_WorldViewProjectionMatrix; +uniform mat4 g_WorldMatrix; +uniform mat4 m_LightViewProjectionMatrix0; + +in vec3 inPosition; + +out vec4 shadowCoord; + +void main() { + gl_Position = g_WorldViewProjectionMatrix * vec4(inPosition, 1.0); + vec4 worldPos = g_WorldMatrix * vec4(inPosition, 1.0); + shadowCoord = m_LightViewProjectionMatrix0 * worldPos; +} 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 9337971..cf23d55 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -267,6 +267,7 @@ public class EditorApp extends Application { private ToggleButton importLod2Btn; private Button modelImportLod1ClearBtn; private Button modelImportLod2ClearBtn; + private Button modelImportLod2GenBtn; // 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<>(); @@ -692,8 +693,11 @@ public class EditorApp extends Application { // 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); + if (modelEditorLod2GenBtn != null) { + boolean lod1Set = input.modelEditorHasEmbeddedLod1 || !modelEditorLod1Path.isBlank() || input.modelImportHasLod1; + boolean lod2Set = input.modelEditorHasEmbeddedLod2 || !modelEditorLod2Path.isBlank() || input.modelImportHasLod2; + modelEditorLod2GenBtn.setDisable(!lod1Set || lod2Set); + } // Importer LOD-Buttons freischalten sobald generiert if (importLod1Btn != null) importLod1Btn.setDisable(!input.modelImportHasLod1); if (importLod2Btn != null) importLod2Btn.setDisable(!input.modelImportHasLod2); @@ -707,6 +711,7 @@ public class EditorApp extends Application { if (importLod2Btn != null) { importLod2Btn.setSelected(lodPreview == 2); } if (modelImportLod1ClearBtn != null) modelImportLod1ClearBtn.setDisable(!input.modelImportHasLod1); if (modelImportLod2ClearBtn != null) modelImportLod2ClearBtn.setDisable(!input.modelImportHasLod2); + if (modelImportLod2GenBtn != null) modelImportLod2GenBtn.setDisable(!input.modelImportHasLod1 || input.modelImportHasLod2); // Modell-Import/Editor: LOD-Status String lodStatus = input.modelLodGenStatus; @@ -6037,6 +6042,10 @@ public class EditorApp extends Application { receiveCB.setSelected(meta.receiveShadow()); for (CheckBox cb : new CheckBox[]{solidCB, castCB, receiveCB}) cb.setStyle("-fx-text-fill:#ccc;"); + if (relPath.startsWith("Models/trees/")) { + solidCB.setSelected(false); + solidCB.setDisable(true); + } // ── Zufällige Skalierung beim Multi-Place ───────────────────────────── Label rndTitle = new Label("Zufallsskalierung (Multi-Place):"); @@ -6073,16 +6082,23 @@ public class EditorApp extends Application { lod1ClearBtn.setStyle("-fx-text-fill:#c0392b;"); lod1ClearBtn.setOnAction(e -> { if (input.modelEditorHasEmbeddedLod1) { + // ModelEditorState.removeEmbeddedLod(1) kaskadiert automatisch LOD2 input.modelEditorRemoveEmbeddedLod = 1; } if (input.modelImportHasLod1) { + // ModelImportState.clearLod==1 kaskadiert automatisch LOD2 input.modelImportClearLodLevel = 1; if (modelEditorLod1StatusLabel != null) modelEditorLod1StatusLabel.setText("–"); + if (modelEditorLod2StatusLabel != null) modelEditorLod2StatusLabel.setText("–"); } modelEditorLod1Path = ""; input.modelEditorLod1Path = ""; modelEditorLod1Label.setText("(kein Pfad)"); - if (input.modelEditorLodPreview == 1) { + // Kaskade LOD2 (Pfad + Preview) + modelEditorLod2Path = ""; + input.modelEditorLod2Path = ""; + if (modelEditorLod2Label != null) modelEditorLod2Label.setText("(kein Pfad)"); + if (input.modelEditorLodPreview == 1 || input.modelEditorLodPreview == 2) { input.modelEditorLodPreview = 0; input.modelEditorLodChanged = true; } @@ -6095,7 +6111,7 @@ public class EditorApp extends Application { modelEditorLod2GenBtn = new Button("LOD 2 generieren"); modelEditorLod2GenBtn.setMaxWidth(Double.MAX_VALUE); boolean lod2Exists = input.modelEditorHasEmbeddedLod2 || !modelEditorLod2Path.isBlank() || input.modelImportHasLod2; - modelEditorLod2GenBtn.setDisable(lod2Exists); + modelEditorLod2GenBtn.setDisable(!lod1Exists || lod2Exists); modelEditorLod2GenBtn.setOnAction(e -> showLodGenDialog(2, 85)); modelEditorLod2StatusLabel = new Label("–"); @@ -6504,6 +6520,7 @@ public class EditorApp extends Application { modelImportLod2StatusLabel = null; modelImportLod1ClearBtn = null; modelImportLod2ClearBtn = null; + modelImportLod2GenBtn = null; modelImportExportStatusLabel = null; input.modelImportHasLod1 = false; input.modelImportHasLod2 = false; @@ -6731,8 +6748,10 @@ public class EditorApp extends Application { modelImportLod1ClearBtn.setMaxWidth(Double.MAX_VALUE); modelImportLod1ClearBtn.setDisable(true); modelImportLod1ClearBtn.setOnAction(e -> { + // ModelImportState.clearLod==1 kaskadiert automatisch LOD2 input.modelImportClearLodLevel = 1; modelImportLod1StatusLabel.setText("(nicht gesetzt)"); + modelImportLod2StatusLabel.setText("(nicht gesetzt)"); }); // ── LOD 2 ───────────────────────────────────────────────────────────── @@ -6748,7 +6767,8 @@ public class EditorApp extends Application { lod2FileRB.setToggleGroup(lod2Group); lod2MeshRB.setSelected(true); - Button lod2GenBtn = new Button("LOD 2 generieren"); + modelImportLod2GenBtn = new Button("LOD 2 generieren"); + Button lod2GenBtn = modelImportLod2GenBtn; lod2GenBtn.setMaxWidth(Double.MAX_VALUE); lod2GenBtn.setOnAction(e -> { if (lod2MeshRB.isSelected()) { 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 240ecb3..ce26ba3 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 @@ -684,8 +684,13 @@ public class ModelEditorState extends BaseAppState { 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 (lodIdx == 1) { + input.modelEditorHasEmbeddedLod1 = false; + // LOD2 setzt LOD1 voraus — Kaskade: LOD2 ebenfalls entfernen + removeEmbeddedLod(2); + } else if (lodIdx == 2) { + input.modelEditorHasEmbeddedLod2 = false; + } if (input.modelEditorLodPreview == lodIdx) { input.modelEditorLodPreview = 0; @@ -961,7 +966,8 @@ public class ModelEditorState extends BaseAppState { int i = 0; while (i < s.getNumControls()) { com.jme3.scene.control.Control c = s.getControl(i); - if (c instanceof ImpostorUtil.ImpostorViewControl) { i++; } + if (c instanceof ImpostorUtil.ImpostorViewControl + || c instanceof com.jme3.scene.control.BillboardControl) { i++; } else { s.removeControl(c); } } if (s instanceof Node n) n.getChildren().forEach(ModelEditorState::stripControls); @@ -1147,9 +1153,12 @@ public class ModelEditorState extends BaseAppState { log.info("[ModelEditor] LOD2 eingebettet als '{}'", l2.getName()); } - // HD-Modell (Index 0) immer als sichtbar speichern – unabhängig vom aktuellen Preview-Zustand + // HD-Modell (Index 0): sichtbar speichern und als "lod0" kennzeichnen + // damit WorldObjectsState Fall A (eingebettetes LOD) korrekt erkennt. if (!rootNode.getChildren().isEmpty()) { - rootNode.getChild(0).setCullHint(Spatial.CullHint.Inherit); + Spatial hd = rootNode.getChild(0); + hd.setName("lod0"); + hd.setCullHint(Spatial.CullHint.Inherit); } log.info("[ModelEditor] rootNode '{}' hat vor dem Speichern {} Kinder:", 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 ea6c8fb..b267eed 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 @@ -106,10 +106,14 @@ public class ModelImportState extends BaseAppState { input.modelImportHasLod1 = false; input.modelImportLod1PreviewNode.set(null); input.modelLodGenStatus = "LOD1: (entfernt)"; - if (input.modelEditorLodPreview == 1) { + if (input.modelEditorLodPreview == 1 || input.modelEditorLodPreview == 2) { input.modelEditorLodPreview = 0; input.modelEditorLodChanged = true; } + // LOD2 setzt LOD1 voraus — Kaskade + lod2Node = null; + input.modelImportHasLod2 = false; + input.modelImportLod2PreviewNode.set(null); } else if (clearLod == 2) { lod2Node = null; input.modelImportHasLod2 = false; 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 index da4b56a..141d1b7 100644 --- a/blight-editor/src/main/java/de/blight/editor/util/ImpostorUtil.java +++ b/blight-editor/src/main/java/de/blight/editor/util/ImpostorUtil.java @@ -16,6 +16,7 @@ import com.jme3.renderer.ViewPort; import com.jme3.renderer.queue.RenderQueue; import com.jme3.scene.*; import com.jme3.scene.control.AbstractControl; +import com.jme3.scene.control.BillboardControl; import com.jme3.texture.FrameBuffer; import com.jme3.texture.Image; import com.jme3.texture.Texture2D; @@ -87,17 +88,16 @@ public final class ImpostorUtil { applyTreeLighting(capNode, toLight); Node scene = new Node("impCapScene"); - // Hauptlicht (warm, von Kamera-Seite) + // Lichtstärken auf typisches In-Game-Tageslicht abgestimmt. + // Das Game-FPP wendet Gamma-Korrektur an (lineare Werte erscheinen heller); + // deshalb sind die Werte etwas niedriger als die rein visuell korrekten Editor-Werte. scene.addLight(new DirectionalLight(fromLight, - new ColorRGBA(1.2f, 1.1f, 0.9f, 1f))); - // Fülllicht (kühler, von gegenüberliegender Seite) + new ColorRGBA(0.70f, 0.65f, 0.52f, 1f))); 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) + new ColorRGBA(0.16f, 0.16f, 0.22f, 1f))); 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))); + new ColorRGBA(0.12f, 0.14f, 0.17f, 1f))); + scene.addLight(new AmbientLight(new ColorRGBA(0.30f, 0.30f, 0.32f, 1f))); scene.attachChild(capNode); ViewPort vp = app.getRenderManager().createPostView("impCap_" + System.nanoTime(), capCam); @@ -181,17 +181,27 @@ public final class ImpostorUtil { 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); + // Alpha Discard (Cutout) statt Alpha Blend: + // Transparente Pixel werden verworfen → Depth-Write bleibt aktiv → FogFilter, + // WaterFilter und andere Post-Effects sehen die korrekte Tiefe des Impostors. + mat.setFloat("AlphaDiscardThreshold", 0.5f); + mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Off); 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)); + float uMin = (float) d / DIRS; + float uMax = (float)(d + 1) / DIRS; + // Alle Quads in derselben Ebene (yRot=0). Der BillboardControl unten + // dreht den gesamten Node zur Kamera; ImpostorViewControl wählt den + // passenden UV-Streifen per Kamerawinkel. + n.attachChild(buildBillboardQuad("quad_" + d, 0f, yOff, size, mat.clone(), uMin, uMax)); } - n.setQueueBucket(RenderQueue.Bucket.Transparent); + // Opaque-Bucket: Depth-Write aktiv → korrekte Tiefen für alle Post-Effects. + n.setQueueBucket(RenderQueue.Bucket.Opaque); + BillboardControl bc = new BillboardControl(); + bc.setAlignment(BillboardControl.Alignment.AxialY); + n.addControl(bc); return n; } @@ -358,10 +368,9 @@ public final class ImpostorUtil { 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 < dirs; i++) { - quads.get(i).setCullHint(i == best || i == opp + quads.get(i).setCullHint(i == best ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); } diff --git a/blight-editor/src/main/java/de/blight/editor/util/ModelExportUtil.java b/blight-editor/src/main/java/de/blight/editor/util/ModelExportUtil.java index acf208e..5788f7b 100644 --- a/blight-editor/src/main/java/de/blight/editor/util/ModelExportUtil.java +++ b/blight-editor/src/main/java/de/blight/editor/util/ModelExportUtil.java @@ -9,6 +9,7 @@ import com.jme3.scene.Mesh; import com.jme3.scene.Node; import com.jme3.scene.Spatial; import com.jme3.scene.VertexBuffer; +import com.jme3.scene.control.BillboardControl; import java.nio.FloatBuffer; @@ -17,7 +18,13 @@ public final class ModelExportUtil { private ModelExportUtil() {} public static void stripControls(Spatial s) { - while (s.getNumControls() > 0) s.removeControl(s.getControl(0)); + int i = 0; + while (i < s.getNumControls()) { + // BillboardControl bleibt erhalten: JME3-Built-in, überlebt j3o-Roundtrip + // und wird im Game korrekt deserialisiert (kein Camera-Feld, keine externe Klasse). + if (s.getControl(i) instanceof BillboardControl) { i++; } + else { s.removeControl(s.getControl(i)); } + } if (s instanceof Node n) { n.getChildren().forEach(ModelExportUtil::stripControls); } diff --git a/blight-game/src/main/java/de/blight/game/config/GraphicsScreen.java b/blight-game/src/main/java/de/blight/game/config/GraphicsScreen.java index e406a98..2ef4b2a 100644 --- a/blight-game/src/main/java/de/blight/game/config/GraphicsScreen.java +++ b/blight-game/src/main/java/de/blight/game/config/GraphicsScreen.java @@ -8,6 +8,7 @@ import com.jme3.system.lwjgl.LwjglWindow; import de.blight.game.BlightGame; import de.blight.game.state.TerrainChunkState; import de.blight.game.state.WeatherState; +import de.blight.game.state.WorldObjectsState; import de.blight.lang.TextResolver; import org.lwjgl.glfw.GLFW; import org.lwjgl.glfw.GLFWVidMode; @@ -205,6 +206,9 @@ public class GraphicsScreen extends MenuScreen { TerrainChunkState tcs = getApplication().getStateManager().getState(TerrainChunkState.class); if (tcs != null) tcs.setLodRanges(live.viewDistance.lod0Range, live.viewDistance.lod1Range); + WorldObjectsState wos = getApplication().getStateManager().getState(WorldObjectsState.class); + if (wos != null) wos.setLodFactor((float) live.viewDistance.lod0Range); + close(); if (needsRestart) { diff --git a/blight-game/src/main/java/de/blight/game/config/GraphicsSettings.java b/blight-game/src/main/java/de/blight/game/config/GraphicsSettings.java index f79d7e3..e376e93 100644 --- a/blight-game/src/main/java/de/blight/game/config/GraphicsSettings.java +++ b/blight-game/src/main/java/de/blight/game/config/GraphicsSettings.java @@ -39,9 +39,9 @@ public class GraphicsSettings { } public enum ViewDistance { - SHORT (0.55f, 0, 2), - NORMAL(1.00f, 1, 3), - FAR (1.60f, 2, 5); + SHORT (1.00f, 1, 3), + NORMAL(1.60f, 2, 5), + FAR (2.50f, 3, 7); public final float fogFactor; public final int lod0Range; diff --git a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java index 3bc1a41..b77dad5 100644 --- a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java +++ b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java @@ -254,7 +254,7 @@ public class WorldScene extends BaseAppState { BlightGame.status("Lade Welt-Objekte..."); app.getStateManager().attach(new RiverState()); WorldObjectsState worldObjects = new WorldObjectsState(); - worldObjects.setLodFactor(graphicsSettings.viewDistance.fogFactor); + worldObjects.setLodFactor((float) graphicsSettings.viewDistance.lod0Range); app.getStateManager().attach(worldObjects); app.getStateManager().attach(new WorldLightState(sharedFPP)); app.getStateManager().attach(new StoneWorldState()); diff --git a/blight-game/src/main/java/de/blight/game/state/ImpostorViewControl.java b/blight-game/src/main/java/de/blight/game/state/ImpostorViewControl.java index 3f0a7df..ac02249 100644 --- a/blight-game/src/main/java/de/blight/game/state/ImpostorViewControl.java +++ b/blight-game/src/main/java/de/blight/game/state/ImpostorViewControl.java @@ -43,10 +43,9 @@ public class ImpostorViewControl extends AbstractControl { 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; for (int i = 0; i < dirs; i++) { - quads.get(i).setCullHint(i == best || i == opp + quads.get(i).setCullHint(i == best ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); } diff --git a/blight-game/src/main/java/de/blight/game/state/ModelLodControl.java b/blight-game/src/main/java/de/blight/game/state/ModelLodControl.java index c14e772..b9e4bb6 100644 --- a/blight-game/src/main/java/de/blight/game/state/ModelLodControl.java +++ b/blight-game/src/main/java/de/blight/game/state/ModelLodControl.java @@ -34,8 +34,7 @@ public class ModelLodControl extends AbstractControl { private final Camera cam; private final float lod1DistSq; private final float lod2DistSq; - private final float cullDistSq; - private int currentSlot = 0; // 0=lod0, 1=lod1, 2=lod2, -1=culled + private int currentSlot = 0; // 0=lod0, 1=lod1, 2=lod2, -1=ausgeblendet // ── Embedded-LOD-Modus ──────────────────────────────────────────────────── private final Spatial embLod0; @@ -55,18 +54,17 @@ public class ModelLodControl extends AbstractControl { /** * Embedded-LOD-Modus: Die j3o-Nodes lod0/lod1/lod2 existieren bereits als * Child-Nodes im Spatial, an dem dieser Control befestigt wird. - * lod1 und lod2 dürfen null sein (dann bleibt lod0 bis zur cullDistance sichtbar). + * lod1 und lod2 dürfen null sein (dann wird lod0 ab der jeweiligen Schwelle ausgeblendet). */ public ModelLodControl(Camera cam, Spatial lod0, Spatial lod1, Spatial lod2, - float lod1Distance, float lod2Distance, float cullDistance) { + float lod1Distance, float lod2Distance) { this.cam = cam; this.embLod0 = lod0; this.embLod1 = lod1; this.embLod2 = lod2; - this.lod1DistSq = lod1 != null ? lod1Distance * lod1Distance : Float.MAX_VALUE; - this.lod2DistSq = lod2 != null ? lod2Distance * lod2Distance : Float.MAX_VALUE; - this.cullDistSq = cullDistance * cullDistance; + this.lod1DistSq = lod1Distance > 0f ? lod1Distance * lod1Distance : Float.MAX_VALUE; + this.lod2DistSq = lod2Distance > 0f ? lod2Distance * lod2Distance : Float.MAX_VALUE; this.assets = null; } @@ -76,14 +74,13 @@ public class ModelLodControl extends AbstractControl { */ public ModelLodControl(AssetManager assets, Camera cam, String lod1Path, String lod2Path, - float lod1Distance, float lod2Distance, float cullDistance) { + float lod1Distance, float lod2Distance) { this.assets = assets; this.cam = cam; this.lod1Path = (lod1Path != null && !lod1Path.isBlank()) ? lod1Path : null; this.lod2Path = (lod2Path != null && !lod2Path.isBlank()) ? lod2Path : null; - this.lod1DistSq = lod1Distance * lod1Distance; - this.lod2DistSq = lod2Distance * lod2Distance; - this.cullDistSq = cullDistance * cullDistance; + this.lod1DistSq = lod1Distance > 0f ? lod1Distance * lod1Distance : Float.MAX_VALUE; + this.lod2DistSq = lod2Distance > 0f ? lod2Distance * lod2Distance : Float.MAX_VALUE; this.embLod0 = null; this.embLod1 = null; this.embLod2 = null; @@ -115,21 +112,25 @@ public class ModelLodControl extends AbstractControl { boolean embedded = embLod0 != null; + boolean hasLod1 = embedded ? embLod1 != null : lod1Path != null; + boolean hasLod2 = embedded ? embLod2 != null : lod2Path != null; + + // Sichtbarkeit hängt nur vom LOD-System ab — kein separates Culling. + // Fehlt LOD1 aber LOD2 vorhanden: bei lod1Distance direkt zu LOD2 wechseln (nicht ausblenden). + // Fehlt beides (z.B. Kaktusfeige): ab lod1Distance ausblenden. int targetSlot; - if (distSq >= cullDistSq) { - targetSlot = -1; - } else if (embedded) { - if (embLod2 != null && distSq >= lod2DistSq) targetSlot = 2; - else if (embLod1 != null && distSq >= lod1DistSq) targetSlot = 1; - else targetSlot = 0; + if (distSq >= lod2DistSq) { + targetSlot = hasLod2 ? 2 : -1; + } else if (distSq >= lod1DistSq) { + if (hasLod1) targetSlot = 1; + else if (hasLod2) targetSlot = 2; // kein LOD1, aber LOD2 vorhanden → Impostor früher + else targetSlot = -1; // weder LOD1 noch LOD2 → ausblenden } else { - if (lod2Path != null && distSq >= lod2DistSq) targetSlot = 2; - else if (lod1Path != null && distSq >= lod1DistSq) targetSlot = 1; - else targetSlot = 0; + targetSlot = 0; } if (targetSlot == currentSlot) return; - log.debug("[LOD] {} Slot {} → {} (dist={}m)", + log.info("[LOD] {} Slot {} → {} (dist={}m)", spatial.getName(), currentSlot, targetSlot, String.format("%.1f", Math.sqrt(distSq))); currentSlot = targetSlot; diff --git a/blight-game/src/main/java/de/blight/game/state/WorldItemsState.java b/blight-game/src/main/java/de/blight/game/state/WorldItemsState.java index fdbe87e..4086495 100644 --- a/blight-game/src/main/java/de/blight/game/state/WorldItemsState.java +++ b/blight-game/src/main/java/de/blight/game/state/WorldItemsState.java @@ -226,9 +226,9 @@ public class WorldItemsState extends BaseAppState { float hMargin = cam.getWidth() * 0.10f; float screenH = cam.getHeight(); - boolean doLog = hoverLogTimer >= 1f; + boolean doLog = hoverLogTimer >= 1f && !items.isEmpty(); + if (hoverLogTimer >= 1f) hoverLogTimer = 0f; if (doLog) { - hoverLogTimer = 0f; log.info("[Hover] items={} screenW={} screenH={} centerX={} hMargin={}", items.size(), cam.getWidth(), cam.getHeight(), centerX, hMargin); } diff --git a/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java b/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java index 466353b..ef65de6 100644 --- a/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java +++ b/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java @@ -6,14 +6,18 @@ import com.jme3.app.state.BaseAppState; import com.jme3.asset.AssetManager; import com.jme3.asset.plugins.FileLocator; import com.jme3.bullet.BulletAppState; +import com.jme3.bullet.collision.shapes.CapsuleCollisionShape; +import com.jme3.bullet.collision.shapes.CompoundCollisionShape; import com.jme3.bullet.control.RigidBodyControl; import com.jme3.bullet.util.CollisionShapeFactory; +import com.jme3.bounding.BoundingBox; import com.jme3.material.Material; import com.jme3.material.MatParam; import com.jme3.material.RenderState; import com.jme3.math.*; import com.jme3.renderer.queue.RenderQueue; import com.jme3.scene.*; +import com.jme3.scene.control.BillboardControl; import com.jme3.scene.shape.*; import com.jme3.texture.Texture; import de.blight.common.PlacedModel; @@ -34,9 +38,13 @@ public class WorldObjectsState extends BaseAppState { private SimpleApplication app; private AssetManager assets; private BulletAppState bulletAppState; - private final List sceneLitMaterials = new ArrayList<>(); - private final List windMaterials = new ArrayList<>(); - private final List pbrMaterials = new ArrayList<>(); + private final List sceneLitMaterials = new ArrayList<>(); + private final List windMaterials = new ArrayList<>(); + private final List pbrMaterials = new ArrayList<>(); + /** Impostor-Unshaded-Materialien: erhalten pro Frame einen Ambient-Tint (Color-Uniform). */ + private final List impostorMaterials = new ArrayList<>(); + /** Capture-Ambient, mit dem der Impostor-Atlas gerendert wurde. Normalisiert den Tint. */ + private static final float IMPOSTOR_CAP_AMB = 0.30f; private float debugTimer = 0f; private float lodFactor = 1.0f; @@ -92,6 +100,30 @@ public class WorldObjectsState extends BaseAppState { else shadowMode = RenderQueue.ShadowMode.Off; s.setShadowMode(shadowMode); + // Baum-Stammkollision: Capsule statt Vollmesh (Blätter ausgeschlossen) + String objPath = m.modelPath() != null ? m.modelPath() : ""; + if (objPath.startsWith("Models/trees/") && bulletAppState != null) { + try { + s.updateGeometricState(); + float treeHeight = 6f; + com.jme3.bounding.BoundingVolume bv = s.getWorldBound(); + if (bv instanceof BoundingBox bb) { + treeHeight = bb.getYExtent() * 2f; + } + float cylHeight = Math.max(0.5f, treeHeight - 0.5f); + CapsuleCollisionShape capsule = + new CapsuleCollisionShape(0.25f, cylHeight); + CompoundCollisionShape compound = + new CompoundCollisionShape(); + compound.addChildShape(capsule, new Vector3f(0f, treeHeight * 0.5f, 0f)); + RigidBodyControl treeRbc = new RigidBodyControl(compound, 0f); + s.addControl(treeRbc); + bulletAppState.getPhysicsSpace().add(treeRbc); + } catch (Exception pe) { + log.warn("[WorldObjects] Baum-Physik für '{}' nicht erzeugbar: {}", m.modelPath(), pe.getMessage()); + } + } + // Physik-Kollision für solide Objekte if (m.solid() && bulletAppState != null) { try { @@ -142,6 +174,17 @@ public class WorldObjectsState extends BaseAppState { for (Material mat : pbrMaterials) { mat.setColor("Emissive", new ColorRGBA(ac.r, ac.g, ac.b, 1f)); } + // Impostor-Tint: Unshaded-Atlas folgt dem Tag/Nacht-Zyklus über den Color-Multiplikator. + // Normiert auf die Capture-Ambient (0.30): Bei gleichem Ambient = 1.0, Nacht ≈ 0.05. + if (!impostorMaterials.isEmpty()) { + float tR = Math.min(1.2f, ac.r / IMPOSTOR_CAP_AMB); + float tG = Math.min(1.2f, ac.g / IMPOSTOR_CAP_AMB); + float tB = Math.min(1.2f, ac.b / IMPOSTOR_CAP_AMB); + ColorRGBA tint = new ColorRGBA(tR, tG, tB, 1f); + for (Material mat : impostorMaterials) { + mat.setColor("Color", tint); + } + } debugTimer += tpf; if (debugTimer >= 5f) { debugTimer = 0f; @@ -193,27 +236,54 @@ public class WorldObjectsState extends BaseAppState { } spatial.setName("obj_" + path); - // LOD / Distance-Culling - if (m.cullDistance() > 0f) { + // LOD — kein cullDistance mehr; Objekte ohne LOD-Stufe werden ab der jeweiligen Distanz ausgeblendet + if (m.lod1Distance() > 0f || m.lod2Distance() > 0f) { if (spatial instanceof Node treeNode) { Spatial lod0 = treeNode.getChild("lod0"); Spatial lod1 = treeNode.getChild("lod1"); Spatial lod2 = treeNode.getChild("lod2"); - // Fall A: j3o hat explizite lod0/lod1/lod2 Child-Nodes. + // Neues eingebettetes Format: child[0] ist das HD-Modell, aber nicht explizit "lod0" + // benannt. Erkennungsmerkmal: "lod1" ist vorhanden (alter Impostor-Format hat nie lod1). + if (lod0 == null && lod1 != null && !treeNode.getChildren().isEmpty()) { + lod0 = treeNode.getChild(0); + } + + // Fall A: j3o hat eingebettete LOD-Child-Nodes (lod0 explizit oder per Index 0 erkannt). if (lod0 != null) { + log.info("[WorldObjects-LOD] Fall A: {} | Kinder={} | lod0='{}' lod1={} lod2={} | dist={}/{}m", + path, treeNode.getChildren().size(), + lod0.getName(), lod1 != null ? "'" + lod1.getName() + "'" : "null", + lod2 != null ? "'" + lod2.getName() + "'" : "null", + m.lod1Distance() * lodFactor, m.lod2Distance() * lodFactor); lod0.setCullHint(Spatial.CullHint.Inherit); if (lod1 != null) lod1.setCullHint(Spatial.CullHint.Always); - if (lod2 != null) lod2.setCullHint(Spatial.CullHint.Always); + if (lod2 != null) { + lod2.setCullHint(Spatial.CullHint.Always); + if (lod2 instanceof Node lod2Node && !lod2Node.getChildren().isEmpty() + && isImpostorNode(lod2Node)) { + initImpostor(lod2Node); + } + } treeNode.addControl(new ModelLodControl( app.getCamera(), lod0, lod1, lod2, - m.lod1Distance() * lodFactor, m.lod2Distance() * lodFactor, m.cullDistance() * lodFactor)); + m.lod1Distance() * lodFactor, m.lod2Distance() * lodFactor)); return treeNode; } + // Vorhandene Controls im geladenen j3o loggen (sollten nach stripControls nicht vorhanden sein) + if (treeNode.getNumControls() > 0) { + log.warn("[WorldObjects-LOD] {} hat {} Control(s) nach dem Laden! Controls werden entfernt.", + path, treeNode.getNumControls()); + while (treeNode.getNumControls() > 0) treeNode.removeControl(treeNode.getControl(0)); + } + // Fall B: j3o hat nur "lod2" als Kind — Root ist lod0, lod2 ist das vereinfachte Mesh. // lod2 aus dem Root ausgliedern, damit beide unabhängig ein/ausgeblendet werden können. if (lod2 != null) { + log.info("[WorldObjects-LOD] Fall B: {} | Kinder={} | lod2='{}' | dist=0/{}m", + path, treeNode.getChildren().size(), lod2.getName(), + m.lod2Distance() * lodFactor); treeNode.detachChild(lod2); Node lodRoot = new Node("lodRoot_" + path); lodRoot.attachChild(treeNode); // index 0 = lod0 (volle Qualität) @@ -232,11 +302,14 @@ public class WorldObjectsState extends BaseAppState { lod2.setLocalTranslation(0f, -groundY, 0f); } } - // Impostor: wählt pro Frame das kamerawinkelrichtige Quad-Paar. - lod2.addControl(new ImpostorViewControl(app.getCamera())); + // Impostor: wählt pro Frame das kamerawinkelrichtige Quad. + if (lod2 instanceof Node lod2Node && !lod2Node.getChildren().isEmpty() + && isImpostorNode(lod2Node)) { + initImpostor(lod2Node); + } lodRoot.addControl(new ModelLodControl( app.getCamera(), treeNode, null, lod2, - m.lod1Distance() * lodFactor, m.lod2Distance() * lodFactor, m.cullDistance() * lodFactor)); + m.lod1Distance() * lodFactor, m.lod2Distance() * lodFactor)); return lodRoot; } } @@ -247,7 +320,7 @@ public class WorldObjectsState extends BaseAppState { ModelLodControl ctrl = new ModelLodControl( assets, app.getCamera(), m.lod1Path(), m.lod2Path(), - m.lod1Distance() * lodFactor, m.lod2Distance() * lodFactor, m.cullDistance() * lodFactor); + m.lod1Distance() * lodFactor, m.lod2Distance() * lodFactor); ctrl.setLodLoadCallback(lod -> { convertUnshadedToPbr(lod); collectSceneLitMaterials(lod); @@ -259,6 +332,83 @@ public class WorldObjectsState extends BaseAppState { return spatial; } + private boolean isImpostorNode(Node n) { + if (n.getControl(BillboardControl.class) != null) { + log.info("[Impostor] '{}' erkannt via BillboardControl", n.getName()); + return true; + } + if (n.getChildren().isEmpty()) { + log.info("[Impostor] '{}' hat keine Kinder → kein Impostor", n.getName()); + return false; + } + Spatial first = n.getChild(0); + log.info("[Impostor] '{}' erstes Kind: {} ({})", n.getName(), first.getName(), first.getClass().getSimpleName()); + if (!(first instanceof Geometry g)) { + log.info("[Impostor] '{}' erstes Kind ist kein Geometry → kein Impostor", n.getName()); + return false; + } + Material mat = g.getMaterial(); + String matName = mat != null ? mat.getMaterialDef().getName() : "null"; + log.info("[Impostor] '{}' Material: '{}'", n.getName(), matName); + return mat != null && "Unshaded".equals(matName); + } + + private void initImpostor(Node lod2Node) { + lod2Node.addControl(new ImpostorViewControl(app.getCamera())); + if (lod2Node.getControl(BillboardControl.class) == null) { + BillboardControl bc = new BillboardControl(); + bc.setAlignment(BillboardControl.Alignment.AxialY); + lod2Node.addControl(bc); + } + + // In Opaque-Bucket verschieben (auch für ältere j3os, die noch Transparent hatten). + lod2Node.setQueueBucket(com.jme3.renderer.queue.RenderQueue.Bucket.Opaque); + + java.util.Set seen = new java.util.HashSet<>(); + boolean firstMat = true; + for (Spatial child : lod2Node.getChildren()) { + if (!(child instanceof com.jme3.scene.Geometry geo)) continue; + Material mat = geo.getMaterial(); + if (mat == null) continue; + + mat.setFloat("AlphaDiscardThreshold", 0.5f); + mat.getAdditionalRenderState().setBlendMode(com.jme3.material.RenderState.BlendMode.Off); + // FaceCullMode.Off: Billboard dreht sich immer zur Kamera → beide Seiten rendern + // ist sicherer als Back, da Winding-Order nach Billboard-Rotation nicht deterministisch ist. + mat.getAdditionalRenderState().setFaceCullMode(com.jme3.material.RenderState.FaceCullMode.Off); + geo.setQueueBucket(com.jme3.renderer.queue.RenderQueue.Bucket.Opaque); + + com.jme3.material.MatParam colorMap = mat.getParam("ColorMap"); + if (colorMap != null && colorMap.getValue() instanceof com.jme3.texture.Texture t) { + t.getImage().setColorSpace(com.jme3.texture.image.ColorSpace.sRGB); + log.info("[Impostor] Tex '{}': {}×{} fmt={}", + t.getName(), t.getImage().getWidth(), t.getImage().getHeight(), t.getImage().getFormat()); + // Diagnose: Alpha-Wert im Zentrum von Strip 0 prüfen (Pixel 256,256) + if (firstMat) { + firstMat = false; + java.nio.ByteBuffer imgData = t.getImage().getData(0); + if (imgData != null && imgData.capacity() >= (256 * 4096 + 256) * 4 + 4) { + int off = (256 * 4096 + 256) * 4; + int r = imgData.get(off) & 0xFF; + int g = imgData.get(off + 1) & 0xFF; + int b = imgData.get(off + 2) & 0xFF; + int a = imgData.get(off + 3) & 0xFF; + log.info("[Impostor] Atlas Pixel(256,256) Strip0: R={} G={} B={} A={}", r, g, b, a); + } else { + log.warn("[Impostor] Atlas-Daten nicht lesbar (capacity={})", + imgData != null ? imgData.capacity() : -1); + } + } + } else { + log.warn("[Impostor] Quad '{}' hat KEINE ColorMap!", geo.getName()); + } + + if (seen.add(mat)) { + impostorMaterials.add(mat); + } + } + } + private Spatial createPrimitive(String type) { return switch (type) { case "sphere" -> new Geometry("sphere", new Sphere(16, 16, 1f)); @@ -319,6 +469,11 @@ public class WorldObjectsState extends BaseAppState { mat.setVector3("AmbientColor", new Vector3f(0.04f, 0.05f, 0.08f)); mat.setVector3("LightDir", new Vector3f(0f, -1f, 0f)); sceneLitMaterials.add(mat); + if ("TreeLeaf".equals(name)) { + // discard statt blend → Blätter müssen Tiefenwerte schreiben (Nebel, Wasser). + geo.setQueueBucket(RenderQueue.Bucket.Opaque); + mat.getAdditionalRenderState().setDepthWrite(true); + } } else if ("Fern".equals(name)) { windMaterials.add(mat); } else if ("PBRLighting".equals(name)) {