Merge pull request 'Add Hebat Allah wreck to the sunlit shallows site' (#3) from olix21/feat/wreck-hebat-allah into main
Quality Gate / Quality Gate (push) Successful in 6m23s

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
chindit committed 2026-10-07 17:30:00 +02:00
commit a8d913a320
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: