diff --git a/README.md b/README.md index 1e51f1d..8a0de5a 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,15 @@ aussen (signed volume > 0), UV0 + UV1 vorhanden, und gibt eine Zusammenfassung --- +## Changelog v2.2.0 + +- **Textur-Bake-Button:** backt ein nahtlos **tileables** Stylized-Rock-Set + (BaseColor / Normal / Roughness / AO) als PNG. Nahtlos durch **4D-Torus- + Projektion** der UV (`cos/sin` von U und V) → tilet in beide Richtungen ohne + Naht, ideal fuer **Triplanar** in Unreal. +- Anleitung zur UE-Material-Verdrahtung: siehe + [docs/UE_Material_Triplanar.md](docs/UE_Material_Triplanar.md). + ## Changelog v2.1.0 - **FBX-Export-Button (UE-ready):** exportiert jeden Rock / jede LOD-Gruppe als diff --git a/blender_manifest.toml b/blender_manifest.toml index 2e5414b..fe08bf4 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "stylized_rock_generator" -version = "2.1.0" +version = "2.2.0" name = "Stylized Rock Generator" tagline = "Batch-Generator fuer stylized Rocks mit UE5-Export" maintainer = "D4rkst3r" diff --git a/dist/index.json b/dist/index.json index 8853a70..966b395 100644 --- a/dist/index.json +++ b/dist/index.json @@ -7,7 +7,7 @@ "id": "stylized_rock_generator", "name": "Stylized Rock Generator", "tagline": "Batch-Generator fuer stylized Rocks mit UE5-Export", - "version": "2.1.0", + "version": "2.2.0", "type": "add-on", "maintainer": "D4rkst3r", "license": [ @@ -23,9 +23,9 @@ "Mesh", "Modeling" ], - "archive_url": "./stylized_rock_generator-2.1.0.zip", - "archive_size": 11477, - "archive_hash": "sha256:76911905d9e283ce2cbe85ab341194e3fcfcb431eb7cf3816b96e6d2358214ae" + "archive_url": "./stylized_rock_generator-2.2.0.zip", + "archive_size": 14107, + "archive_hash": "sha256:4a4a83e9f6955f0deb0fe56dce59d8e540424df21982639e457db1b3533d9cf5" } ] } \ No newline at end of file diff --git a/dist/stylized_rock_generator-2.1.0.zip b/dist/stylized_rock_generator-2.1.0.zip deleted file mode 100644 index 67625a9..0000000 Binary files a/dist/stylized_rock_generator-2.1.0.zip and /dev/null differ diff --git a/dist/stylized_rock_generator-2.2.0.zip b/dist/stylized_rock_generator-2.2.0.zip new file mode 100644 index 0000000..e21f127 Binary files /dev/null and b/dist/stylized_rock_generator-2.2.0.zip differ diff --git a/docs/UE_Material_Triplanar.md b/docs/UE_Material_Triplanar.md new file mode 100644 index 0000000..550a98f --- /dev/null +++ b/docs/UE_Material_Triplanar.md @@ -0,0 +1,101 @@ +# Unreal-Material: Stylized Rock (Triplanar / World-Aligned) + +Ziel: **ein** Material fuer **alle** Rocks. Dank Triplanar (world-aligned) wird die +Textur nach Weltposition projiziert – unabhaengig von den UVs jedes einzelnen +Steins. Du musst also nichts pro Rock texturieren. + +Die vier Maps kommen aus dem Addon (Panel *Rock Gen → Textur-Bake*): + +| Datei | Inhalt | UE sRGB | Compression | +|------------------|-------------|---------|--------------------| +| `*_BC.png` | BaseColor | **an** | Default (BC7/DXT1) | +| `*_N.png` | Normal | aus | **Normalmap** | +| `*_R.png` | Roughness | aus | Default/Masks | +| `*_AO.png` | AO / Cavity | aus | Default/Masks | + +> Die Normal Map ist OpenGL (+Y). In UE beim Import **„Flip Green Channel"** +> aktivieren (oder gruen einmalig invertieren), sonst wirkt das Relief eingedellt. + +--- + +## 1. Texturen importieren + +1. Die vier PNGs in den Content Browser ziehen. +2. Fuer `_N`: Compression = **Normalmap**, sRGB aus, **Flip Green Channel** an. +3. Fuer `_R` und `_AO`: sRGB **aus** (lineare Daten), Compression „Masks" ist ok. +4. `_BC`: sRGB **an** (Standard). + +*(Optional, spart Samples: `_AO`, `_R` und ggf. Metallic in eine „ORM"-Textur +packen – R=AO, G=Roughness, B=Metallic. Dann unten die einzelnen Sampler durch +einen ORM-Sampler + Component-Mask ersetzen.)* + +--- + +## 2. Material anlegen + +Neues Material `M_Rock_Stylized`, *Two Sided* aus, *Shading Model* Default Lit. + +### Parameter (fuer Instanzen) +- **ScalarParameter** `WorldSize` (Default `256`) → Weltgroesse einer Kachel in + cm. Kleiner = Textur wirkt groesser/detaillierter. +- (optional) **ScalarParameter** `NormalIntensity` (Default `1.0`). + +### BaseColor / Roughness / AO (World Aligned Texture) +Fuer jede der drei Maps einen **`WorldAlignedTexture`**-Node: + +``` +TextureObject(T_..._BC) ─┐ +WorldSize (Scalar) ──────► WorldAlignedTexture ─► XYZTexture ─► BaseColor +``` + +- `WorldAlignedTexture` → Eingang **TextureObject** = die jeweilige Textur + (als *TextureObject*-Node, nicht *TextureSample*). +- Eingang **WorldSize** = `WorldSize`-Parameter. +- Ausgang **XYZTexture** verbinden: + - `T_..._BC` → **Base Color** + - `T_..._R` → **Roughness** + - `T_..._AO` → **Ambient Occlusion** + +### Normal (World Aligned Normal) +Normalen brauchen den speziellen Node **`WorldAlignedNormal`**: + +``` +TextureObject(T_..._N) ─► WorldAlignedNormal ─► XYZNormal ─► (FlattenNormal / Normal) +WorldSize ─────────────► +``` + +- Ausgang **XYZTexture (Normal)** → **Normal**. +- Intensitaet optional ueber `FlattenNormal` (Flatness = `1 - NormalIntensity`) + oder einen Multiply auf den XY-Anteil. + +> `WorldAlignedTexture`/`WorldAlignedNormal` haben einen Haken +> **„Use World Space Normal to Blend"** bzw. eine `WorldSpaceNormal`-Eingabe – +> Default passt fuer Rocks. Der Node blendet automatisch die drei Projektions- +> achsen (Triplanar). + +--- + +## 3. Feinschliff (optional) + +- **Farbvariation pro Instanz:** BaseColor mit `PerInstanceRandom` * einer leichten + Tint-Range multiplizieren → jeder Rock leicht anders, gleiche Textur. +- **Nahtlos in der Hoehe:** Da die Bake-Textur bereits nahtlos tilet, entstehen bei + Triplanar keine sichtbaren Kanten. +- **LODs:** Beim FBX-Import „Import LODs" an – das Material gilt automatisch fuer alle + LOD-Stufen. +- **Lightmaps:** Der UV1-Kanal (Lightmap) aus dem Addon wird von UE fuer statische + Beleuchtung genutzt; das Triplanar-Material nutzt ihn nicht, das ist korrekt so. + +--- + +## Kurzfassung der Verdrahtung + +``` +WorldSize(Scalar) + ├─► WorldAlignedTexture(BC) ─► XYZTexture ─► BaseColor + ├─► WorldAlignedTexture(R) ─► XYZTexture ─► Roughness + ├─► WorldAlignedTexture(AO) ─► XYZTexture ─► Ambient Occlusion + └─► WorldAlignedNormal(N) ─► XYZNormal ─► Normal +``` + +Ein Material, liegt auf jedem Rock. Fertig. diff --git a/stylized_rock_generator.py b/stylized_rock_generator.py index f2e1a4a..fced9f6 100644 --- a/stylized_rock_generator.py +++ b/stylized_rock_generator.py @@ -1,7 +1,7 @@ bl_info = { "name": "Stylized Rock Generator", "author": "D4rkst3r", - "version": (2, 1, 0), + "version": (2, 2, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Rock Gen", "description": ( @@ -107,9 +107,12 @@ def _face_count(obj): def _deselect_all(context): - for o in context.view_layer.objects: - if o.select_get(): + for o in list(context.view_layer.objects): + try: o.select_set(False) + except (ReferenceError, AttributeError, RuntimeError): + # Kann nach dem Entfernen eines Objekts kurzzeitig ungueltig sein + pass def _ensure_object_mode(context): @@ -728,6 +731,25 @@ class RockGenSettings(PropertyGroup): description="Ab dieser Rock-Anzahl wird der modale (nicht-blockierende) Modus genutzt", ) + # --- Textur-Bake (tileable, fuer Triplanar in UE) --- + bake_dir: StringProperty( + name="Bake-Ordner", default="", subtype='DIR_PATH', + description="Zielordner fuer die gebackenen Textur-PNGs", + ) + bake_prefix: StringProperty( + name="Textur-Prefix", default="T_Rock_Stylized", + description="Dateiname-Prefix (UE-Konvention T_)", + ) + bake_resolution: EnumProperty( + name="Aufloesung", + items=[('512', "512", ""), ('1024', "1024", ""), ('2048', "2048", "")], + default='1024', + ) + bake_color: BoolProperty(name="BaseColor", default=True) + bake_normal: BoolProperty(name="Normal", default=True) + bake_roughness: BoolProperty(name="Roughness", default=True) + bake_ao: BoolProperty(name="AO / Cavity", default=True) + # --- FBX-Export --- export_dir: StringProperty( name="Export-Ordner", default="", subtype='DIR_PATH', @@ -911,6 +933,226 @@ class OBJECT_OT_export_rocks_fbx(Operator): return {'FINISHED'} +# --------------------------------------------------------------------------- +# Textur-Bake (nahtlos tileable via 4D-Torus-Projektion, fuer Triplanar in UE) +# --------------------------------------------------------------------------- + +def _build_bake_material(): + """Baut ein prozedurales, nahtlos tileables Stylized-Rock-Material. + + Trick: Die 2D-UV wird als (cos u, sin u, cos v, sin v) auf einen Torus + projiziert und als 4D-Noise gesampelt -> tilet in U und V ohne Naht. + Liefert (mat, sockets) mit den fertigen Ausgangs-Sockets fuer die Bake-Passes. + """ + mat = bpy.data.materials.new("__RockBakeMat") + mat.use_nodes = True + nt = mat.node_tree + nodes, links = nt.nodes, nt.links + nodes.clear() + + out = nodes.new("ShaderNodeOutputMaterial") + bsdf = nodes.new("ShaderNodeBsdfPrincipled") + emit = nodes.new("ShaderNodeEmission") + texco = nodes.new("ShaderNodeTexCoord") + sep = nodes.new("ShaderNodeSeparateXYZ") + links.new(texco.outputs["UV"], sep.inputs[0]) + + def _circle(coord_socket): + mul = nodes.new("ShaderNodeMath"); mul.operation = 'MULTIPLY' + mul.inputs[1].default_value = TWO_PI + links.new(coord_socket, mul.inputs[0]) + cos = nodes.new("ShaderNodeMath"); cos.operation = 'COSINE' + links.new(mul.outputs[0], cos.inputs[0]) + sin = nodes.new("ShaderNodeMath"); sin.operation = 'SINE' + links.new(mul.outputs[0], sin.inputs[0]) + return cos.outputs[0], sin.outputs[0] + + cu, su = _circle(sep.outputs["X"]) + cv, sv = _circle(sep.outputs["Y"]) + comb = nodes.new("ShaderNodeCombineXYZ") + links.new(cu, comb.inputs[0]) + links.new(su, comb.inputs[1]) + links.new(cv, comb.inputs[2]) + + def _torus_noise(scale, detail): + n = nodes.new("ShaderNodeTexNoise") + n.noise_dimensions = '4D' + n.inputs["Scale"].default_value = scale + n.inputs["Detail"].default_value = detail + links.new(comb.outputs[0], n.inputs["Vector"]) + links.new(sv, n.inputs["W"]) + return n + + noise_base = _torus_noise(2.5, 6.0) # grobe Formvariation + noise_cavity = _torus_noise(5.0, 6.0) # feinere Risse/Cavity + noise_detail = _torus_noise(9.0, 8.0) # Mikro-Detail fuer die Normal Map + + # Cavity/AO: Kontrast per MapRange + ao = nodes.new("ShaderNodeMapRange") + links.new(noise_cavity.outputs["Fac"], ao.inputs["Value"]) + ao.inputs["From Min"].default_value = 0.3 + ao.inputs["From Max"].default_value = 0.7 + ao.inputs["To Min"].default_value = 0.35 + ao.inputs["To Max"].default_value = 1.0 + ao.clamp = True + + # BaseColor: kombinierter Fac (Form * Cavity) durch ColorRamp + comb_fac = nodes.new("ShaderNodeMath"); comb_fac.operation = 'MULTIPLY' + links.new(noise_base.outputs["Fac"], comb_fac.inputs[0]) + links.new(ao.outputs[0], comb_fac.inputs[1]) + ramp = nodes.new("ShaderNodeValToRGB") + ramp.color_ramp.elements[0].position = 0.25 + ramp.color_ramp.elements[0].color = (0.09, 0.09, 0.10, 1.0) + ramp.color_ramp.elements[1].position = 0.85 + ramp.color_ramp.elements[1].color = (0.46, 0.42, 0.37, 1.0) + links.new(comb_fac.outputs[0], ramp.inputs["Fac"]) + + # Roughness: rauer in dunklen Bereichen + rough = nodes.new("ShaderNodeMapRange") + links.new(noise_base.outputs["Fac"], rough.inputs["Value"]) + rough.inputs["To Min"].default_value = 0.9 + rough.inputs["To Max"].default_value = 0.6 + rough.clamp = True + + # Bump -> Normal (fuer den Normal-Bake). Height = grobe Form + Mikro-Detail, + # damit die Normal Map sichtbare Struktur bekommt (nicht flach). + height_mix = nodes.new("ShaderNodeMath"); height_mix.operation = 'ADD' + height_mix.inputs[1].default_value = 0.0 + hb = nodes.new("ShaderNodeMath"); hb.operation = 'MULTIPLY' + hb.inputs[1].default_value = 0.6 + links.new(noise_base.outputs["Fac"], hb.inputs[0]) + hd = nodes.new("ShaderNodeMath"); hd.operation = 'MULTIPLY' + hd.inputs[1].default_value = 0.4 + links.new(noise_detail.outputs["Fac"], hd.inputs[0]) + links.new(hb.outputs[0], height_mix.inputs[0]) + links.new(hd.outputs[0], height_mix.inputs[1]) + bump = nodes.new("ShaderNodeBump") + bump.inputs["Strength"].default_value = 0.9 + bump.inputs["Distance"].default_value = 0.5 + links.new(height_mix.outputs[0], bump.inputs["Height"]) + links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) + links.new(rough.outputs[0], bsdf.inputs["Roughness"]) + links.new(bump.outputs["Normal"], bsdf.inputs["Normal"]) + links.new(bsdf.outputs[0], out.inputs["Surface"]) + + sockets = { + "out": out, "bsdf": bsdf, "emit": emit, + "color": ramp.outputs["Color"], + "roughness": rough.outputs[0], + "ao": ao.outputs[0], + } + return mat, sockets + + +def _bake_one(context, mat, img_node, name, folder, res, bake_type, non_color): + img = bpy.data.images.new(name, res, res, alpha=False, float_buffer=False) + if non_color: + img.colorspace_settings.name = 'Non-Color' + img_node.image = img + mat.node_tree.nodes.active = img_node + if bake_type == 'NORMAL': + bpy.ops.object.bake(type='NORMAL', normal_space='TANGENT', use_clear=True) + else: + bpy.ops.object.bake(type='EMIT', use_clear=True) + path = os.path.join(folder, name + ".png") + img.filepath_raw = path + img.file_format = 'PNG' + img.save() + bpy.data.images.remove(img) + return path + + +class OBJECT_OT_bake_rock_textures(Operator): + bl_idname = "object.bake_rock_textures" + bl_label = "Textur backen" + bl_description = "Backt ein nahtlos tileables Stylized-Rock-Textur-Set (fuer Triplanar in UE)" + + def execute(self, context): + settings = context.scene.rock_gen_settings + folder = bpy.path.abspath(settings.bake_dir) if settings.bake_dir else "" + if not folder or not os.path.isdir(folder): + self.report({'ERROR'}, "Bitte einen gueltigen Bake-Ordner waehlen.") + return {'CANCELLED'} + + res = int(settings.bake_resolution) + prefix = settings.bake_prefix.strip() or "T_Rock_Stylized" + scene = context.scene + + # Zustand sichern + prev_engine = scene.render.engine + prev_active = context.view_layer.objects.active + prev_selected = [o for o in context.selected_objects] + prev_samples = getattr(scene.cycles, "samples", None) if hasattr(scene, "cycles") else None + + _ensure_object_mode(context) + saved = [] + plane = None + mat = None + try: + bpy.ops.mesh.primitive_plane_add(size=2.0) + plane = context.active_object + plane.name = "__RockBakePlane" + mat, sk = _build_bake_material() + plane.data.materials.clear() + plane.data.materials.append(mat) + + scene.render.engine = 'CYCLES' + if hasattr(scene, "cycles"): + scene.cycles.samples = 1 # Emit/Normal sind deterministisch + scene.render.bake.margin = max(2, res // 128) + + nodes = mat.node_tree.nodes + links = mat.node_tree.links + img_node = nodes.new("ShaderNodeTexImage") + + _deselect_all(context) + plane.select_set(True) + context.view_layer.objects.active = plane + + if settings.bake_normal: + links.new(sk["bsdf"].outputs[0], sk["out"].inputs["Surface"]) + saved.append(_bake_one(context, mat, img_node, prefix + "_N", + folder, res, 'NORMAL', non_color=True)) + + def _emit_pass(source_socket, suffix, non_color): + links.new(source_socket, sk["emit"].inputs["Color"]) + links.new(sk["emit"].outputs[0], sk["out"].inputs["Surface"]) + saved.append(_bake_one(context, mat, img_node, prefix + suffix, + folder, res, 'EMIT', non_color)) + + if settings.bake_color: + _emit_pass(sk["color"], "_BC", non_color=False) + if settings.bake_roughness: + _emit_pass(sk["roughness"], "_R", non_color=True) + if settings.bake_ao: + _emit_pass(sk["ao"], "_AO", non_color=True) + + except Exception as exc: # noqa: BLE001 + print("[RockGen] Bake fehlgeschlagen: %s" % exc) + traceback.print_exc() + self.report({'ERROR'}, "Bake fehlgeschlagen: %s (Details in der Konsole)" % exc) + finally: + if plane is not None: + bpy.data.objects.remove(plane, do_unlink=True) + if mat is not None and mat.users == 0: + bpy.data.materials.remove(mat) + scene.render.engine = prev_engine + if prev_samples is not None: + scene.cycles.samples = prev_samples + _deselect_all(context) + for o in prev_selected: + try: + o.select_set(True) + except ReferenceError: + pass + context.view_layer.objects.active = prev_active + + if saved: + self.report({'INFO'}, "%d Textur(en) gebacken nach %s" % (len(saved), folder)) + return {'FINISHED'} + return {'CANCELLED'} + + # --------------------------------------------------------------------------- # Operatoren: Presets # --------------------------------------------------------------------------- @@ -1099,6 +1341,18 @@ class VIEW3D_PT_rock_generator(Panel): box.prop(settings, "export_selected_only") box.operator("object.export_rocks_fbx", icon='EXPORT') + box = layout.box() + box.label(text="Textur-Bake (tileable / Triplanar)") + box.prop(settings, "bake_dir") + box.prop(settings, "bake_prefix") + box.prop(settings, "bake_resolution") + row = box.row(align=True) + row.prop(settings, "bake_color", toggle=True) + row.prop(settings, "bake_normal", toggle=True) + row.prop(settings, "bake_roughness", toggle=True) + row.prop(settings, "bake_ao", toggle=True) + box.operator("object.bake_rock_textures", icon='TEXTURE') + # --------------------------------------------------------------------------- # Registration @@ -1108,6 +1362,7 @@ classes = ( RockGenSettings, OBJECT_OT_generate_rocks, OBJECT_OT_export_rocks_fbx, + OBJECT_OT_bake_rock_textures, ROCKGEN_OT_preset_save, ROCKGEN_OT_preset_load, ROCKGEN_OT_preset_delete,