v2.2.0: Textur-Bake-Button (tileable, fuer Triplanar in UE) + UE-Material-Anleitung

- Bake BaseColor/Normal/Roughness/AO als PNG
- Nahtlos tileable via 4D-Torus-Projektion der UV (cos/sin u,v)
- Cycles-Bake mit Zustands-Restore, temporaere Bake-Plane + Cleanup
- docs/UE_Material_Triplanar.md: WorldAlignedTexture/Normal-Verdrahtung
- _deselect_all gegen transiente None-Refs nach Objekt-Removal gehaertet
- Live gegen Blender 5.1.2 getestet (4 Maps, Normal jetzt mit Struktur), Headless gruen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 07:11:35 +02:00
co-authored by Claude Opus 4.8
parent 46fc05eec4
commit a8ca7a6453
7 changed files with 373 additions and 8 deletions
+258 -3
View File
@@ -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,