bl_info = { "name": "Stylized Tree Generator", "author": "D4rkst3r", "version": (1, 17, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Tree Gen", "description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen", "category": "Add Mesh", } # Parametrischer Stylized-Baum als Geometry-Nodes-Gruppe (Stamm + Aeste). # Blaetter/Krone macht der Nutzer selbst - dieses Addon liefert das Geaest. # # HINWEIS: Der Node-Aufbau unten ist identisch mit EcoGame tools/blender_tree_gen.py # (dort als CLI-Variante). Aenderungen bitte in beiden pflegen. # # LIVE BEARBEITEN: Nach dem Erzeugen liegen alle Regler am Modifier "GN_Tree" - # im Viewport ziehen, der Baum aktualisiert sich sofort. import bpy import sys import os GROUP_NAME = "GN_StylizedTree" OBJ_NAME = "StylizedTree" # ============================ REGLER (Defaults) ============================ DEFAULTS = { "Seed": 0, "Height": 5.0, "Trunk Radius": 0.07, # TreeIt-Verhaeltnis: Radius/Hoehe ~0.013 # (0.079 bei 6.04 m). Bei 5 m also ~0.065-0.07. "Bend": 0.5, "Taper": 0.9, "Branch Count": 9, "Branch Length": 1.3, "Branch Up": 1.3, # >1 = Aeste zeigen nach oben; negativ = haengend "Branch Start": 0.35, # ab wo am Stamm Aeste sitzen (0..1) "Branch End": 0.95, "Branch Bend": 0.75, # Krummheit je Ast/Zweig (0 = gerade Staebe) "Branch Droop": 0.0, # >0 haengt nach unten (Palme), <0 kruemmt nach oben (Kaktus) "Droop Curve": 2.0, # 2 = weicher Bogen, 4+ = scharfer Ellbogen "Trunk Tip": 0.3, # Anteil von Tip Blunt fuer die Stammspitze; # klein = laeuft duenn aus, 1.0 = stumpfe Kuppe "Branch Thickness": 0.17, # Ast-Dicke relativ zum Stamm. Am TreeIt-Baum # gemessen: 0.008-0.013 bei Stamm 0.079 = 11-16 %. # 0.32 war doppelt zu dick -> grobe Ansaetze. "Branch Taper": 0.55, # schwach verjuengen -> Roehre statt Kegel/Dorn "Sub Count": 2, # Sub-Aeste je Hauptast (0 = aus) "Sub Start": 0.45, # Ansatzbereich der Zweige am Ast ... "Sub End": 0.95, # ... nahe 1.0 + hohes Sub Up = Gabelung "Sub Length": 0.55, "Sub Up": 2.2, # hoch = Zweig folgt dem Ast (Gabel statt Nadel) "Sides": 12, # Stamm rund genug fuer die Wurzelbretter # (+104 Tris ggue. 8 - gemessen, lohnt sich) "Branch Sides": 4, # Aeste sparsamer "Sub Sides": 3, # Zweige nur Dreiecke - sieht man nicht "UV Scale": 1.0, # Rinden-Dichte; UVs sind world-space (m) "Tip Blunt": 0.18, # >0 verhindert Nadelspitzen (0 = spitz, 0.45 = Kaktus) "Crown Taper": 0.35, # >0 kegelfoermig, 0 saeulig, <0 besenfoermig "Crown Bulge": 0.0, # >0 = Bauch in der Mitte (runde Krone) "Root Flare": 1.55, # Wurzelanlauf. Ziel ist das TreeIt-Verhaeltnis # Fuss/Stamm ~2.2 (dort 0.179 zu 0.082). "Root Lobes": 6.0, # Wurzelbretter; sollte in "Sides" aufgehen, # sonst sampelt das Profil die Lappen weg "Root Lobe Depth": 0.65, # wie weit die Lappen rausstehen. Am TreeIt-Baum # gemessen: Unrundheit am Basisring 1.60. # Messfalle: Ringe nicht ueber z-Bereiche mitteln, # sonst mischt man Nachbarringe rein (ergab faelschlich 5.1). "Root Height": 1.1, # Hoehe (m) des Anlaufs; zu klein -> zu wenige # Ringe liegen drin und man sieht nichts "Detail": 0.55, # skaliert alle Resample-Counts = Tri-Budget-Regler "Branch Collar": 0.55, # Ansatz-Verdickung: Uebergang Ast->Stamm (0 = aus) "Merge": 0.0, # 1 = Aeste per Voxel-Remesh mit dem Stamm verschmelzen "Merge Voxel": 0.03, # Voxelgroesse (m): klein = feiner, aber teuer "Ribs": 0.0, # senkrechte Kanneluren (0 = glatt); Kaktus ~9 "Rib Depth": 0.0, } # Presets: nur die Abweichungen von DEFAULTS. PRESETS = { "baum": {}, "palme": { "Height": 7.0, "Trunk Radius": 0.16, "Bend": 1.1, "Taper": 0.55, "Branch Count": 11, "Branch Length": 2.6, "Branch Up": 0.55, "Branch Start": 0.93, "Branch End": 1.0, "Branch Bend": 0.25, "Branch Droop": 1.5, "Sub Count": 0, # Wedel-Fiederung macht der User als Blattwerk "Sides": 6, }, "busch": { "Height": 1.6, "Trunk Radius": 0.07, "Bend": 0.4, "Taper": 0.8, "Branch Count": 14, "Branch Length": 0.9, "Branch Up": 1.0, "Branch Start": 0.15, "Branch End": 0.95, "Branch Bend": 0.6, "Branch Droop": 0.0, "Sub Count": 1, "Sub Length": 0.35, "Sub Up": 1.6, "Sides": 4, "Detail": 0.3, "Branch Count": 10, }, # --- Varianten ueber die Kronenform (Idee: modular_tree "envelope shapes") --- "tanne": { # kegelfoermig: unten lange Aeste, oben kurz, leicht haengend "Height": 7.0, "Trunk Radius": 0.15, "Bend": 0.15, "Taper": 0.95, "Branch Count": 22, "Branch Length": 1.9, "Branch Up": -0.25, "Branch Start": 0.12, "Branch End": 0.97, "Crown Taper": 0.9, "Crown Bulge": 0.0, "Branch Bend": 0.3, "Branch Thickness": 0.12, "Sub Count": 1, "Sub Length": 0.4, "Root Flare": 0.7, }, "eiche": { # runde, breite Krone: Bauch in der Mitte, dicker Stamm "Height": 5.5, "Trunk Radius": 0.24, "Bend": 0.5, "Taper": 0.75, "Branch Count": 10, "Branch Length": 2.2, "Branch Up": 0.9, "Branch Start": 0.3, "Branch End": 0.95, "Crown Taper": -0.15, "Crown Bulge": 0.5, "Branch Bend": 0.9, "Branch Thickness": 0.24, "Sub Count": 3, "Sub Length": 0.8, "Root Flare": 1.5, "Root Lobe Depth": 0.4, }, "birke": { # schlank, hoher Ansatz, feine Zweige "Height": 7.5, "Trunk Radius": 0.09, "Bend": 0.7, "Taper": 0.92, "Branch Count": 12, "Branch Length": 1.4, "Branch Up": 1.1, "Branch Start": 0.45, "Branch End": 0.98, "Crown Taper": 0.1, "Crown Bulge": 0.25, "Branch Bend": 0.8, "Branch Thickness": 0.11, "Sub Count": 3, "Sub Length": 0.5, "Root Flare": 0.6, }, "weide": { # haengende Zweige (Droop positiv), breit ausladend "Height": 5.0, "Trunk Radius": 0.18, "Bend": 0.8, "Taper": 0.8, "Branch Count": 12, "Branch Length": 2.0, "Branch Up": 0.5, "Branch Start": 0.35, "Branch End": 0.95, "Crown Taper": -0.2, "Crown Bulge": 0.4, "Branch Droop": 1.2, "Branch Bend": 0.7, "Branch Thickness": 0.16, "Sub Count": 3, "Sub Length": 0.9, "Root Flare": 1.2, }, "toter_baum": { # knorrig, kaum Zweige, starke Kruemmung "Height": 4.5, "Trunk Radius": 0.17, "Bend": 1.5, "Taper": 0.88, "Branch Count": 6, "Branch Length": 1.5, "Branch Up": 0.9, "Branch Start": 0.3, "Branch End": 0.9, "Crown Taper": 0.3, "Crown Bulge": 0.0, "Branch Bend": 1.4, "Branch Thickness": 0.18, "Sub Count": 1, "Sub Length": 0.5, "Root Flare": 1.4, "Tip Blunt": 0.08, }, # Kaktus: dicker, kaum verjuengter Stamm, wenige Arme, die per NEGATIVEM # Droop nach oben kruemmen (Saguaro-Silhouette). Keine Sub-Aeste. "kaktus": { "Height": 3.4, "Trunk Radius": 0.28, "Bend": 0.08, "Taper": 0.10, "Branch Count": 2, "Branch Length": 1.9, "Branch Up": 0.12, "Branch Start": 0.30, "Branch End": 0.46, "Branch Bend": 0.0, "Branch Droop": -1.35, # negativ = Arme kruemmen nach OBEN (Saguaro); # zu gross und die Arme ueberragen den Stamm "Droop Curve": 4.5, # spaeter, dafuer scharfer Ellbogen "Branch Thickness": 0.72, # Arme fast so dick wie der Stamm "Branch Taper": 0.12, # Arme bleiben dick statt spitz zuzulaufen "Sub Count": 0, "Crown Taper": 0.0, "Root Flare": 0.25, "Trunk Tip": 1.0, "Root Lobe Depth": 0.0, "Tip Blunt": 0.45, "Sides": 16, "Branch Sides": 12, "Ribs": 9.0, "Rib Depth": 0.09, }, } # =========================================================================== def _sock(node, *names): """Falle 1: Socket nach Namen holen, mit Alternativen.""" for n in names: if n in node.inputs: return node.inputs[n] raise KeyError("Socket %s nicht in %s" % (names, node.bl_idname)) def _out(node, *names): for n in names: if n in node.outputs: return node.outputs[n] return node.outputs[0] def build_group(): old = bpy.data.node_groups.get(GROUP_NAME) if old: bpy.data.node_groups.remove(old) ng = bpy.data.node_groups.new(GROUP_NAME, "GeometryNodeTree") N, L = ng.nodes.new, ng.links.new iface = ng.interface def _count(base, x, y): """Resample-Count = base * Detail, mindestens 3. Der zentrale Tri-Budget-Regler: die Segmentzahl entlang der Kurven dominiert die Dreiecke (Tris ~ Segmente * Sides * 2).""" mul = N("ShaderNodeMath"); mul.location = (x, y); mul.operation = 'MULTIPLY' mul.inputs[0].default_value = float(base) L(V["Detail"], mul.inputs[1]) mx = N("ShaderNodeMath"); mx.location = (x + 150, y); mx.operation = 'MAXIMUM' mx.inputs[1].default_value = 3.0 L(mul.outputs[0], mx.inputs[0]) return mx def _new_any(*bl_idnames): """Falle 2: erster Node-Typ, den diese Blender-Version kennt.""" for bid in bl_idnames: try: return N(bid) except Exception: continue return None iface.new_socket("Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry') def add_in(name, stype, default, mn=None, mx=None): s = iface.new_socket(name, in_out='INPUT', socket_type=stype) s.default_value = default if mn is not None: s.min_value = mn if mx is not None: s.max_value = mx add_in("Seed", 'NodeSocketInt', DEFAULTS["Seed"], 0, 9999) add_in("Height", 'NodeSocketFloat', DEFAULTS["Height"], 0.5, 30.0) add_in("Trunk Radius", 'NodeSocketFloat', DEFAULTS["Trunk Radius"], 0.01, 3.0) add_in("Bend", 'NodeSocketFloat', DEFAULTS["Bend"], 0.0, 3.0) add_in("Taper", 'NodeSocketFloat', DEFAULTS["Taper"], 0.0, 1.0) add_in("Branch Count", 'NodeSocketInt', DEFAULTS["Branch Count"], 0, 60) add_in("Branch Length", 'NodeSocketFloat', DEFAULTS["Branch Length"], 0.1, 10.0) add_in("Branch Up", 'NodeSocketFloat', DEFAULTS["Branch Up"], -3.0, 3.0) add_in("Branch Start", 'NodeSocketFloat', DEFAULTS["Branch Start"], 0.0, 1.0) add_in("Branch End", 'NodeSocketFloat', DEFAULTS["Branch End"], 0.0, 1.0) add_in("Branch Bend", 'NodeSocketFloat', DEFAULTS["Branch Bend"], 0.0, 2.0) # Droop darf NEGATIV sein: dann kruemmen sich die Aeste nach OBEN # (= Kaktus-Arme statt Palmwedel). add_in("Branch Droop", 'NodeSocketFloat', DEFAULTS["Branch Droop"], -3.0, 3.0) add_in("Droop Curve", 'NodeSocketFloat', DEFAULTS["Droop Curve"], 1.0, 8.0) add_in("Trunk Tip", 'NodeSocketFloat', DEFAULTS["Trunk Tip"], 0.05, 1.0) add_in("Branch Thickness", 'NodeSocketFloat', DEFAULTS["Branch Thickness"], 0.05, 1.0) add_in("Branch Taper", 'NodeSocketFloat', DEFAULTS["Branch Taper"], 0.0, 1.0) add_in("Sub Count", 'NodeSocketInt', DEFAULTS["Sub Count"], 0, 12) add_in("Sub Start", 'NodeSocketFloat', DEFAULTS["Sub Start"], 0.0, 1.0) add_in("Sub End", 'NodeSocketFloat', DEFAULTS["Sub End"], 0.0, 1.0) add_in("Sub Length", 'NodeSocketFloat', DEFAULTS["Sub Length"], 0.05, 5.0) add_in("Sub Up", 'NodeSocketFloat', DEFAULTS["Sub Up"], -3.0, 3.0) add_in("Sides", 'NodeSocketInt', DEFAULTS["Sides"], 3, 32) add_in("Branch Sides", 'NodeSocketInt', DEFAULTS["Branch Sides"], 3, 16) add_in("Sub Sides", 'NodeSocketInt', DEFAULTS["Sub Sides"], 3, 12) add_in("Tip Blunt", 'NodeSocketFloat', DEFAULTS["Tip Blunt"], 0.0, 0.9) add_in("UV Scale", 'NodeSocketFloat', DEFAULTS["UV Scale"], 0.01, 20.0) add_in("Detail", 'NodeSocketFloat', DEFAULTS["Detail"], 0.2, 2.0) add_in("Crown Taper", 'NodeSocketFloat', DEFAULTS["Crown Taper"], -1.5, 1.5) add_in("Crown Bulge", 'NodeSocketFloat', DEFAULTS["Crown Bulge"], -1.0, 2.0) add_in("Root Flare", 'NodeSocketFloat', DEFAULTS["Root Flare"], 0.0, 4.0) add_in("Root Lobes", 'NodeSocketFloat', DEFAULTS["Root Lobes"], 0.0, 12.0) add_in("Root Lobe Depth", 'NodeSocketFloat', DEFAULTS["Root Lobe Depth"], 0.0, 3.0) add_in("Root Height", 'NodeSocketFloat', DEFAULTS["Root Height"], 0.05, 3.0) add_in("Branch Collar", 'NodeSocketFloat', DEFAULTS["Branch Collar"], 0.0, 3.0) add_in("Merge", 'NodeSocketFloat', DEFAULTS["Merge"], 0.0, 1.0) add_in("Merge Voxel", 'NodeSocketFloat', DEFAULTS["Merge Voxel"], 0.005, 0.2) add_in("Ribs", 'NodeSocketFloat', DEFAULTS["Ribs"], 0.0, 20.0) add_in("Rib Depth", 'NodeSocketFloat', DEFAULTS["Rib Depth"], 0.0, 0.5) gin = N("NodeGroupInput"); gin.location = (-1400, 0) gout = N("NodeGroupOutput"); gout.location = (1400, 0) V = gin.outputs # ---------- Stamm ---------- line = N("GeometryNodeCurvePrimitiveLine"); line.location = (-1150, 200) top = N("ShaderNodeCombineXYZ"); top.location = (-1300, 120) L(V["Height"], top.inputs["Z"]) L(top.outputs[0], _sock(line, "End")) res = N("GeometryNodeResampleCurve"); res.location = (-950, 200) L(_count(24, -1150, 380).outputs[0], _sock(res, "Count")) L(_out(line, "Curve"), _sock(res, "Curve")) spar = N("GeometryNodeSplineParameter"); spar.location = (-950, -60) pos = N("GeometryNodeInputPosition"); pos.location = (-1150, -220) noise = N("ShaderNodeTexNoise"); noise.location = (-950, -260) noise.noise_dimensions = '4D' _sock(noise, "Scale").default_value = 0.55 L(pos.outputs[0], _sock(noise, "Vector")) L(V["Seed"], _sock(noise, "W")) nsub = N("ShaderNodeVectorMath"); nsub.location = (-750, -260) nsub.operation = 'SUBTRACT'; nsub.inputs[1].default_value = (0.5, 0.5, 0.5) L(noise.outputs["Color"], nsub.inputs[0]) # Falle 5: Z des Offsets platt machen flat = N("ShaderNodeVectorMath"); flat.location = (-580, -260) flat.operation = 'MULTIPLY'; flat.inputs[1].default_value = (1.0, 1.0, 0.0) L(nsub.outputs[0], flat.inputs[0]) # Falle 4: Offset mit Spline-Faktor skalieren -> Fuss bleibt stehen bendf = N("ShaderNodeMath"); bendf.location = (-750, -60) bendf.operation = 'MULTIPLY' L(V["Bend"], bendf.inputs[0]) L(spar.outputs["Factor"], bendf.inputs[1]) offs = N("ShaderNodeVectorMath"); offs.location = (-400, -200) offs.operation = 'SCALE' L(flat.outputs[0], offs.inputs[0]) L(bendf.outputs[0], _sock(offs, "Scale")) setpos = N("GeometryNodeSetPosition"); setpos.location = (-250, 200) L(_out(res, "Curve"), _sock(setpos, "Geometry")) L(offs.outputs[0], _sock(setpos, "Offset")) # Radius: TrunkRadius * (1 - Taper * factor) tf = N("ShaderNodeMath"); tf.location = (-580, 60); tf.operation = 'MULTIPLY' L(V["Taper"], tf.inputs[0]); L(spar.outputs["Factor"], tf.inputs[1]) inv = N("ShaderNodeMath"); inv.location = (-420, 60); inv.operation = 'SUBTRACT' inv.inputs[0].default_value = 1.0 L(tf.outputs[0], inv.inputs[1]) # Kuppel-Profil: (1 - f^3)^0.5 -> breite, gleichmaessige Kuppe. Mit einem # zu spaeten/steilen Profil (f^8) trifft die Rundung nur 1-2 Resample- # Punkte und wird zum KEGEL. Breit + genug Punkte = runde Kuppe. # Bei starkem Taper (Baum) dominiert ohnehin die lineare Verjuengung. def _dome(spar_node, x, y): pw = N("ShaderNodeMath"); pw.location = (x, y); pw.operation = 'POWER' pw.inputs[1].default_value = 3.0 L(spar_node.outputs["Factor"], pw.inputs[0]) s = N("ShaderNodeMath"); s.location = (x + 150, y); s.operation = 'SUBTRACT' s.inputs[0].default_value = 1.0 L(pw.outputs[0], s.inputs[1]) rt = N("ShaderNodeMath"); rt.location = (x + 300, y); rt.operation = 'POWER' rt.inputs[1].default_value = 0.5 L(s.outputs[0], rt.inputs[0]) return rt # Verjuengung UND Kuppel GEMEINSAM begrenzen, dann erst mit dem Basisradius # multiplizieren. Wirkt der Mindestwert nur auf die Kuppel, bleibt der bereits # verjuengte Radius trotzdem winzig — gemessen: Sub-Ast-Spitze 0.8 mm bei # 31 mm Basis, also weiterhin eine Nadel. def _shape(inv_taper_socket, dome_node, base_socket, x, y, spar_node=None, collar=False): mul = N("ShaderNodeMath"); mul.location = (x, y); mul.operation = 'MULTIPLY' L(inv_taper_socket, mul.inputs[0]); L(dome_node.outputs[0], mul.inputs[1]) mx = N("ShaderNodeMath"); mx.location = (x + 150, y); mx.operation = 'MAXIMUM' L(mul.outputs[0], mx.inputs[0]); L(V["Tip Blunt"], mx.inputs[1]) src = mx if collar and spar_node is not None: # Ansatz-Verdickung ("Collar"): der Ast wird an seiner Basis breiter # und laeuft schnell aus -> der Uebergang zum Stamm sieht gewachsen # aus statt wie eine durchgesteckte Roehre. Kostet KEINE Verts. ci = N("ShaderNodeMath"); ci.location = (x, y - 160); ci.operation = 'SUBTRACT' ci.inputs[0].default_value = 1.0 L(spar_node.outputs["Factor"], ci.inputs[1]) cp = N("ShaderNodeMath"); cp.location = (x + 150, y - 160); cp.operation = 'POWER' # Exponent NIEDRIG halten: bei ~7 Segmenten pro Ast ist ein hoher # Exponent nur EINEN Punkt breit -> sichtbarer Widerhaken statt # Uebergang. 2.5 verteilt die Verdickung auf 2-3 Segmente. cp.inputs[1].default_value = 2.5 L(ci.outputs[0], cp.inputs[0]) cm = N("ShaderNodeMath"); cm.location = (x + 300, y - 160); cm.operation = 'MULTIPLY' L(V["Branch Collar"], cm.inputs[0]); L(cp.outputs[0], cm.inputs[1]) ca = N("ShaderNodeMath"); ca.location = (x + 450, y - 160); ca.operation = 'ADD' ca.inputs[0].default_value = 1.0 L(cm.outputs[0], ca.inputs[1]) cf = N("ShaderNodeMath"); cf.location = (x + 450, y - 60); cf.operation = 'MULTIPLY' L(mx.outputs[0], cf.inputs[0]); L(ca.outputs[0], cf.inputs[1]) src = cf r = N("ShaderNodeMath"); r.location = (x + 620, y); r.operation = 'MULTIPLY' L(base_socket, r.inputs[0]); L(src.outputs[0], r.inputs[1]) return r tdome = _dome(spar, -900, 220) # Stammspitze duenner auslaufen lassen als die Aeste: sonst endet der Stamm # mit einem sichtbaren flachen Deckel. Ein eigener, kleinerer Mindestwert. ttip = N("ShaderNodeMath"); ttip.location = (-420, -80); ttip.operation = 'MULTIPLY' L(V["Tip Blunt"], ttip.inputs[0]); L(V["Trunk Tip"], ttip.inputs[1]) tmulr = N("ShaderNodeMath"); tmulr.location = (-260, 60); tmulr.operation = 'MULTIPLY' L(inv.outputs[0], tmulr.inputs[0]); L(tdome.outputs[0], tmulr.inputs[1]) tmaxr = N("ShaderNodeMath"); tmaxr.location = (-110, 60); tmaxr.operation = 'MAXIMUM' L(tmulr.outputs[0], tmaxr.inputs[0]); L(ttip.outputs[0], tmaxr.inputs[1]) # Wurzelanlauf: der Stamm verbreitert sich am Fuss. Hoher Exponent -> nur # das unterste Stueck ist betroffen. Kostet keine Tris, nur Radius. rfi = N("ShaderNodeMath"); rfi.location = (-420, -220); rfi.operation = 'SUBTRACT' rfi.inputs[0].default_value = 1.0 L(spar.outputs["Factor"], rfi.inputs[1]) rfp = N("ShaderNodeMath"); rfp.location = (-270, -220); rfp.operation = 'POWER' rfp.inputs[1].default_value = 9.0 L(rfi.outputs[0], rfp.inputs[0]) rfm = N("ShaderNodeMath"); rfm.location = (-120, -220); rfm.operation = 'MULTIPLY' L(V["Root Flare"], rfm.inputs[0]); L(rfp.outputs[0], rfm.inputs[1]) rfa = N("ShaderNodeMath"); rfa.location = (30, -220); rfa.operation = 'ADD' rfa.inputs[0].default_value = 1.0 L(rfm.outputs[0], rfa.inputs[1]) tflare = N("ShaderNodeMath"); tflare.location = (30, -60); tflare.operation = 'MULTIPLY' L(tmaxr.outputs[0], tflare.inputs[0]); L(rfa.outputs[0], tflare.inputs[1]) trad = N("ShaderNodeMath"); trad.location = (190, 60); trad.operation = 'MULTIPLY' L(V["Trunk Radius"], trad.inputs[0]); L(tflare.outputs[0], trad.inputs[1]) setrad = N("GeometryNodeSetCurveRadius"); setrad.location = (-80, 200) L(_out(setpos, "Geometry"), _sock(setrad, "Curve")) L(trad.outputs[0], _sock(setrad, "Radius")) # ---------- Ast-Ursprünge auf dem oberen Stamm ---------- trim = N("GeometryNodeTrimCurve"); trim.location = (100, 320) L(_out(setrad, "Curve"), _sock(trim, "Curve")) # Ansatzbereich der Aeste: Baum = breit gestreut, Palme = alles ganz oben. try: L(V["Branch Start"], _sock(trim, "Start")) L(V["Branch End"], _sock(trim, "End")) except KeyError: pass c2p = N("GeometryNodeCurveToPoints"); c2p.location = (280, 320) try: c2p.mode = 'COUNT' # Falle 3 except Exception: pass L(_out(trim, "Curve"), _sock(c2p, "Curve")) L(V["Branch Count"], _sock(c2p, "Count")) # ---------- Ast-Geometrie (verjüngte Linie) ---------- bline = N("GeometryNodeCurvePrimitiveLine"); bline.location = (100, 40) btop = N("ShaderNodeCombineXYZ"); btop.location = (-60, -20) L(V["Branch Length"], btop.inputs["Z"]) L(btop.outputs[0], _sock(bline, "End")) bres = N("GeometryNodeResampleCurve"); bres.location = (280, 40) L(_count(14, 100, 200).outputs[0], _sock(bres, "Count")) L(_out(bline, "Curve"), _sock(bres, "Curve")) # Ast-Verjuengung: 1 - BranchTaper*factor. Bei Kakteen klein halten, # sonst laufen die Arme spitz zu wie Dornen. bspar = N("GeometryNodeSplineParameter"); bspar.location = (280, -140) btap = N("ShaderNodeMath"); btap.location = (360, -240); btap.operation = 'MULTIPLY' L(V["Branch Taper"], btap.inputs[0]) L(bspar.outputs["Factor"], btap.inputs[1]) binv = N("ShaderNodeMath"); binv.location = (440, -140) binv.operation = 'SUBTRACT'; binv.inputs[0].default_value = 1.0 L(btap.outputs[0], binv.inputs[1]) # Ast-Dicke relativ zum Stamm. Beim Kaktus muessen die Arme fast so dick # sein wie der Stamm (0.7+), beim Baum deutlich duenner (~0.3). bbase = N("ShaderNodeMath"); bbase.location = (600, -140); bbase.operation = 'MULTIPLY' L(V["Trunk Radius"], bbase.inputs[0]); L(V["Branch Thickness"], bbase.inputs[1]) bdome = _dome(bspar, 600, -600) bscl = _shape(binv.outputs[0], bdome, bbase.outputs[0], 780, -140, spar_node=bspar, collar=True) bsetr = N("GeometryNodeSetCurveRadius"); bsetr.location = (600, 40) L(_out(bres, "Curve"), _sock(bsetr, "Curve")) L(bscl.outputs[0], _sock(bsetr, "Radius")) # ---------- Ausrichtung relativ zur ELTERNKURVE ---------- # Curve to Points liefert Tangent + Normal des Ansatzpunktes gratis mit. # Die Normale um die Tangente zu drehen (Index * Goldener Winkel) verteilt # die Aeste spiralig um den Elternast (Phyllotaxis) UND folgt automatisch # dessen Biegung. Vorher wurde die Richtung aus Weltkoordinaten gerechnet -> # Aeste standen wie angeklebte Nadeln quer zum Ast. def _child_dir(points_node, up_socket, x, y): i = N("GeometryNodeInputIndex"); i.location = (x, y + 220) a = N("ShaderNodeMath"); a.location = (x + 150, y + 220); a.operation = 'MULTIPLY' a.inputs[1].default_value = 2.399963 # Goldener Winkel L(i.outputs[0], a.inputs[0]) vr = N("ShaderNodeVectorRotate"); vr.location = (x + 320, y + 120) vr.rotation_type = 'AXIS_ANGLE' L(_out(points_node, "Normal"), vr.inputs["Vector"]) L(_out(points_node, "Tangent"), vr.inputs["Axis"]) L(a.outputs[0], vr.inputs["Angle"]) lean = N("ShaderNodeVectorMath"); lean.location = (x + 320, y - 60) lean.operation = 'SCALE' L(_out(points_node, "Tangent"), lean.inputs[0]) L(up_socket, _sock(lean, "Scale")) add = N("ShaderNodeVectorMath"); add.location = (x + 500, y + 40) add.operation = 'ADD' L(vr.outputs[0], add.inputs[0]); L(lean.outputs[0], add.inputs[1]) return add # ---------- radiale Ausrichtung je Ast ---------- dirv = _child_dir(c2p, V["Branch Up"], 100, 560) align = _new_any("FunctionNodeAlignRotationToVector", "FunctionNodeAlignEulerToVector") if align is not None: align.location = (900, 560) try: align.axis = 'Z' except Exception: pass L(dirv.outputs[0], _sock(align, "Vector")) # Aeste an die ELTERNDICKE anpassen: "Instance on Points" wertet Felder auf # der Punkt-Domain aus, dort liefert "Input Radius" den Stammradius genau am # Ansatzpunkt. Ohne das sind Aeste oben (duenner Stamm) genauso dick wie # unten - und damit dicker als der Stamm selbst. prad = N("GeometryNodeInputRadius"); prad.location = (900, 120) pratio = N("ShaderNodeMath"); pratio.location = (1050, 120); pratio.operation = 'DIVIDE' L(prad.outputs[0], pratio.inputs[0]); L(V["Trunk Radius"], pratio.inputs[1]) pscale = N("ShaderNodeMapRange"); pscale.location = (1200, 120) L(pratio.outputs[0], pscale.inputs["Value"]) pscale.inputs["To Min"].default_value = 0.35 # nicht ganz kollabieren lassen pscale.inputs["To Max"].default_value = 1.0 pscale.clamp = True # ---------- Kronenform (botanische Huellkurve) ---------- # Idee aus modular_tree / Weber-Penn: die Astlaenge haengt von der relativen # Hoehe am Stamm ab. Das entscheidet die Silhouette: # Crown Taper > 0 -> unten lang, oben kurz = kegelfoermig (Tanne) # Crown Taper = 0 -> ueberall gleich = saeulenfoermig (Pappel) # Crown Taper < 0 -> unten kurz, oben lang = besenfoermig # Crown Bulge > 0 -> Maximum in der Mitte = rund (Eiche) czp = N("GeometryNodeInputPosition"); czp.location = (750, -20) czs = N("ShaderNodeSeparateXYZ"); czs.location = (900, -20) L(czp.outputs[0], czs.inputs[0]) czn = N("ShaderNodeMath"); czn.location = (1050, -20); czn.operation = 'DIVIDE' L(czs.outputs["Z"], czn.inputs[0]); L(V["Height"], czn.inputs[1]) czc = N("ShaderNodeMath"); czc.location = (1200, -20); czc.operation = 'MINIMUM' czc.inputs[1].default_value = 1.0 L(czn.outputs[0], czc.inputs[0]) ctm = N("ShaderNodeMath"); ctm.location = (1350, -20); ctm.operation = 'MULTIPLY' L(V["Crown Taper"], ctm.inputs[0]); L(czc.outputs[0], ctm.inputs[1]) cts = N("ShaderNodeMath"); cts.location = (1500, -20); cts.operation = 'SUBTRACT' cts.inputs[0].default_value = 1.0 L(ctm.outputs[0], cts.inputs[1]) # Bauch in der Mitte: sin(pi * t) cbs = N("ShaderNodeMath"); cbs.location = (1350, -180); cbs.operation = 'MULTIPLY' cbs.inputs[1].default_value = 3.14159 L(czc.outputs[0], cbs.inputs[0]) cbi = N("ShaderNodeMath"); cbi.location = (1500, -180); cbi.operation = 'SINE' L(cbs.outputs[0], cbi.inputs[0]) cbm = N("ShaderNodeMath"); cbm.location = (1650, -180); cbm.operation = 'MULTIPLY' L(V["Crown Bulge"], cbm.inputs[0]); L(cbi.outputs[0], cbm.inputs[1]) cba = N("ShaderNodeMath"); cba.location = (1800, -180); cba.operation = 'ADD' cba.inputs[0].default_value = 1.0 L(cbm.outputs[0], cba.inputs[1]) ccm = N("ShaderNodeMath"); ccm.location = (1800, -20); ccm.operation = 'MULTIPLY' L(cts.outputs[0], ccm.inputs[0]); L(cba.outputs[0], ccm.inputs[1]) ccl = N("ShaderNodeMath"); ccl.location = (1950, -20); ccl.operation = 'MAXIMUM' ccl.inputs[1].default_value = 0.05 L(ccm.outputs[0], ccl.inputs[0]) # mit der Elterndicken-Skalierung kombinieren pfin = N("ShaderNodeMath"); pfin.location = (2100, 120); pfin.operation = 'MULTIPLY' L(pscale.outputs[0], pfin.inputs[0]); L(ccl.outputs[0], pfin.inputs[1]) inst = N("GeometryNodeInstanceOnPoints"); inst.location = (1400, 320) L(_out(c2p, "Points"), _sock(inst, "Points")) L(_out(bsetr, "Curve"), _sock(inst, "Instance")) L(pfin.outputs[0], _sock(inst, "Scale")) if align is not None: L(align.outputs[0], _sock(inst, "Rotation")) real = N("GeometryNodeRealizeInstances"); real.location = (1200, 320) L(_out(inst, "Instances", "Geometry"), _sock(real, "Geometry")) # ---------- Ast-Biegung NACH dem Realize ---------- # Trick fuer Variation je Ast: hier ist die Position bereits die WELT-Position # des jeweiligen Astes. Dieselbe Noise liefert damit pro Ast einen anderen # Wert — vor dem Realize haetten alle Instanzen identische lokale Coords # (und wuerden exakt gleich gebogen). rspar = N("GeometryNodeSplineParameter"); rspar.location = (1200, 60) rpos = N("GeometryNodeInputPosition"); rpos.location = (1200, -100) rnoise = N("ShaderNodeTexNoise"); rnoise.location = (1350, -140) rnoise.noise_dimensions = '4D' _sock(rnoise, "Scale").default_value = 0.9 L(rpos.outputs[0], _sock(rnoise, "Vector")) L(V["Seed"], _sock(rnoise, "W")) rsub = N("ShaderNodeVectorMath"); rsub.location = (1500, -140) rsub.operation = 'SUBTRACT'; rsub.inputs[1].default_value = (0.5, 0.5, 0.5) L(rnoise.outputs["Color"], rsub.inputs[0]) # Staerke waechst zur Astspitze -> Ansatz bleibt am Stamm rfac = N("ShaderNodeMath"); rfac.location = (1350, 60); rfac.operation = 'MULTIPLY' L(V["Branch Bend"], rfac.inputs[0]) L(rspar.outputs["Factor"], rfac.inputs[1]) rscale = N("ShaderNodeVectorMath"); rscale.location = (1650, -140) rscale.operation = 'SCALE' L(rsub.outputs[0], rscale.inputs[0]) L(rfac.outputs[0], _sock(rscale, "Scale")) # Droop: Spitzen haengen nach unten (Palmwedel) -- quadratisch = schoene Kurve # Droop-Kurve: Exponent 2 = weicher Bogen (Palmwedel), hoeher = der Arm # bleibt erst flach und knickt dann scharf ab (Saguaro-Ellbogen). dsq = N("ShaderNodeMath"); dsq.location = (1350, -320); dsq.operation = 'POWER' L(rspar.outputs["Factor"], dsq.inputs[0]); L(V["Droop Curve"], dsq.inputs[1]) dmul = N("ShaderNodeMath"); dmul.location = (1500, -320); dmul.operation = 'MULTIPLY' L(V["Branch Droop"], dmul.inputs[0]); L(dsq.outputs[0], dmul.inputs[1]) dneg = N("ShaderNodeMath"); dneg.location = (1650, -320); dneg.operation = 'MULTIPLY' dneg.inputs[1].default_value = -1.0 L(dmul.outputs[0], dneg.inputs[0]) dvec = N("ShaderNodeCombineXYZ"); dvec.location = (1800, -320) L(dneg.outputs[0], dvec.inputs["Z"]) roffs = N("ShaderNodeVectorMath"); roffs.location = (1800, -140) roffs.operation = 'ADD' L(rscale.outputs[0], roffs.inputs[0]) L(dvec.outputs[0], roffs.inputs[1]) rsetp = N("GeometryNodeSetPosition"); rsetp.location = (1950, 320) L(_out(real, "Geometry"), _sock(rsetp, "Geometry")) L(roffs.outputs[0], _sock(rsetp, "Offset")) # ---------- Sub-Aeste (zweite Verzweigungsebene) ---------- # Sitzen auf den bereits gebogenen Hauptaesten. Bei "Sub Count" = 0 liefert # Curve to Points keine Punkte -> es entsteht schlicht nichts (kein Fehler). strim = N("GeometryNodeTrimCurve"); strim.location = (2100, 520) L(_out(rsetp, "Geometry"), _sock(strim, "Curve")) # Wo am Elternast die Zweige sitzen. Nahe 1.0 zusammengedraengt + hohes # "Sub Up" ergibt eine GABELUNG (Weber-Penn "split") statt seitlicher # Kinder - das ist der Unterschied zwischen "Stab mit Nadeln" und Geaest. try: L(V["Sub Start"], _sock(strim, "Start")) L(V["Sub End"], _sock(strim, "End")) except KeyError: pass sp = N("GeometryNodeCurveToPoints"); sp.location = (2250, 520) try: sp.mode = 'COUNT' except Exception: pass L(_out(strim, "Curve"), _sock(sp, "Curve")) L(V["Sub Count"], _sock(sp, "Count")) sline = N("GeometryNodeCurvePrimitiveLine"); sline.location = (2100, 760) stop = N("ShaderNodeCombineXYZ"); stop.location = (1950, 700) L(V["Sub Length"], stop.inputs["Z"]) L(stop.outputs[0], _sock(sline, "End")) sres = N("GeometryNodeResampleCurve"); sres.location = (2250, 760) L(_count(9, 2100, 700).outputs[0], _sock(sres, "Count")) L(_out(sline, "Curve"), _sock(sres, "Curve")) # Sub-Aeste: gleiche Verjuengungs-Logik wie Hauptaeste (Branch Taper + Kuppel). # Vorher lief der Radius hier hart auf 0 -> die Sub-Aeste wurden zu Nadeln. sspar = N("GeometryNodeSplineParameter"); sspar.location = (2250, 900) # ... und verjuengen sich nur SCHWACH. Ein dicker Ansatz, der spitz zulaeuft, # ist genau die Dorn-Form - ein echter Zweig ist duenn und gleichmaessig. shalf = N("ShaderNodeMath"); shalf.location = (2180, 1000); shalf.operation = 'MULTIPLY' L(V["Branch Taper"], shalf.inputs[0]); shalf.inputs[1].default_value = 0.35 stap = N("ShaderNodeMath"); stap.location = (2330, 1000); stap.operation = 'MULTIPLY' L(shalf.outputs[0], stap.inputs[0]); L(sspar.outputs["Factor"], stap.inputs[1]) sinv = N("ShaderNodeMath"); sinv.location = (2400, 900) sinv.operation = 'SUBTRACT'; sinv.inputs[0].default_value = 1.0 L(stap.outputs[0], sinv.inputs[1]) sbase = N("ShaderNodeMath"); sbase.location = (2550, 900); sbase.operation = 'MULTIPLY' L(V["Trunk Radius"], sbase.inputs[0]) # Zweige muessen DEUTLICH duenner sein als die Hauptaeste. Bei Branch # Thickness 0.17 waren 0.12 rund 70 % davon -> Zweige fast so dick wie # der Ast, an dem sie sitzen. 0.06 = ~35 %, passt zum TreeIt-Verhaeltnis. sbase.inputs[1].default_value = 0.06 sdome = _dome(sspar, 2550, 1200) srad2 = _shape(sinv.outputs[0], sdome, sbase.outputs[0], 2750, 900, spar_node=sspar, collar=True) ssetr = N("GeometryNodeSetCurveRadius"); ssetr.location = (2550, 760) L(_out(sres, "Curve"), _sock(ssetr, "Curve")) L(srad2.outputs[0], _sock(ssetr, "Radius")) # Richtung: radial gestreut (Index) + "Sub Up" sdir = _child_dir(sp, V["Sub Up"], 2100, 1060) salign = _new_any("FunctionNodeAlignRotationToVector", "FunctionNodeAlignEulerToVector") if salign is not None: salign.location = (2700, 1060) try: salign.axis = 'Z' except Exception: pass L(sdir.outputs[0], _sock(salign, "Vector")) # Zweige ebenso an die Dicke ihres Elternastes koppeln. sprad = N("GeometryNodeInputRadius"); sprad.location = (2700, 300) sbaseref = N("ShaderNodeMath"); sbaseref.location = (2700, 180); sbaseref.operation = 'MULTIPLY' L(V["Trunk Radius"], sbaseref.inputs[0]); L(V["Branch Thickness"], sbaseref.inputs[1]) sratio = N("ShaderNodeMath"); sratio.location = (2850, 240); sratio.operation = 'DIVIDE' L(sprad.outputs[0], sratio.inputs[0]); L(sbaseref.outputs[0], sratio.inputs[1]) sscale = N("ShaderNodeMapRange"); sscale.location = (3000, 240) L(sratio.outputs[0], sscale.inputs["Value"]) sscale.inputs["To Min"].default_value = 0.4 sscale.inputs["To Max"].default_value = 1.0 sscale.clamp = True sinst = N("GeometryNodeInstanceOnPoints"); sinst.location = (3150, 520) L(_out(sp, "Points"), _sock(sinst, "Points")) L(_out(ssetr, "Curve"), _sock(sinst, "Instance")) L(sscale.outputs[0], _sock(sinst, "Scale")) if salign is not None: L(salign.outputs[0], _sock(sinst, "Rotation")) sreal = N("GeometryNodeRealizeInstances"); sreal.location = (3000, 520) L(_out(sinst, "Instances", "Geometry"), _sock(sreal, "Geometry")) # Sub-Aeste genauso biegen wie Stamm und Hauptaeste. Ohne diesen Schritt # blieben sie schnurgerade - das ist der Grund, warum sie wie angeklebte # Staebe wirkten. Auch hier NACH dem Realize, damit die Weltposition jedem # Zweig ein eigenes Noise gibt (sonst biegen alle identisch). sbspar = N("GeometryNodeSplineParameter"); sbspar.location = (3000, 300) sbpos = N("GeometryNodeInputPosition"); sbpos.location = (3000, 160) sbnoise = N("ShaderNodeTexNoise"); sbnoise.location = (3150, 160) sbnoise.noise_dimensions = '4D' _sock(sbnoise, "Scale").default_value = 1.6 L(sbpos.outputs[0], _sock(sbnoise, "Vector")) L(V["Seed"], _sock(sbnoise, "W")) sbsub = N("ShaderNodeVectorMath"); sbsub.location = (3320, 160) sbsub.operation = 'SUBTRACT'; sbsub.inputs[1].default_value = (0.5, 0.5, 0.5) L(sbnoise.outputs["Color"], sbsub.inputs[0]) sbfac = N("ShaderNodeMath"); sbfac.location = (3150, 300); sbfac.operation = 'MULTIPLY' L(V["Branch Bend"], sbfac.inputs[0]) L(sbspar.outputs["Factor"], sbfac.inputs[1]) sbamt = N("ShaderNodeMath"); sbamt.location = (3320, 300); sbamt.operation = 'MULTIPLY' sbamt.inputs[1].default_value = 0.5 # Zweige biegen etwas weniger als Aeste L(sbfac.outputs[0], sbamt.inputs[0]) sboff = N("ShaderNodeVectorMath"); sboff.location = (3480, 220) sboff.operation = 'SCALE' L(sbsub.outputs[0], sboff.inputs[0]) L(sbamt.outputs[0], _sock(sboff, "Scale")) sbset = N("GeometryNodeSetPosition"); sbset.location = (3640, 520) L(_out(sreal, "Geometry"), _sock(sbset, "Geometry")) L(sboff.outputs[0], _sock(sbset, "Offset")) # ---------- Curve -> Mesh, PRO EBENE mit eigener Profilaufloesung ---------- # Referenz (TreeIt): Stamm ~9 Segmente, duenne Zweige nur Dreiecke. Ein # "Curve to Mesh" kennt aber nur EIN Profil -> deshalb wird jede Ebene # einzeln gewandelt und erst danach zusammengefuegt. Spart massiv Tris, # weil die vielen duennen Zweige nicht die Rundung des Stammes brauchen. def _profile(sides_socket, x, y): c = N("GeometryNodeCurvePrimitiveCircle"); c.location = (x, y) L(sides_socket, _sock(c, "Resolution")) _sock(c, "Radius").default_value = 1.0 # Rippen (Kaktus-Kanneluren): radiale Welle auf dem Profil sp = N("GeometryNodeSplineParameter"); sp.location = (x, y - 200) ang = N("ShaderNodeMath"); ang.location = (x + 150, y - 200); ang.operation = 'MULTIPLY' ang.inputs[1].default_value = 6.283185 L(sp.outputs["Factor"], ang.inputs[0]) rb = N("ShaderNodeMath"); rb.location = (x + 300, y - 200); rb.operation = 'MULTIPLY' L(ang.outputs[0], rb.inputs[0]); L(V["Ribs"], rb.inputs[1]) co = N("ShaderNodeMath"); co.location = (x + 450, y - 200); co.operation = 'COSINE' L(rb.outputs[0], co.inputs[0]) am = N("ShaderNodeMath"); am.location = (x + 600, y - 200); am.operation = 'MULTIPLY' L(co.outputs[0], am.inputs[0]); L(V["Rib Depth"], am.inputs[1]) po = N("GeometryNodeInputPosition"); po.location = (x + 300, y - 340) dn = N("ShaderNodeVectorMath"); dn.location = (x + 450, y - 340); dn.operation = 'NORMALIZE' L(po.outputs[0], dn.inputs[0]) of = N("ShaderNodeVectorMath"); of.location = (x + 750, y - 300); of.operation = 'SCALE' L(dn.outputs[0], of.inputs[0]); L(am.outputs[0], _sock(of, "Scale")) st = N("GeometryNodeSetPosition"); st.location = (x + 900, y) L(_out(c, "Curve"), _sock(st, "Geometry")); L(of.outputs[0], _sock(st, "Offset")) # uv_u = Bogenlaenge um das Profil sp2 = N("GeometryNodeSplineParameter"); sp2.location = (x + 900, y - 200) su = N("GeometryNodeStoreNamedAttribute"); su.location = (x + 1050, y) su.domain = 'POINT'; su.data_type = 'FLOAT' L(_out(st, "Geometry"), _sock(su, "Geometry")) _sock(su, "Name").default_value = "uv_u" L(sp2.outputs["Length"], _sock(su, "Value")) return su def _to_mesh(curve_node, curve_out_name, sides_socket, x, y): """Kurven -> Mesh inkl. world-space UVs. Pro Ast-Ebene einmal.""" cs = N("GeometryNodeSplineParameter"); cs.location = (x, y - 140) sv = N("GeometryNodeStoreNamedAttribute"); sv.location = (x + 150, y) sv.domain = 'POINT'; sv.data_type = 'FLOAT' L(_out(curve_node, curve_out_name), _sock(sv, "Geometry")) _sock(sv, "Name").default_value = "uv_v" L(cs.outputs["Length"], _sock(sv, "Value")) rd = N("GeometryNodeInputRadius"); rd.location = (x, y - 280) sw = N("GeometryNodeStoreNamedAttribute"); sw.location = (x + 300, y) sw.domain = 'POINT'; sw.data_type = 'FLOAT' L(_out(sv, "Geometry"), _sock(sw, "Geometry")) _sock(sw, "Name").default_value = "uv_r" L(rd.outputs[0], _sock(sw, "Value")) prof = _profile(sides_socket, x - 1100, y - 500) cm = N("GeometryNodeCurveToMesh"); cm.location = (x + 500, y) L(_out(sw, "Geometry"), _sock(cm, "Curve")) L(_out(prof, "Geometry"), _sock(cm, "Profile Curve")) # Falle 7 (Blender 5.x): Radius-Attribut wird NICHT mehr implizit # ausgewertet -> muss an den "Scale"-Eingang. if "Scale" in cm.inputs: ra = N("GeometryNodeInputRadius"); ra.location = (x + 350, y - 420) L(ra.outputs[0], _sock(cm, "Scale")) try: _sock(cm, "Fill Caps").default_value = True except KeyError: pass nu = N("GeometryNodeInputNamedAttribute"); nu.location = (x + 500, y - 200) nu.data_type = 'FLOAT'; _sock(nu, "Name").default_value = "uv_u" nv = N("GeometryNodeInputNamedAttribute"); nv.location = (x + 500, y - 320) nv.data_type = 'FLOAT'; _sock(nv, "Name").default_value = "uv_v" nr = N("GeometryNodeInputNamedAttribute"); nr.location = (x + 500, y - 440) nr.data_type = 'FLOAT'; _sock(nr, "Name").default_value = "uv_r" um = N("ShaderNodeMath"); um.location = (x + 650, y - 200); um.operation = 'MULTIPLY' L(_out(nu, "Attribute"), um.inputs[0]); L(_out(nr, "Attribute"), um.inputs[1]) us_ = N("ShaderNodeMath"); us_.location = (x + 800, y - 200); us_.operation = 'MULTIPLY' L(um.outputs[0], us_.inputs[0]); L(V["UV Scale"], us_.inputs[1]) vs_ = N("ShaderNodeMath"); vs_.location = (x + 800, y - 320); vs_.operation = 'MULTIPLY' L(_out(nv, "Attribute"), vs_.inputs[0]); L(V["UV Scale"], vs_.inputs[1]) uv = N("ShaderNodeCombineXYZ"); uv.location = (x + 950, y - 260) L(us_.outputs[0], uv.inputs["X"]); L(vs_.outputs[0], uv.inputs["Y"]) sm = N("GeometryNodeStoreNamedAttribute"); sm.location = (x + 1100, y) sm.domain = 'CORNER'; sm.data_type = 'FLOAT2' L(_out(cm, "Mesh", "Geometry"), _sock(sm, "Geometry")) _sock(sm, "Name").default_value = "UVMap" L(uv.outputs[0], _sock(sm, "Value")) return sm mesh_trunk0 = _to_mesh(setrad, "Curve", V["Sides"], 3400, 900) # ---------- Wurzelanlauf ausformen (Wurzelbretter) ---------- # Der reine Radius-Flare ergibt einen glatten Trichter. Echte Wurzelanlaeufe # sind GELAPPT. Das laesst sich nicht ueber den Kurvenradius machen (der ist # rundum gleich), sondern erst am fertigen Mesh: Vertices nahe dem Boden # radial nach aussen schieben, moduliert ueber den Winkel. Kostet 0 Tris. fpos = N("GeometryNodeInputPosition"); fpos.location = (4750, 1150) fsep = N("ShaderNodeSeparateXYZ"); fsep.location = (4900, 1150) L(fpos.outputs[0], fsep.inputs[0]) # Hoehen-Abfall: nur das unterste Stueck ist betroffen fh = N("ShaderNodeMath"); fh.location = (5050, 1300); fh.operation = 'DIVIDE' L(fsep.outputs["Z"], fh.inputs[0]); L(V["Root Height"], fh.inputs[1]) fc = N("ShaderNodeMath"); fc.location = (5200, 1300); fc.operation = 'MINIMUM' fc.inputs[1].default_value = 1.0 L(fh.outputs[0], fc.inputs[0]) fi = N("ShaderNodeMath"); fi.location = (5350, 1300); fi.operation = 'SUBTRACT' fi.inputs[0].default_value = 1.0 L(fc.outputs[0], fi.inputs[1]) fp = N("ShaderNodeMath"); fp.location = (5500, 1300); fp.operation = 'POWER' fp.inputs[1].default_value = 2.5 L(fi.outputs[0], fp.inputs[0]) fpc = N("ShaderNodeMath"); fpc.location = (5650, 1300); fpc.operation = 'MAXIMUM' fpc.inputs[1].default_value = 0.0 L(fp.outputs[0], fpc.inputs[0]) # Lappen ueber den Winkel: cos(Lobes * atan2(y,x)), auf 0..1 gehoben fat = N("ShaderNodeMath"); fat.location = (5050, 1000); fat.operation = 'ARCTAN2' L(fsep.outputs["Y"], fat.inputs[0]); L(fsep.outputs["X"], fat.inputs[1]) fml = N("ShaderNodeMath"); fml.location = (5200, 1000); fml.operation = 'MULTIPLY' L(fat.outputs[0], fml.inputs[0]); L(V["Root Lobes"], fml.inputs[1]) fco = N("ShaderNodeMath"); fco.location = (5350, 1000); fco.operation = 'COSINE' L(fml.outputs[0], fco.inputs[0]) # cos direkt verwenden (-1..1), NICHT auf 0..1 heben: sonst druecken die # Lappen nur nach aussen und der MITTLERE Radius waechst mit der Lappentiefe # (gemessen: 0.184 -> 0.224). Zentriert bleibt der Mittelwert konstant und # es entstehen echte Taeler zwischen den Wurzelbrettern. fno = fco # Betrag = RootLobeDepth * Hoehenabfall * Lappen * Stammradius fa1 = N("ShaderNodeMath"); fa1.location = (5800, 1150); fa1.operation = 'MULTIPLY' L(fpc.outputs[0], fa1.inputs[0]); L(fno.outputs[0], fa1.inputs[1]) fa2 = N("ShaderNodeMath"); fa2.location = (5950, 1150); fa2.operation = 'MULTIPLY' L(fa1.outputs[0], fa2.inputs[0]); L(V["Root Lobe Depth"], fa2.inputs[1]) fa3 = N("ShaderNodeMath"); fa3.location = (6100, 1150); fa3.operation = 'MULTIPLY' L(fa2.outputs[0], fa3.inputs[0]); L(V["Trunk Radius"], fa3.inputs[1]) # Richtung: radial nach aussen (XY), Z bleibt unangetastet fxy = N("ShaderNodeCombineXYZ"); fxy.location = (5200, 850) L(fsep.outputs["X"], fxy.inputs["X"]); L(fsep.outputs["Y"], fxy.inputs["Y"]) fnrm = N("ShaderNodeVectorMath"); fnrm.location = (5350, 850); fnrm.operation = 'NORMALIZE' L(fxy.outputs[0], fnrm.inputs[0]) foff = N("ShaderNodeVectorMath"); foff.location = (6250, 1000); foff.operation = 'SCALE' L(fnrm.outputs[0], foff.inputs[0]); L(fa3.outputs[0], _sock(foff, "Scale")) mesh_trunk = N("GeometryNodeSetPosition"); mesh_trunk.location = (6400, 900) L(_out(mesh_trunk0, "Geometry"), _sock(mesh_trunk, "Geometry")) L(foff.outputs[0], _sock(mesh_trunk, "Offset")) mesh_branch = _to_mesh(rsetp, "Geometry", V["Branch Sides"], 3400, 200) mesh_sub = _to_mesh(sbset, "Geometry", V["Sub Sides"], 3400, -500) join = N("GeometryNodeJoinGeometry"); join.location = (4700, 300) L(_out(mesh_sub, "Geometry"), join.inputs[0]) L(_out(mesh_branch, "Geometry"), join.inputs[0]) L(_out(mesh_trunk, "Geometry"), join.inputs[0]) stuv = join # Name beibehalten fuer den nachfolgenden Merge-Zweig # ---------- optional: Aeste mit dem Stamm VERSCHMELZEN ---------- # Voxel-Remesh (Mesh to Volume -> Volume to Mesh) macht aus den sich # durchdringenden Roehren EINE organische Oberflaeche mit weichen Uebergaengen. # ACHTUNG, ehrlicher Preis: der Remesh erzeugt komplett neue Topologie -> # die UVs sind danach WEG und der Vert-Count steigt deutlich. Deshalb als # Schalter (Merge = 0 laesst den schnellen, UV-tauglichen Pfad unberuehrt). m2v = N("GeometryNodeMeshToVolume"); m2v.location = (1650, -200) L(_out(stuv, "Geometry"), _sock(m2v, "Mesh")) try: _sock(m2v, "Resolution Mode").default_value = 'Size' except Exception: pass L(V["Merge Voxel"], _sock(m2v, "Voxel Size")) v2m = N("GeometryNodeVolumeToMesh"); v2m.location = (1820, -200) L(_out(m2v, "Volume", "Geometry"), _sock(v2m, "Volume")) try: _sock(v2m, "Resolution Mode").default_value = 'Size' except Exception: pass L(V["Merge Voxel"], _sock(v2m, "Voxel Size")) pick = N("GeometryNodeSwitch"); pick.location = (2000, 100) pick.input_type = 'GEOMETRY' mgt = N("ShaderNodeMath"); mgt.location = (1820, 60); mgt.operation = 'GREATER_THAN' mgt.inputs[1].default_value = 0.5 L(V["Merge"], mgt.inputs[0]) L(mgt.outputs[0], _sock(pick, "Switch")) L(_out(stuv, "Geometry"), _sock(pick, "False")) L(_out(v2m, "Mesh", "Geometry"), _sock(pick, "True")) shade = N("GeometryNodeSetShadeSmooth"); shade.location = (2200, 200) L(_out(pick, "Output"), _sock(shade, "Geometry")) L(_out(shade, "Geometry"), gout.inputs[0]) return ng # Wachstums-Stufen: Faktoren/Werte fuer t = 0 (Setzling) .. 1 (ausgewachsen). # Gleicher Seed -> dieselbe Baum-"Identitaet", nur juenger. Genau das braucht # ein Wachstums-System, damit Stufe 1 und Stufe 4 wie DERSELBE Baum wirken. GROWTH_YOUNG = { "Height": 0.16, # Faktoren (werden mit dem Zielwert multipliziert) "Trunk Radius": 0.30, "Branch Length": 0.30, "Branch Count": 0.35, "Sub Count": 0.0, # Setzling hat noch keine Sub-Aeste } def growth_values(preset, t): """Parameter fuer Wachstums-Fortschritt t (0 = Setzling, 1 = ausgewachsen).""" full = dict(DEFAULTS) full.update(PRESETS.get(preset, {})) out = dict(full) t = max(0.0, min(1.0, t)) for key, young_factor in GROWTH_YOUNG.items(): if key not in full: continue target = full[key] young = target * young_factor v = young + (target - young) * t out[key] = max(1, int(round(v))) if isinstance(target, int) and key != "Sub Count" \ else (int(round(v)) if isinstance(target, int) else v) return out def socket_ids(ng): """Name -> Modifier-Key ('Socket_3'). Fuer Presets/Skripting.""" out = {} for s in ng.interface.items_tree: if getattr(s, "in_out", "") == 'INPUT': out[s.name] = s.identifier return out def make_object(ng, preset="baum", name=None, growth=None, seed=None): name = name or OBJ_NAME old = bpy.data.objects.get(name) if old: bpy.data.objects.remove(old, do_unlink=True) me = bpy.data.meshes.new(name + "Mesh") ob = bpy.data.objects.new(name, me) bpy.context.scene.collection.objects.link(ob) md = ob.modifiers.new("GN_Tree", 'NODES') md.node_group = ng ids = socket_ids(ng) if growth is None: values = dict(DEFAULTS) values.update(PRESETS.get(preset, {})) else: values = growth_values(preset, growth) if seed is not None: values["Seed"] = seed for k, v in values.items(): if k in ids: md[ids[k]] = v bpy.context.view_layer.objects.active = ob return ob # --------------------------------------------------------------------------- # Addon-UI # --------------------------------------------------------------------------- from bpy.props import ( IntProperty, FloatProperty, EnumProperty, BoolProperty, PointerProperty, ) from bpy.types import Operator, Panel, PropertyGroup def _preset_enum(self, context): return [(k, k.capitalize(), "") for k in PRESETS] class TreeGenSettings(PropertyGroup): preset: EnumProperty(name="Preset", items=_preset_enum) seed: IntProperty(name="Seed", default=0, min=0, max=9999) use_growth: BoolProperty( name="Wachstums-Stufen", default=False, description="Erzeugt mehrere Stufen (Setzling .. ausgewachsen) mit gleichem Seed", ) stages: IntProperty(name="Anzahl Stufen", default=4, min=2, max=8) spacing: FloatProperty(name="Abstand", default=4.0, min=0.0, max=20.0) apply_modifier: BoolProperty( name="Modifier anwenden", default=False, description="Ergebnis als normales Mesh einfrieren (fuer den Export)", ) generate_lightmap_uv: BoolProperty( name="UV1 Lightmap-UV", default=True, description=("Zweiter, nicht ueberlappender UV-Kanal fuer Unreal-Lightmaps. " "Braucht 'Modifier anwenden' (nur echte Meshes lassen sich unwrappen)"), ) def _place(ob, x): ob.location.x = x def _add_lightmap_uv(context, ob, op): """UV1 fuer Unreal-Lightmaps. Nur auf echten Meshes moeglich (nach Apply).""" if not ob.data.polygons: return lm = ob.data.uv_layers.get("Lightmap") or ob.data.uv_layers.new(name="Lightmap") # WICHTIG: aktiven Kanal setzen, sonst ueberschreibt lightmap_pack UV0. ob.data.uv_layers.active = lm for o in context.view_layer.objects: try: o.select_set(False) except (ReferenceError, RuntimeError): pass ob.select_set(True) context.view_layer.objects.active = ob try: bpy.ops.uv.lightmap_pack(PREF_CONTEXT='ALL_FACES', PREF_PACK_IN_ONE=True, PREF_NEW_UVLAYER=False, PREF_BOX_DIV=12, PREF_MARGIN_DIV=0.2) except RuntimeError as exc: op.report({'WARNING'}, "Lightmap-UV: %s" % exc) # UV0 wieder als Kanal 0 / Render-UV (Unreal nutzt die Reihenfolge) if ob.data.uv_layers: ob.data.uv_layers.active_index = 0 ob.data.uv_layers[0].active_render = True class TREEGEN_OT_create(Operator): bl_idname = "object.treegen_create" bl_label = "Baum erzeugen" bl_description = "Erzeugt einen parametrischen Baum (Regler danach am Modifier)" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): s = context.scene.tree_gen_settings try: ng = build_group() except Exception as exc: # noqa: BLE001 self.report({'ERROR'}, "Node-Gruppe fehlgeschlagen: %s" % exc) return {'CANCELLED'} try: ng.asset_mark() ng.asset_data.description = "Parametrischer Stylized-Baum (Stamm + Aeste)" except Exception: pass created = [] x = 0.0 try: if s.use_growth: for i in range(s.stages): t = i / float(s.stages - 1) if s.stages > 1 else 1.0 ob = make_object(ng, preset=s.preset, growth=t, seed=s.seed, name="%s_%s_stage%d" % (OBJ_NAME, s.preset, i)) _place(ob, x); x += s.spacing created.append(ob) else: ob = make_object(ng, preset=s.preset, seed=s.seed, name="%s_%s" % (OBJ_NAME, s.preset)) created.append(ob) except Exception as exc: # noqa: BLE001 self.report({'ERROR'}, "Erzeugen fehlgeschlagen: %s" % exc) return {'CANCELLED'} if s.apply_modifier: for ob in created: context.view_layer.objects.active = ob try: bpy.ops.object.modifier_apply(modifier="GN_Tree") except RuntimeError as exc: self.report({'WARNING'}, "Modifier-Apply: %s" % exc) continue # UV0 heisst nach dem Apply "UVMap" (aus den Geometry Nodes). if s.generate_lightmap_uv: _add_lightmap_uv(context, ob, self) context.view_layer.update() total = 0 dg = context.evaluated_depsgraph_get() for ob in created: try: total += len(ob.evaluated_get(dg).data.vertices) except Exception: pass self.report({'INFO'}, "%d Objekt(e) erzeugt, %d Verts gesamt." % (len(created), total)) return {'FINISHED'} class VIEW3D_PT_tree_generator(Panel): bl_label = "Tree Generator" bl_idname = "VIEW3D_PT_tree_generator" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = "Tree Gen" def draw(self, context): layout = self.layout s = context.scene.tree_gen_settings box = layout.box() box.label(text="Vorlage", icon='PRESET') box.prop(s, "preset", text="") box.prop(s, "seed") box = layout.box() box.label(text="Wachstum") box.prop(s, "use_growth") sub = box.column(align=True) sub.enabled = s.use_growth sub.prop(s, "stages") sub.prop(s, "spacing") box = layout.box() box.label(text="Export") box.prop(s, "apply_modifier") row = box.row() row.enabled = s.apply_modifier row.prop(s, "generate_lightmap_uv") layout.separator() layout.operator("object.treegen_create", icon='OUTLINER_OB_MESH') layout.label(text="Feinjustierung: am Modifier 'GN_Tree'", icon='INFO') classes = ( TreeGenSettings, TREEGEN_OT_create, VIEW3D_PT_tree_generator, ) def register(): for cls in classes: bpy.utils.register_class(cls) bpy.types.Scene.tree_gen_settings = PointerProperty(type=TreeGenSettings) def unregister(): del bpy.types.Scene.tree_gen_settings for cls in reversed(classes): bpy.utils.unregister_class(cls) if __name__ == "__main__": register()