diff --git a/Game/assets/blender_models/nebula_decoration.blend b/Game/assets/blender_models/nebula_decoration.blend index 8a60a814..53a3362f 100644 Binary files a/Game/assets/blender_models/nebula_decoration.blend and b/Game/assets/blender_models/nebula_decoration.blend differ diff --git a/Game/assets/models/nebula_debris.glb b/Game/assets/models/nebula_debris.glb index fc1b19b2..e00f507a 100644 Binary files a/Game/assets/models/nebula_debris.glb and b/Game/assets/models/nebula_debris.glb differ diff --git a/Game/assets/models/nebula_station.glb b/Game/assets/models/nebula_station.glb index 5d43fb93..e4226024 100644 Binary files a/Game/assets/models/nebula_station.glb and b/Game/assets/models/nebula_station.glb differ diff --git a/TODO.md b/TODO.md index 8be7e84b..842fcdf0 100644 --- a/TODO.md +++ b/TODO.md @@ -38,7 +38,7 @@ A subagent ran the game and critiqued arena_02 head-on against Rocket League 2 i - [x] **Real PBR lighting + materials, fresnel glass boundary**: SDFGI enabled and a `ReflectionProbe` added to all three arenas (`arena_01`/`02`/`03.tscn`); `arena_boundary.tscn`'s shared `StandardMaterial3D_field` converted from unshaded to shaded (roughness 0.05, rim-enabled fresnel highlight, alpha still near-invisible face-on). Verified via godot-mcp screenshots (60 FPS / 17ms frame time, no measurable cost) and zero-error headless runs of `free_play`/`match`/`spectate`/`training`. Prompt: "In Cosmic Clash, enable SDFGI and add reflection probes to the arena scenes (start with `arena_02.tscn`) so surfaces get real bounce lighting/reflections instead of flat ambient. Convert `arena_boundary.tscn`'s `StandardMaterial3D_field` from unshaded to a proper shaded material with a fresnel-based rim highlight (bright at grazing angles, near-invisible face-on) so it reads as glass rather than a tinted overlay — check the performance impact of moving it off unshaded, given it's a large always-visible surface. Verify visually via godot-mcp screenshots, and confirm headless runs (`free_play`/`match`/`spectate`/`training`) still show zero errors." -- [ ] **Greeble/detail pass on the station + debris models** (Blender remodel — explicit models to redo). `nebula_station.glb`/`nebula_debris.glb` are unmistakably Blender primitive kitbashes up close: plain boxes/cylinders with bevel modifiers, no panel lines, damage decals, or surface detail, and debris chunks look undifferentiated from the station. +- [x] **Greeble/detail pass on the station + debris models** (Blender remodel — explicit models to redo). Rebuilt via a new committed generator, `tools/blender/gen_nebula.py` (`nebula_decoration.blend` had no prior script, unlike ship/ball — this brings it in line): the station gets panel-line inset/extrude greeble across three passes and 3 separate emissive window strips (was 1), and debris gets its own `Mat_DebrisRock`/`Mat_DebrisScorch` materials plus per-vertex jitter and impact-crater gouges instead of cloning the station's `Hull_Metal_Dark`. The full normal-map/AO bake was skipped as too fragile to script reliably (per this item's own fallback allowance) — detail is geometry + material-only, no trim texture needed on top. Prompt: "Using the blender MCP, rebuild `nebula_station` (source in `Game/assets/blender_models/nebula_decoration.blend`) with actual surface detail: greebled panel-line insets via bmesh inset-and-extrude on selected faces (more scriptable and robust than chained boolean cuts — prefer this over booleans for the main detailing pass), and 2-3 emissive window/light strips distinct from the existing single accent strip. A full normal-map/AO bake pipeline (low-poly + high-poly pair, UV unwrap, bake settings) is the ideal AAA-style finish but is fragile to script end-to-end in one pass — attempt it, but if it proves too unreliable, fall back to the inset/extrude geometric detail alone plus a simple tiled trim-sheet-style texture rather than forcing a bake that doesn't work. Give `nebula_debris` chunks a rockier/damaged material distinct from the station's clean hull (darker, rougher, maybe scorch-mark variation) so they read as separate debris rather than clones of the station's material. Re-export both to `Game/assets/models/nebula_station.glb`/`nebula_debris.glb`, re-save the shared `.blend`, and re-verify placement/transforms in `arena_02.tscn` still look right (screenshot check)." - [ ] **Richer nebula sky + planet surface textures** (extends the existing procedural generation — no new asset types). `sky_nebula.png` is essentially one noise-filter pass over a bright core; `planet_surface.png` is a flat gradient with a single vortex swirl and no bands, craters, or day/night terminator. diff --git a/tools/blender/gen_nebula.py b/tools/blender/gen_nebula.py new file mode 100644 index 00000000..21323f7f --- /dev/null +++ b/tools/blender/gen_nebula.py @@ -0,0 +1,279 @@ +"""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 /tools/blender/gen_nebula.py. + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + build_nebula() + export(repo_root)