LOD für Palmen und Trauerweiden angepasst

This commit is contained in:
2026-09-14 21:57:41 +02:00
parent 45f7ee1045
commit 1d6f43f9ce
33 changed files with 431 additions and 417 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 B

View File

@@ -3,7 +3,7 @@ attachedEmitters.count=0
attachedLights.count=0 attachedLights.count=0
castShadow=true castShadow=true
category= category=
cullDistance=120.0 cullDistance=200.0
footstepSurface= footstepSurface=
interactableOffsetX=0.0 interactableOffsetX=0.0
interactableOffsetY=0.5 interactableOffsetY=0.5

View File

@@ -3,7 +3,7 @@ attachedEmitters.count=0
attachedLights.count=0 attachedLights.count=0
castShadow=true castShadow=true
category= category=
cullDistance=120.0 cullDistance=200.0
footstepSurface= footstepSurface=
interactableOffsetX=0.0 interactableOffsetX=0.0
interactableOffsetY=0.5 interactableOffsetY=0.5

View File

@@ -0,0 +1,30 @@
#Mon Sep 14 19:54:34 CEST 2026
attachedEmitters.count=0
attachedLights.count=0
attachedSounds.count=0
castShadow=true
category=trees
cullDistance=200.0
footstepSurface=
interactableOffsetX=0.0
interactableOffsetY=0.5
interactableOffsetZ=0.0
interactableRotY=0.0
interactableType=NONE
lod1Distance=30.0
lod1Path=
lod2Distance=80.0
lod2Path=
modelCategory=PALM
name=palm_20260914_195434
pivotOffsetY=0.0
placementOffsetY=0.0
randomScaleMax=1.0
randomScaleMin=1.0
receiveShadow=true
scaleX=1.0
scaleY=1.0
scaleZ=1.0
solid=true
tags=
uniformScale=true

View File

@@ -3,7 +3,7 @@ attachedEmitters.count=0
attachedLights.count=0 attachedLights.count=0
castShadow=true castShadow=true
category= category=
cullDistance=120.0 cullDistance=200.0
footstepSurface= footstepSurface=
interactableOffsetX=0.0 interactableOffsetX=0.0
interactableOffsetY=0.5 interactableOffsetY=0.5

View File

@@ -3,7 +3,7 @@ attachedEmitters.count=0
attachedLights.count=0 attachedLights.count=0
castShadow=true castShadow=true
category= category=
cullDistance=120.0 cullDistance=200.0
footstepSurface= footstepSurface=
interactableOffsetX=0.0 interactableOffsetX=0.0
interactableOffsetY=0.5 interactableOffsetY=0.5

View File

@@ -0,0 +1,30 @@
#Mon Sep 14 20:06:09 CEST 2026
attachedEmitters.count=0
attachedLights.count=0
attachedSounds.count=0
castShadow=true
category=trees
cullDistance=200.0
footstepSurface=
interactableOffsetX=0.0
interactableOffsetY=0.5
interactableOffsetZ=0.0
interactableRotY=0.0
interactableType=NONE
lod1Distance=30.0
lod1Path=
lod2Distance=80.0
lod2Path=
modelCategory=TREE
name=willow_20260914_200608
pivotOffsetY=0.0
placementOffsetY=0.0
randomScaleMax=1.0
randomScaleMin=1.0
receiveShadow=true
scaleX=1.0
scaleY=1.0
scaleZ=1.0
solid=true
tags=
uniformScale=true

View File

@@ -0,0 +1,30 @@
#Mon Sep 14 20:32:47 CEST 2026
attachedEmitters.count=0
attachedLights.count=0
attachedSounds.count=0
castShadow=true
category=trees
cullDistance=200.0
footstepSurface=
interactableOffsetX=0.0
interactableOffsetY=0.5
interactableOffsetZ=0.0
interactableRotY=0.0
interactableType=NONE
lod1Distance=30.0
lod1Path=
lod2Distance=80.0
lod2Path=
modelCategory=TREE
name=willow_20260914_203247
pivotOffsetY=0.0
placementOffsetY=0.0
randomScaleMax=1.0
randomScaleMin=1.0
receiveShadow=true
scaleX=1.0
scaleY=1.0
scaleZ=1.0
solid=true
tags=
uniformScale=true

View File

@@ -0,0 +1,30 @@
#Mon Sep 14 20:44:22 CEST 2026
attachedEmitters.count=0
attachedLights.count=0
attachedSounds.count=0
castShadow=true
category=trees
cullDistance=200.0
footstepSurface=
interactableOffsetX=0.0
interactableOffsetY=0.5
interactableOffsetZ=0.0
interactableRotY=0.0
interactableType=NONE
lod1Distance=30.0
lod1Path=
lod2Distance=80.0
lod2Path=
modelCategory=TREE
name=willow_20260914_204422
pivotOffsetY=0.0
placementOffsetY=0.0
randomScaleMax=1.0
randomScaleMin=1.0
receiveShadow=true
scaleX=1.0
scaleY=1.0
scaleZ=1.0
solid=true
tags=
uniformScale=true

View File

@@ -0,0 +1,30 @@
#Mon Sep 14 20:57:54 CEST 2026
attachedEmitters.count=0
attachedLights.count=0
attachedSounds.count=0
castShadow=true
category=trees
cullDistance=200.0
footstepSurface=
interactableOffsetX=0.0
interactableOffsetY=0.5
interactableOffsetZ=0.0
interactableRotY=0.0
interactableType=NONE
lod1Distance=30.0
lod1Path=
lod2Distance=80.0
lod2Path=
modelCategory=TREE
name=willow_20260914_205754
pivotOffsetY=0.0
placementOffsetY=0.0
randomScaleMax=1.0
randomScaleMin=1.0
receiveShadow=true
scaleX=1.0
scaleY=1.0
scaleZ=1.0
solid=true
tags=
uniformScale=true

View File

@@ -0,0 +1,30 @@
#Mon Sep 14 21:05:38 CEST 2026
attachedEmitters.count=0
attachedLights.count=0
attachedSounds.count=0
castShadow=true
category=trees
cullDistance=200.0
footstepSurface=
interactableOffsetX=0.0
interactableOffsetY=0.5
interactableOffsetZ=0.0
interactableRotY=0.0
interactableType=NONE
lod1Distance=30.0
lod1Path=
lod2Distance=80.0
lod2Path=
modelCategory=TREE
name=willow_20260914_210538
pivotOffsetY=0.0
placementOffsetY=0.0
randomScaleMax=1.0
randomScaleMin=1.0
receiveShadow=true
scaleX=1.0
scaleY=1.0
scaleZ=1.0
solid=true
tags=
uniformScale=true

View File

@@ -14,35 +14,28 @@ import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager; import com.jme3.asset.AssetManager;
import com.jme3.bounding.BoundingBox; import com.jme3.bounding.BoundingBox;
import com.jme3.export.binary.BinaryExporter; import com.jme3.export.binary.BinaryExporter;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material; import com.jme3.material.Material;
import com.jme3.material.RenderState; import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA; import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f; import com.jme3.math.Vector3f;
import com.jme3.post.SceneProcessor;
import com.jme3.profile.AppProfiler;
import com.jme3.renderer.Camera; import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager; import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort; import com.jme3.renderer.ViewPort;
import com.jme3.renderer.queue.RenderQueue; import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry; import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node; import com.jme3.scene.Node;
import com.jme3.scene.Spatial; import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.scene.control.AbstractControl; import com.jme3.scene.control.AbstractControl;
import com.jme3.texture.FrameBuffer; import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image; import com.jme3.texture.Image;
import com.jme3.texture.Texture; import com.jme3.texture.Texture;
import com.jme3.texture.Texture2D; import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput; import de.blight.editor.SharedInput;
import de.blight.editor.state.ThumbnailRenderer;
import de.blight.editor.tree.PalmMeshBuilder; import de.blight.editor.tree.PalmMeshBuilder;
import de.blight.editor.tree.PalmOptions; import de.blight.editor.tree.PalmOptions;
import de.blight.editor.util.ImpostorUtil;
import de.blight.editor.state.ThumbnailRenderer;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -50,11 +43,7 @@ public class PalmGeneratorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(PalmGeneratorState.class); private static final Logger log = LoggerFactory.getLogger(PalmGeneratorState.class);
private static final int IMPOSTOR_SIZE = 512; private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("blight-assets", "src", "main", "resources");
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 Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("blight-assets", "src", "main", "resources");
private final SharedInput input; private final SharedInput input;
private SimpleApplication app; private SimpleApplication app;
@@ -69,9 +58,9 @@ public class PalmGeneratorState extends BaseAppState {
private String pendingFileName = null; private String pendingFileName = null;
private ViewPort captureVP = null; private ViewPort captureVP = null;
private FrameBuffer captureFB = null; private FrameBuffer captureFB = null;
private volatile boolean captureReady = false; private boolean[] captureReady = new boolean[]{false};
private int capturePass = 0; private int capturePass = 0;
private ByteBuffer[] capturePixels = new ByteBuffer[ATLAS_DIRS]; private ByteBuffer[] capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
public PalmGeneratorState(SharedInput input) { this.input = input; } public PalmGeneratorState(SharedInput input) { this.input = input; }
@@ -92,12 +81,12 @@ public class PalmGeneratorState extends BaseAppState {
if (previewHost == null) return; if (previewHost == null) return;
} }
if (pendingRequest != null && captureReady) { if (pendingRequest != null && captureReady[0]) {
finishCapture(); finishCapture();
return; return;
} }
if (pendingRequest != null) return; // capture still in progress if (pendingRequest != null) return;
SharedInput.PalmGenRequest req = input.palmGenQueue.poll(); SharedInput.PalmGenRequest req = input.palmGenQueue.poll();
if (req != null) startGeneration(req); if (req != null) startGeneration(req);
@@ -114,7 +103,7 @@ public class PalmGeneratorState extends BaseAppState {
Texture t = assets.loadTexture(opts.leafTexture); Texture t = assets.loadTexture(opts.leafTexture);
int w = t.getImage().getWidth(); int w = t.getImage().getWidth();
int h = t.getImage().getHeight(); int h = t.getImage().getHeight();
opts.leafTextureAspect = (float) h / w; // Stiel entlang U → Aspekt = Höhe/Breite opts.leafTextureAspect = (float) h / w;
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
@@ -125,7 +114,7 @@ public class PalmGeneratorState extends BaseAppState {
BoundingBox bb = lod0.getWorldBound() instanceof BoundingBox b ? b : null; BoundingBox bb = lod0.getWorldBound() instanceof BoundingBox b ? b : null;
if (bb == null) bb = new BoundingBox(Vector3f.ZERO, 3f, 8f, 3f); if (bb == null) bb = new BoundingBox(Vector3f.ZERO, 3f, 8f, 3f);
float dist = Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent())) * 3f; float dist = Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent())) * 3f;
Vector3f target = new Vector3f(0f, bb.getCenter().y, 0f); Vector3f target = new Vector3f(0f, bb.getCenter().y, 0f);
previewHost.setPreviewContent(lod0, dist, target); previewHost.setPreviewContent(lod0, dist, target);
@@ -138,17 +127,17 @@ public class PalmGeneratorState extends BaseAppState {
pendingBb = bb; pendingBb = bb;
pendingFileName = "palm_" + timestamp; pendingFileName = "palm_" + timestamp;
capturePass = 0; capturePass = 0;
capturePixels = new ByteBuffer[ATLAS_DIRS]; capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
startCapturePass(0); startCapturePass(0);
input.treeGenStatusMsg = "Palme: Rendere Impostor (1/" + ATLAS_DIRS + ")…"; input.treeGenStatusMsg = "Palme: Rendere Impostor (1/" + ImpostorUtil.DIRS + ")…";
} }
private Node buildLod1Palm(PalmOptions opts) { private Node buildLod1Palm(PalmOptions opts) {
PalmOptions ld = opts.copy(); PalmOptions ld = opts.copy();
// frondCount wird NICHT reduziert – nur Stamm-Geometrie vereinfachen
ld.trunkSegments = Math.max(4, opts.trunkSegments / 2); ld.trunkSegments = Math.max(4, opts.trunkSegments / 2);
ld.trunkSections = Math.max(4, opts.trunkSections / 2); ld.trunkSections = Math.max(4, opts.trunkSections / 2);
ld.frondCount = Math.max(8, opts.frondCount / 2);
Node n = PalmMeshBuilder.build(ld); Node n = PalmMeshBuilder.build(ld);
applyMaterials(n, ld); applyMaterials(n, ld);
return n; return n;
@@ -158,32 +147,45 @@ public class PalmGeneratorState extends BaseAppState {
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
private void startCapturePass(int pass) { private void startCapturePass(int pass) {
Texture2D capTex = new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.RGBA8); BoundingBox safeBb = pendingBb != null ? pendingBb : new BoundingBox(Vector3f.ZERO, 3f, 8f, 3f);
captureFB = new FrameBuffer(IMPOSTOR_SIZE, IMPOSTOR_SIZE, 1);
// Palmen haben einen Lean-Offset: bb.center X/Z weicht vom Modell-Ursprung (Stammfuß)
// ab. makeImpostorNode() platziert das Quad bei (0, cy, 0). Damit Textur-Inhalt und
// Quad-Position übereinstimmen, die Kamera auf (0, cy, 0) ausrichten und die XZ-Extents
// um den Lean-Versatz vergrößern, damit die ganze Krone noch im Bild bleibt.
float cx = safeBb.getCenter().x;
float cz = safeBb.getCenter().z;
BoundingBox capBb = new BoundingBox(
new Vector3f(0f, safeBb.getCenter().y, 0f),
safeBb.getXExtent() + Math.abs(cx),
safeBb.getYExtent(),
safeBb.getZExtent() + Math.abs(cz));
Texture2D capTex = new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.RGBA8);
captureFB = new FrameBuffer(ImpostorUtil.SIZE, ImpostorUtil.SIZE, 1);
captureFB.addColorTexture(capTex); captureFB.addColorTexture(capTex);
captureFB.setDepthTexture(new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.Depth)); captureFB.setDepthTexture(new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.Depth));
float angle = pass * FastMath.HALF_PI; captureReady[0] = false;
captureVP = buildCaptureViewPort(pendingLod0, pendingBb, captureFB, angle); captureVP = ImpostorUtil.startCapturePass(app, pendingLod0, capBb, captureFB, pass, captureReady);
captureReady = false;
} }
private void finishCapture() { private void finishCapture() {
ByteBuffer pixels = BufferUtils.createByteBuffer(IMPOSTOR_SIZE * IMPOSTOR_SIZE * 4); capturePixels[capturePass] = ImpostorUtil.readPixels(app, captureFB);
app.getRenderer().readFrameBuffer(captureFB, pixels); ImpostorUtil.cleanup(app, captureVP, captureFB);
capturePixels[capturePass] = pixels; captureVP = null;
cleanupCapture(); captureFB = null;
captureReady[0] = false;
if (capturePass < ATLAS_DIRS - 1) { if (capturePass < ImpostorUtil.DIRS - 1) {
capturePass++; capturePass++;
input.treeGenStatusMsg = "Palme: Rendere Impostor (" + (capturePass + 1) + "/" + ATLAS_DIRS + ")…"; input.treeGenStatusMsg = "Palme: Rendere Impostor (" + (capturePass + 1) + "/" + ImpostorUtil.DIRS + ")…";
startCapturePass(capturePass); startCapturePass(capturePass);
return; return;
} }
// Alle Richtungen erfasst // Alle Richtungen erfasst
String impostorName = "palm_impostor_" + pendingFileName.substring("palm_".length()); ByteBuffer atlas = ImpostorUtil.combineAtlas(capturePixels);
ByteBuffer atlas = combineAtlas(capturePixels); Texture2D impTex = ImpostorUtil.buildAtlasTexture(atlas);
Texture2D impTex = saveImpostor(atlas, impostorName, ATLAS_W, ATLAS_H);
if (pendingRequest.exportAfter()) { if (pendingRequest.exportAfter()) {
Node lodNode = assembleLodNode(impTex); Node lodNode = assembleLodNode(impTex);
@@ -197,7 +199,7 @@ public class PalmGeneratorState extends BaseAppState {
pendingLod1 = null; pendingLod1 = null;
pendingBb = null; pendingBb = null;
pendingFileName = null; pendingFileName = null;
capturePixels = new ByteBuffer[ATLAS_DIRS]; capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
} }
// ── LOD-Aufbau ──────────────────────────────────────────────────────────── // ── LOD-Aufbau ────────────────────────────────────────────────────────────
@@ -205,158 +207,22 @@ public class PalmGeneratorState extends BaseAppState {
private Node assembleLodNode(Texture2D impostorTex) { private Node assembleLodNode(Texture2D impostorTex) {
Node root = new Node("palm"); Node root = new Node("palm");
root.attachChild(pendingLod0); root.attachChild(pendingLod0);
pendingLod1.setName("lod1");
root.attachChild(pendingLod1); root.attachChild(pendingLod1);
Node lod2 = makeImpostorNode(pendingBb, impostorTex); Node lod2 = ImpostorUtil.makeImpostorNode(pendingBb, impostorTex, assets);
root.attachChild(lod2); root.attachChild(lod2);
pendingLod1.setCullHint(Spatial.CullHint.Always); pendingLod1.setCullHint(Spatial.CullHint.Always);
lod2.setCullHint(Spatial.CullHint.Always); lod2.setCullHint(Spatial.CullHint.Always);
lod2.setShadowMode(RenderQueue.ShadowMode.Off); lod2.setShadowMode(RenderQueue.ShadowMode.Off);
ImpostorUtil.addViewControl(lod2, app.getCamera());
root.addControl(new PalmLodControl(app.getCamera(), root.addControl(new PalmLodControl(app.getCamera(),
pendingLod0, pendingLod1, lod2, 50f, 150f)); pendingLod0, pendingLod1, lod2, 50f, 150f));
return root; return root;
} }
private Node makeImpostorNode(BoundingBox bb, Texture2D tex) {
float h = bb.getYExtent() * 2f;
float w = Math.max(bb.getXExtent(), bb.getZExtent()) * 2f;
float size = Math.max(h, w);
float yOff = bb.getCenter().y + 2f;
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
if (tex != null) mat.setTexture("ColorMap", tex);
else mat.setColor("Color", new ColorRGBA(0.22f, 0.65f, 0.14f, 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 uMin = (float) d / ATLAS_DIRS;
float uMax = (float)(d + 1) / ATLAS_DIRS;
n.attachChild(buildBillboardQuad("quad_" + d, d * FastMath.HALF_PI,
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;
}
// ── Offscreen-ViewPort ────────────────────────────────────────────────────
private ViewPort buildCaptureViewPort(Node palmNode, 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("palmCap_" + System.nanoTime(), cam);
vp.setOutputFrameBuffer(fb);
vp.setBackgroundColor(new ColorRGBA(0f, 0f, 0f, 0f));
vp.setClearFlags(true, true, true);
Node scene = new Node("palmCapScene");
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 capPalm = cloneForCapture(palmNode);
scene.attachChild(capPalm);
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 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;
}
// ── Atlas kombinieren + Impostor speichern ────────────────────────────────
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 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);
}
// ── Export ──────────────────────────────────────────────────────────────── // ── Export ────────────────────────────────────────────────────────────────
private void exportPalm(Node lodNode, String fileName) { private void exportPalm(Node lodNode, String fileName) {
@@ -364,8 +230,7 @@ public class PalmGeneratorState extends BaseAppState {
Path modelDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve("palm"); Path modelDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve("palm");
Files.createDirectories(modelDir); Files.createDirectories(modelDir);
File out = modelDir.resolve(fileName + ".j3o").toFile(); File out = modelDir.resolve(fileName + ".j3o").toFile();
while (lodNode.getNumControls() > 0) stripControlsRecursive(lodNode);
lodNode.removeControl(lodNode.getControl(0));
byte[] thumb = null; byte[] thumb = null;
try { try {
thumb = ThumbnailRenderer.render(lodNode.clone(), app.getRenderManager(), app.getRenderer()); thumb = ThumbnailRenderer.render(lodNode.clone(), app.getRenderManager(), app.getRenderer());
@@ -373,6 +238,17 @@ public class PalmGeneratorState extends BaseAppState {
} catch (Exception te) { log.warn("[Palme] Thumbnail-Fehler: {}", te.getMessage()); } } catch (Exception te) { log.warn("[Palme] Thumbnail-Fehler: {}", te.getMessage()); }
BinaryExporter.getInstance().save(lodNode, out); BinaryExporter.getInstance().save(lodNode, out);
if (thumb != null) ThumbnailRenderer.saveSidecar(thumb, out.toPath(), ASSET_ROOT); if (thumb != null) ThumbnailRenderer.saveSidecar(thumb, out.toPath(), ASSET_ROOT);
de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta(
fileName, "trees", "",
1f, 1f, 1f, true,
0f, 0f,
true, true, true,
1f, 1f,
"", "", 30f, 80f, 200f,
java.util.List.of(), java.util.List.of(), java.util.List.of(),
de.blight.common.model.InteractableType.NONE,
0f, 0.5f, 0f, 0f, "", de.blight.common.ModelCategory.PALM);
de.blight.common.ModelMetaIO.save(meta, out.toPath());
log.info("[Palme] Gespeichert: {}", out.getAbsolutePath()); log.info("[Palme] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "Gespeichert: Models/trees/palm/" + fileName + ".j3o"; input.treeGenStatusMsg = "Gespeichert: Models/trees/palm/" + fileName + ".j3o";
input.refreshAssets = true; input.refreshAssets = true;
@@ -382,6 +258,13 @@ public class PalmGeneratorState extends BaseAppState {
} }
} }
private static void stripControlsRecursive(Spatial s) {
while (s.getNumControls() > 0) s.removeControl(s.getControl(0));
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) stripControlsRecursive(child);
}
}
// ── Material-Helpers ────────────────────────────────────────────────────── // ── Material-Helpers ──────────────────────────────────────────────────────
private void applyMaterials(Node palm, PalmOptions opts) { private void applyMaterials(Node palm, PalmOptions opts) {
@@ -484,15 +367,10 @@ public class PalmGeneratorState extends BaseAppState {
// ── Aufräumen ───────────────────────────────────────────────────────────── // ── Aufräumen ─────────────────────────────────────────────────────────────
private void cleanupCapture() { private void cleanupCapture() {
if (captureVP != null) { ImpostorUtil.cleanup(app, captureVP, captureFB);
app.getRenderManager().removePostView(captureVP); captureVP = null;
captureVP = null; captureFB = null;
} captureReady[0] = false;
if (captureFB != null) {
try { captureFB.dispose(); } catch (Exception ignored) {}
captureFB = null;
}
captureReady = false;
} }
// ── LOD-Control ─────────────────────────────────────────────────────────── // ── LOD-Control ───────────────────────────────────────────────────────────

View File

@@ -14,34 +14,27 @@ import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager; import com.jme3.asset.AssetManager;
import com.jme3.bounding.BoundingBox; import com.jme3.bounding.BoundingBox;
import com.jme3.export.binary.BinaryExporter; import com.jme3.export.binary.BinaryExporter;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material; import com.jme3.material.Material;
import com.jme3.material.RenderState; import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA; import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f; import com.jme3.math.Vector3f;
import com.jme3.post.SceneProcessor;
import com.jme3.profile.AppProfiler;
import com.jme3.renderer.Camera; import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager; import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort; import com.jme3.renderer.ViewPort;
import com.jme3.renderer.queue.RenderQueue; import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry; import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node; import com.jme3.scene.Node;
import com.jme3.scene.Spatial; import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.scene.control.AbstractControl; import com.jme3.scene.control.AbstractControl;
import com.jme3.texture.FrameBuffer; import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image; import com.jme3.texture.Image;
import com.jme3.texture.Texture; import com.jme3.texture.Texture;
import com.jme3.texture.Texture2D; import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput; import de.blight.editor.SharedInput;
import de.blight.editor.tree.WillowMeshBuilder; import de.blight.editor.tree.WillowMeshBuilder;
import de.blight.editor.tree.WillowOptions; import de.blight.editor.tree.WillowOptions;
import de.blight.editor.util.ImpostorUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -49,19 +42,15 @@ public class WillowGeneratorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(WillowGeneratorState.class); private static final Logger log = LoggerFactory.getLogger(WillowGeneratorState.class);
private static final int IMPOSTOR_SIZE = 512; private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve(
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"); "blight-assets", "src", "main", "resources");
private final SharedInput input; private final SharedInput input;
private SimpleApplication app; private SimpleApplication app;
private AssetManager assets; private AssetManager assets;
private TreeGeneratorState previewHost; private TreeGeneratorState previewHost;
// ── Capture-Kontext ─────────────────────────────────────────────────────── // ── Pending-Capture-Kontext ───────────────────────────────────────────────
private SharedInput.WillowGenRequest pendingRequest = null; private SharedInput.WillowGenRequest pendingRequest = null;
private Node pendingLod0 = null; private Node pendingLod0 = null;
private Node pendingLod1 = null; private Node pendingLod1 = null;
@@ -69,9 +58,9 @@ public class WillowGeneratorState extends BaseAppState {
private String pendingFileName = null; private String pendingFileName = null;
private ViewPort captureVP = null; private ViewPort captureVP = null;
private FrameBuffer captureFB = null; private FrameBuffer captureFB = null;
private volatile boolean captureReady = false; private boolean[] captureReady = new boolean[]{false};
private int capturePass = 0; private int capturePass = 0;
private ByteBuffer[] capturePixels = new ByteBuffer[ATLAS_DIRS]; private ByteBuffer[] capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
public WillowGeneratorState(SharedInput input) { this.input = input; } public WillowGeneratorState(SharedInput input) { this.input = input; }
@@ -91,7 +80,7 @@ public class WillowGeneratorState extends BaseAppState {
if (previewHost == null) return; if (previewHost == null) return;
} }
if (pendingRequest != null && captureReady) { if (pendingRequest != null && captureReady[0]) {
finishCapture(); finishCapture();
return; return;
} }
@@ -102,7 +91,7 @@ public class WillowGeneratorState extends BaseAppState {
if (req != null) startGeneration(req); if (req != null) startGeneration(req);
} }
// ── Phase 1: Generierung ────────────────────────────────────────────────── // ── Phase 1: Generierung + Capture-Setup ─────────────────────────────────
private void startGeneration(SharedInput.WillowGenRequest req) { private void startGeneration(SharedInput.WillowGenRequest req) {
cleanupCapture(); cleanupCapture();
@@ -115,9 +104,8 @@ public class WillowGeneratorState extends BaseAppState {
BoundingBox bb = lod0.getWorldBound() instanceof BoundingBox b ? b : null; BoundingBox bb = lod0.getWorldBound() instanceof BoundingBox b ? b : null;
if (bb == null) bb = new BoundingBox(Vector3f.ZERO, 5f, 8f, 5f); if (bb == null) bb = new BoundingBox(Vector3f.ZERO, 5f, 8f, 5f);
float dist = Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent())) * 3f; float dist = Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent())) * 3f;
Vector3f target = new Vector3f(0f, bb.getCenter().y, 0f); Vector3f target = new Vector3f(0f, bb.getCenter().y, 0f);
previewHost.setPreviewContent(lod0, dist, target); previewHost.setPreviewContent(lod0, dist, target);
String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
@@ -128,21 +116,17 @@ public class WillowGeneratorState extends BaseAppState {
pendingBb = bb; pendingBb = bb;
pendingFileName = "willow_" + timestamp; pendingFileName = "willow_" + timestamp;
capturePass = 0; capturePass = 0;
capturePixels = new ByteBuffer[ATLAS_DIRS]; capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
startCapturePass(0); startCapturePass(0);
input.treeGenStatusMsg = "Trauerweide: Rendere Impostor (1/" + ATLAS_DIRS + ")…"; input.treeGenStatusMsg = "Trauerweide: Rendere Impostor (1/" + ImpostorUtil.DIRS + ")…";
} }
private Node buildLod1(WillowOptions opts) { private Node buildLod1(WillowOptions opts) {
WillowOptions ld = opts.copy(); // Keine strukturellen Änderungen: trunkSegments bestimmt Ast-Ansatzpunkte,
ld.trunkSegments = Math.max(4, opts.trunkSegments / 2); // trunkSections die Ast-Anzahl entlang des Stamms – beides ist erkennungsrelevant.
ld.trunkSections = Math.max(4, opts.trunkSections / 2); Node n = WillowMeshBuilder.build(opts);
ld.branchSections = Math.max(4, opts.branchSections / 2); applyMaterials(n, opts);
ld.subBranchSections = Math.max(3, opts.subBranchSections / 2);
ld.subBranchCount = Math.max(1, opts.subBranchCount / 2);
Node n = WillowMeshBuilder.build(ld);
applyMaterials(n, ld);
return n; return n;
} }
@@ -150,31 +134,41 @@ public class WillowGeneratorState extends BaseAppState {
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
private void startCapturePass(int pass) { private void startCapturePass(int pass) {
Texture2D capTex = new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.RGBA8); BoundingBox safeBb = pendingBb != null ? pendingBb : new BoundingBox(Vector3f.ZERO, 5f, 8f, 5f);
captureFB = new FrameBuffer(IMPOSTOR_SIZE, IMPOSTOR_SIZE, 1); // XZ-Offset-Ausgleich: Kamera auf (0, cy, 0) ausrichten und Extents um den
// Versatz erweitern, damit der gesamte Kronendurchmesser im Bild bleibt
// (gleiche Logik wie PalmGeneratorState).
float cx = safeBb.getCenter().x;
float cz = safeBb.getCenter().z;
BoundingBox capBb = new BoundingBox(
new com.jme3.math.Vector3f(0f, safeBb.getCenter().y, 0f),
safeBb.getXExtent() + Math.abs(cx),
safeBb.getYExtent(),
safeBb.getZExtent() + Math.abs(cz));
Texture2D capTex = new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.RGBA8);
captureFB = new FrameBuffer(ImpostorUtil.SIZE, ImpostorUtil.SIZE, 1);
captureFB.addColorTexture(capTex); captureFB.addColorTexture(capTex);
captureFB.setDepthTexture(new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.Depth)); captureFB.setDepthTexture(new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.Depth));
float angle = pass * FastMath.HALF_PI; captureReady[0] = false;
captureVP = buildCaptureViewPort(pendingLod0, pendingBb, captureFB, angle); captureVP = ImpostorUtil.startCapturePass(app, pendingLod0, capBb, captureFB, pass, captureReady);
captureReady = false;
} }
private void finishCapture() { private void finishCapture() {
ByteBuffer pixels = BufferUtils.createByteBuffer(IMPOSTOR_SIZE * IMPOSTOR_SIZE * 4); capturePixels[capturePass] = ImpostorUtil.readPixels(app, captureFB);
app.getRenderer().readFrameBuffer(captureFB, pixels); ImpostorUtil.cleanup(app, captureVP, captureFB);
capturePixels[capturePass] = pixels; captureVP = null;
cleanupCapture(); captureFB = null;
captureReady[0] = false;
if (capturePass < ATLAS_DIRS - 1) { if (capturePass < ImpostorUtil.DIRS - 1) {
capturePass++; capturePass++;
input.treeGenStatusMsg = "Trauerweide: Rendere Impostor (" + (capturePass + 1) + "/" + ATLAS_DIRS + ")…"; input.treeGenStatusMsg = "Trauerweide: Rendere Impostor (" + (capturePass + 1) + "/" + ImpostorUtil.DIRS + ")…";
startCapturePass(capturePass); startCapturePass(capturePass);
return; return;
} }
String impostorName = "willow_impostor_" + pendingFileName.substring("willow_".length()); ByteBuffer atlas = ImpostorUtil.combineAtlas(capturePixels);
ByteBuffer atlas = combineAtlas(capturePixels); Texture2D impTex = ImpostorUtil.buildAtlasTexture(atlas);
Texture2D impTex = saveImpostor(atlas, impostorName, ATLAS_W, ATLAS_H);
if (pendingRequest.exportAfter()) { if (pendingRequest.exportAfter()) {
Node lodNode = assembleLodNode(impTex); Node lodNode = assembleLodNode(impTex);
@@ -188,7 +182,7 @@ public class WillowGeneratorState extends BaseAppState {
pendingLod1 = null; pendingLod1 = null;
pendingBb = null; pendingBb = null;
pendingFileName = null; pendingFileName = null;
capturePixels = new ByteBuffer[ATLAS_DIRS]; capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
} }
// ── LOD-Aufbau ──────────────────────────────────────────────────────────── // ── LOD-Aufbau ────────────────────────────────────────────────────────────
@@ -196,154 +190,22 @@ public class WillowGeneratorState extends BaseAppState {
private Node assembleLodNode(Texture2D impostorTex) { private Node assembleLodNode(Texture2D impostorTex) {
Node root = new Node("willow"); Node root = new Node("willow");
root.attachChild(pendingLod0); root.attachChild(pendingLod0);
pendingLod1.setName("lod1");
root.attachChild(pendingLod1); root.attachChild(pendingLod1);
Node lod2 = makeImpostorNode(pendingBb, impostorTex); Node lod2 = ImpostorUtil.makeImpostorNode(pendingBb, impostorTex, assets);
root.attachChild(lod2); root.attachChild(lod2);
pendingLod1.setCullHint(Spatial.CullHint.Always); pendingLod1.setCullHint(Spatial.CullHint.Always);
lod2.setCullHint(Spatial.CullHint.Always); lod2.setCullHint(Spatial.CullHint.Always);
lod2.setShadowMode(RenderQueue.ShadowMode.Off); lod2.setShadowMode(RenderQueue.ShadowMode.Off);
ImpostorUtil.addViewControl(lod2, app.getCamera());
root.addControl(new WillowLodControl(app.getCamera(), root.addControl(new WillowLodControl(app.getCamera(),
pendingLod0, pendingLod1, lod2, 50f, 150f)); pendingLod0, pendingLod1, lod2, 50f, 150f));
return root; return root;
} }
private Node makeImpostorNode(BoundingBox bb, Texture2D tex) {
float h = bb.getYExtent() * 2f;
float w = Math.max(bb.getXExtent(), bb.getZExtent()) * 2f;
float size = Math.max(h, w);
float yOff = bb.getCenter().y + 2f;
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
if (tex != null) mat.setTexture("ColorMap", tex);
else mat.setColor("Color", new ColorRGBA(0.22f, 0.62f, 0.14f, 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 uMin = (float) d / ATLAS_DIRS;
float uMax = (float)(d + 1) / ATLAS_DIRS;
n.attachChild(buildBillboardQuad("quad_" + d, d * FastMath.HALF_PI,
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;
}
// ── Offscreen-ViewPort ────────────────────────────────────────────────────
private ViewPort buildCaptureViewPort(Node willowNode, 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("willowCap_" + System.nanoTime(), cam);
vp.setOutputFrameBuffer(fb);
vp.setBackgroundColor(new ColorRGBA(0f, 0f, 0f, 0f));
vp.setClearFlags(true, true, true);
Node scene = new Node("willowCapScene");
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(willowNode));
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 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");
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;
}
// ── Atlas kombinieren ─────────────────────────────────────────────────────
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 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);
}
// ── Export ──────────────────────────────────────────────────────────────── // ── Export ────────────────────────────────────────────────────────────────
private void exportWillow(Node lodNode, String fileName) { private void exportWillow(Node lodNode, String fileName) {
@@ -351,8 +213,7 @@ public class WillowGeneratorState extends BaseAppState {
Path modelDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve("willow"); Path modelDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve("willow");
Files.createDirectories(modelDir); Files.createDirectories(modelDir);
File out = modelDir.resolve(fileName + ".j3o").toFile(); File out = modelDir.resolve(fileName + ".j3o").toFile();
while (lodNode.getNumControls() > 0) stripControlsRecursive(lodNode);
lodNode.removeControl(lodNode.getControl(0));
byte[] thumb = null; byte[] thumb = null;
try { try {
thumb = ThumbnailRenderer.render(lodNode.clone(), app.getRenderManager(), app.getRenderer()); thumb = ThumbnailRenderer.render(lodNode.clone(), app.getRenderManager(), app.getRenderer());
@@ -360,6 +221,23 @@ public class WillowGeneratorState extends BaseAppState {
} catch (Exception te) { log.warn("[Trauerweide] Thumbnail-Fehler: {}", te.getMessage()); } } catch (Exception te) { log.warn("[Trauerweide] Thumbnail-Fehler: {}", te.getMessage()); }
BinaryExporter.getInstance().save(lodNode, out); BinaryExporter.getInstance().save(lodNode, out);
if (thumb != null) ThumbnailRenderer.saveSidecar(thumb, out.toPath(), ASSET_ROOT); if (thumb != null) ThumbnailRenderer.saveSidecar(thumb, out.toPath(), ASSET_ROOT);
// pivotOffsetY = Bounding-Box-Minimum der Trauerweide (negativ wenn Äste unter Y=0 hängen).
// ModelEditorState.applyPivot nutzt diesen Wert damit der Stammfuß (Y=0) als Boden gilt,
// nicht die tiefsten Blattspitzen.
float pivotY = (pendingBb != null)
? pendingBb.getCenter().y - pendingBb.getYExtent()
: 0f;
de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta(
fileName, "trees", "",
1f, 1f, 1f, true,
pivotY, 0f,
true, true, true,
1f, 1f,
"", "", 30f, 80f, 200f,
java.util.List.of(), java.util.List.of(), java.util.List.of(),
de.blight.common.model.InteractableType.NONE,
0f, 0.5f, 0f, 0f, "", de.blight.common.ModelCategory.TREE);
de.blight.common.ModelMetaIO.save(meta, out.toPath());
log.info("[Trauerweide] Gespeichert: {}", out.getAbsolutePath()); log.info("[Trauerweide] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "Gespeichert: Models/trees/willow/" + fileName + ".j3o"; input.treeGenStatusMsg = "Gespeichert: Models/trees/willow/" + fileName + ".j3o";
input.refreshAssets = true; input.refreshAssets = true;
@@ -369,6 +247,13 @@ public class WillowGeneratorState extends BaseAppState {
} }
} }
private static void stripControlsRecursive(Spatial s) {
while (s.getNumControls() > 0) s.removeControl(s.getControl(0));
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) stripControlsRecursive(child);
}
}
// ── Materialien ─────────────────────────────────────────────────────────── // ── Materialien ───────────────────────────────────────────────────────────
private void applyMaterials(Node willow, WillowOptions opts) { private void applyMaterials(Node willow, WillowOptions opts) {
@@ -441,18 +326,13 @@ public class WillowGeneratorState extends BaseAppState {
// ── Aufräumen ───────────────────────────────────────────────────────────── // ── Aufräumen ─────────────────────────────────────────────────────────────
private void cleanupCapture() { private void cleanupCapture() {
if (captureVP != null) { ImpostorUtil.cleanup(app, captureVP, captureFB);
app.getRenderManager().removePostView(captureVP); captureVP = null;
captureVP = null; captureFB = null;
} captureReady[0] = false;
if (captureFB != null) {
try { captureFB.dispose(); } catch (Exception ignored) {}
captureFB = null;
}
captureReady = false;
} }
// ── LOD-Control ─────────────────────────────────────────────────────────── // ── LOD-Control (Editor-Vorschau) ─────────────────────────────────────────
private static final class WillowLodControl extends AbstractControl { private static final class WillowLodControl extends AbstractControl {
private final Camera cam; private final Camera cam;

View File

@@ -34,10 +34,12 @@ public class ModelLodControl extends AbstractControl {
/** Erzwingt LOD2 für alle Instanzen. Wird per Konsolen-Befehl "set debugLod2 true" gesetzt. */ /** Erzwingt LOD2 für alle Instanzen. Wird per Konsolen-Befehl "set debugLod2 true" gesetzt. */
public static volatile boolean debugLod2 = false; public static volatile boolean debugLod2 = false;
private final Camera cam; private final Camera cam;
private final float lod1DistSq; private final float lod1DistSq;
private final float lod2DistSq; private final float lod2DistSq;
private int currentSlot = 0; // 0=lod0, 1=lod1, 2=lod2, -1=ausgeblendet private float[] lodFactorRef = {1.0f};
private int currentSlot = 0; // 0=lod0, 1=lod1, 2=lod2, -1=ausgeblendet
private boolean firstUpdate = true;
// ── Embedded-LOD-Modus ──────────────────────────────────────────────────── // ── Embedded-LOD-Modus ────────────────────────────────────────────────────
private final Spatial embLod0; private final Spatial embLod0;
@@ -93,6 +95,8 @@ public class ModelLodControl extends AbstractControl {
this.lodLoadCallback = cb; this.lodLoadCallback = cb;
} }
public void setLodFactorRef(float[] ref) { this.lodFactorRef = ref; }
@Override @Override
protected void controlRender(RenderManager rm, ViewPort vp) {} protected void controlRender(RenderManager rm, ViewPort vp) {}
@@ -113,6 +117,15 @@ public class ModelLodControl extends AbstractControl {
float dz = cam.getLocation().z - wt.z; float dz = cam.getLocation().z - wt.z;
float distSq = dx*dx + dy*dy + dz*dz; float distSq = dx*dx + dy*dy + dz*dz;
if (firstUpdate) {
firstUpdate = false;
log.info("[LOD] Control aktiv: {} | embedded={} | lod1={}m lod2={}m | pos=({},{},{})",
spatial.getName(), embLod0 != null,
String.format("%.0f", Math.sqrt(lod1DistSq == Float.MAX_VALUE ? 0 : lod1DistSq)),
String.format("%.0f", Math.sqrt(lod2DistSq == Float.MAX_VALUE ? 0 : lod2DistSq)),
String.format("%.1f", wt.x), String.format("%.1f", wt.y), String.format("%.1f", wt.z));
}
boolean embedded = embLod0 != null; boolean embedded = embLod0 != null;
boolean hasLod1 = embedded ? embLod1 != null : lod1Path != null; boolean hasLod1 = embedded ? embLod1 != null : lod1Path != null;
@@ -121,13 +134,17 @@ public class ModelLodControl extends AbstractControl {
// Sichtbarkeit hängt nur vom LOD-System ab — kein separates Culling. // 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 LOD1 aber LOD2 vorhanden: bei lod1Distance direkt zu LOD2 wechseln (nicht ausblenden).
// Fehlt beides (z.B. Kaktusfeige): ab lod1Distance ausblenden. // Fehlt beides (z.B. Kaktusfeige): ab lod1Distance ausblenden.
float f = lodFactorRef[0];
float d1Sq = lod1DistSq == Float.MAX_VALUE ? Float.MAX_VALUE : lod1DistSq * f * f;
float d2Sq = lod2DistSq == Float.MAX_VALUE ? Float.MAX_VALUE : lod2DistSq * f * f;
int targetSlot; int targetSlot;
if (debugLod2) { if (debugLod2) {
// Debug: erzwingt die niedrigste verfügbare LOD-Stufe // Debug: erzwingt die niedrigste verfügbare LOD-Stufe
targetSlot = hasLod2 ? 2 : (hasLod1 ? 1 : 0); targetSlot = hasLod2 ? 2 : (hasLod1 ? 1 : 0);
} else if (distSq >= lod2DistSq) { } else if (distSq >= d2Sq) {
targetSlot = hasLod2 ? 2 : -1; targetSlot = hasLod2 ? 2 : -1;
} else if (distSq >= lod1DistSq) { } else if (distSq >= d1Sq) {
if (hasLod1) targetSlot = 1; if (hasLod1) targetSlot = 1;
else if (hasLod2) targetSlot = 2; // kein LOD1, aber LOD2 vorhanden → Impostor früher else if (hasLod2) targetSlot = 2; // kein LOD1, aber LOD2 vorhanden → Impostor früher
else targetSlot = -1; // weder LOD1 noch LOD2 → ausblenden else targetSlot = -1; // weder LOD1 noch LOD2 → ausblenden

View File

@@ -53,9 +53,9 @@ public class WorldObjectsState extends BaseAppState {
private static final float IMPOSTOR_CAP_AMB_G = 0.30f; private static final float IMPOSTOR_CAP_AMB_G = 0.30f;
private static final float IMPOSTOR_CAP_AMB_B = 0.32f; private static final float IMPOSTOR_CAP_AMB_B = 0.32f;
private float debugTimer = 0f; private float debugTimer = 0f;
private float lodFactor = 1.0f; private final float[] lodFactorRef = {1.0f};
public void setLodFactor(float f) { this.lodFactor = f; } public void setLodFactor(float f) { this.lodFactorRef[0] = f; }
/** RigidBodyControl pro Interactable-ID, damit Kollision während Animationen deaktiviert werden kann. */ /** RigidBodyControl pro Interactable-ID, damit Kollision während Animationen deaktiviert werden kann. */
private final Map<String, RigidBodyControl> interactableRbcs = new HashMap<>(); private final Map<String, RigidBodyControl> interactableRbcs = new HashMap<>();
@@ -253,6 +253,7 @@ public class WorldObjectsState extends BaseAppState {
// Exportiertes Mesh hat Vorrang vor modelPath // Exportiertes Mesh hat Vorrang vor modelPath
String path = (m.meshFile() != null && !m.meshFile().isBlank()) String path = (m.meshFile() != null && !m.meshFile().isBlank())
? m.meshFile() : m.modelPath(); ? m.meshFile() : m.modelPath();
log.info("[WorldObjects-LOD] buildSpatial: {} lod1={}m lod2={}m", path, m.lod1Distance(), m.lod2Distance());
Spatial spatial; Spatial spatial;
if (path.startsWith("@")) { if (path.startsWith("@")) {
@@ -265,8 +266,29 @@ public class WorldObjectsState extends BaseAppState {
} }
spatial.setName("obj_" + path); spatial.setName("obj_" + path);
// LOD-Distanzen: ModelMeta zur Laufzeit bevorzugen (überschreibt veraltete Scene-TSV-Werte,
// die 0 sein können wenn das Modell vor Einführung der Meta-Datei platziert wurde).
float lodDist1 = m.lod1Distance();
float lodDist2 = m.lod2Distance();
if (!path.startsWith("@")) {
de.blight.common.ModelMeta mm = tryLoadMeta(path);
if (mm != null && (mm.lod1Distance() > 0f || mm.lod2Distance() > 0f)) {
lodDist1 = mm.lod1Distance();
lodDist2 = mm.lod2Distance();
}
}
// Fallback für j3os, die eingebettete LOD-Knoten haben aber keine ModelMeta
// (Modelle, die vor Einführung der Meta-Datei erzeugt und platziert wurden).
// Vorhandensein von "lod1" oder "lod2" als Kind beweist, dass LOD beabsichtigt war.
if (lodDist1 == 0f && lodDist2 == 0f && spatial instanceof Node tn
&& (tn.getChild("lod1") != null || tn.getChild("lod2") != null)) {
lodDist1 = 50f;
lodDist2 = 150f;
log.debug("[WorldObjects-LOD] {} – keine Meta, LOD-Knoten gefunden → Fallback 50/150m", path);
}
// LOD — kein cullDistance mehr; Objekte ohne LOD-Stufe werden ab der jeweiligen Distanz ausgeblendet // LOD — kein cullDistance mehr; Objekte ohne LOD-Stufe werden ab der jeweiligen Distanz ausgeblendet
if (m.lod1Distance() > 0f || m.lod2Distance() > 0f) { if (lodDist1 > 0f || lodDist2 > 0f) {
if (spatial instanceof Node treeNode) { if (spatial instanceof Node treeNode) {
Spatial lod0 = treeNode.getChild("lod0"); Spatial lod0 = treeNode.getChild("lod0");
Spatial lod1 = treeNode.getChild("lod1"); Spatial lod1 = treeNode.getChild("lod1");
@@ -284,7 +306,36 @@ public class WorldObjectsState extends BaseAppState {
path, treeNode.getChildren().size(), path, treeNode.getChildren().size(),
lod0.getName(), lod1 != null ? "'" + lod1.getName() + "'" : "null", lod0.getName(), lod1 != null ? "'" + lod1.getName() + "'" : "null",
lod2 != null ? "'" + lod2.getName() + "'" : "null", lod2 != null ? "'" + lod2.getName() + "'" : "null",
m.lod1Distance() * lodFactor, m.lod2Distance() * lodFactor); lodDist1 * lodFactorRef[0], lodDist2 * lodFactorRef[0]);
// Pivot-Korrektur: Falls der Mesh-Ursprung des Generators nicht bei Y=0 liegt,
// treeNode in lodRoot einbetten und lod0 verschieben (wie Fall B).
treeNode.updateGeometricState();
if (lod0.getWorldBound() instanceof com.jme3.bounding.BoundingBox lod0Bb) {
float groundY = lod0Bb.getCenter().y - lod0Bb.getYExtent();
log.info("[WorldObjects-LOD] Pivot: {} groundY={}m", path, String.format("%.2f", groundY));
if (groundY > 0.01f) {
Node lodRoot = new Node("lodRoot_" + path);
lodRoot.attachChild(treeNode);
treeNode.setLocalTranslation(0f, -groundY, 0f);
lod0.setCullHint(Spatial.CullHint.Inherit);
if (lod1 != null) lod1.setCullHint(Spatial.CullHint.Always);
if (lod2 != null) {
lod2.setCullHint(Spatial.CullHint.Always);
if (lod2 instanceof Node lod2Node && !lod2Node.getChildren().isEmpty()
&& isImpostorNode(lod2Node)) {
initImpostor(lod2Node);
}
}
if (lod1 != null || lod2 != null) {
ModelLodControl lodCtrl = new ModelLodControl(
app.getCamera(), lod0, lod1, lod2,
lodDist1, lodDist2);
lodCtrl.setLodFactorRef(lodFactorRef);
treeNode.addControl(lodCtrl);
}
return lodRoot;
}
}
lod0.setCullHint(Spatial.CullHint.Inherit); lod0.setCullHint(Spatial.CullHint.Inherit);
if (lod1 != null) lod1.setCullHint(Spatial.CullHint.Always); if (lod1 != null) lod1.setCullHint(Spatial.CullHint.Always);
if (lod2 != null) { if (lod2 != null) {
@@ -297,9 +348,11 @@ public class WorldObjectsState extends BaseAppState {
// ModelLodControl nur wenn tatsächliche LOD-Varianten vorhanden — sonst // ModelLodControl nur wenn tatsächliche LOD-Varianten vorhanden — sonst
// würde das Objekt ab lod1Distance komplett ausgeblendet (targetSlot=-1). // würde das Objekt ab lod1Distance komplett ausgeblendet (targetSlot=-1).
if (lod1 != null || lod2 != null) { if (lod1 != null || lod2 != null) {
treeNode.addControl(new ModelLodControl( ModelLodControl lodCtrl = new ModelLodControl(
app.getCamera(), lod0, lod1, lod2, app.getCamera(), lod0, lod1, lod2,
m.lod1Distance() * lodFactor, m.lod2Distance() * lodFactor)); lodDist1, lodDist2);
lodCtrl.setLodFactorRef(lodFactorRef);
treeNode.addControl(lodCtrl);
} }
return treeNode; return treeNode;
} }
@@ -316,7 +369,7 @@ public class WorldObjectsState extends BaseAppState {
if (lod2 != null) { if (lod2 != null) {
log.info("[WorldObjects-LOD] Fall B: {} | Kinder={} | lod2='{}' | dist=0/{}m", log.info("[WorldObjects-LOD] Fall B: {} | Kinder={} | lod2='{}' | dist=0/{}m",
path, treeNode.getChildren().size(), lod2.getName(), path, treeNode.getChildren().size(), lod2.getName(),
m.lod2Distance() * lodFactor); lodDist2 * lodFactorRef[0]);
treeNode.detachChild(lod2); treeNode.detachChild(lod2);
Node lodRoot = new Node("lodRoot_" + path); Node lodRoot = new Node("lodRoot_" + path);
lodRoot.attachChild(treeNode); // index 0 = lod0 (volle Qualität) lodRoot.attachChild(treeNode); // index 0 = lod0 (volle Qualität)
@@ -340,9 +393,11 @@ public class WorldObjectsState extends BaseAppState {
&& isImpostorNode(lod2Node)) { && isImpostorNode(lod2Node)) {
initImpostor(lod2Node); initImpostor(lod2Node);
} }
lodRoot.addControl(new ModelLodControl( ModelLodControl lodCtrl = new ModelLodControl(
app.getCamera(), treeNode, null, lod2, app.getCamera(), treeNode, null, lod2,
m.lod1Distance() * lodFactor, m.lod2Distance() * lodFactor)); lodDist1, lodDist2);
lodCtrl.setLodFactorRef(lodFactorRef);
lodRoot.addControl(lodCtrl);
return lodRoot; return lodRoot;
} }
} }
@@ -358,7 +413,8 @@ public class WorldObjectsState extends BaseAppState {
ModelLodControl ctrl = new ModelLodControl( ModelLodControl ctrl = new ModelLodControl(
assets, app.getCamera(), assets, app.getCamera(),
lp1, lp2, lp1, lp2,
m.lod1Distance() * lodFactor, m.lod2Distance() * lodFactor); lodDist1, lodDist2);
ctrl.setLodFactorRef(lodFactorRef);
ctrl.setLodLoadCallback(lod -> { ctrl.setLodLoadCallback(lod -> {
convertUnshadedToPbr(lod); convertUnshadedToPbr(lod);
collectSceneLitMaterials(lod); collectSceneLitMaterials(lod);
@@ -371,6 +427,17 @@ public class WorldObjectsState extends BaseAppState {
return spatial; return spatial;
} }
private de.blight.common.ModelMeta tryLoadMeta(String assetPath) {
try {
java.nio.file.Path root = de.blight.game.animation.AnimationLibrary.findAssetRoot();
java.nio.file.Path j3o = root.resolve(assetPath);
if (!java.nio.file.Files.exists(j3o)) return null;
return de.blight.common.ModelMetaIO.load(j3o);
} catch (Exception e) {
return null;
}
}
private boolean isImpostorNode(Node n) { private boolean isImpostorNode(Node n) {
if (n.getControl(BillboardControl.class) != null) { if (n.getControl(BillboardControl.class) != null) {
return true; return true;

View File

@@ -2,7 +2,7 @@
"id": "9ac9943d-0e12-4d5c-8323-0e2b92eebdec", "id": "9ac9943d-0e12-4d5c-8323-0e2b92eebdec",
"benchType": "Simple", "benchType": "Simple",
"sitzX": 236.63928, "sitzX": 236.63928,
"sitzY": -5.81074, "sitzY": 1.90243,
"sitzZ": -888.1745, "sitzZ": -888.1745,
"sitzRotY": 4.7967663, "sitzRotY": 4.7967663,
"sitzSet": true "sitzSet": true

View File

@@ -1,16 +1,6 @@
# modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow lod1Path lod2Path lod1Distance lod2Distance cullDistance interactableType interactableId modelCategory # modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow lod1Path lod2Path lod1Distance lod2Distance cullDistance interactableType interactableId modelCategory
Models/northcoast/wrack1.j3o 286.06750 -3.50554 -947.28595 -1.76657 1.00000 -0.00000 -0.50184 false true true 30.00000 80.00000 120.00000 NONE Models/northcoast/wrack1.j3o 286.06750 -3.50554 -947.28595 -1.76657 1.00000 -0.00000 -0.50184 false true true 30.00000 80.00000 120.00000 NONE
Models/trees/palm/palm_20260816_213338.j3o 277.36694 2.74803 -956.39905 0.43292 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 NONE Models/imported/bank1.j3o 236.63928 1.40243 -888.17450 -3.22597 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BENCH 9ac9943d-0e12-4d5c-8323-0e2b92eebdec NONE
Models/trees/palm/palm_20260816_213338.j3o 280.27863 2.66144 -928.60248 -1.51992 1.00000 -0.00000 0.00000 false true true 30.00000 80.00000 120.00000 NONE
Models/trees/palm/palm_20260816_213341.j3o 249.12691 6.49606 -895.80035 -0.96942 1.00000 -0.00000 0.00000 false true true 30.00000 80.00000 120.00000 NONE
Models/trees/palm/palm_20260816_213341.j3o 243.51527 1.40601 -894.84039 -2.48591 1.00000 -0.00000 0.00000 false true true 30.00000 80.00000 120.00000 NONE
Models/trees/palm/palm_20260816_213338.j3o 233.49467 1.43232 -892.32526 2.66339 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 NONE
Models/trees/palm/palm_20260816_213341.j3o 247.05431 6.44288 -888.47949 1.23606 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 NONE
Models/trees/palm/palm_20260816_213341.j3o 239.43253 0.94988 -886.23218 -2.49448 1.00000 -0.00000 0.00000 false true true 30.00000 80.00000 120.00000 NONE
Models/trees/palm/palm_20260816_213341.j3o 270.73062 3.06689 -913.67090 -1.45828 1.00000 -0.00000 0.00000 false true true 30.00000 80.00000 120.00000 NONE
Models/imported/bank1.j3o 236.63928 -6.31074 -888.17450 -3.22597 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BENCH 9ac9943d-0e12-4d5c-8323-0e2b92eebdec NONE
Models/trees/willow/willow_20260823_101850.j3o 152.40488 11.49070 -888.86847 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 NONE
Models/trees/willow/willow_20260823_101856.j3o 170.68971 11.48961 -873.92236 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 NONE
Models/imported/bank1.j3o 0.30349 2.00000 -5.00573 0.00000 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BENCH b26f42df-7897-4840-9e7f-5f35329aa0a6 NONE Models/imported/bank1.j3o 0.30349 2.00000 -5.00573 0.00000 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BENCH b26f42df-7897-4840-9e7f-5f35329aa0a6 NONE
Models/trees/pine/medium/pine_medium_20260706_190939.j3o 157.82524 2.00000 -793.89911 -2.44322 1.00000 -0.00000 0.00000 false true true 30.00000 80.00000 120.00000 TREE Models/trees/pine/medium/pine_medium_20260706_190939.j3o 157.82524 2.00000 -793.89911 -2.44322 1.00000 -0.00000 0.00000 false true true 30.00000 80.00000 120.00000 TREE
Models/trees/pine/medium/pine_medium_20260706_190939.j3o 170.43440 2.00000 -784.16235 2.81244 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 TREE Models/trees/pine/medium/pine_medium_20260706_190939.j3o 170.43440 2.00000 -784.16235 2.81244 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 TREE
@@ -22,3 +12,5 @@ Models/northcoast/alte_fischerhytte.j3o 317.92999 2.01000 -863.58002 0.00000 1.0
Models/northcoast/alte_fischerhytte2.j3o 312.98001 2.00000 -851.37000 -3.50811 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BUILDING Models/northcoast/alte_fischerhytte2.j3o 312.98001 2.00000 -851.37000 -3.50811 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BUILDING
Models/northcoast/alte_fischerhytte.j3o 322.73001 2.00000 -856.09003 -1.83155 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BUILDING Models/northcoast/alte_fischerhytte.j3o 322.73001 2.00000 -856.09003 -1.83155 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BUILDING
Models/imported/feuerstelle1.j3o 1.46137 2.00000 -1.08840 0.00000 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 NONE Models/imported/feuerstelle1.j3o 1.46137 2.00000 -1.08840 0.00000 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 NONE
Models/trees/palm/palm_20260914_195434.j3o 226.76038 1.25643 -893.77020 -1.59663 1.00000 -0.00000 0.00000 true true true 50.00000 150.00000 300.00000 PALM
Models/trees/willow/willow_20260914_210538.j3o 256.04678 5.84959 -887.66675 0.00000 1.00000 0.00000 0.00000 true true true 50.00000 150.00000 300.00000 TREE