Rock Gen 2.15.0: Erzfels-Set (Punkt 5) + Smooth/dicht (Punkt 6)

Punkt 5 - EIN neutrales Erzfels-Set fuer ALLE Erzsorten
  A-Kanal traegt die Erz-Maske: sie sagt nur WO Erz sitzt. WELCHES Erz
  (Kupfer/Eisen/Gold = Tint + Gluehen) macht die Material-Instanz in UE.
  Knopf "Erzfels-Set erzeugen (S + M)" liefert Rock_Ore_S_NN und Rock_Ore_M_NN
  und setzt alle Einstellungen danach zurueck. Regler: Erz-Anteil,
  Nugget-Groesse (Radius in m), Erz-Seed.

  Nuggets statt Noise: gestreute Zentren mit weichem Abfall. Ein Noise-Feld
  gaebe wolkige Schlieren, Erz sitzt aber in abgegrenzten Einschluessen. Die
  Zentren bevorzugen Fugen - Adern folgen Rissen, und das Gluehen liest sich
  in einer Vertiefung besser.

  Ohne Erz-Bake bleibt A auf 1.0 - wie im eingefrorenen Layout v2 und wie das
  bestehende 25er-Set es traegt.

Punkt 6 vorgezogen, weil Punkt 5 davon abhaengt
  Die Maskenaufloesung haengt an der Vertex-Dichte. Bei den bisherigen
  ~150-Vert-Steinen war jedes dritte Vertex ein Nugget-Zentrum: im Render
  liefen die Nuggets zu grossen Schlieren zusammen statt einzelne Einschluesse
  zu bleiben. Bei ~1350 Verts liest es sich richtig. Deshalb gibt es jetzt
  "Smooth & dicht" (Unterteilung 5, Decimate 0.40/0.80, Auto-Smooth) und das
  Erz-Set stellt selbst darauf um. Gemessen ueber drei Seeds: 2708/2658/2716
  Tris - im Zielfenster 1500-3000. Zusaetzlich ist die Nuggetzahl auf ein
  Zentrum je ~10 Verts gedeckelt.

Nebenbei: _asset_name schrieb die Form-Bezeichnung pauschal gross, daraus wurde
"Rock_ORE_S_01" statt des vereinbarten "Rock_Ore_S_01". Einzelne Buchstaben
(Form A, B, C) werden weiter gross geschrieben, laengere Bezeichner behalten
ihre Schreibweise.

Gemessen: Erz-Anteil 0.08 -> 4 %, 0.18 -> 9 %, 0.35 -> 15 % der Oberflaeche,
A 0.00..1.00, S bis 0.80 m / M ab 1.71 m, Maske ueberlebt den FBX-Roundtrip.
Neu: tests/test_erz.py. 16 Tests bestehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 08:13:31 +02:00
co-authored by Claude Opus 4.8
parent 20a751edca
commit f5b528a874
8 changed files with 436 additions and 15 deletions
+227 -6
View File
@@ -1,7 +1,7 @@
bl_info = {
"name": "Stylized Rock Generator",
"author": "D4rkst3r",
"version": (2, 14, 0),
"version": (2, 15, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > Rock Gen",
"description": (
@@ -48,6 +48,16 @@ MATERIAL_PALETTE = (
# die FORM innerhalb einer Klasse und wird von "keep_size" auf konstantes
# Volumen normiert, damit sie sich nicht mit den ScaleMin/Max der Engine
# multipliziert.
# "Smooth & dicht": kein Low-Poly-Look, und die Vertex-Masken (Cavity, Moos,
# Erz) haengen direkt an der Vertex-Dichte. Gemessen ueber drei Seeds:
# ~1350 Verts / ~2694 Tris - im Zielfenster 1500-3000 Tris.
DENSE_PRESET = {
"subsurf_levels": 5,
"decimate_ratio_1": 0.40,
"decimate_ratio_2": 0.80,
"shading_mode": 'AUTO',
}
CLASS_SIZES = {'S': 0.45, 'M': 1.0, 'L': 1.9, 'XL': 3.2}
# Property-Keys, die von Presets gespeichert/geladen werden.
@@ -117,9 +127,13 @@ def _apply_modifier(context, obj, mod_name):
def _asset_name(settings, index, batch_count):
"""EcoGame-Schema: Fels_<Form>_<Klasse>, bei Batches mit laufender Nummer."""
# Einzelne Buchstaben werden gross geschrieben (Form A, B, C ...), laengere
# Bezeichner behalten ihre Schreibweise - sonst wuerde aus dem vereinbarten
# "Rock_Ore_S_01" ein "Rock_ORE_S_01".
form = settings.name_form.strip() or "A"
form = form.upper() if len(form) == 1 else form
base = "%s_%s_%s" % (settings.name_prefix.strip() or "Fels",
(settings.name_form.strip() or "A").upper(),
settings.name_class)
form, settings.name_class)
if settings.name_numbered and batch_count > 1:
return "%s_%02d" % (base, index + 1)
return base
@@ -480,6 +494,85 @@ def _apply_random_transform(context, obj, settings, rng):
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
def _ore_mask(me, settings):
"""Erz-Maske je VERTEX: gestreute Nuggets mit weichem Abfall.
Warum Nuggets und kein Noise: ein Noise-Feld ergibt wolkige Schlieren ueber
den ganzen Stein. Erz sitzt aber in abgegrenzten Einschluessen. Deshalb
werden Zentren gestreut und der Abfall darum herum gerechnet - dadurch sind
Menge und Korngroesse unabhaengig voneinander regelbar.
Die Zentren bevorzugen Fugen (niedrige Cavity): Adern folgen Rissen, und
das Gluehen liest sich in einer Vertiefung deutlich besser als auf einer
flachen Platte.
"""
import random as _rnd
from mathutils import kdtree
n_verts = len(me.vertices)
if n_verts == 0:
return None
radius = max(settings.ore_nugget_size, 1e-4)
# Anzahl aus der Oberflaeche ableiten, nicht fest: sonst haette ein
# S-Brocken dieselbe Nuggetzahl wie ein M-Brocken und waere uebersaet.
area = sum(p.area for p in me.polygons)
pro_nugget = math.pi * radius * radius
n_nuggets = int(round(settings.ore_amount * area / max(pro_nugget, 1e-6)))
# Deckel an der AUFLOESUNG, nicht nur an einer festen Zahl: die Maske haengt
# an der Vertex-Dichte. Bei einem 150-Vert-Stein waren 46 Zentren jeder
# dritte Vertex - die Nuggets liefen zu grossen Schlieren zusammen statt
# einzelne Einschluesse zu bleiben. Ein Zentrum je ~10 Verts haelt sie
# getrennt. Wer feinere Nuggets will, braucht ein dichteres Mesh.
n_nuggets = max(1, min(n_nuggets, 96, max(1, n_verts // 10)))
# Fugen-Gewicht je Vertex aus der bereits gebackenen Cavity im R-Kanal.
layer = me.color_attributes.get("Cavity")
gewicht = [1.0] * n_verts
if layer is not None:
summe = [0.0] * n_verts
zahl = [0] * n_verts
for li, loop in enumerate(me.loops):
vi = loop.vertex_index
summe[vi] += layer.data[li].color[0]
zahl[vi] += 1
for vi in range(n_verts):
if zahl[vi]:
# 1 - Cavity -> in der Fuge gross
gewicht[vi] = 0.25 + 0.75 * (1.0 - summe[vi] / zahl[vi])
rng = _rnd.Random(settings.ore_seed)
gesamt = sum(gewicht) or 1.0
zentren = []
for _ in range(n_nuggets):
# gewichtetes Ziehen ohne cumsum-Tabelle (Meshes sind klein)
ziel = rng.random() * gesamt
lauf = 0.0
pick = n_verts - 1
for vi in range(n_verts):
lauf += gewicht[vi]
if lauf >= ziel:
pick = vi
break
zentren.append(me.vertices[pick].co.copy())
baum = kdtree.KDTree(len(zentren))
for i, co in enumerate(zentren):
baum.insert(co, i)
baum.balance()
maske = [0.0] * n_verts
for vi, v in enumerate(me.vertices):
_, _, dist = baum.find(v.co)
if dist is None or dist >= radius:
continue
t = 1.0 - dist / radius
maske[vi] = t * t * (3.0 - 2.0 * t) # Smoothstep: weiche Raender
return maske
def _bake_vertex_cavity(context, obj, settings):
"""Vertex-Farb-Layout v2 - KANAL-GETRENNT im Attribut "Cavity".
@@ -536,6 +629,17 @@ def _bake_vertex_cavity(context, obj, settings):
moss = settings.moss_amount
mnoise = settings.moss_noise
# --- A: Erz-Maske (Nuggets) ---------------------------------------------
# EIN neutrales Set fuer ALLE Erzsorten: die Maske sagt nur WO Erz sitzt.
# WELCHES Erz (Kupfer/Eisen/Gold = Tint + Gluehen) entscheidet die
# Material-Instanz in UE.
#
# Nuggets statt Rauschen: gestreute Zentren mit weichem Abfall. Ein
# Noise-Feld gaebe wolkige Schlieren, keine Einschluesse.
ore_a = None
if getattr(settings, "bake_ore", False) and settings.ore_amount > 0.0:
ore_a = _ore_mask(me, settings)
for poly in me.polygons:
nz = poly.normal.z # 1 = zeigt nach oben
for li in poly.loop_indices:
@@ -555,7 +659,12 @@ def _bake_vertex_cavity(context, obj, settings):
n = 1.0 - mnoise + mnoise * vnoise[vi]
g = max(0.0, min(1.0, base * n * moss * 1.6))
data[li].color = (r, g, b, 1.0)
# A: Erz-Maske. Ohne Erz bleibt der Kanal auf 1.0 - so wie im
# eingefrorenen Layout v2 festgelegt und wie das bestehende
# 25er-Set es bereits traegt.
a = 1.0 if ore_a is None else ore_a[vi]
data[li].color = (r, g, b, a)
def _apply_shading(context, obj, settings):
@@ -877,6 +986,27 @@ class RockGenSettings(PropertyGroup):
name="Weichzeichnen", default=1, min=0, max=5,
description="Glaettet den Cavity-Verlauf ueber benachbarte Vertices",
)
# --- Erz (A-Kanal) ---
bake_ore: BoolProperty(
name="Erz-Maske (A-Kanal)", default=False,
description=("Schreibt Erz-Nuggets in den A-Kanal. EIN neutrales Set fuer "
"ALLE Erzsorten - welches Erz es ist (Tint + Gluehen), "
"entscheidet die Material-Instanz in UE. Aus = A bleibt 1.0"),
)
ore_amount: FloatProperty(
name="Erz-Anteil", default=0.18, min=0.0, max=1.0,
description=("Wie viel der Oberflaeche Erz traegt. Die Nuggetzahl wird aus "
"der Flaeche abgeleitet, damit ein S-Brocken nicht uebersaet ist"),
)
ore_nugget_size: FloatProperty(
name="Nugget-Groesse", default=0.10, min=0.01, max=1.0, unit='LENGTH',
description="Radius eines einzelnen Erz-Einschlusses in Metern",
)
ore_seed: IntProperty(
name="Erz-Seed", default=0, min=0, max=9999,
description="Andere Verteilung der Nuggets bei gleicher Form",
)
part_mask_from_shells: BoolProperty(
name="G = Teil-Maske (Shells)", default=False,
description=("Statt der Moos-Maske: getrennte Mesh-Teile markieren. "
@@ -1435,6 +1565,84 @@ def _safe_filename(name):
return "".join(c for c in name if c.isalnum() or c in keep).strip() or "Rock"
class ROCKGEN_OT_dense_preset(Operator):
"""Stellt auf smooth und dicht um (~2700 Tris statt ~620)."""
bl_idname = "rockgen.dense_preset"
bl_label = "Smooth & dicht"
bl_description = ("Setzt Unterteilung, Decimate und Shading auf den dichten "
"Smooth-Look (~1500-3000 Tris). Feinere Cavity- und "
"Erz-Aufloesung, kein Low-Poly-Look")
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
s = context.scene.rock_gen_settings
for k, v in DENSE_PRESET.items():
setattr(s, k, v)
self.report({'INFO'}, "Smooth & dicht: Unterteilung %d, Decimate %.2f/%.2f, "
"Shading %s" % (DENSE_PRESET["subsurf_levels"],
DENSE_PRESET["decimate_ratio_1"],
DENSE_PRESET["decimate_ratio_2"],
DENSE_PRESET["shading_mode"]))
return {'FINISHED'}
class ROCKGEN_OT_ore_set(Operator):
"""Erzeugt das Erzfels-Set: EIN neutrales Set in S und M.
Bewusst kein Set je Erzsorte - die A-Kanal-Maske sagt nur, WO Erz sitzt.
Welches Erz es ist (Kupfer/Eisen/Gold = Tint + Gluehen), entscheidet die
Material-Instanz in UE. S und M, weil die Brocken in Hoehlengaenge passen
muessen.
"""
bl_idname = "rockgen.ore_set"
bl_label = "Erzfels-Set erzeugen (S + M)"
bl_description = ("Erzeugt Rock_Ore_S_NN und Rock_Ore_M_NN mit Erz-Maske im "
"A-Kanal - ein Set fuer alle Erzsorten")
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
s = context.scene.rock_gen_settings
vorher = {
"name_prefix": s.name_prefix, "name_form": s.name_form,
"name_class": s.name_class, "name_numbered": s.name_numbered,
"bake_ore": s.bake_ore, "bake_vertex_cavity": s.bake_vertex_cavity,
"use_class_size": s.use_class_size, "seed": s.seed,
}
vorher.update({k: getattr(s, k) for k in DENSE_PRESET})
# Erz MUSS dicht: die Maske haengt an der Vertex-Dichte. Bei ~150 Verts
# sind die Nugget-Zentren jeder dritte Vertex und laufen zu Schlieren
# zusammen - gemessen und im Render bestaetigt. Bei ~1350 Verts liest es
# sich als einzelne Einschluesse.
for k, v in DENSE_PRESET.items():
setattr(s, k, v)
s.name_prefix = "Rock"
s.name_form = "Ore"
s.name_numbered = True
s.bake_ore = True
s.bake_vertex_cavity = True # A haengt am selben Attribut
s.use_class_size = True # S und M sollen sich in der Groesse unterscheiden
gesamt = 0
try:
for i, klasse in enumerate(("S", "M")):
s.name_class = klasse
# Anderer Seed je Klasse, sonst ist der M-Brocken nur ein
# aufgeblasener S-Brocken.
s.seed = vorher["seed"] + i * 4177
res = bpy.ops.object.generate_rocks_batch()
if 'FINISHED' not in res:
self.report({'WARNING'}, "Klasse %s abgebrochen." % klasse)
break
gesamt += s.batch_count
finally:
for k, v in vorher.items():
setattr(s, k, v)
self.report({'INFO'}, "Erzfels-Set: %d Brocken (Rock_Ore_S/M_NN), "
"Erz im A-Kanal." % gesamt)
return {'FINISHED'}
class OBJECT_OT_export_rocks_fbx(Operator):
bl_idname = "object.export_rocks_fbx"
bl_label = "FBX exportieren"
@@ -2098,6 +2306,7 @@ class VIEW3D_PT_rock_shape(_RockSub):
layout.prop(s, "box_proportions")
layout.prop(s, "box_variation")
layout.prop(s, "subsurf_levels")
layout.operator("rockgen.dense_preset", icon='MOD_SMOOTH')
layout.separator()
layout.prop(s, "texture_type")
col = layout.column(align=True)
@@ -2171,8 +2380,18 @@ class VIEW3D_PT_rock_vcol(_RockSub):
sub.prop(s, "moss_noise")
sub.prop(s, "moss_seed")
col.prop(s, "part_mask_from_shells")
col.label(text="R=Cavity G=%s B=Hoehe"
% ("Teil-Maske" if s.part_mask_from_shells else "Moos"),
col.separator()
col.prop(s, "bake_ore")
sub = col.column(align=True)
sub.active = s.bake_ore
sub.prop(s, "ore_amount")
sub.prop(s, "ore_nugget_size")
sub.prop(s, "ore_seed")
sub.separator()
sub.operator("rockgen.ore_set", icon='MOD_EXPLODE')
col.label(text="R=Cavity G=%s B=Hoehe A=%s"
% ("Teil-Maske" if s.part_mask_from_shells else "Moos",
"Erz" if s.bake_ore else "1.0"),
icon='INFO')
# Nicht in der ausgegrauten Spalte: der Knopf arbeitet auf der Auswahl
# und ist auch dann sinnvoll, wenn beim Erzeugen nicht gebacken wird.
@@ -2283,6 +2502,8 @@ classes = (
OBJECT_OT_export_rocks_fbx,
OBJECT_OT_bake_rock_textures,
ROCKGEN_OT_bake_cavity_selection,
ROCKGEN_OT_dense_preset,
ROCKGEN_OT_ore_set,
ROCKGEN_OT_randomize,
ROCKGEN_OT_preset_save,
ROCKGEN_OT_preset_load,