diff --git a/.gitignore b/.gitignore index 1f4f087..3a18863 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ builds/ # Textures que Godot extrait des .glb a l import : regenerees, non versionnees game/presentation/cockpit/assets/models/*.png +game/world/wrecks/assets/**/*.png diff --git a/docs/ASSET_LICENSES.md b/docs/ASSET_LICENSES.md new file mode 100644 index 0000000..2ffca5a --- /dev/null +++ b/docs/ASSET_LICENSES.md @@ -0,0 +1,49 @@ +# Registre des licences de ressources externes + +Registre exigé par `GAME_DESIGN_PLAN.md` §19 : chaque ressource tierce (modèle, +texture, son, police) a une entrée ici avant d'entrer dans le dépôt ou dans une +build. Une ressource dont la licence interdit la diffusion commerciale est +marquée `À REMPLACER AVANT COMMERCIALISATION` et doit sortir du projet avant +toute vente, précommande, financement participatif ou page store payante. + +Statuts possibles : `OK commercial`, `À VÉRIFIER`, `À REMPLACER AVANT +COMMERCIALISATION`, `RETIRÉ`. + +## Modèles 3D + +### MV Hebat Allah — `OK commercial` (crédit obligatoire) + +- Source : https://sketchfab.com/3d-models/shipwreck-mv-hebat-allah-3a5306f3961146729d054ee42bc063e5 +- Auteur : Holger Buss (`holger.buss`), Sketchfab. +- Licence : **CC BY 4.0**. Usage commercial et dérivés autorisés à condition de + créditer l'auteur. Crédit à inscrire dans l'écran des crédits : + « MV Hebat Allah shipwreck scan by Holger Buss (sketchfab.com), CC BY 4.0, + modified ». +- Contenu d'origine : scan photogrammétrique d'un caboteur de 44 m coulé + volontairement en mer Rouge (2004). 2 375 184 triangles, 109 morceaux, une + texture 8K. Fichier brut conservé hors dépôt dans + `C:\Users\olivi\Assets\hebat_allah\raw\`, jamais committé. +- Dérivés committés (`game/world/wrecks/assets/hebat_allah/`) : coque remise à + l'échelle sur 44 m, jupe de sédiment coupée à 3 m, soupe de triangles fusionnée + par distance, fragments isolés retirés, surface refermée par remesh voxel + 0,4 m, LOD0 58 k triangles et LOD1 14,5 k avec UV refaits, albédo rebaké en 2K + puis neutralisé (le canal rouge du scan est inexploitable, la teinte est + reconstruite depuis la luminance), collision trimesh séparée de 2,9 k + triangles. Pipeline reproductible : `scripts/assets/hebat_allah_prepare_albedo.py` + puis `scripts/assets/hebat_allah_blender_pipeline.py`. +- Usage dans le projet : kit « acier du XXe siècle », site des hauts-fonds + ensoleillés (`game/world/sites/sunlit_shallows_environment.tscn`). + +## Modèles du cockpit — `À VÉRIFIER` (polices seulement) + +- `game/presentation/cockpit/assets/models/*.glb` : générés par scripts Blender + écrits pour le projet, aucune ressource tierce (voir + `docs/briefs/cockpit-modeles-integration.md`). Modèles : `OK commercial`. +- Polices rendues en texture dans les cadrans et étiquettes : Arial et Impact, + polices système Windows. Point de licence à vérifier avant diffusion ; prévoir + une police libre (SIL OFL) en remplacement si nécessaire. + +## Images de référence + +- `docs/visual_targets/cockpit-objective-v1.jpg` : générée pour le projet, + référence interne non distribuée dans les builds. Pas de contrainte. diff --git a/game/world/sites/sunlit_shallows_environment.tscn b/game/world/sites/sunlit_shallows_environment.tscn index a34ac31..1cfd129 100644 --- a/game/world/sites/sunlit_shallows_environment.tscn +++ b/game/world/sites/sunlit_shallows_environment.tscn @@ -1,6 +1,7 @@ -[gd_scene load_steps=3 format=3] +[gd_scene load_steps=4 format=3] [ext_resource type="Script" path="res://game/world/sites/sunlit_shallows_environment.gd" id="1_environment"] +[ext_resource type="PackedScene" path="res://game/world/wrecks/hebat_allah_wreck.tscn" id="2_wreck"] [sub_resource type="Environment" id="EnvironmentSunlitShallows"] background_mode = 1 @@ -62,3 +63,7 @@ transform = Transform3D(1, 0, 0, 0, 0.9962, 0.0872, 0, -0.0872, 0.9962, 0, -30, fov = 72.0 near = 0.1 far = 400.0 + +[node name="HebatAllahWreck" parent="." instance=ExtResource("2_wreck")] +position = Vector3(-8, -41, -62) +rotation_degrees = Vector3(0, 55, 3) diff --git a/game/world/wrecks/assets/hebat_allah/hebat_allah_collision.glb b/game/world/wrecks/assets/hebat_allah/hebat_allah_collision.glb new file mode 100644 index 0000000..037d368 Binary files /dev/null and b/game/world/wrecks/assets/hebat_allah/hebat_allah_collision.glb differ diff --git a/game/world/wrecks/assets/hebat_allah/hebat_allah_visual.glb b/game/world/wrecks/assets/hebat_allah/hebat_allah_visual.glb new file mode 100644 index 0000000..2ba1c4c Binary files /dev/null and b/game/world/wrecks/assets/hebat_allah/hebat_allah_visual.glb differ diff --git a/game/world/wrecks/hebat_allah_wreck.tscn b/game/world/wrecks/hebat_allah_wreck.tscn new file mode 100644 index 0000000..8e76947 --- /dev/null +++ b/game/world/wrecks/hebat_allah_wreck.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://game/world/wrecks/wreck_actor.gd" id="1_wreck_actor"] +[ext_resource type="PackedScene" path="res://game/world/wrecks/assets/hebat_allah/hebat_allah_visual.glb" id="2_visual"] +[ext_resource type="PackedScene" path="res://game/world/wrecks/assets/hebat_allah/hebat_allah_collision.glb" id="3_collision"] + +[node name="HebatAllahWreck" type="StaticBody3D"] +script = ExtResource("1_wreck_actor") +visual_scene = ExtResource("2_visual") +collision_scene = ExtResource("3_collision") +lod_switch_distance_m = 45.0 +sonar_reflectivity = 0.9 diff --git a/game/world/wrecks/wreck_actor.gd b/game/world/wrecks/wreck_actor.gd new file mode 100644 index 0000000..9dd8d30 --- /dev/null +++ b/game/world/wrecks/wreck_actor.gd @@ -0,0 +1,65 @@ +class_name WreckActor +extends StaticBody3D + +## A wreck resting on a site: two visual levels of detail from one glTF and a +## separate, much lighter trimesh collision. The sonar reads the body's +## metadata, so this node is the one owner of how a wreck echoes. + +const LOD0_NODE_NAME := "WreckLOD0" +const LOD1_NODE_NAME := "WreckLOD1" +const COLLISION_NODE_NAME := "WreckCollision" +const COLLISION_SHAPE_NAME := "Collision" +const VISUAL_NAME := "Visual" +## Hysteresis around the LOD handover so a hovering submarine never flickers. +const LOD_MARGIN_M := 4.0 + +@export var visual_scene: PackedScene +@export var collision_scene: PackedScene +## Beyond this distance the coarse LOD is drawn instead of the detailed one. +@export_range(10.0, 400.0, 1.0) var lod_switch_distance_m := 45.0 +@export_range(0.0, 1.0, 0.01) var sonar_reflectivity := 0.9 + + +func _ready() -> void: + set_meta("sonar_material", SonarMaterial.Value.METAL) + set_meta("sonar_reflectivity", sonar_reflectivity) + _build_visual() + _build_collision() + + +func _build_visual() -> void: + if visual_scene == null: + push_error("WreckActor %s has no visual scene" % name) + return + var visual := visual_scene.instantiate() as Node3D + visual.name = VISUAL_NAME + add_child(visual) + var lod0 := visual.find_child(LOD0_NODE_NAME, true, false) as MeshInstance3D + var lod1 := visual.find_child(LOD1_NODE_NAME, true, false) as MeshInstance3D + if lod0 == null or lod1 == null: + push_error("WreckActor %s visual scene lacks %s or %s" % [ + name, LOD0_NODE_NAME, LOD1_NODE_NAME + ]) + return + lod0.visibility_range_end = lod_switch_distance_m + lod0.visibility_range_end_margin = LOD_MARGIN_M + lod1.visibility_range_begin = lod_switch_distance_m + lod1.visibility_range_begin_margin = LOD_MARGIN_M + + +func _build_collision() -> void: + if collision_scene == null: + push_error("WreckActor %s has no collision scene" % name) + return + var source := collision_scene.instantiate() as Node3D + var collision_mesh := source.find_child(COLLISION_NODE_NAME, true, false) as MeshInstance3D + if collision_mesh == null or collision_mesh.mesh == null: + push_error("WreckActor %s collision scene lacks %s" % [name, COLLISION_NODE_NAME]) + source.free() + return + var shape := CollisionShape3D.new() + shape.name = COLLISION_SHAPE_NAME + shape.shape = collision_mesh.mesh.create_trimesh_shape() + shape.transform = collision_mesh.transform + source.free() + add_child(shape) diff --git a/scripts/assets/hebat_allah_blender_pipeline.py b/scripts/assets/hebat_allah_blender_pipeline.py new file mode 100644 index 0000000..e0b52c0 --- /dev/null +++ b/scripts/assets/hebat_allah_blender_pipeline.py @@ -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= + 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) diff --git a/scripts/assets/hebat_allah_prepare_albedo.py b/scripts/assets/hebat_allah_prepare_albedo.py new file mode 100644 index 0000000..7e68626 --- /dev/null +++ b/scripts/assets/hebat_allah_prepare_albedo.py @@ -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 + +Writes albedo_2k_balanced.png and albedo_1k_balanced.png into . +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])) diff --git a/tests/integration/run_sunlit_shallows_environment.gd b/tests/integration/run_sunlit_shallows_environment.gd index 607122d..1e8fbee 100644 --- a/tests/integration/run_sunlit_shallows_environment.gd +++ b/tests/integration/run_sunlit_shallows_environment.gd @@ -38,6 +38,7 @@ func _run() -> void: _test_scattering_environment(site) _test_air_fog_cut(site) _test_surface_ceiling(site) + _test_wreck(site) site.queue_free() await process_frame @@ -231,6 +232,70 @@ func _test_air_fog_cut(site: SunlitShallowsEnvironment) -> void: ) +func _test_wreck(site: SunlitShallowsEnvironment) -> void: + var wreck := site.get_node_or_null("HebatAllahWreck") as WreckActor + _expect(wreck != null, "site places the Hebat Allah wreck") + if wreck == null: + return + _expect( + int(wreck.get_meta("sonar_material", SonarMaterial.Value.UNKNOWN)) + == SonarMaterial.Value.METAL, + "wreck echoes as metal on the sonar" + ) + _expect((wreck.collision_layer & 1) == 1, "wreck sits on the sonar collision layer") + _expect( + wreck.position.y < SunlitShallowsEnvironment.SURFACE_LEVEL_M + and absf(wreck.position.y - SunlitShallowsEnvironment.FLOOR_DEPTH_M) < 3.0, + "wreck rests on the site floor" + ) + var collision := wreck.get_node_or_null(WreckActor.COLLISION_SHAPE_NAME) as CollisionShape3D + _expect( + collision != null and collision.shape is ConcavePolygonShape3D, + "wreck has a trimesh collision separate from its visuals" + ) + if collision != null and collision.shape is ConcavePolygonShape3D: + var faces := (collision.shape as ConcavePolygonShape3D).get_faces() + _expect(faces.size() >= 3 * 500, "wreck collision mesh is not empty") + _expect(faces.size() <= 3 * 6000, "wreck collision mesh stays light") + var lod0 := wreck.find_child(WreckActor.LOD0_NODE_NAME, true, false) as MeshInstance3D + var lod1 := wreck.find_child(WreckActor.LOD1_NODE_NAME, true, false) as MeshInstance3D + _expect(lod0 != null and lod1 != null, "wreck carries two visual LODs") + if lod0 == null or lod1 == null: + return + _expect( + lod0.visibility_range_end > 0.0 + and is_equal_approx(lod1.visibility_range_begin, lod0.visibility_range_end), + "wreck LODs hand over at one distance" + ) + var lod0_triangles := _triangle_count(lod0.mesh) + var lod1_triangles := _triangle_count(lod1.mesh) + _expect( + lod0_triangles > 0 and lod0_triangles <= 120000, + "wreck LOD0 stays within the triangle budget" + ) + _expect(lod1_triangles > 0 and lod1_triangles < lod0_triangles / 2, "wreck LOD1 is far lighter") + _expect( + lod0.mesh.surface_get_material(0) != null + or lod0.get_surface_override_material(0) != null, + "wreck LOD0 carries a material" + ) + + +func _triangle_count(mesh: Mesh) -> int: + if mesh == null: + return 0 + var triangles := 0 + for surface in mesh.get_surface_count(): + var arrays := mesh.surface_get_arrays(surface) + var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] + if indices.is_empty(): + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + triangles += vertices.size() / 3 + else: + triangles += indices.size() / 3 + return triangles + + func _expect(condition: bool, label: String) -> void: _checks += 1 if not condition: