mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
3257f5cbcc
Add tools/blender/gen_nebula.py (previously nebula_decoration.blend had no generator script, unlike ship/ball) to rebuild the station with inset/extrude panel-line greeble and three separate emissive window strips, and give debris its own rockier, damage-scarred materials instead of cloning the station's hull material.
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""Procedurally generates the Cosmic Clash nebula-arena decoration models.
|
|
|
|
Run inside Blender (e.g. via the blender-mcp `execute_blender_code` tool, or
|
|
`blender --background --python gen_nebula.py`). Builds two named mesh
|
|
objects — NebulaStation and NebulaDebris — greebled from bmesh primitives
|
|
(bevel + inset/extrude, same technique as `gen_ship.py`'s hull), saves
|
|
nebula_decoration.blend, then exports each as its own glb file
|
|
(nebula_station.glb, nebula_debris.glb).
|
|
|
|
Unlike ship/ball, these are decoration props instanced directly as glTF
|
|
scenes in `arena_02.tscn` (see `Decoration` node) with no per-part node-name
|
|
or forward-orientation requirement, so — also unlike ship/ball — there is no
|
|
`extract_meshes.gd` step needed afterward and no axis-correction rotation to
|
|
keep in sync. Each object is still exported as a single-object glb (rather
|
|
than combining both into one file) so the arena scene's existing
|
|
`ext_resource` references keep resolving to the same top-level structure
|
|
they already expect.
|
|
|
|
NebulaPlanet lives in the same source .blend but is a separate, unrelated
|
|
asset (`Planet_Surface` material) — this script deliberately never touches
|
|
it; `clear_scene()` only removes the two objects it rebuilds.
|
|
|
|
Bake pipeline: a full low-poly/high-poly normal+AO bake was considered (per
|
|
the TODO item this script implements) but skipped as too fragile to script
|
|
reliably end-to-end without a human eyeballing the bake result — detail here
|
|
comes entirely from real geometry (inset/extrude greeble) plus per-face
|
|
material variation instead, which is the TODO's explicitly sanctioned
|
|
fallback.
|
|
"""
|
|
|
|
import random
|
|
|
|
import bmesh
|
|
import bpy
|
|
from mathutils import Vector
|
|
|
|
BLEND_PATH = "Game/assets/blender_models/nebula_decoration.blend"
|
|
GLB_PATHS = {
|
|
"NebulaStation": "Game/assets/models/nebula_station.glb",
|
|
"NebulaDebris": "Game/assets/models/nebula_debris.glb",
|
|
}
|
|
|
|
|
|
def new_mesh_object(name, bm):
|
|
mesh = bpy.data.meshes.new(name)
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
obj = bpy.data.objects.new(name, mesh)
|
|
bpy.context.collection.objects.link(obj)
|
|
return obj
|
|
|
|
|
|
def clear_scene():
|
|
# Scoped to just the two objects this script rebuilds — NebulaPlanet
|
|
# (and its Planet_Surface material) must survive untouched.
|
|
for name in ("NebulaStation", "NebulaDebris"):
|
|
obj = bpy.data.objects.get(name)
|
|
if obj:
|
|
mesh = obj.data
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
if mesh and mesh.users == 0:
|
|
bpy.data.meshes.remove(mesh)
|
|
for block in list(bpy.data.materials):
|
|
if block.users == 0:
|
|
bpy.data.materials.remove(block)
|
|
|
|
|
|
def build_station():
|
|
random.seed(21)
|
|
bm = bmesh.new()
|
|
bmesh.ops.create_cube(bm, size=1.0)
|
|
# Matches the original kitbash's proportions: a tall central hub, long
|
|
# axis along local Y (half-extents 0.5, 2.0, 0.5).
|
|
bmesh.ops.scale(bm, vec=(1.0, 4.0, 1.0), verts=bm.verts)
|
|
|
|
bm.edges.ensure_lookup_table()
|
|
long_edges = [e for e in bm.edges if (e.verts[0].co - e.verts[1].co).length > 2.0]
|
|
bmesh.ops.bevel(bm, geom=long_edges, offset=0.12, segments=3, affect="EDGES")
|
|
|
|
# Panel-line seams: subdivide the side/top/bottom faces (exclude the Y
|
|
# end-caps), same face-selection logic as gen_ship.py's hull.
|
|
bm.faces.ensure_lookup_table()
|
|
panel_target_faces = [f for f in bm.faces if abs(f.normal.z) > 0.6 or abs(f.normal.x) > 0.6]
|
|
edges_to_cut = list({e for f in panel_target_faces for e in f.edges})
|
|
bmesh.ops.subdivide_edges(bm, edges=edges_to_cut, cuts=4, use_grid_fill=True)
|
|
|
|
def greeble_pass(seed, fraction, thickness, depth_range):
|
|
random.seed(seed)
|
|
bm.faces.ensure_lookup_table()
|
|
candidates = [
|
|
f
|
|
for f in bm.faces
|
|
if (abs(f.normal.z) > 0.55 or abs(f.normal.x) > 0.55) and 0.015 < f.calc_area() < 0.6
|
|
]
|
|
random.shuffle(candidates)
|
|
chosen = candidates[: max(1, int(len(candidates) * fraction))]
|
|
for f in chosen:
|
|
if not f.is_valid:
|
|
continue
|
|
res = bmesh.ops.inset_individual(bm, faces=[f], thickness=thickness)
|
|
new_faces = res["faces"]
|
|
if new_faces:
|
|
nf = new_faces[0]
|
|
depth = random.uniform(*depth_range)
|
|
bmesh.ops.translate(bm, verts=nf.verts, vec=nf.normal * depth)
|
|
|
|
greeble_pass(seed=21, fraction=0.35, thickness=0.045, depth_range=(-0.07, 0.05))
|
|
greeble_pass(seed=55, fraction=0.22, thickness=0.032, depth_range=(-0.05, 0.035))
|
|
greeble_pass(seed=88, fraction=0.15, thickness=0.02, depth_range=(-0.03, 0.02))
|
|
|
|
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
|
obj = new_mesh_object("NebulaStation", bm)
|
|
obj.location = (0.0, 0.0, 1.2871222496032715)
|
|
return obj
|
|
|
|
|
|
def build_debris():
|
|
random.seed(303)
|
|
bm = bmesh.new()
|
|
bmesh.ops.create_cube(bm, size=1.0)
|
|
bmesh.ops.subdivide_edges(bm, edges=list(bm.edges), cuts=2, use_grid_fill=True)
|
|
|
|
# Irregular rock silhouette: per-vertex jitter breaks the clean box
|
|
# shape, unlike the station's crisp architectural hull.
|
|
bm.verts.ensure_lookup_table()
|
|
for v in bm.verts:
|
|
v.co += Vector(
|
|
(
|
|
random.uniform(-0.18, 0.18),
|
|
random.uniform(-0.18, 0.18),
|
|
random.uniform(-0.18, 0.18),
|
|
)
|
|
)
|
|
bmesh.ops.scale(bm, vec=(0.9, 0.7, 0.55), verts=bm.verts)
|
|
|
|
bm.edges.ensure_lookup_table()
|
|
jagged_edges = [e for e in bm.edges if (e.verts[0].co - e.verts[1].co).length > 0.25]
|
|
bmesh.ops.bevel(bm, geom=jagged_edges, offset=0.03, segments=1, affect="EDGES")
|
|
|
|
# Damage gouges/impact craters: inset a subset of faces and push them in.
|
|
bm.faces.ensure_lookup_table()
|
|
candidates = [f for f in bm.faces if 0.01 < f.calc_area() < 0.3]
|
|
random.shuffle(candidates)
|
|
for f in candidates[: max(1, len(candidates) // 4)]:
|
|
if not f.is_valid:
|
|
continue
|
|
res = bmesh.ops.inset_individual(bm, faces=[f], thickness=0.03)
|
|
new_faces = res["faces"]
|
|
if new_faces:
|
|
nf = new_faces[0]
|
|
depth = random.uniform(-0.09, -0.02)
|
|
bmesh.ops.translate(bm, verts=nf.verts, vec=nf.normal * depth)
|
|
|
|
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
|
obj = new_mesh_object("NebulaDebris", bm)
|
|
obj.location = (-0.0355125367641449, -0.0051781535148620605, 0.027770817279815674)
|
|
return obj
|
|
|
|
|
|
def make_material(name, base_rgb, metallic, roughness, emission_rgb=None, emission_strength=0.0):
|
|
existing = bpy.data.materials.get(name)
|
|
if existing:
|
|
bpy.data.materials.remove(existing)
|
|
mat = bpy.data.materials.new(name)
|
|
mat.use_nodes = True
|
|
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
bsdf.inputs["Base Color"].default_value = (*base_rgb, 1.0)
|
|
bsdf.inputs["Metallic"].default_value = metallic
|
|
bsdf.inputs["Roughness"].default_value = roughness
|
|
if emission_rgb is not None:
|
|
bsdf.inputs["Emission Color"].default_value = (*emission_rgb, 1.0)
|
|
bsdf.inputs["Emission Strength"].default_value = emission_strength
|
|
return mat
|
|
|
|
|
|
def assign_station_materials():
|
|
# Same four material names as the original kitbash (slot order
|
|
# preserved) so anything keying off material name downstream (glow
|
|
# tuning, etc.) keeps working unchanged.
|
|
mat_metal = make_material("Hull_Metal", (0.55, 0.57, 0.60), 0.8, 0.45)
|
|
mat_dark = make_material("Hull_Metal_Dark", (0.16, 0.17, 0.19), 0.6, 0.6)
|
|
mat_light = make_material("Hull_Metal_Light", (0.72, 0.73, 0.75), 0.85, 0.35)
|
|
mat_accent = make_material(
|
|
"Hull_Accent", (0.02, 0.02, 0.02), 0.1, 0.3, (0.3, 0.75, 0.95), 3.0
|
|
)
|
|
|
|
obj = bpy.data.objects["NebulaStation"]
|
|
obj.data.materials.clear()
|
|
for m in (mat_metal, mat_dark, mat_light, mat_accent):
|
|
obj.data.materials.append(m)
|
|
|
|
# Three separate emissive window/light strips at different points along
|
|
# the hub's length, replacing the single strip of the original kitbash.
|
|
strip_bands = (-1.4, 0.0, 1.4)
|
|
strip_half_width = 0.15
|
|
|
|
random.seed(103)
|
|
for p in obj.data.polygons:
|
|
cx, cy, cz = p.center
|
|
nx, ny, nz = p.normal
|
|
is_side_facing = abs(nx) > 0.5 or abs(nz) > 0.5
|
|
is_strip = is_side_facing and any(abs(cy - band) < strip_half_width for band in strip_bands)
|
|
if is_strip:
|
|
p.material_index = 3
|
|
continue
|
|
r = random.random()
|
|
if r < 0.06:
|
|
p.material_index = 0
|
|
elif r < 0.35:
|
|
p.material_index = 1
|
|
else:
|
|
p.material_index = 2
|
|
|
|
|
|
def assign_debris_materials():
|
|
# Deliberately distinct from the station's clean Hull_Metal_Dark —
|
|
# darker, rougher rock body plus blotchy scorch-mark patches, so debris
|
|
# reads as separate wreckage rather than a station material clone.
|
|
mat_rock = make_material("Mat_DebrisRock", (0.13, 0.11, 0.10), 0.05, 0.85)
|
|
mat_scorch = make_material("Mat_DebrisScorch", (0.03, 0.03, 0.03), 0.0, 0.95)
|
|
|
|
obj = bpy.data.objects["NebulaDebris"]
|
|
obj.data.materials.clear()
|
|
obj.data.materials.append(mat_rock)
|
|
obj.data.materials.append(mat_scorch)
|
|
|
|
random.seed(404)
|
|
scorch_centers = [
|
|
Vector(
|
|
(
|
|
random.uniform(-0.45, 0.45),
|
|
random.uniform(-0.35, 0.35),
|
|
random.uniform(-0.28, 0.28),
|
|
)
|
|
)
|
|
for _ in range(4)
|
|
]
|
|
scorch_radius = 0.22
|
|
for p in obj.data.polygons:
|
|
c = Vector(p.center)
|
|
near_scorch = any((c - sc).length < scorch_radius for sc in scorch_centers)
|
|
p.material_index = 1 if near_scorch else 0
|
|
|
|
|
|
def build_nebula():
|
|
clear_scene()
|
|
build_station()
|
|
assign_station_materials()
|
|
build_debris()
|
|
assign_debris_materials()
|
|
|
|
|
|
def export(repo_root):
|
|
import os
|
|
|
|
blend_path = os.path.join(repo_root, BLEND_PATH)
|
|
bpy.ops.wm.save_as_mainfile(filepath=blend_path)
|
|
|
|
for obj_name, rel_path in GLB_PATHS.items():
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
obj = bpy.data.objects[obj_name]
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = obj
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=os.path.join(repo_root, rel_path),
|
|
export_format="GLB",
|
|
use_selection=True,
|
|
export_apply=True,
|
|
export_materials="EXPORT",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import os
|
|
|
|
# Assumes this script lives at <repo_root>/tools/blender/gen_nebula.py.
|
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
build_nebula()
|
|
export(repo_root)
|