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

View File

@@ -14,35 +14,28 @@ import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.bounding.BoundingBox;
import com.jme3.export.binary.BinaryExporter;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f;
import com.jme3.post.SceneProcessor;
import com.jme3.profile.AppProfiler;
import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
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.AbstractControl;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture;
import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput;
import de.blight.editor.state.ThumbnailRenderer;
import de.blight.editor.tree.PalmMeshBuilder;
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.LoggerFactory;
@@ -50,11 +43,7 @@ public class PalmGeneratorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(PalmGeneratorState.class);
private static final int IMPOSTOR_SIZE = 512;
private static final int ATLAS_DIRS = 4;
private static final int ATLAS_W = IMPOSTOR_SIZE * ATLAS_DIRS; // 2048
private static final int ATLAS_H = IMPOSTOR_SIZE; // 512
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("blight-assets", "src", "main", "resources");
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("blight-assets", "src", "main", "resources");
private final SharedInput input;
private SimpleApplication app;
@@ -69,9 +58,9 @@ public class PalmGeneratorState extends BaseAppState {
private String pendingFileName = null;
private ViewPort captureVP = null;
private FrameBuffer captureFB = null;
private volatile boolean captureReady = false;
private boolean[] captureReady = new boolean[]{false};
private int capturePass = 0;
private ByteBuffer[] capturePixels = new ByteBuffer[ATLAS_DIRS];
private ByteBuffer[] capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
public PalmGeneratorState(SharedInput input) { this.input = input; }
@@ -92,12 +81,12 @@ public class PalmGeneratorState extends BaseAppState {
if (previewHost == null) return;
}
if (pendingRequest != null && captureReady) {
if (pendingRequest != null && captureReady[0]) {
finishCapture();
return;
}
if (pendingRequest != null) return; // capture still in progress
if (pendingRequest != null) return;
SharedInput.PalmGenRequest req = input.palmGenQueue.poll();
if (req != null) startGeneration(req);
@@ -114,7 +103,7 @@ public class PalmGeneratorState extends BaseAppState {
Texture t = assets.loadTexture(opts.leafTexture);
int w = t.getImage().getWidth();
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) {}
}
@@ -125,7 +114,7 @@ public class PalmGeneratorState extends BaseAppState {
BoundingBox bb = lod0.getWorldBound() instanceof BoundingBox b ? b : null;
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);
previewHost.setPreviewContent(lod0, dist, target);
@@ -138,17 +127,17 @@ public class PalmGeneratorState extends BaseAppState {
pendingBb = bb;
pendingFileName = "palm_" + timestamp;
capturePass = 0;
capturePixels = new ByteBuffer[ATLAS_DIRS];
capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
startCapturePass(0);
input.treeGenStatusMsg = "Palme: Rendere Impostor (1/" + ATLAS_DIRS + ")…";
input.treeGenStatusMsg = "Palme: Rendere Impostor (1/" + ImpostorUtil.DIRS + ")…";
}
private Node buildLod1Palm(PalmOptions opts) {
PalmOptions ld = opts.copy();
// frondCount wird NICHT reduziert – nur Stamm-Geometrie vereinfachen
ld.trunkSegments = Math.max(4, opts.trunkSegments / 2);
ld.trunkSections = Math.max(4, opts.trunkSections / 2);
ld.frondCount = Math.max(8, opts.frondCount / 2);
Node n = PalmMeshBuilder.build(ld);
applyMaterials(n, ld);
return n;
@@ -158,32 +147,45 @@ public class PalmGeneratorState extends BaseAppState {
@SuppressWarnings("deprecation")
private void startCapturePass(int pass) {
Texture2D capTex = new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.RGBA8);
captureFB = new FrameBuffer(IMPOSTOR_SIZE, IMPOSTOR_SIZE, 1);
BoundingBox safeBb = pendingBb != null ? pendingBb : new BoundingBox(Vector3f.ZERO, 3f, 8f, 3f);
// 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.setDepthTexture(new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.Depth));
float angle = pass * FastMath.HALF_PI;
captureVP = buildCaptureViewPort(pendingLod0, pendingBb, captureFB, angle);
captureReady = false;
captureFB.setDepthTexture(new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.Depth));
captureReady[0] = false;
captureVP = ImpostorUtil.startCapturePass(app, pendingLod0, capBb, captureFB, pass, captureReady);
}
private void finishCapture() {
ByteBuffer pixels = BufferUtils.createByteBuffer(IMPOSTOR_SIZE * IMPOSTOR_SIZE * 4);
app.getRenderer().readFrameBuffer(captureFB, pixels);
capturePixels[capturePass] = pixels;
cleanupCapture();
capturePixels[capturePass] = ImpostorUtil.readPixels(app, captureFB);
ImpostorUtil.cleanup(app, captureVP, captureFB);
captureVP = null;
captureFB = null;
captureReady[0] = false;
if (capturePass < ATLAS_DIRS - 1) {
if (capturePass < ImpostorUtil.DIRS - 1) {
capturePass++;
input.treeGenStatusMsg = "Palme: Rendere Impostor (" + (capturePass + 1) + "/" + ATLAS_DIRS + ")…";
input.treeGenStatusMsg = "Palme: Rendere Impostor (" + (capturePass + 1) + "/" + ImpostorUtil.DIRS + ")…";
startCapturePass(capturePass);
return;
}
// Alle Richtungen erfasst
String impostorName = "palm_impostor_" + pendingFileName.substring("palm_".length());
ByteBuffer atlas = combineAtlas(capturePixels);
Texture2D impTex = saveImpostor(atlas, impostorName, ATLAS_W, ATLAS_H);
ByteBuffer atlas = ImpostorUtil.combineAtlas(capturePixels);
Texture2D impTex = ImpostorUtil.buildAtlasTexture(atlas);
if (pendingRequest.exportAfter()) {
Node lodNode = assembleLodNode(impTex);
@@ -197,7 +199,7 @@ public class PalmGeneratorState extends BaseAppState {
pendingLod1 = null;
pendingBb = null;
pendingFileName = null;
capturePixels = new ByteBuffer[ATLAS_DIRS];
capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
}
// ── LOD-Aufbau ────────────────────────────────────────────────────────────
@@ -205,158 +207,22 @@ public class PalmGeneratorState extends BaseAppState {
private Node assembleLodNode(Texture2D impostorTex) {
Node root = new Node("palm");
root.attachChild(pendingLod0);
pendingLod1.setName("lod1");
root.attachChild(pendingLod1);
Node lod2 = makeImpostorNode(pendingBb, impostorTex);
Node lod2 = ImpostorUtil.makeImpostorNode(pendingBb, impostorTex, assets);
root.attachChild(lod2);
pendingLod1.setCullHint(Spatial.CullHint.Always);
lod2.setCullHint(Spatial.CullHint.Always);
lod2.setShadowMode(RenderQueue.ShadowMode.Off);
ImpostorUtil.addViewControl(lod2, app.getCamera());
root.addControl(new PalmLodControl(app.getCamera(),
pendingLod0, pendingLod1, lod2, 50f, 150f));
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 ────────────────────────────────────────────────────────────────
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");
Files.createDirectories(modelDir);
File out = modelDir.resolve(fileName + ".j3o").toFile();
while (lodNode.getNumControls() > 0)
lodNode.removeControl(lodNode.getControl(0));
stripControlsRecursive(lodNode);
byte[] thumb = null;
try {
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()); }
BinaryExporter.getInstance().save(lodNode, out);
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());
input.treeGenStatusMsg = "Gespeichert: Models/trees/palm/" + fileName + ".j3o";
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 ──────────────────────────────────────────────────────
private void applyMaterials(Node palm, PalmOptions opts) {
@@ -484,15 +367,10 @@ public class PalmGeneratorState extends BaseAppState {
// ── Aufräumen ─────────────────────────────────────────────────────────────
private void cleanupCapture() {
if (captureVP != null) {
app.getRenderManager().removePostView(captureVP);
captureVP = null;
}
if (captureFB != null) {
try { captureFB.dispose(); } catch (Exception ignored) {}
captureFB = null;
}
captureReady = false;
ImpostorUtil.cleanup(app, captureVP, captureFB);
captureVP = null;
captureFB = null;
captureReady[0] = false;
}
// ── LOD-Control ───────────────────────────────────────────────────────────

View File

@@ -14,34 +14,27 @@ import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.bounding.BoundingBox;
import com.jme3.export.binary.BinaryExporter;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f;
import com.jme3.post.SceneProcessor;
import com.jme3.profile.AppProfiler;
import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
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.AbstractControl;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture;
import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput;
import de.blight.editor.tree.WillowMeshBuilder;
import de.blight.editor.tree.WillowOptions;
import de.blight.editor.util.ImpostorUtil;
import org.slf4j.Logger;
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 int IMPOSTOR_SIZE = 512;
private static final int ATLAS_DIRS = 4;
private static final int ATLAS_W = IMPOSTOR_SIZE * ATLAS_DIRS;
private static final int ATLAS_H = IMPOSTOR_SIZE;
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve(
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve(
"blight-assets", "src", "main", "resources");
private final SharedInput input;
private SimpleApplication app;
private AssetManager assets;
private final SharedInput input;
private SimpleApplication app;
private AssetManager assets;
private TreeGeneratorState previewHost;
// ── Capture-Kontext ───────────────────────────────────────────────────────
// ── Pending-Capture-Kontext ───────────────────────────────────────────────
private SharedInput.WillowGenRequest pendingRequest = null;
private Node pendingLod0 = null;
private Node pendingLod1 = null;
@@ -69,9 +58,9 @@ public class WillowGeneratorState extends BaseAppState {
private String pendingFileName = null;
private ViewPort captureVP = null;
private FrameBuffer captureFB = null;
private volatile boolean captureReady = false;
private boolean[] captureReady = new boolean[]{false};
private int capturePass = 0;
private ByteBuffer[] capturePixels = new ByteBuffer[ATLAS_DIRS];
private ByteBuffer[] capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
public WillowGeneratorState(SharedInput input) { this.input = input; }
@@ -91,7 +80,7 @@ public class WillowGeneratorState extends BaseAppState {
if (previewHost == null) return;
}
if (pendingRequest != null && captureReady) {
if (pendingRequest != null && captureReady[0]) {
finishCapture();
return;
}
@@ -102,7 +91,7 @@ public class WillowGeneratorState extends BaseAppState {
if (req != null) startGeneration(req);
}
// ── Phase 1: Generierung ──────────────────────────────────────────────────
// ── Phase 1: Generierung + Capture-Setup ─────────────────────────────────
private void startGeneration(SharedInput.WillowGenRequest req) {
cleanupCapture();
@@ -115,9 +104,8 @@ public class WillowGeneratorState extends BaseAppState {
BoundingBox bb = lod0.getWorldBound() instanceof BoundingBox b ? b : null;
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);
previewHost.setPreviewContent(lod0, dist, target);
String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
@@ -128,21 +116,17 @@ public class WillowGeneratorState extends BaseAppState {
pendingBb = bb;
pendingFileName = "willow_" + timestamp;
capturePass = 0;
capturePixels = new ByteBuffer[ATLAS_DIRS];
capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
startCapturePass(0);
input.treeGenStatusMsg = "Trauerweide: Rendere Impostor (1/" + ATLAS_DIRS + ")…";
input.treeGenStatusMsg = "Trauerweide: Rendere Impostor (1/" + ImpostorUtil.DIRS + ")…";
}
private Node buildLod1(WillowOptions opts) {
WillowOptions ld = opts.copy();
ld.trunkSegments = Math.max(4, opts.trunkSegments / 2);
ld.trunkSections = Math.max(4, opts.trunkSections / 2);
ld.branchSections = Math.max(4, opts.branchSections / 2);
ld.subBranchSections = Math.max(3, opts.subBranchSections / 2);
ld.subBranchCount = Math.max(1, opts.subBranchCount / 2);
Node n = WillowMeshBuilder.build(ld);
applyMaterials(n, ld);
// Keine strukturellen Änderungen: trunkSegments bestimmt Ast-Ansatzpunkte,
// trunkSections die Ast-Anzahl entlang des Stamms – beides ist erkennungsrelevant.
Node n = WillowMeshBuilder.build(opts);
applyMaterials(n, opts);
return n;
}
@@ -150,31 +134,41 @@ public class WillowGeneratorState extends BaseAppState {
@SuppressWarnings("deprecation")
private void startCapturePass(int pass) {
Texture2D capTex = new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.RGBA8);
captureFB = new FrameBuffer(IMPOSTOR_SIZE, IMPOSTOR_SIZE, 1);
BoundingBox safeBb = pendingBb != null ? pendingBb : new BoundingBox(Vector3f.ZERO, 5f, 8f, 5f);
// 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.setDepthTexture(new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.Depth));
float angle = pass * FastMath.HALF_PI;
captureVP = buildCaptureViewPort(pendingLod0, pendingBb, captureFB, angle);
captureReady = false;
captureFB.setDepthTexture(new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.Depth));
captureReady[0] = false;
captureVP = ImpostorUtil.startCapturePass(app, pendingLod0, capBb, captureFB, pass, captureReady);
}
private void finishCapture() {
ByteBuffer pixels = BufferUtils.createByteBuffer(IMPOSTOR_SIZE * IMPOSTOR_SIZE * 4);
app.getRenderer().readFrameBuffer(captureFB, pixels);
capturePixels[capturePass] = pixels;
cleanupCapture();
capturePixels[capturePass] = ImpostorUtil.readPixels(app, captureFB);
ImpostorUtil.cleanup(app, captureVP, captureFB);
captureVP = null;
captureFB = null;
captureReady[0] = false;
if (capturePass < ATLAS_DIRS - 1) {
if (capturePass < ImpostorUtil.DIRS - 1) {
capturePass++;
input.treeGenStatusMsg = "Trauerweide: Rendere Impostor (" + (capturePass + 1) + "/" + ATLAS_DIRS + ")…";
input.treeGenStatusMsg = "Trauerweide: Rendere Impostor (" + (capturePass + 1) + "/" + ImpostorUtil.DIRS + ")…";
startCapturePass(capturePass);
return;
}
String impostorName = "willow_impostor_" + pendingFileName.substring("willow_".length());
ByteBuffer atlas = combineAtlas(capturePixels);
Texture2D impTex = saveImpostor(atlas, impostorName, ATLAS_W, ATLAS_H);
ByteBuffer atlas = ImpostorUtil.combineAtlas(capturePixels);
Texture2D impTex = ImpostorUtil.buildAtlasTexture(atlas);
if (pendingRequest.exportAfter()) {
Node lodNode = assembleLodNode(impTex);
@@ -188,7 +182,7 @@ public class WillowGeneratorState extends BaseAppState {
pendingLod1 = null;
pendingBb = null;
pendingFileName = null;
capturePixels = new ByteBuffer[ATLAS_DIRS];
capturePixels = new ByteBuffer[ImpostorUtil.DIRS];
}
// ── LOD-Aufbau ────────────────────────────────────────────────────────────
@@ -196,154 +190,22 @@ public class WillowGeneratorState extends BaseAppState {
private Node assembleLodNode(Texture2D impostorTex) {
Node root = new Node("willow");
root.attachChild(pendingLod0);
pendingLod1.setName("lod1");
root.attachChild(pendingLod1);
Node lod2 = makeImpostorNode(pendingBb, impostorTex);
Node lod2 = ImpostorUtil.makeImpostorNode(pendingBb, impostorTex, assets);
root.attachChild(lod2);
pendingLod1.setCullHint(Spatial.CullHint.Always);
lod2.setCullHint(Spatial.CullHint.Always);
lod2.setShadowMode(RenderQueue.ShadowMode.Off);
ImpostorUtil.addViewControl(lod2, app.getCamera());
root.addControl(new WillowLodControl(app.getCamera(),
pendingLod0, pendingLod1, lod2, 50f, 150f));
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 ────────────────────────────────────────────────────────────────
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");
Files.createDirectories(modelDir);
File out = modelDir.resolve(fileName + ".j3o").toFile();
while (lodNode.getNumControls() > 0)
lodNode.removeControl(lodNode.getControl(0));
stripControlsRecursive(lodNode);
byte[] thumb = null;
try {
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()); }
BinaryExporter.getInstance().save(lodNode, out);
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());
input.treeGenStatusMsg = "Gespeichert: Models/trees/willow/" + fileName + ".j3o";
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 ───────────────────────────────────────────────────────────
private void applyMaterials(Node willow, WillowOptions opts) {
@@ -441,18 +326,13 @@ public class WillowGeneratorState extends BaseAppState {
// ── Aufräumen ─────────────────────────────────────────────────────────────
private void cleanupCapture() {
if (captureVP != null) {
app.getRenderManager().removePostView(captureVP);
captureVP = null;
}
if (captureFB != null) {
try { captureFB.dispose(); } catch (Exception ignored) {}
captureFB = null;
}
captureReady = false;
ImpostorUtil.cleanup(app, captureVP, captureFB);
captureVP = null;
captureFB = null;
captureReady[0] = false;
}
// ── LOD-Control ───────────────────────────────────────────────────────────
// ── LOD-Control (Editor-Vorschau) ─────────────────────────────────────────
private static final class WillowLodControl extends AbstractControl {
private final Camera cam;