Add Hebat Allah wreck to the sunlit shallows site
Quality Gate / Quality Gate (pull_request) Successful in 6m49s

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 committed 2026-10-07 07:30:21 +02:00
1 parent f0072a3e11
commit 50b5aac069
10 files changed
+512 -1

No files matched your search

+1
View File
@@ -6,3 +6,4 @@ builds/
# Textures que Godot extrait des .glb a l import : regenerees, non versionnees # Textures que Godot extrait des .glb a l import : regenerees, non versionnees
game/presentation/cockpit/assets/models/*.png game/presentation/cockpit/assets/models/*.png
game/world/wrecks/assets/**/*.png
+49
View File
@@ -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.
@@ -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="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"] [sub_resource type="Environment" id="EnvironmentSunlitShallows"]
background_mode = 1 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 fov = 72.0
near = 0.1 near = 0.1
far = 400.0 far = 400.0
[node name="HebatAllahWreck" parent="." instance=ExtResource("2_wreck")]
position = Vector3(-8, -41, -62)
rotation_degrees = Vector3(0, 55, 3)
+12
View File
@@ -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
+65
View File
@@ -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)
@@ -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]))
@@ -38,6 +38,7 @@ func _run() -> void:
_test_scattering_environment(site) _test_scattering_environment(site)
_test_air_fog_cut(site) _test_air_fog_cut(site)
_test_surface_ceiling(site) _test_surface_ceiling(site)
_test_wreck(site)
site.queue_free() site.queue_free()
await process_frame 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: func _expect(condition: bool, label: String) -> void:
_checks += 1 _checks += 1
if not condition: if not condition: