"""Blender pipeline turning the raw MV Hebat Allah photogrammetry scan into the game-ready wreck assets committed under game/world/wrecks/assets/hebat_allah/. Run headless with Blender 5.2: WRECK_RAW_DIR= WRECK_OUT_DIR= WRECK_VOXEL=0.4 blender -b --python scripts/assets/hebat_allah_blender_pipeline.py Steps: apply transforms, cut the sediment skirt 3 m around the hull, scale the hull to its real 44 m, merge the triangle soup by distance, drop isolated fragments, voxel remesh to a closed surface, decimate LOD0, smart-project UVs, bake the albedo from the scan, derive LOD1 and a UV-less collision mesh, export two GLBs. Licence: see docs/ASSET_LICENSES.md (CC BY 4.0, Holger Buss). """ import bpy, bmesh, math, json, os, time from mathutils import Vector, Matrix T0 = time.time() RAW = os.environ.get("WRECK_RAW_DIR", r"C:\Users\olivi\Assets\hebat_allah\raw") VOXEL_TAG = os.environ.get("WRECK_VOXEL", "0.4") VOXEL = float(VOXEL_TAG) OUT = os.environ.get("WRECK_OUT_DIR", r"C:\Users\olivi\Assets\hebat_allah\out\v" + VOXEL_TAG) os.makedirs(OUT, exist_ok=True) CHK = os.path.join(OUT, "check") os.makedirs(CHK, exist_ok=True) log = {} def mark(k): log[k] = round(time.time() - T0, 1) print("STEP", k, log[k], flush=True) def activate(o): bpy.ops.object.select_all(action='DESELECT') o.select_set(True) bpy.context.view_layer.objects.active = o bpy.ops.wm.open_mainfile(filepath=RAW + r"\wreck_raw_joined.blend") src = bpy.data.objects["wreck_raw"] activate(src) bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) co = [v.co for v in src.data.vertices if v.co.z > 3.0] fx = (min(c.x for c in co), max(c.x for c in co)) fy = (min(c.y for c in co), max(c.y for c in co)) MARGIN = 3.0 bpy.ops.object.mode_set(mode='EDIT') for pno, pco in [((1, 0, 0), (fx[1] + MARGIN, 0, 0)), ((-1, 0, 0), (fx[0] - MARGIN, 0, 0)), ((0, 1, 0), (0, fy[1] + MARGIN, 0)), ((0, -1, 0), (0, fy[0] - MARGIN, 0))]: bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.bisect(plane_co=pco, plane_no=pno, clear_outer=True, clear_inner=False) bpy.ops.object.mode_set(mode='OBJECT') cx, cy = (fx[0] + fx[1]) / 2, (fy[0] + fy[1]) / 2 zmin = min(v.co.z for v in src.data.vertices) SCALE = 44.0 / (fx[1] - fx[0]) src.data.transform(Matrix.Scale(SCALE, 4) @ Matrix.Rotation(math.radians(90), 4, 'Z') @ Matrix.Translation((-cx, -cy, -zmin))) src.data.update() mark("cut_scaled") bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.remove_doubles(threshold=0.003) bpy.ops.object.mode_set(mode='OBJECT') src.data.calc_loop_triangles() log["verts_after_merge"] = len(src.data.vertices) mark("merged") print("DBG", log, flush=True) def drop_small_components(obj, min_faces): import numpy as np me = obj.data nv = len(me.vertices) ev = np.empty(len(me.edges) * 2, dtype=np.int64) me.edges.foreach_get("vertices", ev) ev = ev.reshape(-1, 2) label = np.arange(nv, dtype=np.int64) for _ in range(10000): m = np.minimum(label[ev[:, 0]], label[ev[:, 1]]) new = label.copy() np.minimum.at(new, ev[:, 0], m) np.minimum.at(new, ev[:, 1], m) for _ in range(4): new = new[new] if np.array_equal(new, label): break label = new nf = len(me.polygons) loop_start = np.empty(nf, dtype=np.int64) me.polygons.foreach_get("loop_start", loop_start) loop_vert = np.empty(len(me.loops), dtype=np.int64) me.loops.foreach_get("vertex_index", loop_vert) flab = label[loop_vert[loop_start]] counts = np.bincount(flab, minlength=nv) small = counts[flab] < min_faces removed = int(np.unique(flab[small]).size) kept = int(np.unique(flab[~small]).size) bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() geom = [bm.faces[int(i)] for i in np.nonzero(small)[0]] if geom: bmesh.ops.delete(bm, geom=geom, context='FACES') bm.to_mesh(me) bm.free() me.update() return removed, kept log["frag_removed_kept"] = drop_small_components(src, 400) mark("fragments") print("DBG", log, flush=True) src.data.calc_loop_triangles() log["tris_after_clean"] = len(src.data.loop_triangles) mat_src = bpy.data.materials.new("src_mat") mat_src.use_nodes = True nt = mat_src.node_tree tex = nt.nodes.new("ShaderNodeTexImage") tex.image = bpy.data.images.load(RAW + r"\albedo_2k_balanced.png") nt.links.new(tex.outputs["Color"], nt.nodes["Principled BSDF"].inputs["Base Color"]) src.data.materials.clear() src.data.materials.append(mat_src) rm = src.copy() rm.data = src.data.copy() rm.name = "remeshed" bpy.context.scene.collection.objects.link(rm) activate(rm) rm.data.remesh_voxel_size = VOXEL rm.data.remesh_voxel_adaptivity = 0.0 rm.data.use_remesh_fix_poles = False rm.data.use_remesh_preserve_volume = True bpy.ops.object.voxel_remesh() rm.data.calc_loop_triangles() log["tris_after_remesh"] = len(rm.data.loop_triangles) mark("remesh") print("DBG", log, flush=True) log["remesh_frag_removed_kept"] = drop_small_components(rm, 300) rm.data.calc_loop_triangles() n_rm = len(rm.data.loop_triangles) lod0 = rm lod0.name = "WreckLOD0" lod0.data.name = "WreckLOD0" m = lod0.modifiers.new("dec", 'DECIMATE') m.ratio = min(1.0, 60000 / n_rm) m.use_collapse_triangulate = True bpy.ops.object.modifier_apply(modifier="dec") bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.smart_project(angle_limit=math.radians(66), island_margin=0.003, correct_aspect=True, scale_to_bounds=False) bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.shade_smooth() mark("lod0_uv") baked = bpy.data.images.new("wreck_hebat_allah_albedo", 2048, 2048) baked.generated_color = (0.3, 0.3, 0.3, 1) mat = bpy.data.materials.new("wreck_hebat_allah") mat.use_nodes = True nt = mat.node_tree tn = nt.nodes.new("ShaderNodeTexImage") tn.image = baked nt.links.new(tn.outputs["Color"], nt.nodes["Principled BSDF"].inputs["Base Color"]) nt.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.85 nt.nodes["Principled BSDF"].inputs["Metallic"].default_value = 0.0 nt.nodes.active = tn lod0.data.materials.clear() lod0.data.materials.append(mat) sc = bpy.context.scene sc.render.engine = 'CYCLES' sc.cycles.device = 'CPU' sc.cycles.samples = 4 sc.render.bake.use_selected_to_active = True sc.render.bake.cage_extrusion = 0.6 sc.render.bake.max_ray_distance = 1.6 sc.render.bake.margin = 8 sc.render.bake.use_pass_direct = False sc.render.bake.use_pass_indirect = False sc.render.bake.use_pass_color = True bpy.ops.object.select_all(action='DESELECT') src.select_set(True) lod0.select_set(True) bpy.context.view_layer.objects.active = lod0 bpy.ops.object.bake(type='DIFFUSE') baked.filepath_raw = OUT + r"\hebat_allah_albedo_2k.png" baked.file_format = 'PNG' baked.save() mark("bake") def derive(name, ratio, keep_mat): o = lod0.copy() o.data = lod0.data.copy() o.name = name o.data.name = name bpy.context.scene.collection.objects.link(o) activate(o) if not keep_mat: while o.data.uv_layers: o.data.uv_layers.remove(o.data.uv_layers[0]) m = o.modifiers.new("dec", 'DECIMATE') m.ratio = ratio m.use_collapse_triangulate = True bpy.ops.object.modifier_apply(modifier="dec") if not keep_mat: o.data.materials.clear() o.data.calc_loop_triangles() return o, len(o.data.loop_triangles) lod1, t1 = derive("WreckLOD1", 0.25, True) col, tc = derive("WreckCollision", 0.05, False) lod0.data.calc_loop_triangles() t0 = len(lod0.data.loop_triangles) bpy.data.objects.remove(src) def export(objs, path): bpy.ops.object.select_all(action='DESELECT') for o in objs: o.select_set(True) bpy.ops.export_scene.gltf(filepath=path, export_format='GLB', use_selection=True, export_apply=True, export_yup=True, export_image_format='AUTO', export_materials='EXPORT') export([lod0, lod1], OUT + r"\hebat_allah_visual.glb") export([col], OUT + r"\hebat_allah_collision.glb") bpy.ops.wm.save_as_mainfile(filepath=OUT + r"\hebat_allah_work.blend") mark("export") sc.render.engine = 'BLENDER_WORKBENCH' sc.display.shading.light = 'STUDIO' sc.display.shading.color_type = 'TEXTURE' sc.render.resolution_x = 1600 sc.render.resolution_y = 900 sc.world = bpy.data.worlds.new("W") sc.world.color = (0.08, 0.1, 0.12) cam = bpy.data.cameras.new("C") cam.lens = 35 cob = bpy.data.objects.new("Cam", cam) sc.collection.objects.link(cob) sc.camera = cob def look(loc, target): cob.location = loc cob.rotation_euler = (Vector(target) - Vector(loc)).to_track_quat('-Z', 'Y').to_euler() def shot(name, vis): for o in (lod0, lod1, col): o.hide_render = o is not vis sc.render.filepath = os.path.join(CHK, name + ".png") bpy.ops.render.render(write_still=True) look((-30, -45, 18), (0, 0, 5)) shot("lod0_bow_quarter", lod0) shot("lod1_bow_quarter", lod1) shot("collision_bow_quarter", col) look((28, 40, 14), (0, 0, 5)) shot("lod0_stern_quarter", lod0) look((-14, -6, 3.5), (0, -8, 4)) shot("lod0_from_12m", lod0) look((0, 0, 70), (0, 0, 0)) shot("lod0_top", lod0) log.update({"scale": SCALE, "lod0_tris": t0, "lod1_tris": t1, "collision_tris": tc, "dims": [round(v, 2) for v in lod0.dimensions], "sizes_MB": {f: round(os.path.getsize(os.path.join(OUT, f)) / 1e6, 1) for f in os.listdir(OUT)}}) print("RESULT" + json.dumps(log), flush=True)