Add Hebat Allah wreck to the sunlit shallows site

First wreck in the production world layer. A WreckActor (StaticBody3D)
instantiates a two-LOD glTF visual, builds a trimesh collision from a
separate lighter glTF, and carries the sonar metadata (metal, 0.9
reflectivity) so the bathymetry rays echo it. The sunlit shallows site
places the MV Hebat Allah 62 m ahead of the submarine on the floor.

The assets derive from a CC BY 4.0 photogrammetry scan by Holger Buss
(Sketchfab). The raw 274 MB scan stays outside the repo; the committed
GLBs come from a reproducible Blender pipeline under scripts/assets/:
skirt cut, 44 m rescale, merge by distance (the scan is a triangle soup),
fragment removal, 0.4 m voxel remesh, decimation to 58k/14.5k/2.9k
triangles, smart UVs and a 2K albedo rebaked from a luminance-rebuilt
texture (the scan's red channel is dead).

docs/ASSET_LICENSES.md starts the licence registry required by the design
plan and records the attribution owed in the credits. The site suite
gains twelve assertions covering sonar metadata, collision, LOD handover
and triangle budgets. Godot extracts the embedded albedo next to the glb
on import; those PNGs are ignored like the cockpit ones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
OlivierandClaude Fable 5.1 authored and chindit committed 2026-10-07 16:11:30 +02:00
1 parent e53bf8dbb4
commit b72cdd5042
10 files changed
+512 -1

No files matched your search

@@ -0,0 +1,272 @@
"""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=<dir with wreck_raw_joined.blend and albedo_2k_balanced.png>
WRECK_OUT_DIR=<output 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)
@@ -0,0 +1,42 @@
"""Rebuild a neutral 2K albedo from the raw MV Hebat Allah scan texture.
The underwater scan has a dead red channel (mean 17/255) and a strong blue
cast, so a white balance only produces magenta noise. This script keeps the
luminance of the two live channels, stretches it, and tints it with a rust /
algae palette driven by the green-to-blue ratio. The game's fog adds the water
colour at runtime.
python scripts/assets/hebat_allah_prepare_albedo.py <albedo_8k.png> <out_dir>
Writes albedo_2k_balanced.png and albedo_1k_balanced.png into <out_dir>.
Requires Pillow and numpy. Licence: see docs/ASSET_LICENSES.md.
"""
import sys
from pathlib import Path
import numpy as np
from PIL import Image
def balance(source: Path, out_dir: Path) -> None:
image = Image.open(source).convert("RGB").resize((2048, 2048), Image.LANCZOS)
rgb = np.asarray(image).astype(np.float32) / 255.0
luminance = 0.55 * rgb[..., 1] + 0.45 * rgb[..., 2]
low, high = np.percentile(luminance, 1), np.percentile(luminance, 99)
luminance = np.clip((luminance - low) / (high - low), 0.0, 1.0)
luminance = luminance ** 0.9 * 0.75 + 0.08
algae_ratio = np.clip((rgb[..., 1] - rgb[..., 2] * 0.68) * 3.0, 0.0, 1.0)
rust = np.array([0.62, 0.52, 0.44])
algae = np.array([0.50, 0.58, 0.46])
tint = rust[None, None, :] * (1.0 - algae_ratio[..., None]) + algae[None, None, :] * algae_ratio[..., None]
out = np.clip(luminance[..., None] * tint / 0.55, 0.0, 1.0)
result = Image.fromarray((out * 255).astype(np.uint8))
out_dir.mkdir(parents=True, exist_ok=True)
result.save(out_dir / "albedo_2k_balanced.png")
result.resize((1024, 1024), Image.LANCZOS).save(out_dir / "albedo_1k_balanced.png")
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit(__doc__)
balance(Path(sys.argv[1]), Path(sys.argv[2]))