bl_info = { "name": "Stylized Grass Generator", "author": "D4rkst3r", "version": (1, 0, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Grass Gen", "description": ( "Gras-, Blumen- und Farn-Bueschel mit sauberer Wind-UV " "(V = 0 an der Wurzel, 1 an der Spitze) fuer SimpleGrassWind." ), "category": "Add Mesh", } # --------------------------------------------------------------------------- # WARUM DIE UV DER KERN IST # # Das Zielmaterial (EcoGame M_Grass) nutzt UEs "SimpleGrassWind" mit # WorldPositionOffset. Dessen WindWeight-Eingang braucht eine Maske, die an der # WURZEL 0 und an der SPITZE 1 ist - sonst rutscht der Halm beim Wehen vom Boden # weg oder bewegt sich gar nicht. Geprueft am Material: es liest # TextureCoordinate, aber KEINE VertexColor. Deshalb liefert dieser Generator # die Windmaske PRIMAER ueber UV0.V. # # Vertex-Farben kommen zusaetzlich (nicht als einzige Quelle): # R = Kopie der Windmaske (Fallback / andere Materialien) # G = Variation je HALM - Ebene UNTER dem Biom-Tint, den die Engine bereits # ueber PerInstanceCustomData 0/1 pro Instanz setzt. Im Material # multiplizieren, nicht ersetzen. # B = AO (unten dunkel) # A = 1 (Reserve) # --------------------------------------------------------------------------- import bpy import bmesh import math import random from bpy.props import ( IntProperty, FloatProperty, EnumProperty, BoolProperty, StringProperty, FloatVectorProperty, PointerProperty, ) from bpy.types import Operator, Panel, PropertyGroup PRESETS = { "gras": dict(blades=14, height=0.42, width=0.030, segments=4, bend=0.42, spread=0.16, tilt=0.55, taper=0.85, curl=0.0, height_var=0.45), "gras_hoch": dict(blades=11, height=0.85, width=0.026, segments=5, bend=0.60, spread=0.20, tilt=0.45, taper=0.90, curl=0.0, height_var=0.40), "steppengras": dict(blades=16, height=0.55, width=0.022, segments=4, bend=0.75, spread=0.26, tilt=0.75, taper=0.95, curl=0.0, height_var=0.55), "farn": dict(blades=7, height=0.50, width=0.075, segments=5, bend=0.70, spread=0.30, tilt=0.85, taper=0.55, curl=0.25, height_var=0.30), "blume": dict(blades=9, height=0.30, width=0.028, segments=3, bend=0.25, spread=0.12, tilt=0.35, taper=0.80, curl=0.0, height_var=0.35), } class GrassGenError(Exception): pass # --------------------------------------------------------------------------- # Geometrie # --------------------------------------------------------------------------- def _add_blade(bm, uv_layer, col_layer, cfg, rng): """Einen Halm als Streifen bauen - inkl. UV und Vertex-Farben. Der Halm wird als Kette von Quads gebaut. Pro Ring: t = 0 an der Wurzel .. 1 an der Spitze UV.V = t -> genau die Windmaske Breite = width * (1 - taper * t) """ seg = max(2, cfg["segments"]) h = cfg["height"] * (1.0 + rng.uniform(-1.0, 1.0) * cfg["height_var"] * 0.5) w = cfg["width"] * (1.0 + rng.uniform(-0.25, 0.25)) # Halm-Ausrichtung: Richtung + Neigung aus der Mitte heraus yaw = rng.uniform(0.0, math.tau) lean = cfg["tilt"] * rng.uniform(0.35, 1.0) root_r = cfg["spread"] * math.sqrt(rng.random()) rx, ry = math.cos(yaw) * root_r, math.sin(yaw) * root_r # Variation je HALM (Kanal G) - eine Zahl fuer den ganzen Halm g = rng.uniform(0.0, 1.0) rows = [] for i in range(seg + 1): t = i / float(seg) # Biegung: quadratisch, damit die Wurzel senkrecht bleibt off = (cfg["bend"] * t * t) * h # optionales Einrollen (Farn) curl = cfg["curl"] * t * t * h px = rx + math.cos(yaw) * (off * lean) py = ry + math.sin(yaw) * (off * lean) pz = h * t - curl hw = 0.5 * w * max(0.02, 1.0 - cfg["taper"] * t) # Breitenachse senkrecht zur Halm-Richtung nx, ny = -math.sin(yaw), math.cos(yaw) v_l = bm.verts.new((px - nx * hw, py - ny * hw, pz)) v_r = bm.verts.new((px + nx * hw, py + ny * hw, pz)) rows.append((v_l, v_r, t, g)) for i in range(seg): a_l, a_r, t0, _ = rows[i] b_l, b_r, t1, _ = rows[i + 1] f = bm.faces.new((a_l, a_r, b_r, b_l)) for loop, (u, t) in zip(f.loops, ((0.0, t0), (1.0, t0), (1.0, t1), (0.0, t1))): loop[uv_layer].uv = (u, t) # V = Wurzel 0 -> Spitze 1 ao = 0.35 + 0.65 * min(1.0, t * 1.6) # unten dunkler loop[col_layer] = (t, g, ao, 1.0) # R=Wind, G=Halm, B=AO def build_tuft(cfg, seed=0, name="Gras_01"): rng = random.Random(seed) me = bpy.data.meshes.new(name) bm = bmesh.new() uv_layer = bm.loops.layers.uv.new("UVMap") col_layer = bm.loops.layers.float_color.new("Cavity") for _ in range(max(1, cfg["blades"])): _add_blade(bm, uv_layer, col_layer, cfg, rng) bm.to_mesh(me) bm.free() me.update() return me # --------------------------------------------------------------------------- # Properties # --------------------------------------------------------------------------- def _preset_items(self, context): return [(k, k.replace("_", " ").capitalize(), "") for k in PRESETS] class GrassGenSettings(PropertyGroup): preset: EnumProperty(name="Preset", items=_preset_items) seed: IntProperty(name="Seed", default=0, min=0, max=9999) art_name: StringProperty( name="Art", default="", description="Name fuer den Export. Leer = Preset-Name", ) variant: IntProperty(name="Variante", default=1, min=1, max=99) blades: IntProperty(name="Halme", default=14, min=1, max=80) height: FloatProperty(name="Hoehe (m)", default=0.42, min=0.02, max=3.0) height_var: FloatProperty(name="Hoehen-Streuung", default=0.45, min=0.0, max=1.0) width: FloatProperty(name="Halmbreite (m)", default=0.030, min=0.002, max=0.3) segments: IntProperty( name="Segmente", default=4, min=2, max=10, description="Unterteilungen je Halm. Mehr = weichere Biegung im Wind", ) bend: FloatProperty(name="Biegung", default=0.42, min=0.0, max=2.0) spread: FloatProperty(name="Streuung am Boden (m)", default=0.16, min=0.0, max=1.5) tilt: FloatProperty(name="Neigung", default=0.55, min=0.0, max=2.0) taper: FloatProperty( name="Verjuengung", default=0.85, min=0.0, max=1.0, description="1 = laeuft spitz zu, 0 = gleich breit bis oben", ) curl: FloatProperty(name="Einrollen", default=0.0, min=0.0, max=1.0) tri_budget: IntProperty( name="Tri-Budget", default=400, min=0, max=20000, description="Warnung bei Ueberschreitung (Gras/Blume laut ASSETS.md: 400)", ) batch_count: IntProperty(name="Anzahl Varianten", default=1, min=1, max=32) spacing: FloatProperty(name="Abstand", default=0.8, min=0.0, max=10.0) def _cfg_from(settings): return dict( blades=settings.blades, height=settings.height, width=settings.width, segments=settings.segments, bend=settings.bend, spread=settings.spread, tilt=settings.tilt, taper=settings.taper, curl=settings.curl, height_var=settings.height_var, ) def _art(settings): return (settings.art_name.strip() or settings.preset).replace("_", " ").title().replace(" ", "") # --------------------------------------------------------------------------- # Operatoren # --------------------------------------------------------------------------- class GRASSGEN_OT_apply_preset(Operator): bl_idname = "grassgen.apply_preset" bl_label = "Preset laden" bl_description = "Uebernimmt die Werte des gewaehlten Presets in die Regler" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): s = context.scene.grass_gen_settings cfg = PRESETS.get(s.preset) if not cfg: self.report({'ERROR'}, "Preset unbekannt.") return {'CANCELLED'} for k, v in cfg.items(): if hasattr(s, k): setattr(s, k, v) self.report({'INFO'}, "Preset '%s' geladen." % s.preset) return {'FINISHED'} class GRASSGEN_OT_randomize(Operator): bl_idname = "grassgen.randomize" bl_label = "Zufaellig" bl_description = "Neuer Seed - gleiche Art, andere Halmverteilung" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): s = context.scene.grass_gen_settings s.seed = random.randint(0, 9999) self.report({'INFO'}, "Seed %d" % s.seed) return {'FINISHED'} class GRASSGEN_OT_create(Operator): bl_idname = "object.grassgen_create" bl_label = "Gras erzeugen" bl_description = "Erzeugt Gras-Bueschel mit Wind-UV (V = Wurzel 0 -> Spitze 1)" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): s = context.scene.grass_gen_settings cfg = _cfg_from(s) created, total = [], 0 try: for i in range(s.batch_count): name = "%s_%02d" % (_art(s), s.variant + i) me = build_tuft(cfg, seed=s.seed + i * 733, name=name) ob = bpy.data.objects.new(name, me) context.collection.objects.link(ob) ob.location.x = i * s.spacing created.append(ob) total += sum(len(p.vertices) - 2 for p in me.polygons) except Exception as exc: # noqa: BLE001 self.report({'ERROR'}, "Erzeugen fehlgeschlagen: %s" % exc) return {'CANCELLED'} for o in context.view_layer.objects: try: o.select_set(False) except (ReferenceError, RuntimeError): pass for ob in created: ob.select_set(True) if created: context.view_layer.objects.active = created[0] per = total // max(1, len(created)) msg = "%d Bueschel, %d Tris (je %d)" % (len(created), total, per) if s.tri_budget and per > s.tri_budget: self.report({'WARNING'}, msg + " - UEBER Budget (%d)" % s.tri_budget) else: self.report({'INFO'}, msg) return {'FINISHED'} # --------------------------------------------------------------------------- # Panel # --------------------------------------------------------------------------- class VIEW3D_PT_grass_generator(Panel): bl_label = "Grass Generator" bl_idname = "VIEW3D_PT_grass_generator" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = "Grass Gen" def draw(self, context): layout = self.layout s = context.scene.grass_gen_settings box = layout.box() box.label(text="Vorlage", icon='PRESET') row = box.row(align=True) row.prop(s, "preset", text="") row.operator("grassgen.apply_preset", text="", icon='IMPORT') row = box.row(align=True) row.prop(s, "art_name", text="") row.prop(s, "variant") box.label(text="Name: %s_%02d" % (_art(s), s.variant), icon='INFO') box = layout.box() box.label(text="Form") box.prop(s, "blades") box.prop(s, "height") box.prop(s, "height_var") box.prop(s, "width") box.prop(s, "taper") box.prop(s, "segments") box = layout.box() box.label(text="Haltung") box.prop(s, "bend") box.prop(s, "tilt") box.prop(s, "spread") box.prop(s, "curl") box = layout.box() box.label(text="Zufall", icon='FILE_REFRESH') row = box.row(align=True) row.prop(s, "seed") row.operator("grassgen.randomize", text="", icon='FILE_REFRESH') box.prop(s, "batch_count") box.prop(s, "spacing") box.prop(s, "tri_budget") layout.separator() layout.operator("object.grassgen_create", icon='OUTLINER_OB_CURVES') col = layout.column(align=True) col.label(text="UV0.V = Windmaske (Wurzel 0 - Spitze 1)", icon='INFO') col.label(text="VCol: R=Wind G=Halm B=AO") classes = ( GrassGenSettings, GRASSGEN_OT_apply_preset, GRASSGEN_OT_randomize, GRASSGEN_OT_create, VIEW3D_PT_grass_generator, ) def register(): for cls in classes: bpy.utils.register_class(cls) bpy.types.Scene.grass_gen_settings = PointerProperty(type=GrassGenSettings) def unregister(): del bpy.types.Scene.grass_gen_settings for cls in reversed(classes): bpy.utils.unregister_class(cls) if __name__ == "__main__": register()