Ein Extension-Repo kann mehrere Addons ausliefern (server-generate listet jedes Zip in dist/) -> die bereits eingetragene Blender-URL liefert jetzt Rock UND Tree, ein 'Check for Updates' aktualisiert beide. - stylized_tree_generator.py: Panel 'Tree Gen', Presets baum/palme/busch/kaktus, Wachstums-Stufen (gleicher Seed = dieselbe Baum-Identitaet), Modifier-Apply - Node-Kern 1:1 aus EcoGame tools/blender_tree_gen.py generiert (keine Abweichung) - build.ps1 baut jetzt beide Extensions - Headless getestet: alle 4 Presets, Stufen aufsteigend (504/744/1236/1872), Apply ok Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1435 lines
54 KiB
Python
1435 lines
54 KiB
Python
bl_info = {
|
|
"name": "Stylized Rock Generator",
|
|
"author": "D4rkst3r",
|
|
"version": (2, 2, 0),
|
|
"blender": (4, 2, 0),
|
|
"location": "View3D > Sidebar > Rock Gen",
|
|
"description": (
|
|
"Batch-Generator fuer stylized Rocks "
|
|
"(Subsurf -> Displace -> Decimate -> Limited Dissolve) "
|
|
"mit Presets, LOD-Gruppen, Material- und UV-Setup fuer Unreal Engine."
|
|
),
|
|
"category": "Object",
|
|
}
|
|
|
|
import bpy
|
|
import os
|
|
import json
|
|
import math
|
|
import random
|
|
import traceback
|
|
|
|
from bpy.props import (
|
|
IntProperty, FloatProperty, FloatVectorProperty, EnumProperty, BoolProperty,
|
|
StringProperty, PointerProperty,
|
|
)
|
|
from bpy.types import Operator, Panel, PropertyGroup
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Konstanten
|
|
# ---------------------------------------------------------------------------
|
|
|
|
TWO_PI = math.pi * 2.0
|
|
|
|
# Platzhalter-Materialien: (Name, Base-Color RGBA). Werden pro Rock rotiert.
|
|
MATERIAL_PALETTE = (
|
|
("M_Rock_Granite", (0.32, 0.30, 0.28, 1.0)),
|
|
("M_Rock_Basalt", (0.11, 0.11, 0.13, 1.0)),
|
|
("M_Rock_Sandstone", (0.55, 0.42, 0.30, 1.0)),
|
|
("M_Rock_Mossy", (0.24, 0.30, 0.18, 1.0)),
|
|
)
|
|
|
|
# Property-Keys, die von Presets gespeichert/geladen werden.
|
|
PRESET_KEYS = (
|
|
"base_shape", "subsurf_levels", "texture_type", "noise_scale",
|
|
"displace_strength", "seed", "decimate_ratio_1", "decimate_ratio_2",
|
|
"dissolve_angle", "generate_uv0", "generate_lightmap_uv",
|
|
"randomize_transform", "scale_min", "scale_max",
|
|
"add_bevel", "bevel_width", "bevel_segments", "add_weighted_normal",
|
|
"assign_material", "generate_lods", "lod_count", "lod_ratio_step",
|
|
"batch_count", "spacing",
|
|
"shading_mode", "autosmooth_angle",
|
|
"randomize_shape", "noise_scale_max", "displace_strength_max", "nonuniform_scale",
|
|
"flatten_bottom", "flatten_ratio", "set_origin_bottom",
|
|
)
|
|
|
|
# Eingebaute Presets (nicht ueberschreibbar/loeschbar).
|
|
BUILTIN_PRESETS = {
|
|
"Kleiner Kiesel": {
|
|
"base_shape": "ICOSPHERE", "subsurf_levels": 3, "texture_type": "CLOUDS",
|
|
"noise_scale": 0.6, "displace_strength": 0.18, "decimate_ratio_1": 0.3,
|
|
"decimate_ratio_2": 0.6, "dissolve_angle": 6.0, "scale_min": 0.4,
|
|
"scale_max": 0.7, "spacing": 1.5,
|
|
},
|
|
"Grosser Bruchfels": {
|
|
"base_shape": "CUBE", "subsurf_levels": 5, "texture_type": "VORONOI",
|
|
"noise_scale": 1.4, "displace_strength": 0.5, "decimate_ratio_1": 0.2,
|
|
"decimate_ratio_2": 0.45, "dissolve_angle": 4.0, "scale_min": 1.5,
|
|
"scale_max": 2.5, "spacing": 5.0,
|
|
},
|
|
"Lava-Rock": {
|
|
"base_shape": "ICOSPHERE", "subsurf_levels": 4,
|
|
"texture_type": "DISTORTED_NOISE", "noise_scale": 1.0,
|
|
"displace_strength": 0.35, "decimate_ratio_1": 0.25,
|
|
"decimate_ratio_2": 0.5, "dissolve_angle": 5.0, "scale_min": 0.9,
|
|
"scale_max": 1.4, "spacing": 3.0,
|
|
},
|
|
}
|
|
|
|
|
|
class RockGenError(Exception):
|
|
"""Kontrollierter Fehler innerhalb der Rock-Generierung.
|
|
|
|
Wird pro Rock gefangen, damit der Batch weiterlaeuft statt komplett
|
|
abzustuerzen.
|
|
"""
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Kleine Helfer (Fehlerbehandlung / Kontext)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _apply_modifier(context, obj, mod_name):
|
|
"""Wendet einen Modifier an und wirft bei Fehlern RockGenError."""
|
|
context.view_layer.objects.active = obj
|
|
try:
|
|
res = bpy.ops.object.modifier_apply(modifier=mod_name)
|
|
except RuntimeError as exc:
|
|
raise RockGenError("Modifier '%s' Apply fehlgeschlagen: %s" % (mod_name, exc))
|
|
if 'CANCELLED' in res:
|
|
raise RockGenError("Modifier '%s' Apply wurde abgebrochen." % mod_name)
|
|
|
|
|
|
def _face_count(obj):
|
|
return len(obj.data.polygons)
|
|
|
|
|
|
def _deselect_all(context):
|
|
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):
|
|
if context.mode != 'OBJECT':
|
|
try:
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
except RuntimeError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preset-Speicher (JSON neben dem Addon, Fallback: User-Config)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _addon_dir():
|
|
try:
|
|
return os.path.dirname(os.path.abspath(__file__))
|
|
except NameError:
|
|
# z.B. wenn aus dem Text-Editor ausgefuehrt
|
|
return bpy.utils.user_resource('CONFIG', path="stylized_rock_gen", create=True)
|
|
|
|
|
|
def _preset_path():
|
|
directory = _addon_dir()
|
|
if not os.access(directory, os.W_OK):
|
|
directory = bpy.utils.user_resource('CONFIG', path="stylized_rock_gen", create=True)
|
|
return os.path.join(directory, "rock_presets.json")
|
|
|
|
|
|
def _load_user_presets():
|
|
path = _preset_path()
|
|
if os.path.isfile(path):
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
if isinstance(data, dict):
|
|
return data
|
|
except (OSError, ValueError):
|
|
print("[RockGen] Konnte Presets nicht lesen:", path)
|
|
return {}
|
|
|
|
|
|
def _save_user_presets(data):
|
|
path = _preset_path()
|
|
try:
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
json.dump(data, fh, indent=2, ensure_ascii=False)
|
|
return True, path
|
|
except OSError as exc:
|
|
return False, str(exc)
|
|
|
|
|
|
def _settings_to_dict(settings):
|
|
out = {}
|
|
for key in PRESET_KEYS:
|
|
out[key] = getattr(settings, key)
|
|
return out
|
|
|
|
|
|
def _apply_dict_to_settings(settings, data):
|
|
for key in PRESET_KEYS:
|
|
if key in data:
|
|
try:
|
|
setattr(settings, key, data[key])
|
|
except (TypeError, ValueError):
|
|
print("[RockGen] Preset-Wert ignoriert:", key, data[key])
|
|
|
|
|
|
# Referenz-Cache: dynamische EnumProperty-Items muessen am Leben gehalten
|
|
# werden, sonst kann Blender abstuerzen (bekannter bpy-Fallstrick).
|
|
_PRESET_ENUM_CACHE = []
|
|
|
|
|
|
def _preset_items(self, context):
|
|
_PRESET_ENUM_CACHE.clear()
|
|
_PRESET_ENUM_CACHE.append(('__NONE__', "- Preset waehlen -", ""))
|
|
for name in BUILTIN_PRESETS:
|
|
_PRESET_ENUM_CACHE.append(("builtin::" + name, name, "Eingebautes Preset"))
|
|
for name in sorted(_load_user_presets().keys()):
|
|
_PRESET_ENUM_CACHE.append(("user::" + name, name, "Eigenes Preset"))
|
|
return _PRESET_ENUM_CACHE
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Material-Zuweisung (Platzhalter)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _get_or_create_material(name, color):
|
|
mat = bpy.data.materials.get(name)
|
|
if mat is None:
|
|
mat = bpy.data.materials.new(name=name)
|
|
mat.use_nodes = True
|
|
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
if bsdf is not None:
|
|
if "Base Color" in bsdf.inputs:
|
|
bsdf.inputs["Base Color"].default_value = color
|
|
if "Roughness" in bsdf.inputs:
|
|
bsdf.inputs["Roughness"].default_value = 0.85
|
|
return mat
|
|
|
|
|
|
def _assign_material(obj, index):
|
|
name, color = MATERIAL_PALETTE[index % len(MATERIAL_PALETTE)]
|
|
mat = _get_or_create_material(name, color)
|
|
obj.data.materials.clear()
|
|
obj.data.materials.append(mat)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# UV-Setup (UV0 = Smart Project, UV1 = Lightmap)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_uv0(context, obj):
|
|
"""Smart UV Project auf Kanal 0 (angle_limit ist in Blender 4.x/5.x rad!)."""
|
|
# Primitive bringen bereits eine "UVMap" mit -> Kanal 0 nutzen, sonst neu.
|
|
if not obj.data.uv_layers:
|
|
obj.data.uv_layers.new(name="UV0")
|
|
obj.data.uv_layers.active_index = 0
|
|
context.view_layer.objects.active = obj
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_all(action='SELECT')
|
|
try:
|
|
bpy.ops.uv.smart_project(
|
|
angle_limit=math.radians(66.0),
|
|
island_margin=0.02,
|
|
)
|
|
except RuntimeError as exc:
|
|
_ensure_object_mode(context)
|
|
raise RockGenError("Smart UV Project fehlgeschlagen: %s" % exc)
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
# Kanal 0 einheitlich benennen (Unreal nutzt die Reihenfolge, nicht den Namen)
|
|
if obj.data.uv_layers and obj.data.uv_layers[0].name != "Lightmap":
|
|
obj.data.uv_layers[0].name = "UV0"
|
|
|
|
|
|
def _make_lightmap_uv(context, obj):
|
|
"""Separates Lightmap-UV auf Kanal 1, danach UV0 wieder aktiv/Render."""
|
|
_deselect_all(context)
|
|
context.view_layer.objects.active = obj
|
|
obj.select_set(True)
|
|
lm = obj.data.uv_layers.get("Lightmap")
|
|
if lm is None:
|
|
lm = obj.data.uv_layers.new(name="Lightmap")
|
|
# WICHTIG: aktiven UV-Layer explizit auf Lightmap setzen, sonst wuerde
|
|
# lightmap_pack UV0 ueberschreiben.
|
|
obj.data.uv_layers.active = lm
|
|
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:
|
|
raise RockGenError("Lightmap Pack fehlgeschlagen: %s" % exc)
|
|
# UV0 wieder als Kanal 0 / Render-UV setzen (Unreal nutzt Kanal-Reihenfolge)
|
|
if obj.data.uv_layers:
|
|
obj.data.uv_layers.active_index = 0
|
|
obj.data.uv_layers[0].active_render = True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mesh-Aufbau
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_rock_mesh(context, settings, index, location, rng):
|
|
"""Baut das reine Rock-Mesh (ohne Transform/Material). Kann RockGenError werfen."""
|
|
# 1. Basis-Mesh
|
|
if settings.base_shape == 'CUBE':
|
|
bpy.ops.mesh.primitive_cube_add(size=2, location=location)
|
|
else:
|
|
bpy.ops.mesh.primitive_ico_sphere_add(
|
|
subdivisions=1, radius=1.0, location=location
|
|
)
|
|
obj = context.active_object
|
|
obj.name = "Rock_%03d" % index
|
|
|
|
# Effektive Form-Parameter (optional pro Rock variiert)
|
|
eff_noise = settings.noise_scale
|
|
eff_displace = settings.displace_strength
|
|
if settings.randomize_shape:
|
|
lo_n, hi_n = sorted((settings.noise_scale, settings.noise_scale_max))
|
|
lo_d, hi_d = sorted((settings.displace_strength, settings.displace_strength_max))
|
|
eff_noise = rng.uniform(lo_n, hi_n)
|
|
eff_displace = rng.uniform(lo_d, hi_d)
|
|
|
|
# 2. Subdivision Surface -> Apply
|
|
subsurf = obj.modifiers.new(name="Subsurf", type='SUBSURF')
|
|
subsurf.levels = settings.subsurf_levels
|
|
subsurf.render_levels = settings.subsurf_levels
|
|
_apply_modifier(context, obj, subsurf.name)
|
|
|
|
# 3. Displace + Noise-Textur -> Apply
|
|
tex = bpy.data.textures.new(name="RockTex_%03d" % index, type=settings.texture_type)
|
|
if settings.texture_type == 'VORONOI':
|
|
tex.noise_scale = eff_noise
|
|
elif settings.texture_type == 'CLOUDS':
|
|
tex.noise_scale = eff_noise
|
|
tex.noise_depth = 2
|
|
elif settings.texture_type == 'DISTORTED_NOISE':
|
|
tex.noise_scale = eff_noise
|
|
tex.distortion = 1.0
|
|
|
|
displace = obj.modifiers.new(name="Displace", type='DISPLACE')
|
|
displace.texture = tex
|
|
displace.texture_coords = 'GLOBAL'
|
|
displace.strength = eff_displace
|
|
displace.mid_level = 0.5
|
|
|
|
# Variation pro Instanz: Objekt vor dem Apply an eine zufaellige (moderate!)
|
|
# Weltposition schieben, damit ein anderer Ausschnitt der Noise-Textur
|
|
# gesampelt wird. Offset klein halten -> keine Float-Praezisionsprobleme.
|
|
original_loc = obj.location.copy()
|
|
obj.location = (
|
|
original_loc.x + rng.uniform(-25.0, 25.0),
|
|
original_loc.y + rng.uniform(-25.0, 25.0),
|
|
original_loc.z + rng.uniform(-25.0, 25.0),
|
|
)
|
|
context.view_layer.update()
|
|
try:
|
|
_apply_modifier(context, obj, displace.name)
|
|
finally:
|
|
obj.location = original_loc
|
|
if tex.users == 0:
|
|
bpy.data.textures.remove(tex)
|
|
|
|
# 4. Decimate, zweistufig
|
|
dec1 = obj.modifiers.new(name="Decimate1", type='DECIMATE')
|
|
dec1.ratio = settings.decimate_ratio_1
|
|
_apply_modifier(context, obj, dec1.name)
|
|
if _face_count(obj) == 0:
|
|
raise RockGenError("Nach Decimate 1 keine Faces mehr (ratio zu niedrig).")
|
|
|
|
dec2 = obj.modifiers.new(name="Decimate2", type='DECIMATE')
|
|
dec2.ratio = settings.decimate_ratio_2
|
|
_apply_modifier(context, obj, dec2.name)
|
|
if _face_count(obj) == 0:
|
|
raise RockGenError("Nach Decimate 2 keine Faces mehr (ratio zu niedrig).")
|
|
|
|
# 5. Limited Dissolve + Normalen neu berechnen (angle_limit ist rad!)
|
|
context.view_layer.objects.active = obj
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_all(action='SELECT')
|
|
bpy.ops.mesh.dissolve_limited(angle_limit=math.radians(settings.dissolve_angle))
|
|
bpy.ops.mesh.select_all(action='SELECT')
|
|
bpy.ops.mesh.normals_make_consistent(inside=False)
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
if _face_count(obj) == 0:
|
|
raise RockGenError("Nach Limited Dissolve keine Faces mehr uebrig.")
|
|
|
|
# 5c. Boden abflachen (optional) - vor den UVs, damit die Unterseite UVs bekommt
|
|
if settings.flatten_bottom:
|
|
_flatten_bottom(context, obj, settings)
|
|
|
|
# 6. UVs
|
|
if settings.generate_uv0:
|
|
_make_uv0(context, obj)
|
|
if settings.generate_lightmap_uv:
|
|
_make_lightmap_uv(context, obj)
|
|
|
|
return obj
|
|
|
|
|
|
def _apply_hardsurface(context, obj, settings):
|
|
"""Optional: Bevel + Weighted Normal fuer einen 'clean' Kantenlook."""
|
|
if settings.add_bevel:
|
|
bev = obj.modifiers.new(name="Bevel", type='BEVEL')
|
|
bev.width = settings.bevel_width
|
|
bev.segments = settings.bevel_segments
|
|
bev.limit_method = 'ANGLE'
|
|
bev.angle_limit = math.radians(30.0)
|
|
_apply_modifier(context, obj, bev.name)
|
|
if settings.add_weighted_normal:
|
|
_deselect_all(context)
|
|
context.view_layer.objects.active = obj
|
|
obj.select_set(True)
|
|
wn = obj.modifiers.new(name="WeightedNormal", type='WEIGHTED_NORMAL')
|
|
wn.keep_sharp = True
|
|
_apply_modifier(context, obj, wn.name)
|
|
|
|
|
|
def _apply_random_transform(context, obj, settings, rng):
|
|
if not settings.randomize_transform:
|
|
return
|
|
lo, hi = sorted((settings.scale_min, settings.scale_max))
|
|
if settings.nonuniform_scale:
|
|
obj.scale = (rng.uniform(lo, hi), rng.uniform(lo, hi), rng.uniform(lo, hi))
|
|
else:
|
|
s = rng.uniform(lo, hi)
|
|
obj.scale = (s, s, s)
|
|
obj.rotation_euler = (
|
|
rng.uniform(0.0, TWO_PI),
|
|
rng.uniform(0.0, TWO_PI),
|
|
rng.uniform(0.0, TWO_PI),
|
|
)
|
|
_deselect_all(context)
|
|
context.view_layer.objects.active = obj
|
|
obj.select_set(True)
|
|
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
|
|
|
|
|
|
def _apply_shading(context, obj, settings):
|
|
"""Setzt den Shading-Look: FLAT (facettiert), SMOOTH oder AUTO (nach Winkel)."""
|
|
_deselect_all(context)
|
|
context.view_layer.objects.active = obj
|
|
obj.select_set(True)
|
|
mode = settings.shading_mode
|
|
if mode == 'FLAT':
|
|
bpy.ops.object.shade_flat()
|
|
elif mode == 'SMOOTH':
|
|
bpy.ops.object.shade_smooth()
|
|
else: # AUTO
|
|
try:
|
|
bpy.ops.object.shade_auto_smooth(angle=math.radians(settings.autosmooth_angle))
|
|
except (RuntimeError, TypeError):
|
|
bpy.ops.object.shade_smooth()
|
|
# Scharfe Kanten nach Winkel markieren -> FBX-EDGE-Export traegt sie mit.
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_all(action='SELECT')
|
|
bpy.ops.mesh.mark_sharp(clear=True)
|
|
bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(settings.autosmooth_angle))
|
|
bpy.ops.mesh.mark_sharp()
|
|
bpy.ops.mesh.select_all(action='SELECT')
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
|
|
def _flatten_bottom(context, obj, settings):
|
|
"""Schneidet eine flache Unterseite (Bisect), damit der Rock plan aufsteht."""
|
|
zs = [v.co.z for v in obj.data.vertices]
|
|
if not zs:
|
|
return
|
|
zmin, zmax = min(zs), max(zs)
|
|
cut_z = zmin + (zmax - zmin) * settings.flatten_ratio
|
|
context.view_layer.objects.active = obj
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_all(action='SELECT')
|
|
try:
|
|
bpy.ops.mesh.bisect(
|
|
plane_co=(0.0, 0.0, cut_z),
|
|
plane_no=(0.0, 0.0, 1.0),
|
|
clear_inner=True,
|
|
use_fill=True,
|
|
)
|
|
except RuntimeError as exc:
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
raise RockGenError("Boden abflachen (bisect) fehlgeschlagen: %s" % exc)
|
|
bpy.ops.mesh.select_all(action='SELECT')
|
|
bpy.ops.mesh.normals_make_consistent(inside=False)
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
if _face_count(obj) == 0:
|
|
raise RockGenError("Nach Boden-Abflachen keine Faces mehr uebrig.")
|
|
|
|
|
|
def _set_origin_bottom(context, obj):
|
|
"""Setzt den Origin auf die Unterseiten-Mitte (fuer sauberes Aufstellen in UE)."""
|
|
mw = obj.matrix_world
|
|
coords = [mw @ v.co for v in obj.data.vertices]
|
|
if not coords:
|
|
return
|
|
cx = sum(c.x for c in coords) / len(coords)
|
|
cy = sum(c.y for c in coords) / len(coords)
|
|
zmin = min(c.z for c in coords)
|
|
scene = context.scene
|
|
prev_cursor = scene.cursor.location.copy()
|
|
scene.cursor.location = (cx, cy, zmin)
|
|
_deselect_all(context)
|
|
context.view_layer.objects.active = obj
|
|
obj.select_set(True)
|
|
bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
|
|
scene.cursor.location = prev_cursor
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LOD-Gruppen (fbx_type = LodGroup -> von Unreal beim FBX-Import erkannt)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _duplicate_object(context, src, new_name):
|
|
new_obj = src.copy()
|
|
new_obj.data = src.data.copy()
|
|
new_obj.name = new_name
|
|
new_obj.data.name = new_name
|
|
context.collection.objects.link(new_obj)
|
|
return new_obj
|
|
|
|
|
|
def _parent_keep_transform(child, parent):
|
|
child.parent = parent
|
|
child.matrix_parent_inverse = parent.matrix_world.inverted()
|
|
|
|
|
|
def _build_lod_group(context, settings, index, location, rng):
|
|
base_name = "Rock_%03d" % index
|
|
|
|
lod0 = _build_rock_mesh(context, settings, index, location, rng)
|
|
_apply_hardsurface(context, lod0, settings)
|
|
_apply_random_transform(context, lod0, settings, rng)
|
|
if settings.assign_material:
|
|
_assign_material(lod0, index)
|
|
lod0.name = base_name + "_LOD0"
|
|
lod0.data.name = base_name + "_LOD0"
|
|
|
|
created = [lod0]
|
|
|
|
ratio = 1.0
|
|
for lvl in range(1, settings.lod_count):
|
|
ratio *= settings.lod_ratio_step
|
|
lod = _duplicate_object(context, lod0, "%s_LOD%d" % (base_name, lvl))
|
|
dec = lod.modifiers.new(name="LODdec%d" % lvl, type='DECIMATE')
|
|
dec.ratio = max(0.01, ratio)
|
|
_apply_modifier(context, lod, dec.name)
|
|
if _face_count(lod) == 0:
|
|
raise RockGenError("LOD%d hat keine Faces mehr (lod_ratio_step zu niedrig)." % lvl)
|
|
# Normalen + Lightmap fuer die reduzierte Topologie neu
|
|
context.view_layer.objects.active = lod
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_all(action='SELECT')
|
|
bpy.ops.mesh.normals_make_consistent(inside=False)
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
if settings.generate_lightmap_uv:
|
|
_make_lightmap_uv(context, lod)
|
|
created.append(lod)
|
|
|
|
# Shading pro LOD (nach dem Decimate, damit scharfe Kanten zur Topologie passen)
|
|
for lod in created:
|
|
_apply_shading(context, lod, settings)
|
|
|
|
# Empty als LOD-Group-Parent
|
|
empty = bpy.data.objects.new(base_name + "_LODGroup", None)
|
|
empty.empty_display_type = 'PLAIN_AXES'
|
|
empty.location = location
|
|
empty["fbx_type"] = "LodGroup" # von Unreal beim FBX-Import ausgewertet
|
|
context.collection.objects.link(empty)
|
|
for lod in created:
|
|
_parent_keep_transform(lod, empty)
|
|
created.append(empty)
|
|
return created
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ein Rock (bzw. eine LOD-Gruppe) erzeugen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def make_one_rock(context, settings, index):
|
|
"""Erzeugt genau einen Rock / eine LOD-Gruppe. Liefert Liste der Objekte."""
|
|
rng = random.Random(settings.seed + index * 9973)
|
|
location = (index * settings.spacing, 0.0, 0.0)
|
|
|
|
if settings.generate_lods:
|
|
return _build_lod_group(context, settings, index, location, rng)
|
|
|
|
obj = _build_rock_mesh(context, settings, index, location, rng)
|
|
_apply_hardsurface(context, obj, settings)
|
|
_apply_random_transform(context, obj, settings, rng)
|
|
_apply_shading(context, obj, settings)
|
|
if settings.set_origin_bottom:
|
|
_set_origin_bottom(context, obj)
|
|
if settings.assign_material:
|
|
_assign_material(obj, index)
|
|
return [obj]
|
|
|
|
|
|
def _cleanup_partial(context, names_before):
|
|
"""Entfernt Objekte, die seit dem Snapshot neu entstanden sind (fehlerhafter Rock)."""
|
|
_ensure_object_mode(context)
|
|
for name in list(bpy.data.objects.keys()):
|
|
if name not in names_before:
|
|
obj = bpy.data.objects.get(name)
|
|
if obj is not None:
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
|
|
|
|
def _report_result(op, created, failed, cancelled=False):
|
|
n_mesh = sum(1 for o in created if getattr(o, "type", None) == 'MESH')
|
|
if failed:
|
|
op.report(
|
|
{'WARNING'},
|
|
"%d Rock(s) erzeugt, %d fehlgeschlagen (Details in der System-Konsole)." % (n_mesh, len(failed)),
|
|
)
|
|
elif cancelled:
|
|
op.report({'INFO'}, "Abgebrochen - %d Rock(s) erzeugt." % n_mesh)
|
|
else:
|
|
op.report({'INFO'}, "%d Rock(s) erzeugt." % n_mesh)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Property Group
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class RockGenSettings(PropertyGroup):
|
|
# --- Basis ---
|
|
base_shape: EnumProperty(
|
|
name="Basis-Form",
|
|
items=[('CUBE', "Cube", ""), ('ICOSPHERE', "Ico-Sphere", "")],
|
|
default='CUBE',
|
|
)
|
|
subsurf_levels: IntProperty(name="Subsurf Levels", default=5, min=1, max=6)
|
|
|
|
# --- Noise / Displace ---
|
|
texture_type: EnumProperty(
|
|
name="Noise-Typ",
|
|
items=[
|
|
('VORONOI', "Voronoi", "Facettiert / kristallin"),
|
|
('CLOUDS', "Clouds", "Weich / organisch"),
|
|
('DISTORTED_NOISE', "Distorted Noise", "Unregelmaessig"),
|
|
],
|
|
default='VORONOI',
|
|
)
|
|
noise_scale: FloatProperty(name="Noise Scale", default=1.0, min=0.01, max=10.0)
|
|
displace_strength: FloatProperty(name="Displace Staerke", default=0.3, min=0.0, max=2.0)
|
|
seed: IntProperty(name="Seed", default=0, min=0)
|
|
randomize_shape: BoolProperty(
|
|
name="Form pro Rock variieren", default=False,
|
|
description="Noise-Scale und Displace-Staerke pro Rock zufaellig zwischen Min und Max",
|
|
)
|
|
noise_scale_max: FloatProperty(name="Noise Scale Max", default=2.0, min=0.01, max=10.0)
|
|
displace_strength_max: FloatProperty(name="Displace Max", default=0.5, min=0.0, max=2.0)
|
|
|
|
# --- Decimate / Cleanup ---
|
|
decimate_ratio_1: FloatProperty(name="Decimate 1", default=0.2, min=0.01, max=1.0)
|
|
decimate_ratio_2: FloatProperty(name="Decimate 2", default=0.5, min=0.01, max=1.0)
|
|
dissolve_angle: FloatProperty(
|
|
name="Dissolve Winkel (Grad)", default=5.0, min=0.0, max=45.0,
|
|
description="Groesserer Winkel = mehr Faces werden zusammengefasst",
|
|
)
|
|
|
|
# --- UV (Unreal) ---
|
|
generate_uv0: BoolProperty(
|
|
name="UV0 (Smart Project)", default=True,
|
|
description="Fuer Material-Maker-Texturen (Albedo/Normal/Roughness/AO)",
|
|
)
|
|
generate_lightmap_uv: BoolProperty(
|
|
name="UV1 Lightmap-UV", default=True,
|
|
description="Separates, nicht ueberlappendes UV fuer Unreal Static-Mesh-Lightmaps",
|
|
)
|
|
|
|
# --- Hard-Surface (optional) ---
|
|
add_bevel: BoolProperty(
|
|
name="Bevel", default=False,
|
|
description="Optionaler Bevel-Modifier fuer einen 'clean' Kantenlook",
|
|
)
|
|
bevel_width: FloatProperty(name="Bevel Breite", default=0.02, min=0.0, max=0.5)
|
|
bevel_segments: IntProperty(name="Bevel Segmente", default=2, min=1, max=8)
|
|
add_weighted_normal: BoolProperty(
|
|
name="Weighted Normal", default=False,
|
|
description="Weighted-Normal-Modifier fuer sauberere Shading-Uebergaenge",
|
|
)
|
|
|
|
# --- Shading ---
|
|
shading_mode: EnumProperty(
|
|
name="Shading",
|
|
items=[
|
|
('FLAT', "Faceted", "Harte Flaechen - klassischer stylized Low-Poly-Look"),
|
|
('SMOOTH', "Smooth", "Voll geglaettet"),
|
|
('AUTO', "Auto-Smooth", "Glatt, aber scharfe Kanten oberhalb des Winkels"),
|
|
],
|
|
default='AUTO',
|
|
)
|
|
autosmooth_angle: FloatProperty(
|
|
name="Auto-Smooth Winkel (Grad)", default=30.0, min=0.0, max=180.0,
|
|
description="Kanten steiler als dieser Winkel bleiben hart",
|
|
)
|
|
|
|
# --- Form / Boden ---
|
|
flatten_bottom: BoolProperty(
|
|
name="Boden abflachen", default=False,
|
|
description="Schneidet eine flache Unterseite, damit der Rock plan aufsteht",
|
|
)
|
|
flatten_ratio: FloatProperty(
|
|
name="Schnitthoehe", default=0.1, min=0.0, max=0.45,
|
|
description="Wie weit von unten abgeschnitten wird (Anteil der Hoehe)",
|
|
)
|
|
set_origin_bottom: BoolProperty(
|
|
name="Origin unten-mittig", default=False,
|
|
description="Setzt den Origin auf die Unterseiten-Mitte (sauberes Aufstellen in UE)",
|
|
)
|
|
|
|
# --- Material ---
|
|
assign_material: BoolProperty(
|
|
name="Material zuweisen", default=False,
|
|
description="Weist pro Rock ein Platzhalter-Material zu (rotiert durch eine Liste)",
|
|
)
|
|
|
|
# --- LOD ---
|
|
generate_lods: BoolProperty(
|
|
name="LOD-Set erzeugen", default=False,
|
|
description="Erzeugt LOD0-LODn als LOD-Gruppe (Empty mit fbx_type=LodGroup) fuer Unreal",
|
|
)
|
|
lod_count: IntProperty(
|
|
name="LOD-Stufen", default=4, min=2, max=4,
|
|
description="Anzahl LOD-Stufen inkl. LOD0",
|
|
)
|
|
lod_ratio_step: FloatProperty(
|
|
name="LOD Reduktion/Stufe", default=0.5, min=0.1, max=0.9,
|
|
description="Decimate-Faktor je zusaetzlicher LOD-Stufe (0.5 = halbe Faces pro Stufe)",
|
|
)
|
|
|
|
# --- Transform ---
|
|
randomize_transform: BoolProperty(name="Skalierung/Rotation randomisieren", default=True)
|
|
scale_min: FloatProperty(name="Scale Min", default=0.8, min=0.1, max=5.0)
|
|
scale_max: FloatProperty(name="Scale Max", default=1.2, min=0.1, max=5.0)
|
|
nonuniform_scale: BoolProperty(
|
|
name="Nicht-uniforme Skalierung", default=False,
|
|
description="Skaliert jede Achse einzeln (flache Platten vs. runde Brocken)",
|
|
)
|
|
|
|
# --- Batch ---
|
|
batch_count: IntProperty(name="Anzahl Rocks", default=5, min=1, max=500)
|
|
spacing: FloatProperty(name="Abstand", default=3.0, min=0.0, max=20.0)
|
|
use_modal: BoolProperty(
|
|
name="Modal (Progress-Bar)", default=True,
|
|
description="Grosse Batches nicht-blockierend mit Fortschrittsanzeige erzeugen",
|
|
)
|
|
modal_threshold: IntProperty(
|
|
name="Modal ab", default=25, min=1, max=1000,
|
|
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)
|
|
bake_base_color: FloatVectorProperty(
|
|
name="Grundfarbe", subtype='COLOR', size=4,
|
|
default=(0.52, 0.47, 0.40, 1.0), min=0.0, max=1.0,
|
|
description="Grundton des Steins; Outline/Highlight werden davon abgeleitet",
|
|
)
|
|
bake_cell_scale: FloatProperty(
|
|
name="Zellgroesse", default=2.5, min=0.5, max=12.0,
|
|
description="Kleiner = groessere Zellen (weniger Bruchstuecke)",
|
|
)
|
|
|
|
# --- FBX-Export ---
|
|
export_dir: StringProperty(
|
|
name="Export-Ordner", default="", subtype='DIR_PATH',
|
|
description="Zielordner fuer die FBX-Dateien (je Rock/LOD-Gruppe eine Datei)",
|
|
)
|
|
export_selected_only: BoolProperty(
|
|
name="Nur Auswahl exportieren", default=False,
|
|
description="Exportiert nur die aktuell selektierten Rocks statt aller",
|
|
)
|
|
|
|
# --- Presets ---
|
|
active_preset: EnumProperty(name="Preset", items=_preset_items)
|
|
new_preset_name: StringProperty(name="Preset-Name", default="")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Operator: Batch erzeugen (synchron ODER modal)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class OBJECT_OT_generate_rocks(Operator):
|
|
bl_idname = "object.generate_rocks_batch"
|
|
bl_label = "Generate Rocks"
|
|
bl_description = "Erzeugt N stylized Rocks mit randomisierten Parametern"
|
|
bl_options = {'REGISTER', 'UNDO'}
|
|
|
|
_timer = None
|
|
_index = 0
|
|
_created = None
|
|
_failed = None
|
|
|
|
# --- synchroner Pfad (kleine Batches, Hintergrund, Tests) ---
|
|
def execute(self, context):
|
|
settings = context.scene.rock_gen_settings
|
|
created, failed = [], []
|
|
for i in range(settings.batch_count):
|
|
names_before = set(bpy.data.objects.keys())
|
|
try:
|
|
created.extend(make_one_rock(context, settings, i))
|
|
except Exception as exc: # noqa: BLE001 - bewusst breit, ein Rock darf nicht den Batch killen
|
|
failed.append((i, str(exc)))
|
|
print("[RockGen] Rock %d fehlgeschlagen: %s" % (i, exc))
|
|
traceback.print_exc()
|
|
_cleanup_partial(context, names_before)
|
|
_report_result(self, created, failed)
|
|
return {'FINISHED'}
|
|
|
|
# --- Einstiegspunkt: entscheidet synchron vs. modal ---
|
|
def invoke(self, context, event):
|
|
settings = context.scene.rock_gen_settings
|
|
if bpy.app.background or not settings.use_modal or settings.batch_count < settings.modal_threshold:
|
|
return self.execute(context)
|
|
|
|
self._index = 0
|
|
self._created = []
|
|
self._failed = []
|
|
wm = context.window_manager
|
|
wm.progress_begin(0, settings.batch_count)
|
|
self._timer = wm.event_timer_add(0.01, window=context.window)
|
|
wm.modal_handler_add(self)
|
|
context.workspace.status_text_set(
|
|
"Rock Gen: 0/%d (ESC = Abbrechen)" % settings.batch_count
|
|
)
|
|
return {'RUNNING_MODAL'}
|
|
|
|
def modal(self, context, event):
|
|
settings = context.scene.rock_gen_settings
|
|
|
|
if event.type == 'ESC':
|
|
self._finish(context)
|
|
_report_result(self, self._created, self._failed, cancelled=True)
|
|
return {'CANCELLED'}
|
|
|
|
if event.type == 'TIMER':
|
|
if self._index >= settings.batch_count:
|
|
self._finish(context)
|
|
_report_result(self, self._created, self._failed)
|
|
return {'FINISHED'}
|
|
|
|
names_before = set(bpy.data.objects.keys())
|
|
try:
|
|
self._created.extend(make_one_rock(context, settings, self._index))
|
|
except Exception as exc: # noqa: BLE001
|
|
self._failed.append((self._index, str(exc)))
|
|
print("[RockGen] Rock %d fehlgeschlagen: %s" % (self._index, exc))
|
|
traceback.print_exc()
|
|
_cleanup_partial(context, names_before)
|
|
|
|
self._index += 1
|
|
context.window_manager.progress_update(self._index)
|
|
context.workspace.status_text_set(
|
|
"Rock Gen: %d/%d (ESC = Abbrechen)" % (self._index, settings.batch_count)
|
|
)
|
|
return {'RUNNING_MODAL'}
|
|
|
|
return {'PASS_THROUGH'}
|
|
|
|
def _finish(self, context):
|
|
wm = context.window_manager
|
|
if self._timer is not None:
|
|
wm.event_timer_remove(self._timer)
|
|
self._timer = None
|
|
wm.progress_end()
|
|
context.workspace.status_text_set(None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FBX-Export (UE-ready)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _collect_export_targets(context, settings):
|
|
"""Top-Level-Rocks: LOD-Group-Empties + freistehende Rock-Meshes ohne Parent."""
|
|
tops = []
|
|
for o in context.scene.objects:
|
|
is_group = (o.type == 'EMPTY' and o.get("fbx_type") == "LodGroup")
|
|
is_standalone = (o.type == 'MESH' and o.name.startswith("Rock_") and o.parent is None)
|
|
if is_group or is_standalone:
|
|
tops.append(o)
|
|
if settings.export_selected_only:
|
|
sel = set(context.selected_objects)
|
|
tops = [t for t in tops
|
|
if t in sel or any(c in sel for c in t.children_recursive)]
|
|
return tops
|
|
|
|
|
|
def _safe_filename(name):
|
|
keep = "-_.() "
|
|
return "".join(c for c in name if c.isalnum() or c in keep).strip() or "Rock"
|
|
|
|
|
|
class OBJECT_OT_export_rocks_fbx(Operator):
|
|
bl_idname = "object.export_rocks_fbx"
|
|
bl_label = "FBX exportieren"
|
|
bl_description = "Exportiert jeden Rock / jede LOD-Gruppe als eigene FBX (UE-ready)"
|
|
|
|
def execute(self, context):
|
|
settings = context.scene.rock_gen_settings
|
|
directory = bpy.path.abspath(settings.export_dir) if settings.export_dir else ""
|
|
if not directory or not os.path.isdir(directory):
|
|
self.report({'ERROR'}, "Bitte einen gueltigen Export-Ordner waehlen.")
|
|
return {'CANCELLED'}
|
|
|
|
targets = _collect_export_targets(context, settings)
|
|
if not targets:
|
|
self.report({'WARNING'}, "Keine Rocks zum Exportieren gefunden.")
|
|
return {'CANCELLED'}
|
|
|
|
# AUTO -> Edge-Smoothing (traegt scharfe Kanten), sonst Face-Smoothing.
|
|
smooth_type = 'EDGE' if settings.shading_mode == 'AUTO' else 'FACE'
|
|
|
|
_ensure_object_mode(context)
|
|
exported, failed = 0, 0
|
|
for top in targets:
|
|
objs = list(top.children_recursive) + [top]
|
|
_deselect_all(context)
|
|
for o in objs:
|
|
o.select_set(True)
|
|
context.view_layer.objects.active = top
|
|
filepath = os.path.join(directory, _safe_filename(top.name) + ".fbx")
|
|
try:
|
|
bpy.ops.export_scene.fbx(
|
|
filepath=filepath,
|
|
use_selection=True,
|
|
object_types={'MESH', 'EMPTY'},
|
|
use_mesh_modifiers=True,
|
|
mesh_smooth_type=smooth_type,
|
|
use_triangles=True,
|
|
use_custom_props=True, # noetig fuer fbx_type=LodGroup
|
|
add_leaf_bones=False,
|
|
bake_anim=False,
|
|
path_mode='AUTO',
|
|
)
|
|
exported += 1
|
|
except RuntimeError as exc:
|
|
failed += 1
|
|
print("[RockGen] FBX-Export '%s' fehlgeschlagen: %s" % (top.name, exc))
|
|
|
|
if failed:
|
|
self.report({'WARNING'}, "%d FBX exportiert, %d fehlgeschlagen (Konsole)." % (exported, failed))
|
|
else:
|
|
self.report({'INFO'}, "%d FBX-Datei(en) nach %s exportiert." % (exported, directory))
|
|
return {'FINISHED'}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Textur-Bake (nahtlos tileable via 4D-Torus-Projektion, fuer Triplanar in UE)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_bake_material(base_color=(0.52, 0.47, 0.40), cell_scale=2.5):
|
|
"""Baut ein prozedurales, nahtlos tileables Stylized-Rock-Material (Zell-Look).
|
|
|
|
Trick: Die 2D-UV wird als (cos u, sin u, cos v, sin v) auf einen Torus
|
|
projiziert und 4D gesampelt -> tilet in U und V ohne Naht. Voronoi liefert
|
|
hand-painted Zellen mit dunklen Outlines; base_color/cell_scale steuern Ton
|
|
und Zellgroesse. Liefert (mat, sockets) fuer die Bake-Passes.
|
|
"""
|
|
br, bg, bb = base_color[0], base_color[1], base_color[2]
|
|
|
|
def _tone(factor):
|
|
return (min(br * factor, 1.0), min(bg * factor, 1.0), min(bb * factor, 1.0), 1.0)
|
|
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])
|
|
|
|
# Domain-Warp: verzerrt die Zellen leicht organisch (tileable, da gleiche
|
|
# Torus-Coords -> ueber die Naht hinweg stetig).
|
|
warp_noise = nodes.new("ShaderNodeTexNoise")
|
|
warp_noise.noise_dimensions = '4D'
|
|
warp_noise.inputs["Scale"].default_value = 3.0
|
|
warp_noise.inputs["Detail"].default_value = 2.0
|
|
links.new(comb.outputs[0], warp_noise.inputs["Vector"])
|
|
links.new(sv, warp_noise.inputs["W"])
|
|
warp_scale = nodes.new("ShaderNodeVectorMath"); warp_scale.operation = 'SCALE'
|
|
links.new(warp_noise.outputs["Color"], warp_scale.inputs[0])
|
|
warp_scale.inputs[3].default_value = 0.15
|
|
comb_w = nodes.new("ShaderNodeVectorMath"); comb_w.operation = 'ADD'
|
|
links.new(comb.outputs[0], comb_w.inputs[0])
|
|
links.new(warp_scale.outputs[0], comb_w.inputs[1])
|
|
|
|
# Zwei Voronoi-Nodes mit identischen Coords: Rand-Distanz + Per-Zell-Farbe.
|
|
vor_edge = nodes.new("ShaderNodeTexVoronoi")
|
|
vor_edge.voronoi_dimensions = '4D'
|
|
vor_edge.feature = 'DISTANCE_TO_EDGE'
|
|
vor_edge.inputs["Scale"].default_value = cell_scale
|
|
links.new(comb_w.outputs[0], vor_edge.inputs["Vector"])
|
|
links.new(sv, vor_edge.inputs["W"])
|
|
|
|
vor_cell = nodes.new("ShaderNodeTexVoronoi")
|
|
vor_cell.voronoi_dimensions = '4D'
|
|
vor_cell.feature = 'F1'
|
|
vor_cell.inputs["Scale"].default_value = cell_scale
|
|
links.new(comb_w.outputs[0], vor_cell.inputs["Vector"])
|
|
links.new(sv, vor_cell.inputs["W"])
|
|
|
|
# cell_fac: 0 an den Zellraendern (dunkle Outlines), 1 in der Zellmitte.
|
|
# Breiter Bereich -> jede Zelle bekommt einen weichen Woelbungs-Gradient.
|
|
cell_fac = nodes.new("ShaderNodeMapRange")
|
|
links.new(vor_edge.outputs["Distance"], cell_fac.inputs["Value"])
|
|
cell_fac.inputs["From Min"].default_value = 0.0
|
|
cell_fac.inputs["From Max"].default_value = 0.32
|
|
cell_fac.inputs["To Min"].default_value = 0.0
|
|
cell_fac.inputs["To Max"].default_value = 1.0
|
|
cell_fac.clamp = True
|
|
|
|
# Per-Zell-Helligkeit (leichte Variation Zelle zu Zelle)
|
|
cell_bw = nodes.new("ShaderNodeRGBToBW")
|
|
links.new(vor_cell.outputs["Color"], cell_bw.inputs[0])
|
|
cell_var = nodes.new("ShaderNodeMapRange")
|
|
links.new(cell_bw.outputs[0], cell_var.inputs["Value"])
|
|
cell_var.inputs["To Min"].default_value = 0.78
|
|
cell_var.inputs["To Max"].default_value = 1.08
|
|
cell_var.clamp = True
|
|
|
|
combined = nodes.new("ShaderNodeMath"); combined.operation = 'MULTIPLY'
|
|
links.new(cell_fac.outputs[0], combined.inputs[0])
|
|
links.new(cell_var.outputs[0], combined.inputs[1])
|
|
|
|
# BaseColor: dunkle Outline -> Zell-Ton -> Highlight (hand-painted Look),
|
|
# alle Toene aus base_color abgeleitet.
|
|
ramp = nodes.new("ShaderNodeValToRGB")
|
|
cr = ramp.color_ramp
|
|
cr.elements[0].position = 0.0
|
|
cr.elements[0].color = _tone(0.12) # Riss / Outline
|
|
cr.elements[1].position = 0.85
|
|
cr.elements[1].color = _tone(1.22) # Zell-Highlight
|
|
mid = cr.elements.new(0.32)
|
|
mid.color = _tone(0.72) # Zell-Grundton
|
|
links.new(combined.outputs[0], ramp.inputs["Fac"])
|
|
|
|
# Roughness: an den Rissen etwas rauer
|
|
rough = nodes.new("ShaderNodeMapRange")
|
|
links.new(cell_fac.outputs[0], rough.inputs["Value"])
|
|
rough.inputs["To Min"].default_value = 0.95
|
|
rough.inputs["To Max"].default_value = 0.72
|
|
rough.clamp = True
|
|
|
|
# AO: Zellraender abgedunkelt
|
|
ao = nodes.new("ShaderNodeMapRange")
|
|
links.new(cell_fac.outputs[0], ao.inputs["Value"])
|
|
ao.inputs["To Min"].default_value = 0.35
|
|
ao.inputs["To Max"].default_value = 1.0
|
|
ao.clamp = True
|
|
|
|
# Bump: Risse werden zu Rillen (cell_fac niedrig am Rand -> vertieft)
|
|
bump = nodes.new("ShaderNodeBump")
|
|
bump.inputs["Strength"].default_value = 0.7
|
|
bump.inputs["Distance"].default_value = 0.4
|
|
links.new(cell_fac.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(
|
|
base_color=tuple(settings.bake_base_color),
|
|
cell_scale=settings.bake_cell_scale,
|
|
)
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class ROCKGEN_OT_preset_save(Operator):
|
|
bl_idname = "rockgen.preset_save"
|
|
bl_label = "Preset speichern"
|
|
bl_description = "Speichert die aktuellen Einstellungen als benanntes Preset (JSON)"
|
|
|
|
def execute(self, context):
|
|
settings = context.scene.rock_gen_settings
|
|
name = settings.new_preset_name.strip()
|
|
if not name:
|
|
self.report({'ERROR'}, "Bitte einen Preset-Namen eingeben.")
|
|
return {'CANCELLED'}
|
|
if name in BUILTIN_PRESETS:
|
|
self.report({'ERROR'}, "Name kollidiert mit einem eingebauten Preset.")
|
|
return {'CANCELLED'}
|
|
presets = _load_user_presets()
|
|
presets[name] = _settings_to_dict(settings)
|
|
ok, info = _save_user_presets(presets)
|
|
if not ok:
|
|
self.report({'ERROR'}, "Speichern fehlgeschlagen: %s" % info)
|
|
return {'CANCELLED'}
|
|
settings.active_preset = "user::" + name
|
|
self.report({'INFO'}, "Preset '%s' gespeichert." % name)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class ROCKGEN_OT_preset_load(Operator):
|
|
bl_idname = "rockgen.preset_load"
|
|
bl_label = "Preset laden"
|
|
bl_description = "Laedt das gewaehlte Preset in die Einstellungen"
|
|
|
|
def execute(self, context):
|
|
settings = context.scene.rock_gen_settings
|
|
sel = settings.active_preset
|
|
if sel == '__NONE__':
|
|
self.report({'WARNING'}, "Kein Preset gewaehlt.")
|
|
return {'CANCELLED'}
|
|
kind, _, name = sel.partition("::")
|
|
data = BUILTIN_PRESETS.get(name) if kind == 'builtin' else _load_user_presets().get(name)
|
|
if not data:
|
|
self.report({'ERROR'}, "Preset nicht gefunden.")
|
|
return {'CANCELLED'}
|
|
_apply_dict_to_settings(settings, data)
|
|
self.report({'INFO'}, "Preset '%s' geladen." % name)
|
|
return {'FINISHED'}
|
|
|
|
|
|
class ROCKGEN_OT_preset_delete(Operator):
|
|
bl_idname = "rockgen.preset_delete"
|
|
bl_label = "Preset loeschen"
|
|
bl_description = "Loescht das gewaehlte eigene Preset"
|
|
|
|
def invoke(self, context, event):
|
|
return context.window_manager.invoke_confirm(self, event)
|
|
|
|
def execute(self, context):
|
|
settings = context.scene.rock_gen_settings
|
|
kind, _, name = settings.active_preset.partition("::")
|
|
if kind != 'user':
|
|
self.report({'WARNING'}, "Nur eigene Presets koennen geloescht werden.")
|
|
return {'CANCELLED'}
|
|
presets = _load_user_presets()
|
|
if name in presets:
|
|
del presets[name]
|
|
ok, info = _save_user_presets(presets)
|
|
if not ok:
|
|
self.report({'ERROR'}, "Loeschen fehlgeschlagen: %s" % info)
|
|
return {'CANCELLED'}
|
|
settings.active_preset = '__NONE__'
|
|
self.report({'INFO'}, "Preset '%s' geloescht." % name)
|
|
return {'FINISHED'}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Panel
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class VIEW3D_PT_rock_generator(Panel):
|
|
bl_label = "Rock Generator"
|
|
bl_idname = "VIEW3D_PT_rock_generator"
|
|
bl_space_type = 'VIEW_3D'
|
|
bl_region_type = 'UI'
|
|
bl_category = "Rock Gen"
|
|
|
|
def draw(self, context):
|
|
layout = self.layout
|
|
settings = context.scene.rock_gen_settings
|
|
|
|
box = layout.box()
|
|
box.label(text="Presets", icon='PRESET')
|
|
box.prop(settings, "active_preset", text="")
|
|
row = box.row(align=True)
|
|
row.operator("rockgen.preset_load", text="Laden", icon='IMPORT')
|
|
row.operator("rockgen.preset_delete", text="Loeschen", icon='TRASH')
|
|
row = box.row(align=True)
|
|
row.prop(settings, "new_preset_name", text="")
|
|
row.operator("rockgen.preset_save", text="Speichern", icon='ADD')
|
|
|
|
box = layout.box()
|
|
box.label(text="Basis")
|
|
box.prop(settings, "base_shape")
|
|
box.prop(settings, "subsurf_levels")
|
|
|
|
box = layout.box()
|
|
box.label(text="Noise / Displace")
|
|
box.prop(settings, "texture_type")
|
|
box.prop(settings, "noise_scale")
|
|
box.prop(settings, "displace_strength")
|
|
box.prop(settings, "seed")
|
|
box.prop(settings, "randomize_shape")
|
|
sub = box.column(align=True)
|
|
sub.enabled = settings.randomize_shape
|
|
sub.prop(settings, "noise_scale_max")
|
|
sub.prop(settings, "displace_strength_max")
|
|
|
|
box = layout.box()
|
|
box.label(text="Decimate / Cleanup")
|
|
box.prop(settings, "decimate_ratio_1")
|
|
box.prop(settings, "decimate_ratio_2")
|
|
box.prop(settings, "dissolve_angle")
|
|
|
|
box = layout.box()
|
|
box.label(text="Hard-Surface (optional)")
|
|
box.prop(settings, "add_bevel")
|
|
sub = box.column(align=True)
|
|
sub.enabled = settings.add_bevel
|
|
sub.prop(settings, "bevel_width")
|
|
sub.prop(settings, "bevel_segments")
|
|
box.prop(settings, "add_weighted_normal")
|
|
|
|
box = layout.box()
|
|
box.label(text="Shading / Form")
|
|
box.prop(settings, "shading_mode")
|
|
row = box.row()
|
|
row.enabled = settings.shading_mode == 'AUTO'
|
|
row.prop(settings, "autosmooth_angle")
|
|
box.prop(settings, "flatten_bottom")
|
|
row = box.row()
|
|
row.enabled = settings.flatten_bottom
|
|
row.prop(settings, "flatten_ratio")
|
|
box.prop(settings, "set_origin_bottom")
|
|
|
|
box = layout.box()
|
|
box.label(text="UV (Unreal Export)")
|
|
box.prop(settings, "generate_uv0")
|
|
box.prop(settings, "generate_lightmap_uv")
|
|
|
|
box = layout.box()
|
|
box.label(text="LOD")
|
|
box.prop(settings, "generate_lods")
|
|
sub = box.column(align=True)
|
|
sub.enabled = settings.generate_lods
|
|
sub.prop(settings, "lod_count")
|
|
sub.prop(settings, "lod_ratio_step")
|
|
|
|
box = layout.box()
|
|
box.label(text="Material")
|
|
box.prop(settings, "assign_material")
|
|
|
|
box = layout.box()
|
|
box.label(text="Transform")
|
|
box.prop(settings, "randomize_transform")
|
|
row = box.row(align=True)
|
|
row.prop(settings, "scale_min")
|
|
row.prop(settings, "scale_max")
|
|
box.prop(settings, "nonuniform_scale")
|
|
|
|
box = layout.box()
|
|
box.label(text="Batch")
|
|
box.prop(settings, "batch_count")
|
|
box.prop(settings, "spacing")
|
|
box.prop(settings, "use_modal")
|
|
sub = box.row()
|
|
sub.enabled = settings.use_modal
|
|
sub.prop(settings, "modal_threshold")
|
|
|
|
layout.separator()
|
|
layout.operator("object.generate_rocks_batch", icon='MESH_ICOSPHERE')
|
|
|
|
box = layout.box()
|
|
box.label(text="FBX-Export (Unreal)")
|
|
box.prop(settings, "export_dir")
|
|
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")
|
|
box.prop(settings, "bake_base_color")
|
|
box.prop(settings, "bake_cell_scale")
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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,
|
|
VIEW3D_PT_rock_generator,
|
|
)
|
|
|
|
|
|
def register():
|
|
for cls in classes:
|
|
bpy.utils.register_class(cls)
|
|
bpy.types.Scene.rock_gen_settings = PointerProperty(type=RockGenSettings)
|
|
|
|
|
|
def unregister():
|
|
del bpy.types.Scene.rock_gen_settings
|
|
for cls in reversed(classes):
|
|
bpy.utils.unregister_class(cls)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
register()
|